diff --git a/app/app.vue b/app/app.vue index 2fd644c..8fa6921 100644 --- a/app/app.vue +++ b/app/app.vue @@ -7,7 +7,7 @@ import { listDumps, dropDump, type CachedDump } from './lib/persist' import { listHosted, download, fileNameOf, type HostedDump } from './lib/hosted' import { encodeView, decodeView, emptyView, type ViewState } from './lib/view-state' import type { SortKey } from './lib/dump-store' -import HeaderBar from '@/components/HeaderBar.vue' +import HeaderBar from './components/HeaderBar.vue' const d = useDump() const frames = useFrames() diff --git a/app/components/ObjectTable.vue b/app/components/ObjectTable.vue index 69d71fc..a537191 100644 --- a/app/components/ObjectTable.vue +++ b/app/components/ObjectTable.vue @@ -29,12 +29,14 @@ const emit = defineEmits<{ * existing path sort already pays) followed by the scroll reset that a new * query does anyway. Nothing has to be re-fetched or merged. */ -const COLUMNS: { key: SortKey; label: string; cls: string }[] = [ - { key: 'address', label: 'ADDRESS', cls: 'col-addr' }, - { key: 'type', label: 'TYPE', cls: 'col-type' }, - { key: 'name', label: 'NAME', cls: 'col-name' }, - { key: 'path', label: 'CONTAINER', cls: 'col-path' }, - { key: 'offset', label: 'OFF', cls: 'col-off' }, +type ColId = 'addr' | 'type' | 'name' | 'path' | 'off' + +const COLUMNS: { id: ColId; key: SortKey; label: string; cls: string }[] = [ + { id: 'addr', key: 'address', label: 'ADDRESS', cls: 'col-addr' }, + { id: 'type', key: 'type', label: 'TYPE', cls: 'col-type' }, + { id: 'name', key: 'name', label: 'NAME', cls: 'col-name' }, + { id: 'path', key: 'path', label: 'CONTAINER', cls: 'col-path' }, + { id: 'off', key: 'offset', label: 'OFF', cls: 'col-off' }, ] /** asc → desc → off. The third click restores dump order, which is a view @@ -48,6 +50,85 @@ function cycle(key: SortKey) { const arrow = (key: SortKey) => props.sort !== key ? '' : props.descending ? '↓' : '↑' +// --------------------------------------------------------------- column size + +const DEFAULT_W: Record = { addr: 128, type: 138, name: 300, path: 420, off: 56 } +const MIN_W = 44 +const GAP = 14 +const PAD = 12 +const WIDTH_KEY = 'pdx:column-widths' + +const widths = ref>({ ...DEFAULT_W }) + +const vars = computed(() => { + const w = widths.value + const sum = COLUMNS.reduce((n, c) => n + w[c.id], 0) + return { + '--w-addr': w.addr + 'px', + '--w-type': w.type + 'px', + '--w-name': w.name + 'px', + '--w-path': w.path + 'px', + '--w-off': w.off + 'px', + // Header and rows both take this as a *minimum*: narrower than the + // viewport and CONTAINER absorbs the slack, wider and the table scrolls. + '--row-w': sum + GAP * (COLUMNS.length - 1) + PAD * 2 + 'px', + } +}) + +let drag: { id: ColId; x: number; from: number } | null = null + +function gripDown(e: PointerEvent, id: ColId) { + e.preventDefault() + const grip = e.currentTarget as HTMLElement + const rendered = grip.parentElement?.getBoundingClientRect().width + drag = { id, x: e.clientX, from: Math.round(rendered || widths.value[id]) } + grip.setPointerCapture(e.pointerId) +} + +function gripMove(e: PointerEvent) { + if (!drag) return + widths.value = { + ...widths.value, + [drag.id]: Math.max(MIN_W, Math.round(drag.from + e.clientX - drag.x)), + } +} + +function gripUp() { + if (!drag) return + drag = null + saveWidths() +} + +/** Double-click a grip to put that one column back where it started. */ +function resetWidth(id: ColId) { + widths.value = { ...widths.value, [id]: DEFAULT_W[id] } + saveWidths() +} + +function saveWidths() { + try { + localStorage.setItem(WIDTH_KEY, JSON.stringify(widths.value)) + } catch { + /* private mode, or the quota is full; widths are not worth failing over */ + } +} + +function loadWidths() { + try { + const raw = JSON.parse(localStorage.getItem(WIDTH_KEY) || 'null') + if (!raw) return + const next = { ...DEFAULT_W } + // Read key by key rather than trusting the blob: a stored layout from a + // build with different columns must not resurrect them. + for (const c of COLUMNS) { + if (Number.isFinite(raw[c.id])) next[c.id] = Math.max(MIN_W, raw[c.id]) + } + widths.value = next + } catch { + /* unparseable; the defaults are already in place */ + } +} + const ROW_H = 26 const OVERSCAN = 24 @@ -85,8 +166,19 @@ function measure() { } onMounted(() => { + loadWidths() measure() + measureChar() + // Web fonts land after mount and change the glyph width under us. + document.fonts?.ready.then(measureChar).catch(() => {}) + new ResizeObserver(measure).observe(viewport.value!) + + // Measuring the rendered header cell rather than reading `widths` picks up + // the flex growth too, which on a wide window is most of CONTAINER's width. + const head = viewport.value?.querySelector('.head .col-path') + if (head) new ResizeObserver(([e]) => (pathPx.value = e!.contentRect.width)).observe(head) + refill() }) @@ -112,40 +204,84 @@ const visible = computed(() => { const hex = (n: number) => n.toString(16).toUpperCase().padStart(12, '0') /** - * Show the tail of a long path, which is the part that identifies it. - * Done in JS rather than with `direction: rtl`, because these strings are - * full of slashes and colons — neutral characters that bidi reordering - * happily moves to the wrong end. + * CONTAINER is the one column that cannot use CSS ellipsis. + * + * Ellipsis keeps the head of the string and drops the tail, and for a package + * path the tail is the part that identifies it — `…Engine.Actor:Tick` says + * something, `/Script/Game/Content/Bluepr…` says nothing. So it is trimmed in + * JS from the left. (Not with `direction: rtl`, which reorders the slashes + * and colons — they are bidi-neutral and end up in the wrong place.) + * + * The cost of doing it in JS is that the cut has to know the column's width + * in characters, which is measured rather than assumed: the column is now + * resizable, and it also flexes to absorb slack on a wide window. */ -function tail(s: string, max = 64) { +const pathPx = ref(420) +const charPx = ref(7.2) +const containerMax = computed(() => Math.max(8, Math.floor((pathPx.value - 2) / charPx.value))) + +function tail(s: string, max: number) { return s.length <= max ? s : '…' + s.slice(s.length - max + 1) } + +/** Rows are monospaced, so one glyph measurement describes every string. */ +function measureChar() { + const ctx = document.createElement('canvas').getContext('2d') + if (!ctx) return + const mono = getComputedStyle(document.documentElement).getPropertyValue('--mono') + ctx.font = `12px ${mono || 'monospace'}` + const w = ctx.measureText('0'.repeat(64)).width / 64 + if (w > 0) charPx.value = w +}