UI: tracks drag & drop + minor fixes

Allows tracks to be reordered by drag&drop, supporting
also D&D between scrolling and pinned containers.
Furthermore bunch of small fixes:
 - reduce the track shell width to 250px, was taking
   too much real estate.
 - fix off-by-one pixel caused by the track bottom
   border. The border was being over-painted by the
   track shell having a bg:white. Now it's a border
   shifted back by 1px, so the track stays at full
   height but the border is always overlaid on top.
- Reduces the font size of the track shell.
- Fixes ui_unittests.


Demo: https://primiano-dot-perfetto-ui.appspot.com

Change-Id: I5e99fbc1d7f8118c166ff1e677a7acf805469599
diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss
index ebfadd2..ebb2eed 100644
--- a/ui/src/assets/common.scss
+++ b/ui/src/assets/common.scss
@@ -15,6 +15,9 @@
     --sidebar-width: 256px;
     --topbar-height: 48px;
     --monospace-font: 'Roboto Mono', monospace;
+
+    // Keep in sync with TRACK_SHELL_WIDTH in track_panel.ts .
+    --track-shell-width: 250px;
 }
 
 @mixin transition($time:0.1s) {
@@ -152,7 +155,7 @@
 }
 
 .home-page .logo {
-    width: 250px;
+    width: var(--track-shell-width);
 }
 
 .home-page-title {
@@ -229,30 +232,57 @@
 .track {
     display: grid;
     grid-template-columns: auto 1fr;
-    grid-template-rows: 1fr;
-    border-top: 1px solid #c7d0db;
+    grid-template-rows: 1fr 0;
+
+    &:after {
+      display: block;
+      content: '';
+      grid-column: 1 / span 2;
+      border-top: 1px solid #c7d0db;
+      margin-top: -1px;
+    }
+
     .track-shell {
-        padding: 0 20px;
+        @include transition();
+        padding: 0 10px;
         display: grid;
-        grid-template-areas: "title pin up down";
+        grid-template-areas: "title pin";
         grid-template-columns: 1fr auto auto;
         align-items: center;
-        width: 300px;
+        width: var(--track-shell-width);
         background: #fff;
         border-right: 1px solid #c7d0db;
+
+        &.drag {
+          background-color: #eee;
+          box-shadow: 0 4px 12px -4px #999 inset;
+        }
+        &.drop-before {
+          box-shadow: 0 4px 2px -1px hsl(213, 40%, 50%) inset;
+        }
+        &.drop-after {
+          box-shadow: 0 -4px 2px -1px hsl(213, 40%, 50%) inset;
+        }
+
         h1 {
             grid-area: title;
             margin: 0;
-            font-size: 1em;
+            font-size: 14px;
             text-overflow: ellipsis;
             font-family: 'Google Sans';
             color: hsl(213, 22%, 30%);
         }
         .track-button {
-            margin: 0 5px;
-            color: #495767;
-            cursor: pointer;
-            width: 24px;
+          @include transition();
+          margin: 0 5px;
+          color: #495767;
+          cursor: pointer;
+          width: 24px;
+          opacity: 0;
+        }
+
+        &:hover .track-button{
+          opacity: 1;
         }
     }
 }
@@ -270,7 +300,12 @@
   // Override top level overflow: hidden so height of this flex item can be
   // its content height.
   overflow: visible;
-  border-bottom: 1px solid #262f3c;
+
+  // If there are 3 or more panels pinned (there are pinned tracks), make the
+  // border thicker.
+  .panel:nth-child(3) ~ .panel:last-of-type {
+    border-bottom: 4px solid hsla(213, 55%, 60%, 1);
+  }
 }
 
 // In the scrolling case, since the canvas is overdrawn and continuously
