Skip to content

Commit b84d687

Browse files
committed
File list: align the Modified and Size columns with tabular figures
Dates and sizes in the file list now line up into clean vertical columns without switching to a monospace font, matching the organized look of Total Commander while keeping the macOS system font. - Render the Modified and Size columns with `font-variant-numeric: tabular-nums`, so every digit takes the same advance and the slashes/colons/right-aligned sizes stack across rows. - Remove the old `|` split-date machinery (`.date-left`/`.date-right`, `dateLeft`, `DATE_PARTS_GAP`, the per-half measurement). Every token format emits a fixed character count, so with tabular figures the time halves align on their own; dates now render as one segment list. - Teach the column-width measurer about tabular figures: canvas/pretext can't measure the `tnum` feature, so it sizes numeric columns to the font's widest digit (`tabularize`), which prevents digit-heavy rows (`11/11/1111`) from clipping. - Pad the `system` format to fixed-width locale components (2-digit month/day/hour/minute), so locale formats align too: en-US becomes `02/03/2025, 08:32 PM` instead of `2/3/25, 8:32:26 PM`. The locale still owns field order, separators, and the 12-/24-hour choice. - Make ISO 8601 the default date format (was system default). - Drop the leftover `|` from the Custom format default (now `YYYY-MM-DD HH:mm`) and its description, and fix the stale `Short` option example. - Update colocated docs and tests.
1 parent e449b00 commit b84d687

11 files changed

Lines changed: 154 additions & 228 deletions

File tree

apps/desktop/src/lib/file-explorer/views/CLAUDE.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,13 @@ Virtual-scrolling file list components for rendering 100k+ file directories with
2828
- **`getDirSizeDisplayState()` (in `full-list-utils.ts`) is the single source of truth for a directory's size-column
2929
state.** Both `FullList.svelte`'s size cell and `measure-column-widths.ts` consume it; don't re-inline the
3030
dir/scanning/stale decision in either, or the rendered text and pre-measured width drift.
31-
- **Two paired-constant gotchas in `measure-column-widths.ts`**: `DATE_PARTS_GAP` (4px) mirrors `.date-right`'s
32-
`margin-left: var(--spacing-xs)`, and `HEADER_CHROME_ACTIVE/INACTIVE` mirror `SortableHeader`'s gap + caret (12px
33-
active / 0 inactive). Change the CSS and you must change the constant, or split-date / header column widths drift
31+
- **The Size and Modified columns render with `font-variant-numeric: tabular-nums`** (equal-width digits, so dates and
32+
right-aligned sizes line up into columns without a monospace font). Canvas/pretext can't measure that feature, so
33+
`measure-column-widths.ts` models it by substituting every digit with the widest one (`tabularize`) before measuring.
34+
Keep the CSS and the measurer in sync: if you drop tabular figures from a numeric column, drop the `tabularize` call
35+
for it too, or the column over-reserves width.
36+
- **Paired-constant gotcha in `measure-column-widths.ts`**: `HEADER_CHROME_ACTIVE/INACTIVE` mirror `SortableHeader`'s
37+
gap + caret (12px active / 0 inactive). Change the CSS and you must change the constant, or header column widths drift
3438
(pretext measures without a reference element, so nothing is derived from the live DOM).
3539
- **Index-size refresh (`refresh_listing_index_sizes`) refetches column widths through the existing `cacheGeneration`
3640
reset path, not a separate trigger.** Adding one double-fetches.

apps/desktop/src/lib/file-explorer/views/DETAILS.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -165,15 +165,14 @@ when that row is actually on screen. Otherwise the size column would stay oversi
165165
`SelectionInfo` keeps using `measureDateColumnWidth` (worst-case sampling) because it renders a single-entry snapshot
166166
with no "visible set" to measure from.
167167

168-
**Decision**: Date column may split into two aligned sub-columns via a `|` in the format string **Why**: Time digits
169-
across rows zigzag horizontally when date widths vary (e.g., locale formats, custom strings). The split makes the right
170-
halves line up. The contract: `formatDateForDisplay` (in `lib/settings/format-utils.ts`) returns a `FormattedDate` whose
171-
`parts: { left: DateSegment[], right: DateSegment[] | null }` carries both halves as ordered segment lists;
172-
`computeFullListColumnWidths` measures each half separately (via `joinSegments`) and exposes a `dateLeft` width;
173-
`FullList` walks each half's segments (wrapping any with a non-null `ageClass` in an age-tier span and emitting the rest
174-
as plain text) into `.date-left` (inline-block, fixed width, right-aligned) followed by `.date-right`
175-
(`margin-left: var(--spacing-xs)`). Tooltips/MCP/status bar still see joined strings via `FormattedDate.text` (exposed
176-
as the `formatDateTime` shortcut).
168+
**Decision**: The date column renders as one segment list with tabular figures, no split. **Why**: Earlier the column
169+
split into a fixed-width date half plus a time half so the times lined up across rows despite proportional digits. With
170+
`font-variant-numeric: tabular-nums` on `.col-date` every digit takes the same advance, and every token format (`YYYY`=4
171+
digits, the rest zero-padded to 2) emits a fixed character count, so all dates are the same width and align on their
172+
own. The contract: `formatDateForDisplay` (in `lib/settings/format-utils.ts`) returns a `FormattedDate` whose
173+
`parts.left` carries the ordered segment list (`parts.right` stays `null`); `computeFullListColumnWidths` measures the
174+
joined string once per row (tabular-aware, see the digit gotcha below); `FullList` walks the segments, wrapping any with
175+
a non-null `ageClass` in an age-tier span. Tooltips/MCP/status bar see the joined string via `FormattedDate.text`.
177176

