experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(cmd): keep cursor in view when typing past visible width

The cmd panel uses a transparent input layered over a styled display
(`#command-text`); typing past the visible width scrolled the input but the
display layer stayed put, so the caret rendered off-screen.

Mirror the input's scrollLeft onto the display layer on every scroll event
and at the end of each updateCommandUI pass. Adds a desktop-serial regression
test that types a long string and asserts caret visibility.

+148 -1
+37 -1
app/cmd/panel.js
··· 1515 1515 const commandText = document.getElementById('command-text'); 1516 1516 commandText.innerHTML = ''; 1517 1517 1518 - if (!trimmedText) return; 1518 + if (!trimmedText) { 1519 + syncCommandTextScroll(); 1520 + return; 1521 + } 1519 1522 1520 1523 const typedSpan = document.createElement('span'); 1521 1524 typedSpan.className = 'typed'; 1522 1525 typedSpan.textContent = trimmedText; 1523 1526 commandText.appendChild(typedSpan); 1527 + syncCommandTextScroll(); 1524 1528 } 1525 1529 1526 1530 function hasModifier(e) { ··· 1528 1532 } 1529 1533 1530 1534 // ===== UI Update Functions ===== 1535 + 1536 + /** 1537 + * Mirror the (invisible) command-input's horizontal scroll position onto 1538 + * the (visible) command-text overlay so the caret/typed-end stays inside 1539 + * the visible region when content exceeds the input width. 1540 + * 1541 + * The real <input> has `caret-color: transparent` and `color: transparent` 1542 + * — what the user sees is `#command-text` rendered absolutely over it. 1543 + * The native input scrolls itself to keep the caret visible on every 1544 + * keystroke and arrow-key move; we just reflect that scrollLeft onto 1545 + * the display layer so the same visible region appears. 1546 + * 1547 + * `#command-text` has `overflow: hidden` (no scrollbar) but 1548 + * `scrollLeft` is still settable programmatically — content scrolls 1549 + * underneath the clipping window. 1550 + */ 1551 + function syncCommandTextScroll() { 1552 + const commandInput = document.getElementById('command-input'); 1553 + const commandText = document.getElementById('command-text'); 1554 + if (commandInput && commandText) { 1555 + commandText.scrollLeft = commandInput.scrollLeft; 1556 + } 1557 + } 1531 1558 1532 1559 function updateCommandUI() { 1533 1560 const commandText = document.getElementById('command-text'); ··· 1647 1674 } 1648 1675 } 1649 1676 } 1677 + 1678 + syncCommandTextScroll(); 1650 1679 } 1651 1680 1652 1681 function updateResultsUI() { ··· 2474 2503 2475 2504 commandInput.value = ''; 2476 2505 commandInput.focus(); 2506 + 2507 + // Mirror the input's horizontal scroll onto #command-text so the typed 2508 + // end / caret stays visible when content exceeds the input width. 2509 + // Native <input> fires scroll events on caret-driven scroll (typing, 2510 + // arrow keys, click) — listening once here covers all cases regardless 2511 + // of whether the keystroke also triggered an FSM-driven re-render. 2512 + commandInput.addEventListener('scroll', syncCommandTextScroll); 2477 2513 2478 2514 // Input event -> dispatch to state machine (or filter URL mode) 2479 2515 commandInput.addEventListener('input', () => {
+111
tests/desktop-serial/cmd-cursor-visibility.spec.ts
··· 1 + /** 2 + * Cmd panel cursor-visibility regression guard. 3 + * 4 + * The cmd panel uses a transparent <input> overlaid by #command-text 5 + * (the visible display layer). The native input scrolls horizontally 6 + * to keep the caret visible when content exceeds the input width; 7 + * #command-text must mirror that scrollLeft so the user actually sees 8 + * the typed end. Without the mirror, typing past ~30 characters 9 + * pushes the caret off-screen. 10 + * 11 + * Fix: panel.js syncs commandText.scrollLeft to commandInput.scrollLeft 12 + * via the input's `scroll` event + a sync call at the end of the UI 13 + * update functions. 14 + */ 15 + 16 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 17 + import { Page } from '@playwright/test'; 18 + import { createPerDescribeApp } from '../helpers/test-app'; 19 + import { 20 + waitForExtensionsReady, 21 + waitForPanelCommandsLoaded, 22 + } from '../helpers/window-utils'; 23 + 24 + test.describe('Cmd Cursor Visibility @desktop', () => { 25 + let app: DesktopApp; 26 + let bgWindow: Page; 27 + 28 + test.beforeAll(async () => { 29 + ({ app, bgWindow } = await createPerDescribeApp('cmd-cursor-visibility')); 30 + }); 31 + 32 + test.afterAll(async () => { 33 + if (app) await app.close(); 34 + }); 35 + 36 + test('command-text scrolls with input when typed content exceeds visible width', async () => { 37 + await waitForExtensionsReady(bgWindow, 15000); 38 + 39 + const openResult = await bgWindow.evaluate(async () => { 40 + return await (window as any).app.window.open('peek://cmd/panel.html', { 41 + modal: true, 42 + width: 600, 43 + height: 50, 44 + frame: false, 45 + transparent: true, 46 + alwaysOnTop: true, 47 + center: true, 48 + }); 49 + }); 50 + expect(openResult.success).toBe(true); 51 + 52 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 53 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 54 + await waitForPanelCommandsLoaded(cmdWindow, 10000); 55 + 56 + // Type a long string. Panel input is ~600px wide; at 18px font ~30 57 + // characters fit. 200+ characters guarantees overflow. 58 + const longText = 'the quick brown fox jumps over the lazy dog '.repeat(8); 59 + await cmdWindow.fill('input', longText); 60 + 61 + // Force the input to scroll to its end (programmatic .value=… does 62 + // not trigger native caret-into-view scroll). The scroll event 63 + // listener then mirrors onto #command-text. 64 + await cmdWindow.evaluate(() => { 65 + const input = document.getElementById('command-input') as HTMLInputElement; 66 + if (input) input.scrollLeft = input.scrollWidth; 67 + }); 68 + 69 + // Wait until input has scrolled AND command-text has caught up. 70 + await cmdWindow.waitForFunction( 71 + () => { 72 + const input = document.getElementById('command-input') as HTMLInputElement; 73 + const text = document.getElementById('command-text'); 74 + return !!( 75 + input && 76 + text && 77 + input.scrollLeft > 0 && 78 + text.scrollLeft === input.scrollLeft 79 + ); 80 + }, 81 + undefined, 82 + { timeout: 5000 } 83 + ); 84 + 85 + const snap = await cmdWindow.evaluate(() => { 86 + const input = document.getElementById('command-input') as HTMLInputElement; 87 + const text = document.getElementById('command-text') as HTMLElement; 88 + return { 89 + inputScrollLeft: input.scrollLeft, 90 + textScrollLeft: text.scrollLeft, 91 + inputScrollWidth: input.scrollWidth, 92 + inputClientWidth: input.clientWidth, 93 + }; 94 + }); 95 + 96 + expect(snap.inputScrollLeft).toBeGreaterThan(0); 97 + expect(snap.textScrollLeft).toBe(snap.inputScrollLeft); 98 + // The input scrolled enough that the rightmost content is in view — 99 + // i.e. scrollLeft + clientWidth is at or near scrollWidth (typed 100 + // end visible at right edge). 101 + expect(snap.inputScrollLeft + snap.inputClientWidth).toBeGreaterThanOrEqual( 102 + snap.inputScrollWidth - 4 103 + ); 104 + 105 + if (openResult.id) { 106 + await bgWindow.evaluate(async (id: number) => { 107 + return await (window as any).app.window.close(id); 108 + }, openResult.id); 109 + } 110 + }); 111 + });