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 {