178177
**Decision**: Column-width measurers (canvas in `full-list-utils.ts`, pretext in `measure-column-widths.ts`) cache their
179178
measurer/context per text scale and rebuild on the **debounced** "settled" scale event from
@@ -210,10 +209,11 @@ layout recalc. `transform` uses GPU compositor for 60fps.
210209
200], don't re-fetch. If scrolled to [250, 300], expand fetch to [0, 550] to include buffer. `shouldResetCache()`
211210
handles this.
212211

213-
**Gotcha**: `DATE_PARTS_GAP` (4px) in `measure-column-widths.ts` mirrors the `margin-left: var(--spacing-xs)` on
214-
`.date-right` in `FullList.svelte`. **Why**: The measurer adds it to the total date column width when any visible row
215-
splits via `|`. If you change either value, change both: split-date columns will be one or two pixels off from what the
216-
renderer actually draws otherwise.
212+
**Gotcha**: The Size and Modified columns render with `font-variant-numeric: tabular-nums`, but canvas/pretext can't
213+
measure that OpenType feature (the canvas `font` shorthand has no slot for it). **Why**: `measure-column-widths.ts`
214+
models it by substituting every digit with the font's widest digit (`tabularize`) before measuring, so the
215+
shrink-wrapped column matches what the DOM draws. Without it, a row of narrow digits (`11/11/1111`) renders wider than
216+
measured and ellipsizes. If you drop tabular figures from a numeric column, drop its `tabularize` call too.
217217

218218
**Gotcha**: `HEADER_CHROME_ACTIVE/INACTIVE` in `measure-column-widths.ts` are tied to `SortableHeader`'s flex gap +
219219
caret glyph (4px gap + 8px caret = 12px active, 0px inactive). The button keeps 4px horizontal padding for hover-state

apps/desktop/src/lib/file-explorer/views/FullList.svelte