@@ -304,6 +339,7 @@
 
 .time-axis-panel {
   height: 30px;
+  border-bottom: 1px solid #c7d0db;
 }
 
 .flame-graph-panel {
@@ -423,24 +459,19 @@
     background-color: var(--expanded-background);
     color: white;
     font-weight: bold;
-    .shell {
-      h1 {
-        font-size: 15px;
-      }
-    }
   }
   .shell {
-    padding: 0 20px;
+    padding: 0 10px;
     display: grid;
     grid-template-areas: "title fold-button";
     grid-template-columns: 1fr 24px;
     align-items: center;
     line-height: 1;
-    width: 300px;
+    width: 250px;
     transition: background-color .4s;
     h1 {
       grid-area: title;
-      font-size: 1em;
+      font-size: 14px;
       text-overflow: ellipsis;
       font-family: 'Google Sans';
     }
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index 06f6c90..540abf6 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -138,31 +138,31 @@
   },
 
   moveTrack(
-      state: StateDraft, args: {trackId: string; direction: 'up' | 'down';}):
-      void {
-        const id = args.trackId;
-        const isPinned = state.pinnedTracks.includes(id);
-        const isScrolling = state.scrollingTracks.includes(id);
-        if (!isScrolling && !isPinned) {
-          // TODO(dproy): Handle track moving within track groups.
-          return;
+      state: StateDraft,
+      args: {srcId: string; op: 'before' | 'after', dstId: string}): void {
+    const moveWithinTrackList = (trackList: string[]) => {
+      const newList: string[] = [];
+      for (let i = 0; i < trackList.length; i++) {
+        const curTrackId = trackList[i];
+        if (curTrackId === args.dstId && args.op === 'before') {
+          newList.push(args.srcId);
         }
-        const tracks = isPinned ? state.pinnedTracks : state.scrollingTracks;
+        if (curTrackId !== args.srcId) {
+          newList.push(curTrackId);
+        }
+        if (curTrackId === args.dstId && args.op === 'after') {
+          newList.push(args.srcId);
+        }
+      }
+      trackList.splice(0);
+      newList.forEach(x => {
+        trackList.push(x);
+      });
+    };
 
-        const oldIndex: number = tracks.indexOf(id);
-        const newIndex = args.direction === 'up' ? oldIndex - 1 : oldIndex + 1;
-        const swappedTrackId = tracks[newIndex];
-        if (isPinned && newIndex === state.pinnedTracks.length) {
-          // Move from last element of pinned to first element of scrolling.
-          state.scrollingTracks.unshift(state.pinnedTracks.pop()!);
-        } else if (isScrolling && newIndex === -1) {
-          // Move first element of scrolling to last element of pinned.
-          state.pinnedTracks.push(state.scrollingTracks.shift()!);
-        } else if (swappedTrackId) {
-          tracks[newIndex] = id;
-          tracks[oldIndex] = swappedTrackId;
-        }
-      },
+    moveWithinTrackList(state.pinnedTracks);
+    moveWithinTrackList(state.scrollingTracks);
+  },
 
   toggleTrackPinned(state: StateDraft, args: {trackId: string}): void {
     const id = args.trackId;
diff --git a/ui/src/common/actions_unittest.ts b/ui/src/common/actions_unittest.ts
index 31cdc77..4214c19 100644
--- a/ui/src/common/actions_unittest.ts
+++ b/ui/src/common/actions_unittest.ts
@@ -28,6 +28,7 @@
     engineId: '1',
     kind: 'SOME_TRACK_KIND',
     name: 'A track',
+    trackGroup: SCROLLING_TRACK_GROUP,
     config: {},
   };
   state.tracks[id] = track;
@@ -114,8 +115,9 @@
 
   const twice = produce(once, draft => {
     StateActions.moveTrack(draft, {
-      trackId: `${firstTrackId}`,
-      direction: 'down',
+      srcId: `${firstTrackId}`,
+      op: 'after',
+      dstId: `${secondTrackId}`,
     });
   });
 
@@ -133,8 +135,9 @@
 
   const after = produce(state, draft => {
     StateActions.moveTrack(draft, {
-      trackId: 'b',
-      direction: 'down',
+      srcId: 'b',
+      op: 'before',
+      dstId: 'c',
     });
   });
 
@@ -152,8 +155,9 @@
 
   const after = produce(state, draft => {
     StateActions.moveTrack(draft, {
-      trackId: 'b',
-      direction: 'up',
+      srcId: 'b',
+      op: 'after',
+      dstId: 'a',
     });
   });
 
@@ -171,8 +175,9 @@
 
   const after = produce(state, draft => {
     StateActions.moveTrack(draft, {
-      trackId: 'a',
-      direction: 'up',
+      srcId: 'a',
+      op: 'before',
+      dstId: 'a',
     });
   });
   expect(after).toEqual(state);
@@ -188,8 +193,9 @@
 
   const after = produce(state, draft => {
     StateActions.moveTrack(draft, {
-      trackId: 'c',
-      direction: 'down',
+      srcId: 'c',
+      op: 'after',
+      dstId: 'c',
     });
   });
   expect(after).toEqual(state);
