Skip to content

Commit 6f71782

Browse files
committed
File viewer: ⌘A selects whole file, gold partial highlights
- New `selection.svelte.ts` with a `{ anchor, focus }` model in logical `(line, UTF-16 offset)` coordinates so a selection survives DOM recycling under virtual scroll. Half-open `[start, end)` semantics, pure helpers for normalise, in-range, segment bounds, and byte estimation. - New shared `line-segments.ts` segmenter that merges search-match spans with selection bounds into non-overlapping render spans. Search wins on background colour; selection wins on foreground (gold), matching the file list's "selected = gold" language. - ⌘A handler in `+page.svelte`'s `handleKeyDown`. Empty file: no-op. Search input focused: defers to native ⌘A on the input. ByteSeek-no-index ⌘A handled later in M2 via `RangeEnd::Eof`. - Flipped `.file-content` `user-select` from `text` to `none` (custom model owns selection rendering). `.status-bar` opts back in with `user-select: text` so users can still copy the file name and line count. - Status bar hint reads "W wrap · ⌘A select all · ⌘C copy · ⌘F search · Esc close" (sentence case, macOS-native ⌘). - 51 Vitest tests cover compare, normalise, isLineInRange, segment bounds, makeSelectAll (incl. empty / only-newlines / single-line files), byte estimator (incl. UTF-8 scaling), and the segmenter (incl. search+selection overlap, UTF-16 surrogate pairs). - Updated `routes/viewer/CLAUDE.md` with the selection-model section and two new gotchas (`user-select: none` rationale, UTF-16 offset convention). - Pre-existing failures NOT caused by this commit: `knip` (unused `src/lib/debug/debug-window.ts`), `css-unused` and 3 `desktop-svelte-eslint` errors in `routes/debug/`.
1 parent 915c5f3 commit 6f71782

8 files changed

Lines changed: 896 additions & 65 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,4 @@ apps/desktop/src-tauri/resources/ai
6666
.cargo-docker/
6767

6868
# Entire.io
69-
.entire/
69+
.entire/docs/specs/file-viewer-selection-progress.md

apps/desktop/src/routes/viewer/+page.svelte