Lines changed: 13 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@
213213
const indexing = $derived(isScanning() || isAggregating())
214214
215215
// Column widths are declared after the virtual window, which gates parent-row inclusion.
216-
let columnWidths = $state({ ext: 60, size: 115, date: 80, dateLeft: 0 })
216+
let columnWidths = $state({ ext: 60, size: 115, date: 80 })
217217
let skipTransition = $state(false)
218218
219219
/** Icon column width in the grid template, tracks density × text scale. */
@@ -1013,21 +1013,9 @@
10131013
{/if}
10141014
</span>
10151015
<span class="col-date">
1016-
{#if date.parts.right !== null && columnWidths.dateLeft > 0}
1017-
<span class="date-left" style="width: {columnWidths.dateLeft}px"
1018-
>{#each date.parts.left as seg, i (i)}{#if seg.ageClass}<span
1019-
class={seg.ageClass}>{seg.text}</span
1020-
>{:else}{seg.text}{/if}{/each}</span
1021-
><span class="date-right"
1022-
>{#each date.parts.right as seg, i (i)}{#if seg.ageClass}<span
1023-
class={seg.ageClass}>{seg.text}</span
1024-
>{:else}{seg.text}{/if}{/each}</span
1025-
>
1026-
{:else}
1027-
{#each date.parts.left as seg, i (i)}{#if seg.ageClass}<span class={seg.ageClass}
1028-
>{seg.text}</span
1029-
>{:else}{seg.text}{/if}{/each}
1030-
{/if}
1016+
{#each date.parts.left as seg, i (i)}{#if seg.ageClass}<span class={seg.ageClass}
1017+
>{seg.text}</span
1018+
>{:else}{seg.text}{/if}{/each}
10311019
</span>
10321020
</div>
10331021
{/each}
@@ -1323,6 +1311,10 @@
13231311
align-items: center;
13241312
gap: var(--spacing-xxs);
13251313
font-size: var(--font-size-sm);
1314+
/* Equal-width digits so right-aligned sizes line up into columns even in
1315+
our proportional system font. The measurer mirrors this by sizing the
1316+
column to the widest digit (see `measure-column-widths.ts`). */
1317+
font-variant-numeric: tabular-nums;
13261318
}
13271319
13281320
/* Groups the number triads into one flex item so the right-edge alignment is
@@ -1358,6 +1350,11 @@
13581350
font-size: var(--font-size-sm);
13591351
color: var(--color-text-secondary);
13601352
white-space: nowrap;
1353+
/* Equal-width digits so every row's date lines up into vertical columns
1354+
(the slashes/colons stack) without a monospace font. Each token format
1355+
emits a fixed character count, so with tabular figures every date is
1356+
the same width and the times align with no split-cell trick. */
1357+
font-variant-numeric: tabular-nums;
13611358
}
13621359
13631360
/* The age class lives on child spans. On selected or cursor-active rows,
@@ -1385,23 +1382,6 @@
13851382
color: var(--color-selection-fg);
13861383
}
13871384
1388-
/* Split date cells: `.date-left` is fixed-width (set inline from the
1389-
column-widths measurer) so the right halves align across rows. The 4px
1390-
margin on `.date-right` is mirrored as `DATE_PARTS_GAP` in
1391-
`measure-column-widths.ts`; keep them in sync. */
1392-
.date-left {
1393-
display: inline-block;
1394-
text-align: right;
1395-
overflow: hidden;
1396-
text-overflow: ellipsis;
1397-
white-space: nowrap;
1398-
vertical-align: bottom;
1399-
}
1400-
1401-
.date-right {
1402-
margin-left: var(--spacing-xs);
1403-
}
1404-
14051385
.file-entry.is-selected .col-name {
14061386
color: var(--color-selection-fg);
14071387
}

apps/desktop/src/lib/file-explorer/views/measure-column-widths.test.ts

Lines changed: 16 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,15 @@ function entry(overrides: Partial<FileEntry>): FileEntry {
3030

3131
/**
3232
* Build a stub `FormattedDate` for tests that don't care about per-component
33-
* coloring. Each half is a single literal segment so `joinSegments`
34-
* reproduces it as the full string.
33+
* coloring. The whole string is one literal segment so `joinSegments`
34+
* reproduces it.
3535
*/
36-
function stubDate(left: string, right: string | null = null) {
36+
function stubDate(text: string) {
3737
return {
38-
text: right === null ? left : `${left} ${right}`,
38+
text,
3939
parts: {
40-
left: [{ text: left, ageClass: null }],
41-
right: right === null ? null : [{ text: right, ageClass: null }],
40+
left: [{ text, ageClass: null }],
41+
right: null,
4242
},
4343
}
4444
}
@@ -135,47 +135,33 @@ describe('computeFullListColumnWidths', () => {
135135
expect(long.date).toBeGreaterThan(short.date)
136136
})
137137

138-
it('reports dateLeft and total width when rows have split dates', () => {
138+
it('widens the date column to fit the full formatted date', () => {
139139
_setMeasureForTests(fakeMeasure)
140140
const w = computeFullListColumnWidths({
141141
...baseArgs,
142-
formattedDate: () => stubDate('2026-12-31', '23:59'),
142+
formattedDate: () => stubDate('2026-12-31 23:59'),
143143
entries: [entry({ name: 'a', modifiedAt: 1 })],
144144
})
145-
// left "2026-12-31" = 10 × 7 = 70; right "23:59" = 5 × 7 = 35; gap = 4.
146-
// splitTotal = 70 + 2 (left pad) + 4 + 35 = 111. Final date adds another
147-
// 2 px pad for the right half: 111 + 2 = 113. Still beats MIN_DATE_WIDTH (70).
148-
// dateLeft = 70 + 2 (pad) = 72.
149-
expect(w.dateLeft).toBe(72)
150-
expect(w.date).toBe(113)
145+
// "2026-12-31 23:59" = 16 chars × 7 = 112 + 2 px pad = 114. Beats MIN_DATE_WIDTH (70).
146+
expect(w.date).toBe(16 * 7 + 2)
151147
})
152148

153-
it('uses the widest left half across all rows when splits are uneven', () => {
149+
it('uses the widest date across all rows', () => {
154150
_setMeasureForTests(fakeMeasure)
155151
let i = 0
156152
const formattedDate = () => {
157-
const lefts = ['short', '2026-01-30']
158-
const left = lefts[i % 2]
153+
const texts = ['1/1 0:00', '2026-12-31 23:59']
154+
const text = texts[i % 2]
159155
i++
160-
return stubDate(left, '14:30')
156+
return stubDate(text)
161157
}
162158
const w = computeFullListColumnWidths({
163159
...baseArgs,
164160
formattedDate,
165161
entries: [entry({ name: 'a', modifiedAt: 1 }), entry({ name: 'b', modifiedAt: 2 })],
166162
})
167-
// dateLeft = max("short" = 35, "2026-01-30" = 70) = 70, then + 2 px pad = 72.
168-
expect(w.dateLeft).toBe(72)
169-
})
170-
171-
it('keeps dateLeft at zero when no row produces a split', () => {
172-
_setMeasureForTests(fakeMeasure)
173-
const w = computeFullListColumnWidths({
174-
...baseArgs,
175-
formattedDate: () => stubDate('2026-12-31 23:59'),
176-
entries: [entry({ name: 'a', modifiedAt: 1 })],
177-
})
178-
expect(w.dateLeft).toBe(0)
163+
// Widest is "2026-12-31 23:59" = 16 × 7 = 112 + 2 px pad = 114.
164+
expect(w.date).toBe(16 * 7 + 2)
179165
})
180166

181167
it('reserves icon width when a directory has a stale size during indexing', () => {

0 commit comments

Comments
 (0)