From 04cf5c5dae802e8d13f53f344ad73927cbbd3395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mirek=20Wr=C3=B3bel?= Date: Wed, 14 Oct 2026 22:01:35 +0200 Subject: [PATCH] Stack branch labels when a row is tight; fan them out on hover or selection --- src/App.vue | 61 ++++++++----------- src/components/LabelStack.vue | 109 ++++++++++++++++++++++++++++++++++ src/lib/labels.test.ts | 24 ++++++++ src/lib/labels.ts | 17 ++++++ src/lib/measure.ts | 15 +++++ 5 files changed, 190 insertions(+), 36 deletions(-) create mode 100644 src/components/LabelStack.vue create mode 100644 src/lib/labels.test.ts create mode 100644 src/lib/labels.ts create mode 100644 src/lib/measure.ts diff --git a/src/App.vue b/src/App.vue index 09cdd6f..162bddf 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,13 +6,15 @@ import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { buildRows } from "./lib/collapse"; import { isRefVisible, lineOwners, reachable, refKind, shortName } from "./lib/filter"; import { layout } from "./lib/layout"; +import { textWidth } from "./lib/measure"; import { colorOf } from "./lib/palette"; import type { FilterState, Ref, RefKind, RepoData } from "./lib/types"; import GraphCell from "./components/GraphCell.vue"; -import RefIcon from "./components/RefIcon.vue"; +import LabelStack from "./components/LabelStack.vue"; import Sidebar from "./components/Sidebar.vue"; const ROW_H = 28; +const MESSAGE_FONT = '13px "Inter", "Noto Sans", system-ui, sans-serif'; const repo = shallowRef(null); const error = ref(null); @@ -115,6 +117,10 @@ watch(rows, (now, before) => { const scroller = ref(); const scrollTop = ref(0); 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 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)); @@ -216,7 +222,10 @@ onMounted(() => { window.addEventListener("keydown", onKey); // Pick up new commits when coming back to the window. 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); load(); }); @@ -295,18 +304,14 @@ onUnmounted(() => { > ยทยทยท {{ row.hiddenCount }} commits - - - {{ l.short }} - - +
{{ row.commit.author }}
{{ formatTime(row.commit.time) }}
@@ -371,12 +376,13 @@ onUnmounted(() => { padding-right: 12px; cursor: default; } -.row.alt { background: var(--bg-row-alt); } -.row:hover { background: var(--bg-hover); } -.row.sel { background: var(--bg-select); } +.row { --row-bg: var(--bg); background: var(--row-bg); } +.row.alt { --row-bg: var(--bg-row-alt); } +.row:hover { --row-bg: var(--bg-hover); } +.row.sel { --row-bg: var(--bg-select); } .graph-col { flex: none; height: 100%; overflow: hidden; } -.subject { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; } -.text { flex: 1 1 auto; min-width: 8em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.subject { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; overflow: hidden; } +.text { flex: 1 4 auto; min-width: 4em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .more { flex: none; border: 1px dashed currentColor; @@ -385,23 +391,6 @@ onUnmounted(() => { font-size: 11px; 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; } .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; } diff --git a/src/components/LabelStack.vue b/src/components/LabelStack.vue new file mode 100644 index 0000000..7b01007 --- /dev/null +++ b/src/components/LabelStack.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/src/lib/labels.test.ts b/src/lib/labels.test.ts new file mode 100644 index 0000000..068a043 --- /dev/null +++ b/src/lib/labels.test.ts @@ -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); + }); +}); diff --git a/src/lib/labels.ts b/src/lib/labels.ts new file mode 100644 index 0000000..8b55596 --- /dev/null +++ b/src/lib/labels.ts @@ -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); +} diff --git a/src/lib/measure.ts b/src/lib/measure.ts new file mode 100644 index 0000000..4d2d0e8 --- /dev/null +++ b/src/lib/measure.ts @@ -0,0 +1,15 @@ +const canvas = document.createElement("canvas"); +const ctx = canvas.getContext("2d")!; +const cache = new Map(); + +/** 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; +}