Lines changed: 73 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import { createTextWidthTracker } from './viewer-text-width.svelte'
2727
import { createIndexingPoll } from './viewer-indexing-poll'
2828
import { handleNavigationKey, handleToggleKey } from './viewer-keyboard'
29+
import { createViewerSelection, getLineSegmentBounds } from './selection.svelte'
2930
import Size from '$lib/ui/Size.svelte'
3031
import { initAppMode, decorateChildWindowTitle } from '$lib/app-mode'
3132
import { categorizeForViewerWarning } from '$lib/file-viewer/binary-warning'
@@ -127,6 +128,8 @@
127128
getContentRef: () => scroll.contentRef,
128129
})
129130
131+
const selection = createViewerSelection()
132+
130133
// Fetch lines when visible range changes (debounced)
131134
$effect(() => {
132135
scroll.runFetchEffect()
@@ -211,42 +214,62 @@
211214
setSetting('viewer.wordWrap', scroll.wordWrap)
212215
}
213216
217+
function handleSelectAllShortcut(): void {
218+
if (totalLines === null || totalLines <= 0) return
219+
// ByteSeek-no-index ⌘A is handled in M2 via the `RangeEnd::Eof` IPC variant.
220+
const lastLineText = scroll.lineCache.get(totalLines - 1) ?? ''
221+
selection.selectAll(totalLines, lastLineText.length)
222+
}
223+
224+
function handleEscapeKey(): void {
225+
log.debug('ESC pressed, searchVisible={searchVisible}, windowReady={windowReady}', {
226+
searchVisible: search.searchVisible,
227+
windowReady,
228+
})
229+
if (!search.searchVisible) {
230+
closeWindow()
231+
return
232+
}
233+
if (search.searchStatus === 'running') {
234+
search.stopSearch()
235+
} else {
236+
search.closeSearch()
237+
}
238+
}
239+
214240
function handleKeyDown(e: KeyboardEvent) {
215-
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
241+
const metaOrCtrl = e.metaKey || e.ctrlKey
242+
const searchInputFocused = search.searchVisible && document.activeElement === search.searchInputRef
243+
244+
// ⌘A selects the whole file (independent of the DOM, so it works regardless of
245+
// how many lines the virtual scroller has rendered). If the search input is
246+
// focused, defer to its native ⌘A so it can select the query text.
247+
if (metaOrCtrl && e.key === 'a' && !searchInputFocused) {
248+
e.preventDefault()
249+
handleSelectAllShortcut()
250+
return
251+
}
252+
253+
if (metaOrCtrl && e.key === 'f') {
216254
e.preventDefault()
217255
search.openSearch()
218256
return
219257
}
220258
221259
if (e.key === 'Escape') {
222260
e.preventDefault()
223-
log.debug('ESC pressed, searchVisible={searchVisible}, windowReady={windowReady}', {
224-
searchVisible: search.searchVisible,
225-
windowReady,
226-
})
227-
if (search.searchVisible) {
228-
if (search.searchStatus === 'running') {
229-
search.stopSearch()
230-
} else {
231-
search.closeSearch()
232-
}
233-
} else {
234-
closeWindow()
235-
}
261+
handleEscapeKey()
236262
return
237263
}
238264
239265
if (e.key === 'Enter' && search.searchVisible) {
240266
e.preventDefault()
241-
if (e.shiftKey) {
242-
search.findPrev()
243-
} else {
244-
search.findNext()
245-
}
267+
if (e.shiftKey) search.findPrev()
268+
else search.findNext()
246269
return
247270
}
248271
249-
if (search.searchVisible && document.activeElement === search.searchInputRef) return
272+
if (searchInputFocused) return
250273
251274
if (handleToggleKey(e, toggleWordWrap) || handleNavigationKey(e.key, scroll)) {
252275
e.preventDefault()
@@ -600,9 +623,10 @@
600623
>{lineNumber + 1}</span
601624
>
602625
<span class="line-text"
603-
>{#each search.getHighlightedSegments(lineNumber, text) as seg, segIdx (segIdx)}{#if seg.highlight}<mark
604-
class:active={seg.active}>{seg.text}</mark
605-
>{:else}{seg.text}{/if}{/each}</span
626+
>{#each search.getHighlightedSegments(lineNumber, text, getLineSegmentBounds(selection.selection, lineNumber, text.length)) as seg, segIdx (segIdx)}{#if seg.highlight}<mark
627+
class:active={seg.active}
628+
class:selected={seg.selected}>{seg.text}</mark
629+
>{:else if seg.selected}<span class="selected">{seg.text}</span>{:else}{seg.text}{/if}{/each}</span
606630
>
607631
</div>
608632
{/each}
@@ -647,7 +671,7 @@
647671
>wrap</span
648672
>
649673
{/if}
650-
<span class="shortcut-hint">W wrap &middot; Ctrl+F search &middot; Esc close</span>
674+
<span class="shortcut-hint">W wrap &middot; ⌘A select all &middot; ⌘C copy &middot; ⌘F search &middot; Esc close</span>
651675
</div>
652676
</main>
653677

@@ -826,11 +850,29 @@
826850
font-family: var(--font-mono);
827851
font-size: var(--font-size-sm);
828852
line-height: 1.5;
829-
user-select: text;
830-
-webkit-user-select: text;
853+
/* The viewer owns its own selection model (see selection.svelte.ts). We
854+
* suppress the browser's native selection because it can't render a
855+
* selection that survives DOM recycling under virtual scroll. The custom
856+
* `.selected` class below paints the visible portion. */
857+
user-select: none;
858+
-webkit-user-select: none;
831859
cursor: text;
832860
}
833861
862+
/* Selected text: gold foreground matches the file-list "selected = gold" language
863+
* (see design-system.md § File list). Background uses the accent-subtle token, the
864+
* same tint the cursor highlight uses. Both work in light and dark. */
865+
.line-text :global(.selected) {
866+
background: var(--color-accent-subtle);
867+
color: var(--color-selection-fg);
868+
}
869+
870+
/* Search hit + selection on the same span: keep the highlight background (so search
871+
* remains the dominant signal) and apply the selection foreground colour. */
872+
.line-text :global(mark.selected) {
873+
color: var(--color-selection-fg);
874+
}
875+
834876
.scroll-spacer {
835877
position: relative;
836878
}
@@ -911,6 +953,11 @@
911953
font-size: var(--font-size-sm);
912954
color: var(--color-text-secondary);
913955
flex-shrink: 0;
956+
/* Opt back in to native selection here so users can copy the file name or line
957+
* count. The global reset is `user-select: none`, and `.file-content` keeps
958+
* that for its custom selection model; the status bar is plain chrome. */
959+
user-select: text;
960+
-webkit-user-select: text;
914961
}
915962
916963
.backend-badge {

apps/desktop/src/routes/viewer/CLAUDE.md

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ The file viewer opens files in a separate Tauri window with virtual scrolling an
44

55
## Files
66

7-
| File | Contents |
8-
| ------------------------------- | ----------------------------------------------------------------------------------- |
9-
| `+page.svelte` | Top-level component: lifecycle, window management, UI |
10-
| `viewer-scroll.svelte.ts` | Virtual scroll composable: line cache, fetch debounce, scroll compression, effects |
11-
| `viewer-search.svelte.ts` | Search composable: start/poll/cancel/navigate, match highlighting, debounce |
12-
| `viewer-line-heights.svelte.ts` | Height map for accurate word-wrap scrolling via pretext (FullLoad files only) |
13-
| `viewer-text-width.svelte.ts` | `ResizeObserver`-driven tracker for the rendered `.line-text` width |
14-
| `viewer-indexing-poll.ts` | Periodic `viewer_get_status` poll while the backend builds a line index |
15-
| `viewer-keyboard.ts` | Pure helpers `handleNavigationKey` / `handleToggleKey` mapping keys to scroll calls |
7+
| File | Contents |
8+
| ------------------------------- | ------------------------------------------------------------------------------------------- |
9+
| `+page.svelte` | Top-level component: lifecycle, window management, UI |
10+
| `viewer-scroll.svelte.ts` | Virtual scroll composable: line cache, fetch debounce, scroll compression, effects |
11+
| `viewer-search.svelte.ts` | Search composable: start/poll/cancel/navigate, match highlighting, debounce |
12+
| `viewer-line-heights.svelte.ts` | Height map for accurate word-wrap scrolling via pretext (FullLoad files only) |
13+
| `viewer-text-width.svelte.ts` | `ResizeObserver`-driven tracker for the rendered `.line-text` width |
14+
| `viewer-indexing-poll.ts` | Periodic `viewer_get_status` poll while the backend builds a line index |
15+
| `viewer-keyboard.ts` | Pure helpers `handleNavigationKey` / `handleToggleKey` mapping keys to scroll calls |
16+
| `selection.svelte.ts` | Selection model: state + pure helpers (normalise, in-range, segment bounds, byte estimator) |
17+
| `line-segments.ts` | Pure shared segmenter: merges search matches + selection bounds into render spans |
1618

1719
## Architecture
1820

@@ -41,10 +43,38 @@ scroll-to-match positioning.
4143
existing uniform-height code. The `scrollScale` (for MAX_SCROLL_HEIGHT compression) multiplies height map values at the
4244
scroll layer (the height map stores unscaled positions).
4345

46+
## Selection model
47+
48+
The viewer owns its own selection model (`selection.svelte.ts`) instead of relying on the browser's `Selection` API. The
49+
browser API can't survive virtualisation: as soon as the anchor or focus scrolls out of the visible buffer, its DOM node
50+
is recycled and the selection collapses. The custom model tracks two `LineOffset` endpoints (`{ line, offset }`) in
51+
logical coordinates, independent of which lines happen to be rendered.
52+
53+
- **Range semantics**: half-open `[start, end)`. The start line is included from `start.offset` to its end, intermediate
54+
lines are included in full, the end line is included from offset 0 up to but not including `end.offset`.
55+
- **Offset units**: UTF-16 code units (matches `String.length` and the search column units the search engine already
56+
emits, so the whole frontend speaks one unit). The backend converts to UTF-8 bytes at the IPC boundary, clamping
57+
offsets that land between the high and low surrogate of an astral codepoint.
58+
- **Render**: the page calls `getLineSegmentBounds(selection, lineNumber, lineLength)` and passes the bounds to
59+
`search.getHighlightedSegments(...)`. The shared `segmentLine()` function (in `line-segments.ts`) merges search-match
60+
spans with selection bounds and emits non-overlapping `LineSegment`s tagged `highlight` / `active` / `selected`. The
61+
template renders each segment as a `<mark>` (search) or `<span class="selected">` or plain text.
62+
- **Visual collision**: when a search hit and the selection overlap on the same span, search wins on the background
63+
(`var(--color-highlight)`) and selection wins on the foreground (`var(--color-selection-fg)`, gold). Matches the
64+
"selected = gold" language from the file list (design-system.md § File list).
65+
4466
## Gotchas
4567

4668
- `$state(false)` in `.svelte.ts` triggers `@typescript-eslint/no-unnecessary-condition` because the linter doesn't know
4769
the value is mutated via Svelte reactivity. Use an inline eslint-disable comment with a reason.
70+
- **`user-select: none` on `.file-content` is deliberate.** The viewer owns its own selection model (above); the
71+
browser's native selection would render a competing-and-broken one on top of ours that loses its anchor as soon as the
72+
line scrolls out. `.status-bar` opts back in with `user-select: text` so users can still copy the file name or line
73+
count. `.line-number` keeps the global default (`none`), it's aria-hidden chrome.
74+
- **Selection offsets are UTF-16 code units, not bytes or grapheme clusters.** When you add features that compute
75+
offsets from a click position (M3a's caret math) or accept them across the IPC boundary (M2's `viewer_read_range`),
76+
preserve the UTF-16 convention. The backend handles the conversion to UTF-8 bytes, clamping lone surrogates to the
77+
nearest codepoint boundary.
4878
- `getLineHeight()` (returns `18px × effective scale`) and the CSS rule
4979
`.line { height: calc(18px * var(--font-scale)) }` in `+page.svelte` must stay paired. Both read the same scale: the
5080
JS function for virtualization math, the CSS rule for layout. If you change the 18 base, change both.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, it, expect } from 'vitest'
2+
3+
import { segmentLine } from './line-segments'
4+
5+
describe('segmentLine', () => {
6+
it('no matches and no selection returns one unmarked span', () => {
7+
const segs = segmentLine('hello world', [], null)
8+
expect(segs).toEqual([{ text: 'hello world', highlight: false, active: false, selected: false }])
9+
})
10+
11+
it('empty line returns a single empty span when nothing is highlighted', () => {
12+
const segs = segmentLine('', [], null)
13+
expect(segs).toEqual([{ text: '', highlight: false, active: false, selected: false }])
14+
})
15+
16+
it('single search match splits into three segments', () => {
17+
const segs = segmentLine('hello world', [{ column: 6, length: 5, active: false }], null)
18+
expect(segs).toEqual([
19+
{ text: 'hello ', highlight: false, active: false, selected: false },
20+
{ text: 'world', highlight: true, active: false, selected: false },
21+
])
22+
})
23+
24+
it('active match flag propagates to its segment', () => {
25+
const segs = segmentLine('hello world', [{ column: 0, length: 5, active: true }], null)
26+
expect(segs).toEqual([
27+
{ text: 'hello', highlight: true, active: true, selected: false },
28+
{ text: ' world', highlight: false, active: false, selected: false },
29+
])
30+
})
31+
32+
it('two search matches with text between them', () => {
33+
const segs = segmentLine(
34+
'foo bar baz',
35+
[
36+
{ column: 0, length: 3, active: false },
37+
{ column: 8, length: 3, active: false },
38+
],
39+
null,
40+
)
41+
expect(segs).toEqual([
42+
{ text: 'foo', highlight: true, active: false, selected: false },
43+
{ text: ' bar ', highlight: false, active: false, selected: false },
44+
{ text: 'baz', highlight: true, active: false, selected: false },
45+
])
46+
})
47+
48+
it('selection only (no search) splits into selected and unselected spans', () => {
49+
const segs = segmentLine('hello world', [], { selStart: 6, selEnd: 11 })
50+
expect(segs).toEqual([
51+
{ text: 'hello ', highlight: false, active: false, selected: false },
52+
{ text: 'world', highlight: false, active: false, selected: true },
53+
])
54+
})
55+
56+
it('selection covering the whole line returns one selected segment', () => {
57+
const segs = segmentLine('abc', [], { selStart: 0, selEnd: 3 })
58+
expect(segs).toEqual([{ text: 'abc', highlight: false, active: false, selected: true }])
59+
})
60+
61+
it('selection and search overlap: span is both highlighted and selected', () => {
62+
// "hello world" — search match on "lo w" (cols 3..7), selection on "world" (6..11).
63+
// Overlap: "lo " (3..6) is search only, " w" (6..7) is search+select, "orld" (7..11) is select only.
64+
const segs = segmentLine('hello world', [{ column: 3, length: 4, active: false }], {
65+
selStart: 6,
66+
selEnd: 11,
67+
})
68+
expect(segs).toEqual([
69+
{ text: 'hel', highlight: false, active: false, selected: false },
70+
{ text: 'lo ', highlight: true, active: false, selected: false },
71+
{ text: 'w', highlight: true, active: false, selected: true },
72+
{ text: 'orld', highlight: false, active: false, selected: true },
73+
])
74+
})
75+
76+
it('selection entirely inside a search match: produces three segments (h, h+s, h)', () => {
77+
// "hello world" — match "hello world" (0..11), select "lo w" (3..7).
78+
const segs = segmentLine('hello world', [{ column: 0, length: 11, active: false }], {
79+
selStart: 3,
80+
selEnd: 7,
81+
})
82+
expect(segs).toEqual([
83+
{ text: 'hel', highlight: true, active: false, selected: false },
84+
{ text: 'lo w', highlight: true, active: false, selected: true },
85+
{ text: 'orld', highlight: true, active: false, selected: false },
86+
])
87+
})
88+
89+
it('clamps out-of-range match columns to the line length', () => {
90+
// Match advertises column 20 but line is only 11 chars; we still don't crash and the segment is empty.
91+
const segs = segmentLine('hello world', [{ column: 20, length: 5, active: false }], null)
92+
expect(segs).toEqual([{ text: 'hello world', highlight: false, active: false, selected: false }])
93+
})
94+
95+
it('selection at the line end (selEnd == lineLength) selects through the final char', () => {
96+
const segs = segmentLine('abc', [], { selStart: 1, selEnd: 3 })
97+
expect(segs).toEqual([
98+
{ text: 'a', highlight: false, active: false, selected: false },
99+
{ text: 'bc', highlight: false, active: false, selected: true },
100+
])
101+
})
102+
103+
it('UTF-16 surrogate pair: offsets are in code units', () => {
104+
// "👋hi" = 4 UTF-16 units (2 for emoji + h + i).
105+
// Select just the emoji: 0..2.
106+
const segs = segmentLine('👋hi', [], { selStart: 0, selEnd: 2 })
107+
expect(segs).toEqual([
108+
{ text: '👋', highlight: false, active: false, selected: true },
109+
{ text: 'hi', highlight: false, active: false, selected: false },
110+
])
111+
})
112+
})

0 commit comments

Comments
 (0)