Editor: line numbers, wrap toggle, find bar

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-13 22:38:38 +02:00
co-authored by Claude Fable 5
parent 1652b9e298
commit 6a19097b25
3 changed files with 234 additions and 8 deletions
+82 -2
View File
@@ -26,8 +26,69 @@
</h1>
<span class="shrink-0 text-[11px] text-muted-foreground">{{ ctx.sessionTitle }}</span>
<div class="flex-1"></div>
<button
(click)="toggleWrap()"
title="Wrap long lines — hides line numbers (⌥Z)"
class="rounded-md px-2 py-1 text-[11px] electrobun-webkit-app-region-no-drag hover:bg-accent"
[class.bg-accent]="wrap()"
[class.text-foreground]="wrap()"
[class.text-muted-foreground]="!wrap()"
>
Wrap
</button>
<button
(click)="openFind()"
title="Find (⌘F)"
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaSearch" size="0.85rem" />
</button>
</header>
@if (findOpen()) {
<div class="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<input
#findBox
[value]="findQuery()"
(input)="onFindInput($any($event.target).value)"
(keydown)="onFindKeydown($event)"
placeholder="Find in file"
spellcheck="false"
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
/>
<span
class="text-[11px] tabular-nums"
[class.text-destructive]="findQuery() && !matches().length"
[class.text-muted-foreground]="!findQuery() || matches().length"
>
{{ countLabel() }}
</span>
<button
(click)="findStep(-1)"
title="Previous match (⇧↵)"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaChevronUp" size="0.8rem" />
</button>
<button
(click)="findStep(1)"
title="Next match (↵)"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaChevronDown" size="0.8rem" />
</button>
<div class="flex-1"></div>
<button
(click)="closeFind()"
title="Close (Esc)"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.8rem" />
</button>
</div>
}
@if (loading()) {
<div class="flex flex-1 items-center justify-center">
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" />
@@ -47,16 +108,35 @@
</div>
</div>
} @else {
<div class="flex min-h-0 flex-1">
<!-- gutter and textarea share font/leading/vertical padding — that's
the whole alignment contract; keep the classes in lockstep -->
@if (!wrap()) {
<div
#gutter
aria-hidden="true"
[style.width.ch]="gutterDigits() + 3"
class="shrink-0 overflow-hidden border-r border-border py-4 pr-2 text-right font-mono text-xs leading-relaxed whitespace-pre text-muted-foreground select-none"
>{{ gutterText() }}</div>
}
<textarea
#box
[value]="text()"
(input)="onInput($any($event.target).value)"
(scroll)="onScroll()"
spellcheck="false"
class="min-h-0 flex-1 resize-none bg-transparent p-4 font-mono text-xs leading-relaxed outline-none"
[attr.wrap]="wrap() ? 'soft' : 'off'"
class="min-h-0 flex-1 resize-none overflow-auto bg-transparent px-4 py-4 font-mono text-xs leading-relaxed outline-none"
[class.whitespace-pre]="!wrap()"
></textarea>
</div>
<footer class="flex shrink-0 items-center gap-2 border-t border-border px-4 py-2">
<span class="text-[11px] text-muted-foreground">⌘S save · ⌘W close</span>
<span class="text-[11px] text-muted-foreground">⌘S save · ⌘F find · ⌘W close</span>
<span class="text-[11px] text-muted-foreground">·</span>
<span class="text-[11px] tabular-nums text-muted-foreground">
{{ lineCount() }} {{ lineCount() === 1 ? 'line' : 'lines' }}
</span>
<div class="flex-1"></div>
<button
(click)="requestClose()"
+144
View File
@@ -45,8 +45,47 @@ export class EditorWindow {
readonly saving = signal(false);
readonly closePrompt = signal(false);
readonly sessionDead = signal(false);
/** Soft-wrap long lines. Hides the gutter: correct wrapped line numbers
* would need per-edit height measurement — not worth it without an editor lib. */
readonly wrap = signal(false);
readonly findOpen = signal(false);
readonly findQuery = signal('');
readonly activeMatch = signal(0);
/** Line count without allocating a split array on every keystroke. */
readonly lineCount = computed(() => {
const t = this.text();
let n = 1;
let i = -1;
while ((i = t.indexOf('\n', i + 1)) !== -1) n++;
return n;
});
readonly gutterText = computed(() =>
Array.from({ length: this.lineCount() }, (_, i) => i + 1).join('\n'),
);
readonly gutterDigits = computed(() => String(this.lineCount()).length);
/** Case-insensitive match start offsets for the find query. */
readonly matches = computed(() => {
const query = this.findQuery().toLowerCase();
if (!query) return [];
const hay = this.text().toLowerCase();
const out: number[] = [];
let i = hay.indexOf(query);
while (i !== -1) {
out.push(i);
i = hay.indexOf(query, i + query.length);
}
return out;
});
readonly countLabel = computed(() =>
this.matches().length ? `${this.activeMatch() + 1}/${this.matches().length}` : '0/0',
);
private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box');
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
private cachedCharWidth = 0;
private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService);
private readonly document = inject(DOCUMENT);
@@ -92,6 +131,102 @@ export class EditorWindow {
this.scheduleDraft();
}
/** Keep the line-number gutter locked to the textarea's scroll position. */
onScroll(): void {
const gutter = this.gutter()?.nativeElement;
if (gutter) gutter.scrollTop = this.box()!.nativeElement.scrollTop;
}
toggleWrap(): void {
this.wrap.update((w) => !w);
queueMicrotask(() => this.onScroll());
}
// ---- find ----
openFind(): void {
const el = this.box()?.nativeElement;
// prefill from the current selection when it's a sane single-line snippet
if (el && el.selectionStart !== el.selectionEnd) {
const selected = el.value.slice(el.selectionStart, el.selectionEnd);
if (selected.length < 200 && !selected.includes('\n')) this.findQuery.set(selected);
}
this.findOpen.set(true);
this.activeMatch.set(0);
queueMicrotask(() => {
const input = this.findBox()?.nativeElement;
input?.focus();
input?.select();
});
}
closeFind(): void {
this.findOpen.set(false);
// selection was left on the match — the caret lands there
this.box()?.nativeElement.focus();
}
onFindInput(value: string): void {
this.findQuery.set(value);
const list = this.matches();
if (!list.length) return;
// jump to the first match at/after the caret, else wrap to the first
const from = this.box()?.nativeElement.selectionStart ?? 0;
const at = list.findIndex((start) => start >= from);
this.goTo(at >= 0 ? at : 0);
}
findStep(delta: 1 | -1): void {
const count = this.matches().length;
if (!count) return;
this.goTo((this.activeMatch() + delta + count) % count);
}
onFindKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
event.preventDefault();
this.findStep(event.shiftKey ? -1 : 1);
} else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.closeFind();
}
}
/** Select match `index` and scroll it into the middle of the viewport. */
private goTo(index: number): void {
const el = this.box()?.nativeElement;
const start = this.matches()[index];
if (!el || start === undefined) return;
this.activeMatch.set(index);
el.setSelectionRange(start, start + this.findQuery().length);
const text = this.text();
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20;
let line = 0;
let i = text.indexOf('\n');
while (i !== -1 && i < start) {
line++;
i = text.indexOf('\n', i + 1);
}
el.scrollTop = Math.max(0, line * lineHeight - el.clientHeight / 2);
if (!this.wrap()) {
const column = start - (text.lastIndexOf('\n', start - 1) + 1);
el.scrollLeft = Math.max(0, column * this.charWidth(el) - el.clientWidth / 2);
}
this.onScroll();
}
/** Monospace advance width, measured once per window. */
private charWidth(el: HTMLTextAreaElement): number {
if (!this.cachedCharWidth) {
const canvas = this.document.createElement('canvas').getContext('2d')!;
canvas.font = getComputedStyle(el).font;
this.cachedCharWidth = canvas.measureText('0').width || 7;
}
return this.cachedCharWidth;
}
// ---- draft mirror: bun holds the latest content for the native-close prompt ----
private scheduleDraft(): void {
@@ -164,14 +299,23 @@ export class EditorWindow {
if (mod && event.key.toLowerCase() === 's') {
event.preventDefault();
void this.save();
} else if (mod && event.key.toLowerCase() === 'f') {
event.preventDefault();
this.openFind();
} else if (mod && event.key.toLowerCase() === 'w') {
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W and
// bun routes it back as editorCloseRequest
event.preventDefault();
this.requestClose();
} else if (event.altKey && event.code === 'KeyZ') {
event.preventDefault();
this.toggleWrap();
} else if (event.key === 'Escape' && this.closePrompt()) {
event.preventDefault();
this.cancelClose();
} else if (event.key === 'Escape' && this.findOpen()) {
event.preventDefault();
this.closeFind();
} else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) {
// keep Tab as an input character inside the editor
event.preventDefault();
+2
View File
@@ -11,6 +11,7 @@ import {
mynaCheckCircle,
mynaChevronDown,
mynaChevronRight,
mynaChevronUp,
mynaCode,
mynaCog,
mynaDots,
@@ -46,6 +47,7 @@ export const APP_ICONS = {
mynaCheckCircle,
mynaChevronDown,
mynaChevronRight,
mynaChevronUp,
mynaCode,
mynaCog,
mynaDots,