diff --git a/ui/src/frontend/time_axis_panel.ts b/ui/src/frontend/time_axis_panel.ts
index 4810faa..9f01bec 100644
--- a/ui/src/frontend/time_axis_panel.ts
+++ b/ui/src/frontend/time_axis_panel.ts
@@ -40,7 +40,7 @@
     for (let s = start; s < range.end; s += step) {
       let xPos = TRACK_SHELL_WIDTH;
       xPos += Math.floor(timeScale.timeToPx(s));
-      if (xPos < 0) continue;
+      if (xPos < TRACK_SHELL_WIDTH) continue;
       if (xPos > size.width) break;
       ctx.fillRect(xPos, 0, 1, size.height);
       ctx.fillText(timeToString(s - range.start), xPos + 5, 10);
diff --git a/ui/src/frontend/track_panel.ts b/ui/src/frontend/track_panel.ts
index 9386985..01ee096 100644
--- a/ui/src/frontend/track_panel.ts
+++ b/ui/src/frontend/track_panel.ts
@@ -25,7 +25,7 @@
 
 // TODO(hjd): We should remove the constant where possible.
 // If any uses can't be removed we should read this constant from CSS.
-export const TRACK_SHELL_WIDTH = 300;
+export const TRACK_SHELL_WIDTH = 250;
 
 function isPinned(id: string) {
   return globals.state.pinnedTracks.indexOf(id) !== -1;
@@ -34,26 +34,86 @@
 interface TrackShellAttrs {
   trackState: TrackState;
 }
+
 class TrackShell implements m.ClassComponent<TrackShellAttrs> {
+  // Set to true when we click down and drag the
+  private dragging = false;
+  private dropping: 'before'|'after'|undefined = undefined;
+  private attrs?: TrackShellAttrs;
+
+  oninit(vnode: m.Vnode<TrackShellAttrs>) {
+    this.attrs = vnode.attrs;
+  }
+
   view({attrs}: m.CVnode<TrackShellAttrs>) {
+    const dragClass = this.dragging ? `.drag` : '';
+    const dropClass = this.dropping ? `.drop-${this.dropping}` : '';
     return m(
-        '.track-shell',
+        `.track-shell${dragClass}${dropClass}[draggable=true]`,
+        {
+          onmousedown: this.onmousedown.bind(this),
+          ondragstart: this.ondragstart.bind(this),
+          ondragend: this.ondragend.bind(this),
+          ondragover: this.ondragover.bind(this),
+          ondragleave: this.ondragleave.bind(this),
+          ondrop: this.ondrop.bind(this),
+        },
         m('h1', attrs.trackState.name),
         m(TrackButton, {
-          action: Actions.moveTrack(
-              {trackId: attrs.trackState.id, direction: 'up'}),
-          i: 'arrow_upward_alt',
-        }),
-        m(TrackButton, {
-          action: Actions.moveTrack(
-              {trackId: attrs.trackState.id, direction: 'down'}),
-          i: 'arrow_downward_alt',
-        }),
-        m(TrackButton, {
           action: Actions.toggleTrackPinned({trackId: attrs.trackState.id}),
           i: isPinned(attrs.trackState.id) ? 'star' : 'star_border',
         }));
   }
+
+  onmousedown(e: MouseEvent) {
+    // Prevent that the click is intercepted by the PanAndZoomHandler and that
+    // we start panning while dragging.
+    e.stopPropagation();
+  }
+
+  ondragstart(e: DragEvent) {
+    this.dragging = true;
+    globals.rafScheduler.scheduleFullRedraw();
+    e.dataTransfer.setData('perfetto/track', `${this.attrs!.trackState.id}`);
+    e.dataTransfer.setDragImage(new Image(), 0, 0);
+    e.stopImmediatePropagation();
+  }
+
+  ondragend() {
+    this.dragging = false;
+    globals.rafScheduler.scheduleFullRedraw();
+  }
+
+  ondragover(e: DragEvent) {
+    if (this.dragging) return;
+    if (!(e.target instanceof HTMLElement)) return;
+    if (!e.dataTransfer.types.includes('perfetto/track')) return;
+    e.dataTransfer.dropEffect = 'move';
+    e.preventDefault();
+
+    // Apply some hysteresis to the drop logic so that the lightened border
+    // changes only when we get close enough to the border.
+    if (e.offsetY < e.target.scrollHeight / 3) {
+      this.dropping = 'before';
+    } else if (e.offsetY > e.target.scrollHeight / 3 * 2) {
+      this.dropping = 'after';
+    }
+    globals.rafScheduler.scheduleFullRedraw();
+  }
+
+  ondragleave() {
+    this.dropping = undefined;
+    globals.rafScheduler.scheduleFullRedraw();
+  }
+
+  ondrop(e: DragEvent) {
+    if (this.dropping === undefined) return;
+    globals.rafScheduler.scheduleFullRedraw();
+    const srcId = e.dataTransfer.getData('perfetto/track');
+    const dstId = this.attrs!.trackState.id;
+    globals.dispatch(Actions.moveTrack({srcId, op: this.dropping, dstId}));
+    this.dropping = undefined;
+  }
 }
 
 interface TrackContentAttrs {