Stack branch labels when a row is tight; fan them out on hover or selection
This commit is contained in:
+25
-36
@@ -6,13 +6,15 @@ import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
|||||||
import { buildRows } from "./lib/collapse";
|
import { buildRows } from "./lib/collapse";
|
||||||
import { isRefVisible, lineOwners, reachable, refKind, shortName } from "./lib/filter";
|
import { isRefVisible, lineOwners, reachable, refKind, shortName } from "./lib/filter";
|
||||||
import { layout } from "./lib/layout";
|
import { layout } from "./lib/layout";
|
||||||
|
import { textWidth } from "./lib/measure";
|
||||||
import { colorOf } from "./lib/palette";
|
import { colorOf } from "./lib/palette";
|
||||||
import type { FilterState, Ref, RefKind, RepoData } from "./lib/types";
|
import type { FilterState, Ref, RefKind, RepoData } from "./lib/types";
|
||||||
import GraphCell from "./components/GraphCell.vue";
|
import GraphCell from "./components/GraphCell.vue";
|
||||||
import RefIcon from "./components/RefIcon.vue";
|
import LabelStack from "./components/LabelStack.vue";
|
||||||
import Sidebar from "./components/Sidebar.vue";
|
import Sidebar from "./components/Sidebar.vue";
|
||||||
|
|
||||||
const ROW_H = 28;
|
const ROW_H = 28;
|
||||||
|
const MESSAGE_FONT = '13px "Inter", "Noto Sans", system-ui, sans-serif';
|
||||||
|
|
||||||
const repo = shallowRef<RepoData | null>(null);
|
const repo = shallowRef<RepoData | null>(null);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
@@ -115,6 +117,10 @@ watch(rows, (now, before) => {
|
|||||||
const scroller = ref<HTMLElement>();
|
const scroller = ref<HTMLElement>();
|
||||||
const scrollTop = ref(0);
|
const scrollTop = ref(0);
|
||||||
const viewHeight = ref(600);
|
const viewHeight = ref(600);
|
||||||
|
const viewWidth = ref(1000);
|
||||||
|
// The message column is what is left after the graph and the fixed columns:
|
||||||
|
// author 140, date 118, hash 80, four 14px gaps and 12px right padding.
|
||||||
|
const subjectSpace = computed(() => Math.max(700, viewWidth.value) - graphWidth.value - 338 - 56 - 12);
|
||||||
const firstRow = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_H) - 10));
|
const firstRow = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_H) - 10));
|
||||||
const lastRow = computed(() => Math.min(rows.value.length, Math.ceil((scrollTop.value + viewHeight.value) / ROW_H) + 10));
|
const lastRow = computed(() => Math.min(rows.value.length, Math.ceil((scrollTop.value + viewHeight.value) / ROW_H) + 10));
|
||||||
const pageRows = computed(() => Math.max(1, Math.floor(viewHeight.value / ROW_H) - 1));
|
const pageRows = computed(() => Math.max(1, Math.floor(viewHeight.value / ROW_H) - 1));
|
||||||
@@ -216,7 +222,10 @@ onMounted(() => {
|
|||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
// Pick up new commits when coming back to the window.
|
// Pick up new commits when coming back to the window.
|
||||||
window.addEventListener("focus", load);
|
window.addEventListener("focus", load);
|
||||||
resizeObserver = new ResizeObserver(() => (viewHeight.value = scroller.value?.clientHeight ?? 600));
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
viewHeight.value = scroller.value?.clientHeight ?? 600;
|
||||||
|
viewWidth.value = scroller.value?.clientWidth ?? 1000;
|
||||||
|
});
|
||||||
if (scroller.value) resizeObserver.observe(scroller.value);
|
if (scroller.value) resizeObserver.observe(scroller.value);
|
||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
@@ -295,18 +304,14 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
··· {{ row.hiddenCount }} commits
|
··· {{ row.hiddenCount }} commits
|
||||||
</button>
|
</button>
|
||||||
<span class="labels">
|
<LabelStack
|
||||||
<span
|
v-if="labels.has(row.commit.hash)"
|
||||||
v-for="l in labels.get(row.commit.hash) ?? []"
|
:labels="labels.get(row.commit.hash)!"
|
||||||
:key="l.name"
|
:color="colorOf(graph.rows[firstRow + i - 1].color)"
|
||||||
class="label"
|
:message-width="textWidth(row.commit.subject, MESSAGE_FONT)"
|
||||||
:class="{ head: l.isHead }"
|
:space="subjectSpace - (row.hiddenCount ? 100 : 0)"
|
||||||
:title="l.name"
|
:selected="firstRow + i - 1 === selected"
|
||||||
:style="{ '--c': colorOf(graph.rows[firstRow + i - 1].color) }"
|
/>
|
||||||
>
|
|
||||||
<RefIcon :kind="l.kind" /><span class="label-text">{{ l.short }}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="author">{{ row.commit.author }}</div>
|
<div class="author">{{ row.commit.author }}</div>
|
||||||
<div class="date">{{ formatTime(row.commit.time) }}</div>
|
<div class="date">{{ formatTime(row.commit.time) }}</div>
|
||||||
@@ -371,12 +376,13 @@ onUnmounted(() => {
|
|||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.row.alt { background: var(--bg-row-alt); }
|
.row { --row-bg: var(--bg); background: var(--row-bg); }
|
||||||
.row:hover { background: var(--bg-hover); }
|
.row.alt { --row-bg: var(--bg-row-alt); }
|
||||||
.row.sel { background: var(--bg-select); }
|
.row:hover { --row-bg: var(--bg-hover); }
|
||||||
|
.row.sel { --row-bg: var(--bg-select); }
|
||||||
.graph-col { flex: none; height: 100%; overflow: hidden; }
|
.graph-col { flex: none; height: 100%; overflow: hidden; }
|
||||||
.subject { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
|
.subject { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; overflow: hidden; }
|
||||||
.text { flex: 1 1 auto; min-width: 8em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.text { flex: 1 4 auto; min-width: 4em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.more {
|
.more {
|
||||||
flex: none;
|
flex: none;
|
||||||
border: 1px dashed currentColor;
|
border: 1px dashed currentColor;
|
||||||
@@ -385,23 +391,6 @@ onUnmounted(() => {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
.labels { margin-left: auto; display: flex; gap: 5px; flex: 0 1 auto; min-width: 0; justify-content: flex-end; }
|
|
||||||
.label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 0 7px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 11.5px;
|
|
||||||
color: var(--c);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--c) 55%, transparent);
|
|
||||||
background: color-mix(in srgb, var(--c) 14%, transparent);
|
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 230px;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
.label-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.label.head { color: #10121c; background: var(--c); font-weight: 600; }
|
|
||||||
.author { flex: none; width: 140px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.author { flex: none; width: 140px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.date { flex: none; width: 118px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
.date { flex: none; width: 118px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||||
.hash { flex: none; width: 80px; font-family: ui-monospace, monospace; color: #9aa3c7; }
|
.hash { flex: none; width: 80px; font-family: ui-monospace, monospace; color: #9aa3c7; }
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { shouldStack, stackWidth, PEEK } from "../lib/labels";
|
||||||
|
import { textWidth } from "../lib/measure";
|
||||||
|
import type { RefKind } from "../lib/types";
|
||||||
|
import RefIcon from "./RefIcon.vue";
|
||||||
|
|
||||||
|
export interface Label { name: string; short: string; kind: RefKind; isHead: boolean }
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
labels: Label[];
|
||||||
|
color: string;
|
||||||
|
/** Width of the message text in this row, in pixels. */
|
||||||
|
messageWidth: number;
|
||||||
|
/** Width the message and the labels share, in pixels. */
|
||||||
|
space: number;
|
||||||
|
/** A selected row shows its stack fanned out, as if hovered. */
|
||||||
|
selected: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const FONT = '11.5px "Inter", "Noto Sans", system-ui, sans-serif';
|
||||||
|
const MAX = 230;
|
||||||
|
// Padding, border, icon and the gap after the icon.
|
||||||
|
const CHROME = 14 + 2 + 12 + 4;
|
||||||
|
|
||||||
|
const widths = computed(() =>
|
||||||
|
props.labels.map((l) =>
|
||||||
|
Math.min(MAX, Math.ceil(textWidth(l.short, (l.isHead ? "600 " : "") + FONT) + CHROME)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const stacked = computed(() => shouldStack(widths.value, props.messageWidth, props.space));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="labels"
|
||||||
|
:class="{ stacked, open: selected }"
|
||||||
|
:style="
|
||||||
|
stacked
|
||||||
|
? { width: stackWidth(widths) + 'px', '--w': widths[0] + 'px', '--peek': PEEK + 'px', '--space': space + 'px' }
|
||||||
|
: {}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="stack" :style="{ '--c': color }">
|
||||||
|
<span
|
||||||
|
v-for="(l, i) in labels"
|
||||||
|
:key="l.name"
|
||||||
|
class="label"
|
||||||
|
:class="{ head: l.isHead }"
|
||||||
|
:style="{ zIndex: labels.length - i }"
|
||||||
|
:title="l.name"
|
||||||
|
>
|
||||||
|
<RefIcon :kind="l.kind" /><span class="label-text">{{ l.short }}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.labels { margin-left: auto; flex: 0 1 auto; min-width: 0; position: relative; height: 20px; display: flex; }
|
||||||
|
.stack { display: flex; gap: 5px; align-items: center; min-width: 0; }
|
||||||
|
.label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--c);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--c) 55%, var(--bg));
|
||||||
|
/* Solid, so stacked labels do not show through each other. */
|
||||||
|
background: color-mix(in srgb, var(--c) 16%, var(--bg));
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 230px;
|
||||||
|
min-width: 4.5em;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.label-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.label.head { flex: none; color: #10121c; background: var(--c); font-weight: 600; }
|
||||||
|
|
||||||
|
/* Stacked: every label has the first one's width and hides behind it, all but a sliver. */
|
||||||
|
.labels.stacked { flex: none; }
|
||||||
|
.labels.stacked .stack { gap: 0; }
|
||||||
|
.labels.stacked .label { flex: none; width: var(--w); min-width: 0; transition: margin-left 0.15s; }
|
||||||
|
.labels.stacked .label + .label { margin-left: calc(var(--peek) - var(--w)); }
|
||||||
|
|
||||||
|
/* Hovered or selected: fan the stack out over the message. */
|
||||||
|
.labels.stacked:hover .stack,
|
||||||
|
.labels.stacked.open .stack {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
gap: 5px;
|
||||||
|
padding-left: 10px;
|
||||||
|
z-index: 50;
|
||||||
|
max-width: var(--space);
|
||||||
|
background: linear-gradient(90deg, transparent, var(--row-bg, var(--bg)) 10px);
|
||||||
|
}
|
||||||
|
.labels.stacked:hover .label,
|
||||||
|
.labels.stacked.open .label {
|
||||||
|
width: auto;
|
||||||
|
max-width: 230px;
|
||||||
|
min-width: 4.5em;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { MESSAGE_KEEP, PEEK, shouldStack, stackWidth } from "./labels";
|
||||||
|
|
||||||
|
describe("shouldStack", () => {
|
||||||
|
it("never stacks a single label", () => {
|
||||||
|
expect(shouldStack([500], 900, 100)).toBe(false);
|
||||||
|
});
|
||||||
|
it("does not stack when labels and message fit", () => {
|
||||||
|
expect(shouldStack([100, 100], 200, 600)).toBe(false);
|
||||||
|
});
|
||||||
|
it("stacks when the labels would leave the message too little room", () => {
|
||||||
|
expect(shouldStack([200, 200], 900, 300 + MESSAGE_KEEP)).toBe(true);
|
||||||
|
});
|
||||||
|
it("lets a short message leave its room to the labels", () => {
|
||||||
|
expect(shouldStack([200, 200], 50, 470)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stackWidth", () => {
|
||||||
|
it("is the first label plus a peek for each other label", () => {
|
||||||
|
expect(stackWidth([80, 120, 60])).toBe(80 + 2 * PEEK);
|
||||||
|
expect(stackWidth([])).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/** How far each label behind the first one sticks out of a stack, in pixels. */
|
||||||
|
export const PEEK = 6;
|
||||||
|
export const GAP = 5;
|
||||||
|
/** The message keeps at least this much width before labels start stacking. */
|
||||||
|
export const MESSAGE_KEEP = 240;
|
||||||
|
|
||||||
|
/** Whether a row's labels must stack because they do not fit next to the message. */
|
||||||
|
export function shouldStack(labelWidths: number[], messageWidth: number, space: number): boolean {
|
||||||
|
if (labelWidths.length < 2) return false;
|
||||||
|
const full = labelWidths.reduce((a, b) => a + b, 0) + GAP * (labelWidths.length - 1);
|
||||||
|
// 8px is the gap between the message and the labels.
|
||||||
|
return full + Math.min(messageWidth, MESSAGE_KEEP) + 8 > space;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stackWidth(labelWidths: number[]): number {
|
||||||
|
return labelWidths.length === 0 ? 0 : labelWidths[0] + PEEK * (labelWidths.length - 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
const cache = new Map<string, number>();
|
||||||
|
|
||||||
|
/** Width in pixels of `text` drawn in the CSS `font`. */
|
||||||
|
export function textWidth(text: string, font: string): number {
|
||||||
|
const key = font + "\u0000" + text;
|
||||||
|
let w = cache.get(key);
|
||||||
|
if (w === undefined) {
|
||||||
|
ctx.font = font;
|
||||||
|
w = ctx.measureText(text).width;
|
||||||
|
cache.set(key, w);
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user