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, {