perfetto-ui: Implement pinning for tracks
Change-Id: Iddd074a895d35005bf453b4eb60e88cf7770081f
diff --git a/ui/src/assets/perfetto.scss b/ui/src/assets/perfetto.scss
index 5046c5b..95daabd 100644
--- a/ui/src/assets/perfetto.scss
+++ b/ui/src/assets/perfetto.scss
@@ -231,7 +231,7 @@
.track-shell {
padding: 0 20px;
display: grid;
- grid-template-areas: "title up down";
+ grid-template-areas: "title pin up down";
grid-template-columns: 1fr auto auto;
align-items: center;
width: 300px;
@@ -245,20 +245,11 @@
font-family: 'Google Sans';
color: hsl(213, 22%, 30%);
}
- .track-move-icons {
- justify-self: end;
+ .track-button {
margin: 0 5px;
- color: #fff;
- font-weight: bold;
- text-align: center;
+ color: #495767;
cursor: pointer;
- background: #ced0e7;
- border-radius: 12px;
- display: block;
width: 24px;
- height: 24px;
- border: none;
- outline: none;
}
}
}
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index f90ee08..b06a44a 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -14,6 +14,7 @@
import {State} from './state';
import {TimeSpan} from './time';
+
export interface Action { type: string; }
export function openTraceFromUrl(url: string) {
@@ -101,6 +102,13 @@
};
}
+export function toggleTrackPinned(trackId: string) {
+ return {
+ type: 'TOGGLE_TRACK_PINNED',
+ trackId,
+ };
+}
+
export function setEngineReady(engineId: string, ready = true) {
return {type: 'SET_ENGINE_READY', engineId, ready};
}
diff --git a/ui/src/common/state.ts b/ui/src/common/state.ts
index a57d979..c1fe8b4 100644
--- a/ui/src/common/state.ts
+++ b/ui/src/common/state.ts
@@ -79,7 +79,8 @@
traceTime: TraceTime;
visibleTraceTime: TraceTime;
tracks: ObjectById<TrackState>;
- displayedTrackIds: string[];
+ scrollingTracks: string[];
+ pinnedTracks: string[];
queries: ObjectById<QueryConfig>;
permalink: PermalinkConfig;
status: Status;
@@ -93,7 +94,8 @@
traceTime: {startSec: 0, endSec: 10, lastUpdate: 0},
visibleTraceTime: {startSec: 0, endSec: 10, lastUpdate: 0},
tracks: {},
- displayedTrackIds: [],
+ pinnedTracks: [],
+ scrollingTracks: [],
queries: {},
permalink: {},
status: {msg: '', timestamp: 0},
diff --git a/ui/src/controller/reducer.ts b/ui/src/controller/reducer.ts
index c5ede1b..5988b6f 100644
--- a/ui/src/controller/reducer.ts
+++ b/ui/src/controller/reducer.ts
@@ -50,6 +50,7 @@
case 'ADD_TRACK': {
const nextState = {...state};
nextState.tracks = {...state.tracks};
+ nextState.scrollingTracks = [...state.scrollingTracks];
const id = `${nextState.nextId++}`;
nextState.tracks[id] = {
id,
@@ -59,7 +60,7 @@
maxDepth: 1,
cpu: action.cpu,
};
- nextState.displayedTrackIds.push(id);
+ nextState.scrollingTracks.push(id);
return nextState;
}
@@ -97,7 +98,7 @@
upid: action.upid,
utid: action.utid,
};
- nextState.displayedTrackIds.push(id);
+ nextState.scrollingTracks.push(id);
return nextState;
}
@@ -119,28 +120,58 @@
return nextState;
}
- case 'MOVE_TRACK':
- if (!state.displayedTrackIds.includes(action.trackId) ||
- !action.direction) {
- throw new Error(
- 'Trying to move a track that does not exist' +
- ' or not providing a direction to move to.');
+ case 'MOVE_TRACK': {
+ if (!action.direction) {
+ throw new Error('No direction given');
}
- const nextState = {...state}; // Creates a shallow copy.
- // Copy the displayedTrackIds to prevent side effects.
- nextState.displayedTrackIds = state.displayedTrackIds.slice();
+ const id = action.trackId;
+ const isPinned = state.pinnedTracks.includes(id);
+ const isScrolling = state.scrollingTracks.includes(id);
+ if (!isScrolling && !isPinned) {
+ throw new Error(`No track with id ${id}`);
+ }
+ const nextState = {...state};
+ const scrollingTracks = nextState.scrollingTracks =
+ state.scrollingTracks.slice();
+ const pinnedTracks = nextState.pinnedTracks = state.pinnedTracks.slice();
- const oldIndex = state.displayedTrackIds.indexOf(action.trackId);
+ const tracks = isPinned ? pinnedTracks : scrollingTracks;
+
+ const oldIndex = tracks.indexOf(id);
const newIndex = action.direction === 'up' ? oldIndex - 1 : oldIndex + 1;
- const swappedTrackId = state.displayedTrackIds[newIndex];
-
- if (!swappedTrackId) {
- break;
+ const swappedTrackId = tracks[newIndex];
+ if (isPinned && newIndex === pinnedTracks.length) {
+ // Move from last element of pinned to first element of scrolling.
+ scrollingTracks.unshift(pinnedTracks.pop()!);
+ } else if (isScrolling && newIndex === -1) {
+ // Move first element of scrolling to last element of pinned.
+ pinnedTracks.push(scrollingTracks.shift()!);
+ } else if (swappedTrackId) {
+ tracks[newIndex] = id;
+ tracks[oldIndex] = swappedTrackId;
+ } else {
+ return state;
}
- nextState.displayedTrackIds[newIndex] = action.trackId;
- nextState.displayedTrackIds[oldIndex] = swappedTrackId;
-
return nextState;
+ }
+
+ case 'TOGGLE_TRACK_PINNED': {
+ const id = action.trackId;
+ const isPinned = state.pinnedTracks.includes(id);
+
+ const nextState = {...state};
+ const pinnedTracks = nextState.pinnedTracks = [...state.pinnedTracks];
+ const scrollingTracks = nextState.scrollingTracks =
+ [...state.scrollingTracks];
+ if (isPinned) {
+ pinnedTracks.splice(pinnedTracks.indexOf(id), 1);
+ scrollingTracks.unshift(id);
+ } else {
+ scrollingTracks.splice(scrollingTracks.indexOf(id), 1);
+ pinnedTracks.push(id);
+ }
+ return nextState;
+ }
case 'SET_ENGINE_READY': {
const nextState = {...state}; // Creates a shallow copy.
diff --git a/ui/src/controller/reducer_unittest.ts b/ui/src/controller/reducer_unittest.ts
index 3948739..b297ee3 100644
--- a/ui/src/controller/reducer_unittest.ts
+++ b/ui/src/controller/reducer_unittest.ts
@@ -12,9 +12,24 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import {createEmptyState} from '../common/state';
+import {moveTrack, toggleTrackPinned} from '../common/actions';
+import {createEmptyState, State, TrackState} from '../common/state';
+
import {rootReducer} from './reducer';
+function fakeTrack(state: State, id: string): TrackState {
+ const track: TrackState = {
+ id,
+ engineId: '1',
+ maxDepth: 0,
+ kind: 'SOME_TRACK_KIND',
+ name: 'A track',
+ cpu: 0,
+ };
+ state.tracks[id] = track;
+ return track;
+}
+
test('navigate', async () => {
const before = createEmptyState();
const after = rootReducer(before, {type: 'NAVIGATE', route: '/foo'});
@@ -36,7 +51,7 @@
cpu: '2',
});
expect(Object.values(state.tracks).length).toBe(2);
- expect(state.displayedTrackIds.length).toBe(2);
+ expect(state.scrollingTracks.length).toBe(2);
});
test('reorder tracks', () => {
@@ -54,8 +69,8 @@
cpu: '2',
});
- const firstTrackId = before.displayedTrackIds[0];
- const secondTrackId = before.displayedTrackIds[1];
+ const firstTrackId = before.scrollingTracks[0];
+ const secondTrackId = before.scrollingTracks[1];
const after = rootReducer(before, {
type: 'MOVE_TRACK',
@@ -65,15 +80,98 @@
// Ensure the order is swapped. This test would fail to detect side effects
// if the before state was modified, so other tests are needed as well.
- expect(after.displayedTrackIds[0]).toBe(secondTrackId);
- expect(after.displayedTrackIds[1]).toBe(firstTrackId);
+ expect(after.scrollingTracks[0]).toBe(secondTrackId);
+ expect(after.scrollingTracks[1]).toBe(firstTrackId);
// Ensure the track state contents have actually swapped places in the new
// state, but not in the old one.
- expect(before.tracks[before.displayedTrackIds[0]].engineId).toBe('1');
- expect(before.tracks[before.displayedTrackIds[1]].engineId).toBe('2');
- expect(after.tracks[after.displayedTrackIds[0]].engineId).toBe('2');
- expect(after.tracks[after.displayedTrackIds[1]].engineId).toBe('1');
+ expect(before.tracks[before.scrollingTracks[0]].engineId).toBe('1');
+ expect(before.tracks[before.scrollingTracks[1]].engineId).toBe('2');
+ expect(after.tracks[after.scrollingTracks[0]].engineId).toBe('2');
+ expect(after.tracks[after.scrollingTracks[1]].engineId).toBe('1');
+});
+
+test('reorder pinned to scrolling', () => {
+ const before = createEmptyState();
+
+ fakeTrack(before, 'a');
+ fakeTrack(before, 'b');
+ fakeTrack(before, 'c');
+
+ before.pinnedTracks = ['a', 'b'];
+ before.scrollingTracks = ['c'];
+
+ const after = rootReducer(before, moveTrack('b', 'down'));
+ expect(after.pinnedTracks).toEqual(['a']);
+ expect(after.scrollingTracks).toEqual(['b', 'c']);
+});
+
+test('reorder scrolling to pinned', () => {
+ const before = createEmptyState();
+ fakeTrack(before, 'a');
+ fakeTrack(before, 'b');
+ fakeTrack(before, 'c');
+
+ before.pinnedTracks = ['a'];
+ before.scrollingTracks = ['b', 'c'];
+
+ const after = rootReducer(before, moveTrack('b', 'up'));
+ expect(after.pinnedTracks).toEqual(['a', 'b']);
+ expect(after.scrollingTracks).toEqual(['c']);
+});
+
+test('reorder clamp bottom', () => {
+ const before = createEmptyState();
+ fakeTrack(before, 'a');
+ fakeTrack(before, 'b');
+ fakeTrack(before, 'c');
+
+ before.pinnedTracks = ['a', 'b'];
+ before.scrollingTracks = ['c'];
+
+ const after = rootReducer(before, moveTrack('a', 'up'));
+ expect(after).toEqual(before);
+});
+
+test('reorder clamp top', () => {
+ const before = createEmptyState();
+ fakeTrack(before, 'a');
+ fakeTrack(before, 'b');
+ fakeTrack(before, 'c');
+
+ before.pinnedTracks = ['a'];
+ before.scrollingTracks = ['b', 'c'];
+
+ const after = rootReducer(before, moveTrack('c', 'down'));
+ expect(after).toEqual(before);
+});
+
+test('pin', () => {
+ const before = createEmptyState();
+ fakeTrack(before, 'a');
+ fakeTrack(before, 'b');
+ fakeTrack(before, 'c');
+
+ before.pinnedTracks = ['a'];
+ before.scrollingTracks = ['b', 'c'];
+
+ const after = rootReducer(before, toggleTrackPinned('c'));
+ expect(after.pinnedTracks).toEqual(['a', 'c']);
+ expect(after.scrollingTracks).toEqual(['b']);
+});
+
+test('unpin', () => {
+ const before = createEmptyState();
+ fakeTrack(before, 'a');
+ fakeTrack(before, 'b');
+ fakeTrack(before, 'c');
+
+ before.pinnedTracks = ['a', 'b'];
+ before.scrollingTracks = ['c'];
+
+ const after = rootReducer(before, toggleTrackPinned('a'));
+ expect(after.pinnedTracks).toEqual(['b']);
+ expect(after.scrollingTracks).toEqual(['a', 'c']);
});
test('open trace', async () => {
diff --git a/ui/src/frontend/track_panel.ts b/ui/src/frontend/track_panel.ts
index d2e121d..ccf6041 100644
--- a/ui/src/frontend/track_panel.ts
+++ b/ui/src/frontend/track_panel.ts
@@ -14,7 +14,8 @@
import * as m from 'mithril';
-import {moveTrack} from '../common/actions';
+import {moveTrack, toggleTrackPinned} from '../common/actions';
+import {Action} from '../common/actions';
import {TrackState} from '../common/state';
import {globals} from './globals';
@@ -27,18 +28,26 @@
// If any uses can't be removed we should read this constant from CSS.
export const TRACK_SHELL_WIDTH = 300;
+function isPinned(id: string) {
+ return globals.state.pinnedTracks.indexOf(id) !== -1;
+}
+
const TrackShell = {
view({attrs}) {
return m(
'.track-shell',
m('h1', attrs.trackState.name),
- m(TrackMoveButton, {
- direction: 'up',
- trackId: attrs.trackState.id,
+ m(TrackButton, {
+ action: moveTrack(attrs.trackState.id, 'up'),
+ i: 'arrow_upward_alt',
}),
- m(TrackMoveButton, {
- direction: 'down',
- trackId: attrs.trackState.id,
+ m(TrackButton, {
+ action: moveTrack(attrs.trackState.id, 'down'),
+ i: 'arrow_downward_alt',
+ }),
+ m(TrackButton, {
+ action: toggleTrackPinned(attrs.trackState.id),
+ i: isPinned(attrs.trackState.id) ? 'star' : 'star_border',
}));
},
} as m.Component<{trackState: TrackState}>;
@@ -67,21 +76,20 @@
}
} as m.Component<{trackState: TrackState, track: Track}>;
-const TrackMoveButton = {
+const TrackButton = {
view({attrs}) {
return m(
- 'i.material-icons.track-move-icons',
+ 'i.material-icons.track-button',
{
- onclick: () =>
- globals.dispatch(moveTrack(attrs.trackId, attrs.direction)),
+ onclick: () => globals.dispatch(attrs.action),
},
- attrs.direction === 'up' ? 'arrow_upward_alt' : 'arrow_downward_alt');
+ attrs.i);
}
} as m.Component<{
- direction: 'up' | 'down',
- trackId: string,
+ action: Action,
+ i: string,
},
- {}>;
+ {}>;
interface TrackPanelAttrs {
id: string;
diff --git a/ui/src/frontend/viewer_page.ts b/ui/src/frontend/viewer_page.ts
index fa0ada0..94ee184 100644
--- a/ui/src/frontend/viewer_page.ts
+++ b/ui/src/frontend/viewer_page.ts
@@ -132,11 +132,12 @@
},
view() {
- const scrollingPanels = globals.state.displayedTrackIds.length > 0 ?
+ const scrollingPanels = globals.state.scrollingTracks.length > 0 ?
[
- m(HeaderPanel, {title: 'Tracks'}),
- ...globals.state.displayedTrackIds.map(id => m(TrackPanel, {id})),
- m(FlameGraphPanel),
+ m(HeaderPanel, {title: 'Tracks', key: 'tracksheader'}),
+ ...globals.state.scrollingTracks.map(
+ id => m(TrackPanel, {key: id, id})),
+ m(FlameGraphPanel, {key: 'flamegraph'}),
] :
[];
return m(
@@ -147,8 +148,10 @@
m('.pinned-panel-container', m(PanelContainer, {
doesScroll: false,
panels: [
- m(OverviewTimelinePanel),
- m(TimeAxisPanel),
+ m(OverviewTimelinePanel, {key: 'overview'}),
+ m(TimeAxisPanel, {key: 'timeaxis'}),
+ ...globals.state.pinnedTracks.map(
+ id => m(TrackPanel, {key: id, id})),
],
})),
m('.scrolling-panel-container', m(PanelContainer, {