diff --git a/.gitignore b/.gitignore index 85c3576..c447e76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Dependencies and generated application output node_modules/ +bun.lock out/ dist/ release/ diff --git a/docs/UI_CONTRACT.md b/docs/UI_CONTRACT.md index 90dff1b..a29895f 100644 --- a/docs/UI_CONTRACT.md +++ b/docs/UI_CONTRACT.md @@ -18,12 +18,12 @@ This contract preserves the approved MVP concept and prevents feature ownership - Clicking a provider opens a Focus Card for that provider. The provider is fixed; there is no second provider selector. - The Focus Card contains only the provider mark, project folder, Normal/YOLO profile, launch action, and contextual danger confirmation. -- Settings uses a top section strip: General, Appearance, Controls, Browser, and Plugins. General owns language. Appearance owns palette, background pattern, the shortcut-hint toggle, system HOME tiles, and the HOME editor entry. Controls owns click focus, hover focus, window snapping, edge panning, zoom sensitivity, wheel direction, and keyboard shortcuts. Browser owns agent access, agent-indicator visibility, tab restore, downloads, redacted activity, and browser-data clearing. Plugins owns install preview, permission review, the installed-plugin list, enable/disable/uninstall, and contribution actions. Media controls never appear there. +- Settings uses a top section strip: General, Appearance, Controls, Browser, and Plugins. General owns language. Appearance owns palette, background pattern, the shortcut-hint toggle, system HOME tiles, and the HOME editor entry. Controls owns click focus, hover focus, window snapping, edge panning, zoom sensitivity, wheel direction, ordinary scroll pan/zoom, the Off/On/Key wheel/pinch capture mode, the full canvas navigation override, and action shortcuts. Browser owns agent access, agent-indicator visibility, tab restore, downloads, redacted activity, and browser-data clearing. Plugins owns install preview, permission review, the installed-plugin list, enable/disable/uninstall, and contribution actions. Media controls never appear there. - Click focus has three explicit modes: Off, Single click, and Double click. It is off by default. Selection and its visible outline still work when camera focus is off; a double-click mode never jumps the camera on the first click. -- Selection is exclusive across terminals and the built-in Browser: pressing on empty canvas clears it. The selected live surface holds keyboard focus and loses it on deselect. -- Hover focus is a separate off-by-default selection mode: after a configurable delay (slow `500ms`, normal `250ms`, fast `80ms`) the terminal or built-in Browser under the pointer becomes selected and receives keyboard focus; leaving the card clears the selection after the same delay. +- Selection is currently exclusive across terminals and the built-in Browser: pressing on empty canvas clears it. Input focus is tracked independently so future multi-selection does not have to redefine wheel ownership. +- Hover focus is an off-by-default input-focus mode. After a configurable delay (slow `500ms`, normal `250ms`, fast `80ms`), an input-bearing widget under the pointer receives logical focus without changing selection; terminal and native Browser keyboard input follows that focus. Pointer leave only cancels a pending transfer; assigned focus remains until another focusable widget is clicked/hovered or the user clicks outside every widget. Focusable surfaces are terminals, the native Browser, plugin iframe/canvas surfaces, and actually scrollable HOME lists; decorative and action-only tiles do not take focus. - Keyboard shortcuts are user-remappable and persisted locally. Defaults are `Home` for focusing the Home zone and `F2` for renaming the selected terminal window. Rename is an inline header edit and does not recreate the PTY. A compact passive hint in the canvas bottom-right reflects the persisted bindings immediately and can be hidden from Appearance. -- Snapping is enabled by default and can be disabled without changing existing window bounds. Edge panning remains off by default and exposes slow/normal/fast speed. Wheel direction is configurable separately for terminal scrolling and camera zoom. By default, wheel-down scrolls a live terminal down, while camera zoom preserves the original CanvasTTY direction. +- Snapping is enabled by default and can be disabled without changing existing window bounds. Edge panning remains off by default and exposes slow/normal/fast speed. Wheel direction is configurable separately for terminal scrolling and canvas navigation; canvas inversion applies to both pan axes and historical wheel zoom. Fresh profiles pan on ordinary scroll, while migrated profiles retain ordinary wheel zoom, direction, and sensitivity. ## Visual system @@ -35,14 +35,14 @@ This contract preserves the approved MVP concept and prevents feature ownership - Terminal cards keep a `54px` header. At normal scale the header shows the provider mark and terminal working directory until the user explicitly renames the window; a custom title then replaces the path. Close remains visible at the far right; canvas cards do not expose maximize/fullscreen. There is no lifecycle dot in terminal chrome. - After a provider PTY exits, its terminal card exposes restart in the same session with a header action and `Ctrl+D`. Restart preserves the card, title, bounds, scrollback, and xterm instance while issuing a fresh provider process and browser capability. - Terminal cards switch to semantic summary mode below `0.5×`. Summary typography counter-scales as the camera moves farther out so identical cards keep the same readable hierarchy instead of exposing tiny xterm text. -- In semantic summary mode a card is a canvas navigation target: wheel input zooms the camera around it, and clicking the summary always selects it with a visible card outline. Camera focus follows only the configured Off/Single click/Double click mode. At normal scale the live terminal regains wheel ownership. +- In semantic summary mode, clicking a card still selects it with a visible outline and camera focus follows only the configured Off/Single click/Double click mode. Renderer, xterm, and plugin cards follow the same wheel focus matrix as normal scale: only a focused input-bearing card can keep wheel in Off or released-Key mode; On, an active Key binding, or the held full navigation override gives it to the canvas. A Browser summary or other non-native placeholder is always canvas-owned for wheel/pinch. - Every terminal edge and corner is a resize target. The minimum card size is `420 × 260`; resizing updates the xterm viewport and preserves the opposite edge. - Live terminal selection follows the visible pointer position at every canvas zoom. With a non-empty selection, `Ctrl+C`/`Ctrl+Shift+C` (or `Cmd+C`) copies it; `Ctrl+Shift+V`/`Cmd+V` and `Shift+Insert` paste from the system clipboard. Plain `Ctrl+C` without a selection remains the PTY interrupt. `Shift+Enter` sends a line-break sequence (`ESC [ 13 ; 2 u`) to the PTY instead of submitting the line. - Canvas plugin apps use the same movable card grammar, `54px` header, resize/snap behavior, and semantic summary below `0.5×`. A `window` contribution opens a separate CanvasTTY-owned sandboxed window; arbitrary native window embedding is not part of the contract. - The built-in Browser is one movable, resizable core canvas card, not a plugin contribution. It uses the same `54px` outer window header as other canvas cards, with its identity and hide-card action isolated from the internal tab strip below. Its trusted DOM chrome owns compact tabs/favicons, address/search, back/forward/reload, downloads, per-tab provider badges, site dialogs, and explicit Close tab/Close all. Hiding the card keeps its tabs and shared authenticated Chromium profile alive. Below `0.5×`, during Edit HOME, and behind trusted dialogs/popovers, the native page is hidden and replaced with a stable semantic surface. During card or camera motion it remains live and follows frame-coalesced viewport geometry. - An authenticated connection or heartbeat alone never shows agent presence. A branded badge appears only after that agent actually issues a browser command, and its cursor appears only after a real pointer position exists. Claude uses `#D97757`, Codex `#10A37F`, Kimi `#7C5CFC`, and an unknown provider `#7A8291`. Browser Settings separately controls indicator visibility without revoking browser access; the agent-access kill switch revokes the connection itself. - With window snapping enabled, drag and resize use a hidden `10px` grid and a `10px` magnetic threshold for neighboring edges, centers, and a consistent `20px` gap. -- Wheel input belongs to the surface under the pointer when that surface actually scrolls or consumes wheel input. Fresh profiles route wheel input over terminal and native Browser surfaces to the camera so a large card cannot trap canvas navigation; Controls → Zoom over applications can restore local application wheel ownership. The session list keeps local ownership. Passive Home widgets — limits, clock, media, and launcher tiles — allow camera zoom so Home does not shrink the usable zoom area. +- Empty-canvas drag always pans. On fresh profiles ordinary scroll over empty canvas pans on both axes; pinch and `Cmd/Ctrl + scroll` zoom around the pointer. **Use scroll wheel to zoom** restores historical ordinary-wheel zoom and is enabled by migration for existing profiles. Only a focused input widget interrupts canvas wheel/pinch; unfocused or non-focusable widgets leave those events to the canvas. **Wheel/pinch capture over widgets** has Off, On, and Key modes: Off preserves focused-widget ownership, On always transfers only wheel/pinch to the canvas, and Key transfers only wheel/pinch while its binding is held. A focused live Browser page therefore scrolls normally in Off or released-Key mode; unfocused Browser content, On, an active Key binding, the full override, pinch, and `Cmd/Ctrl + scroll` are canvas-owned. The owner selected for a Browser wheel sequence is held until 250 ms of wheel inactivity. Browser summaries and placeholders are always canvas-owned. Fresh profiles use Key with `Command` on macOS or `Ctrl` elsewhere. A separate configurable **Canvas navigation override** (default `Option` on macOS and `Alt` elsewhere) transfers wheel/pinch and drag while held; standalone Command/Ctrl is valid. While full override is held all canvas surfaces show `grab`; an owned drag shows `grabbing` until pointer up/cancel even after the binding is released. - The canvas edge-pans RTS-style while the pointer rests within `56px` of a viewport edge over empty canvas; speed ramps linearly up to `900px/s` at the edge itself. Edge panning is off by default and enabled in Settings. Motion pauses over interactive surfaces (terminal cards, Home, controls) and while drag-panning. - Dialog close actions stay inside their own header/control row with a consistent inset; they never overlap a field, outline, or panel boundary. diff --git a/docs/UI_CONTRACT.ru.md b/docs/UI_CONTRACT.ru.md index 831bd86..f2341ad 100644 --- a/docs/UI_CONTRACT.ru.md +++ b/docs/UI_CONTRACT.ru.md @@ -18,12 +18,12 @@ - Нажатие на провайдера открывает его Focus Card. Провайдер зафиксирован; второго выбора провайдера нет. - Focus Card содержит только mark провайдера, project folder, profile Normal/YOLO, launch action и контекстное подтверждение опасности. -- Settings использует верхнюю полосу General, Appearance, Controls, Browser и Plugins. General владеет языком. Appearance — palette, background pattern, shortcut hint, системными HOME tiles и входом в HOME editor. Controls — click focus, hover focus, window snapping, edge panning, zoom sensitivity, wheel direction и keyboard shortcuts. Browser — agent access, видимостью agent indicators, восстановлением вкладок, downloads, редактированной activity и очисткой browser data. Plugins — install preview, permission review, installed-plugin list, enable/disable/uninstall и contribution actions. Media controls там не появляются. +- Settings использует верхнюю полосу General, Appearance, Controls, Browser и Plugins. General владеет языком. Appearance — palette, background pattern, shortcut hint, системными HOME tiles и входом в HOME editor. Controls — click focus, hover focus, window snapping, edge panning, zoom sensitivity, wheel direction, режимом pan/zoom для обычного scroll, режимом Off/On/Key для wheel/pinch над виджетами, полным canvas navigation override и action shortcuts. Browser — agent access, видимостью agent indicators, восстановлением вкладок, downloads, редактированной activity и очисткой browser data. Plugins — install preview, permission review, installed-plugin list, enable/disable/uninstall и contribution actions. Media controls там не появляются. - Click focus имеет явные режимы Off, Single click и Double click и по умолчанию выключен. Selection и видимая рамка работают даже без camera focus; режим double click никогда не прыгает камерой по первому клику. -- Selection эксклюзивен для терминалов и встроенного Browser: нажатие на пустой canvas снимает его. Выбранная живая поверхность удерживает keyboard focus и теряет его при deselect. -- Hover focus — отдельный, выключенный по умолчанию режим selection. После настраиваемой задержки (медленно `500ms`, обычно `250ms`, быстро `80ms`) терминал или встроенный Browser под указателем выбирается и получает keyboard focus; уход с карточки снимает selection через ту же задержку. +- Сейчас selection эксклюзивен для терминалов и встроенного Browser: нажатие на пустой canvas снимает его. Input focus хранится отдельно, чтобы будущий multi-selection не требовал переопределять wheel ownership. +- Hover focus — отдельный, выключенный по умолчанию режим input-фокуса. После настраиваемой задержки (медленно `500ms`, обычно `250ms`, быстро `80ms`) input-виджет под указателем получает логический focus без изменения selection; keyboard input терминала и native Browser следует за этим focus. Уход указателя только отменяет ещё не сработавший перенос; назначенный focus сохраняется, пока пользователь не кликнет/наведётся на другой focusable-виджет или не кликнет вне всех виджетов. Focusable-поверхности: терминалы, native Browser, plugin iframe/canvas и действительно прокручиваемые HOME-списки; декоративные и action-only tiles фокус не забирают. - Keyboard shortcuts переназначаются пользователем и сохраняются локально. Defaults: `Home` фокусирует Home, `F2` переименовывает выбранное окно терминала. Rename происходит inline в header и не пересоздаёт PTY. Компактная пассивная подсказка справа внизу canvas сразу отражает сохранённые bindings и скрывается в Appearance. -- Snapping включён по умолчанию и выключается без изменения существующих window bounds. Edge panning по умолчанию выключен и имеет slow/normal/fast speed. Направления колеса для terminal scroll и camera zoom настраиваются раздельно. По умолчанию колесо вниз прокручивает живой терминал вниз, а зум камеры сохраняет исходное направление CanvasTTY. +- Snapping включён по умолчанию и выключается без изменения существующих window bounds. Edge panning по умолчанию выключен и имеет slow/normal/fast speed. Направления колеса для terminal scroll и canvas navigation настраиваются раздельно; canvas inversion применяется к обеим осям pan и к историческому wheel zoom. Свежий профиль использует обычный scroll для pan, а мигрированный сохраняет wheel zoom, направление и чувствительность. ## Визуальная система @@ -35,14 +35,14 @@ - Terminal cards сохраняют header `54px`. При нормальном масштабе header показывает provider mark и working directory, пока пользователь явно не переименует окно; custom title затем заменяет путь. Close всегда видимо справа; maximize/fullscreen в canvas cards нет. Lifecycle dot в terminal chrome отсутствует. - После завершения provider PTY его terminal card позволяет перезапустить процесс в той же сессии через header action или `Ctrl+D`. Card, title, bounds, scrollback и xterm сохраняются, а provider process и browser capability создаются заново. - Ниже `0.5×` terminal cards переходят в semantic summary. Typography counter-scale увеличивается при отдалении камеры, чтобы одинаковые карточки сохраняли читаемую иерархию вместо мелкого xterm text. -- В semantic summary карточка становится navigation target канваса: wheel zooms camera вокруг неё, а нажатие всегда выбирает summary с видимой рамкой. Camera focus подчиняется только Off/Single click/Double click. При нормальном масштабе wheel снова принадлежит живому терминалу. +- В semantic summary нажатие по карточке по-прежнему выбирает её с видимой рамкой, а camera focus подчиняется только Off/Single click/Double click. Renderer-, xterm- и plugin-карточки используют ту же wheel focus-матрицу, что и при нормальном масштабе: только focused input-карточка может оставить wheel себе в Off или при отпущенном Key; On, активный Key binding или полный navigation override передаёт его канвасу. Browser summary и другие non-native placeholder-поверхности всегда передают wheel/pinch канвасу. - Любая грань/угол terminal — resize target. Минимальный размер `420 × 260`; resize обновляет viewport xterm и сохраняет противоположную грань. - Selection живого терминала следует за видимой позицией указателя при любом canvas zoom. При непустом выделении `Ctrl+C`/`Ctrl+Shift+C` или `Cmd+C` копирует; `Ctrl+Shift+V`/`Cmd+V` и `Shift+Insert` вставляют из system clipboard. Обычный `Ctrl+C` без выделения остаётся PTY interrupt. `Shift+Enter` отправляет line-break sequence (`ESC [ 13 ; 2 u`) в PTY вместо отправки строки. - Canvas plugin apps используют ту же movable card grammar, header `54px`, resize/snap и semantic summary ниже `0.5×`. Contribution `window` открывает отдельное sandboxed окно под управлением CanvasTTY; произвольные native windows не встраиваются. - Built-in Browser — одна movable/resizable core canvas card, а не plugin contribution. Она использует тот же внешний header `54px`, что и другие canvas cards: identity и hide-card action отделены от внутренней полосы вкладок ниже. Trusted DOM chrome владеет tabs/favicons, address/search, back/forward/reload, downloads, per-tab provider badges, site dialogs и явными Close tab/Close all. Скрытие карточки сохраняет вкладки и общий аутентифицированный Chromium profile. Ниже `0.5×`, в Edit HOME и за trusted dialogs/popovers native page скрывается и заменяется стабильной semantic surface. Во время движения карточки или камеры он остаётся live и следует за frame-coalesced viewport geometry. - Само authenticated connection или heartbeat не показывает agent presence. Branded badge появляется только после реальной browser-команды агента, а cursor — только после появления настоящей pointer position. Claude использует `#D97757`, Codex — `#10A37F`, Kimi — `#7C5CFC`, unknown provider — `#7A8291`. Browser Settings отдельно управляет видимостью индикаторов, не отзывая доступ; kill switch доступа агентов отзывает само подключение. - При включённом snapping drag/resize используют скрытую сетку `10px`, magnetic threshold `10px` для соседних edges/centers и постоянный gap `20px`. -- Wheel input принадлежит поверхности под указателем, если та действительно scrolls/consumes wheel. На свежем профиле wheel input поверх terminal и native Browser направляется на камеру, чтобы большая карточка не блокировала navigation по canvas; Controls → Zoom over applications может вернуть приложениям локальное владение колесом. Session list сохраняет local ownership. Passive Home widgets — limits, clock, media, launcher — разрешают camera zoom, чтобы Home не сокращал usable zoom area. +- Drag по пустому canvas всегда выполняет pan. В свежем профиле обычный scroll над пустым canvas перемещает его по обеим осям, а pinch и `Cmd/Ctrl + scroll` масштабируют вокруг указателя. **Зум обычным скроллом** возвращает исторический wheel zoom и автоматически включается при миграции существующего профиля. Только focused input-виджет прерывает canvas wheel/pinch; над unfocused или нефокусируемым виджетом события остаются канвасу. **Перехват wheel/pinch над виджетами** имеет режимы Off, On и Key: Off сохраняет владение focused-виджета, On постоянно передаёт канвасу только wheel/pinch, а Key делает это при удержании комбинации. Поэтому focused live Browser page прокручивается обычным scroll в Off или при отпущенном Key; unfocused Browser, On, активный Key binding, полный override, pinch и `Cmd/Ctrl + scroll` принадлежат канвасу. Выбранный в начале Browser wheel-sequence владелец сохраняется до 250 мс без wheel-событий. Browser summary и placeholder всегда принадлежат канвасу. Свежий профиль использует Key с `Command` на macOS или `Ctrl` на остальных платформах. Отдельный **Перехват навигации канвасом** (по умолчанию `Option` на macOS и `Alt` на остальных платформах) передаёт wheel/pinch и drag; одиночный Command/Ctrl разрешён. При удержании полного override все canvas-поверхности показывают `grab`, а начатый pan — `grabbing` до pointer up/cancel даже после отпускания комбинации. - Canvas двигается RTS-style, пока указатель находится в пределах `56px` от viewport edge над пустым canvas; скорость линейно возрастает до `900px/s` на самой границе. Edge panning выключен по умолчанию и включается в Settings. Motion останавливается над interactive surfaces и во время drag-pan. - Dialog close actions остаются внутри своей header/control row с одинаковым inset и не перекрывают field, outline или panel boundary. diff --git a/docs/UI_CONTRACT.zh-CN.md b/docs/UI_CONTRACT.zh-CN.md index a1fb273..2bd0262 100644 --- a/docs/UI_CONTRACT.zh-CN.md +++ b/docs/UI_CONTRACT.zh-CN.md @@ -18,12 +18,12 @@ - 点击服务商会打开该服务商的 Focus Card。服务商固定,不提供第二个 provider selector。 - Focus Card 只包含 provider mark、project folder、Normal/YOLO profile、launch action 与上下文危险确认。 -- Settings 顶部使用 General、Appearance、Controls、Browser、Plugins 分区。General 负责语言;Appearance 负责 palette、background pattern、shortcut hint、system HOME tile 与 HOME editor 入口;Controls 负责 click focus、hover focus、window snapping、edge panning、zoom sensitivity、wheel direction 与 keyboard shortcut;Browser 负责 agent access、agent indicator visibility、tab restore、download、脱敏 activity 与 browser data 清理;Plugins 负责 install preview、permission review、installed-plugin list、enable/disable/uninstall 与 contribution action。Media control 不出现在这里。 +- Settings 顶部使用 General、Appearance、Controls、Browser、Plugins 分区。General 负责语言;Appearance 负责 palette、background pattern、shortcut hint、system HOME tile 与 HOME editor 入口;Controls 负责 click focus、hover focus、window snapping、edge panning、zoom sensitivity、wheel direction、普通 scroll 的 pan/zoom、widget 上 Off/On/Key wheel/pinch capture、完整 canvas navigation override 与 action shortcut;Browser 负责 agent access、agent indicator visibility、tab restore、download、脱敏 activity 与 browser data 清理;Plugins 负责 install preview、permission review、installed-plugin list、enable/disable/uninstall 与 contribution action。Media control 不出现在这里。 - Click focus 有 Off、Single click、Double click 三种明确模式,默认 Off。即使 camera focus 为 Off,selection 与可见 outline 仍然有效;Double click 模式不会在第一次点击时跳转 camera。 -- Terminal 与内置 Browser 之间的 selection 是排他的:点击空白 canvas 会清除选择。被选中的实时界面持有 keyboard focus,取消选择时失去焦点。 -- Hover focus 是独立且默认关闭的 selection 模式。经过可配置延迟(慢速 `500ms`、正常 `250ms`、快速 `80ms`),指针下的终端或内置 Browser 被选中并获得 keyboard focus;离开卡片后以相同延迟取消选择。 +- 当前 Terminal 与内置 Browser 之间的 selection 是排他的:点击空白 canvas 会清除选择。Input focus 独立保存,因此未来加入 multi-selection 时无需重新定义 wheel ownership。 +- Hover focus 是独立且默认关闭的 input-focus 模式。经过可配置延迟(慢速 `500ms`、正常 `250ms`、快速 `80ms`),指针下具备 input 的 widget 会获得逻辑 focus,但不会改变 selection;Terminal 与 native Browser 的 keyboard input 会跟随该 focus。指针离开只会取消尚未触发的 focus 转移;已分配的 focus 会一直保留,直到点击或悬停到另一个 focusable widget,或点击所有 widget 之外。可聚焦界面包括 Terminal、native Browser、plugin iframe/canvas,以及真正可滚动的 HOME 列表;装饰性和仅执行 action 的 tile 不会获取 focus。 - Keyboard shortcut 可由用户重映射并在本地持久化。默认 `Home` 聚焦 Home 区域,`F2` 重命名选中的终端窗口。Rename 在 header 内联完成,不会重建 PTY。Canvas 右下角的紧凑被动提示立即反映持久化 binding,并可在 Appearance 中隐藏。 -- Snapping 默认开启,关闭时不改变现有 window bounds。Edge panning 默认关闭,速度可选 slow/normal/fast。Terminal scroll 与 camera zoom 的滚轮方向分别配置。默认滚轮向下会让实时终端向下滚动,camera zoom 保留 CanvasTTY 原有方向。 +- Snapping 默认开启,关闭时不改变现有 window bounds。Edge panning 默认关闭,速度可选 slow/normal/fast。Terminal scroll 与 canvas navigation 的滚轮方向分别配置;canvas inversion 同时作用于两个 pan 轴和历史 wheel zoom。新配置使用普通 scroll 进行 pan,迁移后的配置保留 wheel zoom、方向与灵敏度。 ## 视觉系统 @@ -35,14 +35,14 @@ - Terminal card 保持 `54px` header。正常 scale 下,在用户明确重命名前,header 显示 provider mark 与 terminal working directory;custom title 随后替换路径。Close 始终显示在最右侧;canvas card 不提供 maximize/fullscreen。Terminal chrome 不显示 lifecycle dot。 - Provider PTY 退出后,可通过 header action 或 `Ctrl+D` 在同一 session 中重启。Card、title、bounds、scrollback 与 xterm 保持不变,同时创建新的 provider process 与 browser capability。 - Terminal card 在低于 `0.5×` 时切换为 semantic summary。Camera 越远,summary typography 反向缩放,使相同卡片保持一致、可读的层级,而不是显示微小 xterm text。 -- Semantic summary 模式中,卡片是 canvas navigation target:wheel 围绕卡片 zoom camera;点击 summary 总会选中卡片并显示 outline。Camera focus 只遵循 Off/Single click/Double click。恢复正常 scale 后,实时终端重新拥有 wheel。 +- Semantic summary 模式中,点击卡片仍会选中并显示 outline,camera focus 只遵循 Off/Single click/Double click。Renderer、xterm 与 plugin 卡片使用和正常 scale 相同的 wheel focus 矩阵:只有 focused 且具备 input 的卡片能在 Off 或 Key 未按下时保留 wheel;On、激活的 Key binding 或完整 navigation override 会把它交给 canvas。Browser summary 与其他 non-native placeholder surface 的 wheel/pinch 始终归 canvas。 - Terminal 任意 edge/corner 都是 resize target。最小 card size 为 `420 × 260`;resize 更新 xterm viewport 并保持对边不动。 - 在任意 canvas zoom 下,实时终端 selection 都跟随可见指针位置。有文字选择时,`Ctrl+C`/`Ctrl+Shift+C` 或 `Cmd+C` 复制;`Ctrl+Shift+V`/`Cmd+V` 与 `Shift+Insert` 从系统剪贴板粘贴。没有选择时,普通 `Ctrl+C` 仍是 PTY interrupt。`Shift+Enter` 向 PTY 发送换行 sequence(`ESC [ 13 ; 2 u`),而不是提交当前行。 - Canvas plugin app 使用同一 movable card grammar、`54px` header、resize/snap behavior,以及 `0.5×` 以下的 semantic summary。`window` contribution 打开 CanvasTTY 管理的独立 sandbox window;不支持嵌入任意原生窗口。 - 内置 Browser 是唯一的可移动、可调整尺寸 core canvas card,而不是 plugin contribution。它使用与其他 canvas card 相同的 `54px` 外层窗口 header,将 identity 与 hide-card action 和下方内部 tab strip 分开。可信 DOM chrome 负责 tab/favicon、address/search、back/forward/reload、download、per-tab provider badge、site dialog 以及明确的 Close tab/Close all。隐藏卡片会保留 tab 与共享的已认证 Chromium profile。低于 `0.5×`、Edit HOME 期间及可信 dialog/popover 后方,native page 会由稳定 semantic surface 替代;卡片或 camera 移动时它保持实时渲染,并跟随按帧合并的 viewport geometry。 - 仅有 authenticated connection 或 heartbeat 不会显示智能体 presence。智能体实际发出 browser command 后才显示品牌 badge,获得真实 pointer position 后才显示 cursor。Claude 使用 `#D97757`,Codex 使用 `#10A37F`,Kimi 使用 `#7C5CFC`,未知 provider 使用 `#7A8291`。Browser Settings 可单独控制 indicator visibility 而不撤销访问;智能体访问 kill switch 会撤销连接本身。 - Window snapping 开启时,drag/resize 使用隐藏 `10px` grid、相邻 edge/center 的 `10px` magnetic threshold,以及一致的 `20px` gap。 -- Wheel input 属于指针下真正滚动或消费 wheel 的界面。新配置默认把 terminal 与 native Browser 上的 wheel input 路由到 camera,避免大卡片困住 canvas navigation;Controls → Zoom over applications 可恢复应用的本地 wheel ownership。Session list 保留本地 ownership。Limits、clock、media 与 launcher 等被动 Home widget 仍允许 camera zoom,避免 Home 缩小可用 zoom area。 +- 在空白 canvas 上 drag 始终执行 pan。新配置中,空白 canvas 上的普通 scroll 会沿两个轴移动,pinch 与 `Cmd/Ctrl + scroll` 会以指针为中心缩放。**Use scroll wheel to zoom** 恢复历史 wheel zoom,并在迁移现有配置时自动启用。只有 focused input widget 会中断 canvas wheel/pinch;在 unfocused 或不可聚焦 widget 上,事件仍归 canvas。**Wheel/pinch capture over widgets** 有 Off、On、Key 三种模式:Off 保留 focused-widget ownership,On 始终只交给 canvas,Key 仅在按住绑定键时交给 canvas。因此,focused live Browser page 在 Off 或 Key 未按下时会由普通 scroll 原生滚动;unfocused Browser、On、激活的 Key binding、完整 override、pinch 与 `Cmd/Ctrl + scroll` 都归 canvas。Browser wheel sequence 开始时选定的 owner 会保持到 250 ms 没有 wheel event。Browser summary 与 placeholder 始终归 canvas。新配置默认使用 Key,在 macOS 为 `Command`,其他平台为 `Ctrl`。独立的 **Canvas navigation override**(macOS 默认 `Option`,其他平台默认 `Alt`)接管 wheel/pinch 与 drag,也允许单独 Command/Ctrl。按住完整 override 时所有 canvas surface 显示 `grab`;已开始的 pan 会显示 `grabbing`,并由 canvas 持有到 pointer up/cancel。 - 指针停在空白 canvas 的 viewport edge `56px` 范围内时,canvas 以 RTS 风格 edge-pan;速度线性增加,到边缘达到 `900px/s`。Edge panning 默认关闭,在 Settings 启用。指针位于 interactive surface 或正在 drag-pan 时暂停。 - Dialog close action 留在自己的 header/control row 内,使用一致 inset,不覆盖 field、outline 或 panel boundary。 diff --git a/docs/adr/ADR-20260808-intent-aware-canvas-navigation.md b/docs/adr/ADR-20260808-intent-aware-canvas-navigation.md new file mode 100644 index 0000000..8e0a9df --- /dev/null +++ b/docs/adr/ADR-20260808-intent-aware-canvas-navigation.md @@ -0,0 +1,225 @@ +# ADR: Use Intent-Aware Canvas Navigation + +**Date:** 2026-08-08 +**Scope / Component:** canvas input, widget ownership, navigation settings, shortcuts, and embedded surfaces +**Risk/Strictness Profile:** Production +**Status:** Accepted +**Related:** [ADR: Preserve Native Browser Wheel Continuity](./ADR-20260808-native-browser-wheel-continuity.md) +**Implementation:** [`canvasNavigation`](../../src/shared/canvasNavigation.ts), [`SettingsStore`](../../src/main/services/SettingsStore.ts), [`CanvasNavigationOverride`](../../src/main/services/CanvasNavigationOverride.ts), and [`WorkspaceCanvas`](../../src/renderer/src/features/workspace/WorkspaceCanvas.tsx) + +## Context and Problem Statement + +CanvasTTY historically interpreted canvas-owned `wheel` input as zoom and ignored `deltaX`. +That preserves a conventional mouse-wheel zoom workflow, but turns two-finger trackpad scroll into +zoom, loses diagonal movement, and does not distinguish pinch-shaped input. + +Neither DOM `WheelEvent` nor Electron `MouseWheelInputEvent` identifies a physical trackpad +reliably. Fractional deltas, event frequency, horizontal movement, and precision flags also occur +with Magic Mouse and high-resolution wheels. The contract therefore has to classify observable +intent rather than infer a device. + +The canvas also contains independently interactive DOM, xterm, plugin iframe, and native Browser +surfaces. Selection cannot define wheel ownership: focus is single-valued while future selection +may be multi-valued, and decorative widgets must not become wheel sinks. Users additionally need +to distinguish temporary wheel-only capture from a full navigation override that also owns drag. + +## Decision Drivers + +- New profiles need two-axis scroll-to-pan and pinch-to-zoom behavior. +- Existing profiles must keep their saved mouse-wheel zoom direction, sensitivity, and legacy + over-widget behavior. +- One focusable input widget may interrupt canvas wheel navigation; non-focusable and unfocused + widgets may not. +- Wheel-only capture and full wheel/pointer capture need independent bindings and visible scope. +- Ownership must be decided before calling `preventDefault()`. +- Equivalent input from DOM, xterm, plugin, and native Browser paths must reach one classifier. +- The design must not depend on physical-device detection. + +## Options Considered + +### Keep every canvas wheel event as zoom + +This preserves the old implementation but cannot provide two-axis trackpad pan and discards +horizontal intent. Rejected. + +### Detect mouse versus trackpad + +This could choose a device-specific profile automatically, but the available fields describe an +event stream rather than hardware. Misclassification would change behavior during a gesture and +would not be portable across Chromium platforms. Rejected. + +### Keep permanent and temporary wheel capture as separate toggles + +An Always toggle plus a temporary shortcut can both be active and describe overlapping policy. +`Off / On / Key` are mutually exclusive forms of the same wheel-only ownership choice. Rejected. + +### Derive ownership from selection or every widget under the pointer + +Selection does not cover every embedded input surface and must remain free to become +multi-valued. Raw hit testing makes action-only widgets wheel sinks. Rejected. + +### Classify intent, use explicit logical focus, and separate wheel-only from full override + +Ordinary scroll pans for new profiles; modifier/pinch input zooms; a persisted setting restores +ordinary wheel zoom. A single logical focus and the `Off / On / Key` mode determine whether a +focused widget keeps wheel input. A separate held binding owns wheel and pointer navigation. +Selected. + +## Decision Outcome + +### Settings and migration + +The runtime contract is: + +```ts +useScrollWheelToZoom: boolean; +canvasWheelCaptureMode: "off" | "always" | "key"; +canvasWheelOverride: string | null; +canvasNavigationOverride: string | null; +``` + +Fresh profiles use scroll-to-pan, Key capture, `Meta` on macOS or `Ctrl` on Windows/Linux for the +wheel-only binding, and `Alt` for full navigation. The UI renders `Alt` as Option on macOS. +`canvasWheelOverride` remains stored while the mode is Off or On so returning to Key restores the +binding. + +Existing profiles without `useScrollWheelToZoom` migrate to `true`. The legacy +`zoomOverApplications` key exists only at the `SettingsStore` persistence boundary: + +| Saved state without the new mode | Migrated mode | +|---|---| +| `zoomOverApplications=true` | `always` | +| `zoomOverApplications=false` and valid binding | `key` | +| `zoomOverApplications=false` without binding | `off` | +| Legacy key absent | Current `key` default; legacy key remains absent | +| Invalid or action-conflicting Key binding | `off`; action shortcuts remain unchanged | + +An explicit mode change starts compatibility writes: `always` writes the legacy value `true`; +`off` and `key` write `false`. Normalized settings are written to a temporary file and atomically +renamed. + +### Binding contract + +Both overrides accept modifier-only or modifier-based chords such as `Meta`, `Ctrl+Alt`, and +`Alt+Space`; a bare ordinary key is invalid. Extra held modifiers do not deactivate a configured +binding. Letters and digits are normalized from physical codes so layout changes do not alter a +saved chord. + +Modifier-only bindings do not conflict with action shortcuts and do not reserve ordinary keys. +A chord containing an ordinary key is checked against action shortcuts and reserves that key only +after its modifier prefix is held. The two override bindings may be identical; the UI warns that +full navigation dominates because it also owns drag. + +Shortcut capture suspends both existing bindings, commits a modifier-plus-key chord on the +ordinary key's `keydown`, retains a modifier-only chord until `keyup`, cancels on Escape, and uses +a synchronous commit guard before asynchronous persistence. Blur, destroyed contents, binding +changes, and capture suspension reset held state. Electron menu shortcuts remain available for +standalone Meta/Ctrl/Shift; standalone Alt and ordinary-key chords use menu suppression where +needed to observe their complete transition without a stuck override. + +### Logical focus and ownership + +Logical input focus is independent from selection. Click assigns focus only to an opt-in focusable +widget. Hover transfers it only after the configured delay. Leaving a widget cancels a pending +transfer but does not clear assigned focus. Clicking a non-focusable widget preserves focus; only +a click outside all widgets clears it. Terminals, native Browser, plugin iframe/canvas surfaces, +and actually scrollable HOME lists opt in. This leaves selection free to become multi-valued. + +| Location and condition | Wheel/pinch owner | Primary drag owner | +|---|---|---| +| Empty canvas | Canvas | Canvas | +| Non-focusable or unfocused widget | Canvas | Widget unless normal canvas hit testing applies | +| Focused widget, Off | Widget | Widget | +| Focused widget, On | Canvas | Widget | +| Focused widget, Key released | Widget | Widget | +| Focused widget, Key held | Canvas | Widget | +| Any widget, full override held | Canvas | Canvas | + +The native Browser follows this policy at a synchronous preload boundary and then applies the +continuity mechanism in the related ADR. Placeholder/summary content is not a live page and is +always canvas-owned. + +Full-override drag is latched through pointer up or cancel even if the chord is released. Its +follow-up click is consumed regardless of movement threshold so the underlying widget cannot +focus, select, close, or activate. Ordinary canvas drag retains its existing threshold behavior. + +### Intent classifier + +All canvas-owned wheel paths use the same shared classifier: + +1. `ctrlKey || metaKey` performs focal zoom with + `clamp(exp(-deltaY / 100), 0.75, 1.25)`. +2. Otherwise, `useScrollWheelToZoom=true` performs historical wheel zoom using + `invertCanvasWheel` and `zoomSensitivity`. +3. Otherwise, input pans in screen space with `camera.x -= deltaX` and + `camera.y -= deltaY`. + +Pan inversion changes both axes. Shift does not invent an axis remap. DOM line deltas use 16 CSS +pixels and page deltas use the current viewport. Pan deltas are accumulated once per animation +frame without application inertia; pending pan is flushed before zoom to preserve event order. +Modifier/pinch zoom intentionally ignores wheel inversion and sensitivity. Zoom remains anchored +at the event's client coordinates and bounded by the existing camera limits. + +### Embedded surfaces and cursor + +The workspace installs one non-passive capture listener. xterm's coordinate adapter exits before +processing canvas-owned wheel. Internal plugin HTML receives a host bridge that cancels and +relays wheel only when effective capture is active; messages are accepted only from the expected +frame source and after schema, coordinate, and finite-delta validation. During full override, +plugin frames become hit-test transparent so pointer capture begins in the workspace without +focusing the plugin. + +Wheel-only capture never changes the cursor. Full navigation forces `grab` across workspace DOM, +xterm, plugin, and native Browser surfaces. A canvas-owned pointer gesture forces `grabbing` until +completion. Native Browser cursor CSS uses user origin, `!important`, and generation-guarded +insert/remove operations across navigation and destruction. + +## Invariants + +1. Production code does not infer a physical input-device identity. +2. Blank-canvas primary drag always pans. +3. New profiles pan on ordinary scroll; migrated profiles retain ordinary scroll zoom. +4. An absent legacy key stays absent and is not treated as explicit `false` or `true`. +5. Pinch and `Cmd/Ctrl + scroll` perform focal zoom whenever the canvas owns the event. +6. Wheel-only capture never owns pointer drag; full override owns wheel and drag. +7. Only the focused, focusable widget may interrupt canvas wheel input without an override. +8. Ownership is decided before cancellation; camera pan may be frame-coalesced. +9. Pan preserves both axes and native momentum and adds no inertia. +10. Modifier-only bindings preserve ordinary application shortcuts. +11. Focus and selection remain independent; this decision does not restrict future multi-select. + +## Consequences and Mitigations + +- Trackpad navigation works without device heuristics and existing saved mouse profiles keep their + behavior. +- New mouse-only profiles pan until the user enables ordinary wheel zoom; the Controls setting + makes that choice explicit. +- Reserved ordinary-key chords cannot reach a focused widget while held. Modifier-only bindings, + Disabled full override, and Off wheel mode remain available. +- Plugin and native Browser boundaries require trusted bridges, bounded payloads, and dedicated + lifecycle handling. Their policy remains shared even though transport differs. +- Chromium-generated pinch and physical `Ctrl + wheel` are indistinguishable and intentionally + map to the same zoom intent. + +## Validation and Confidence + +Unit coverage fixes the classifier order, delta modes, both axes, inversion, clamp, migration, +binding normalization, shortcut conflicts, focus ownership, xterm/plugin routing, pointer latch, +and menu-shortcut policy. Browser-specific integration is covered by the related ADR. + +The intent and storage contracts have high confidence because they are pure, deterministic, and +covered at their I/O boundaries. Cross-platform gesture feel still requires the release matrix on +macOS, Windows Precision Touchpad, and Linux/libinput; this does not change the accepted device- +agnostic contract. + +## Open Questions Outside This Decision + +- A future best-effort Auto mode may be researched separately. +- `hasPreciseScrollingDeltas` is not used by this decision and is not a device identifier. + +## References + +- [W3C UI Events: Wheel Events](https://w3c.github.io/uievents/split/wheel-events.html) +- [Electron MouseWheelInputEvent](https://www.electronjs.org/docs/latest/api/structures/mouse-wheel-input-event) +- [Chromium touchpad pinch event queue](https://chromium.googlesource.com/chromium/src.git/+/refs/heads/lkgr/components/input/touchpad_pinch_event_queue.cc) +- [Figma: Pan and zoom in FigJam](https://help.figma.com/hc/en-us/articles/1500004414582-Pan-and-zoom-in-FigJam) diff --git a/docs/adr/ADR-20260808-native-browser-wheel-continuity.md b/docs/adr/ADR-20260808-native-browser-wheel-continuity.md new file mode 100644 index 0000000..94dc9f8 --- /dev/null +++ b/docs/adr/ADR-20260808-native-browser-wheel-continuity.md @@ -0,0 +1,253 @@ +# ADR: Preserve Native Browser Wheel Continuity + +**Date:** 2026-08-08 +**Scope / Component:** native Browser wheel ownership, cross-surface hit testing, frozen rendering, and pointer routing +**Risk/Strictness Profile:** Production +**Status:** Accepted +**Related:** [ADR: Use Intent-Aware Canvas Navigation](./ADR-20260808-intent-aware-canvas-navigation.md) +**Implementation:** [`BrowserCanvasGestureController`](../../src/main/services/browser/BrowserCanvasGestureController.ts), [`BrowserCanvasPointerRouter`](../../src/main/services/browser/BrowserCanvasPointerRouter.ts), [`BrowserCanvasFreeze`](../../src/main/services/browser/BrowserCanvasFreeze.ts), [`BrowserCanvasSinkViewport`](../../src/main/services/browser/BrowserCanvasSinkViewport.ts), [`browser` preload](../../src/preload/browser.ts), and [`BrowserCard`](../../src/renderer/src/features/browser/BrowserCard.tsx) + +## Context and Problem Statement + +The canvas lives in the owner renderer while a live Browser page lives in a child Electron +`WebContentsView`. These are separate native input and composition surfaces. Correctly classifying +an event as page- or canvas-owned does not make an operating-system wheel sequence move safely +between those surfaces. + +Live macOS testing established two boundary failures: + +1. An owner-renderer pan stopped when the moving native Browser rectangle reached the stationary + cursor. The child view became the hit-test target before owner DOM could observe another event. +2. A canvas-owned gesture beginning inside an unfocused Browser relayed one event, then stalled + when the native view was hidden. macOS retained the active sequence against the original + `WebContentsView` rather than retargeting it to the owner renderer. + +Keeping only a 4 DIP parent clip preserved the target but did not reliably clip the child visual; +the Browser appeared as a detached static rectangle. Page CSS opacity replaced it with a black +native compositor surface and required a restoration acknowledgement race. Shrinking the actual +child fixed composition but changed the page viewport, causing responsive reflow and resize side +effects. Electron device emulation is needed to decouple the physical hit-test surface from the +page's logical viewport. + +The solution must decide ownership synchronously, latch it through momentum, preserve the original +native target when a Browser-origin canvas sequence needs it, remove broad native hit testing, and +keep the Browser card visually coherent while it moves or zooms. + +## Decision Drivers + +- Focused Browser plain scroll must remain page-owned in Off or released-Key mode. +- Pinch-shaped `Ctrl/Meta` wheel and every policy-selected canvas gesture must be cancellable before + page delivery. +- Ownership may not change mid-sequence because focus, modifiers, frame target, or override state + changes. +- Canvas gestures must remain continuous in both Browser-to-canvas and canvas-to-Browser + directions. +- The same `WebContentsView` target must survive Browser-origin momentum without retaining a + full-size stale hit region or changing page layout. +- Placeholder/summary transitions must not terminate a still-active canvas gesture. +- Click, selection, context menu, focus, and full-navigation pointer ownership must remain defined + while the native surface is substituted. +- The workaround must apply to wheel-class input without trackpad detection. + +## Options Considered + +### Always make Browser wheel canvas-owned + +This avoids a focus policy but prevents ordinary scrolling in a focused page. It was implemented +and then rejected after live use showed the page could no longer scroll naturally. + +### Cancel native wheel only in `before-mouse-event` or `input-event` + +Electron's main-process notifications did not reliably prevent Chromium page scroll for the +observed macOS path. Cancellation and relay also risked coming from different events. Rejected. + +### Hide the native view and rely on retargeting + +This solves owner-origin entry but Browser-origin momentum remained bound to the hidden target and +stopped after one event. Rejected. + +### Keep a full-size child inside a small parent clip + +Input continuity worked, but live composition showed the child outside the expected parent bounds +as a detached Browser rectangle. Parent clipping was not a sufficient visual boundary. Rejected. + +### Conceal the native page with opacity or a restoration acknowledgement + +CSS opacity controlled document pixels, not the complete native compositor, producing a black +rectangle. The asynchronous acknowledgement introduced a race without solving hit testing. +Rejected. + +### Render unfocused Browser pages permanently as screenshots + +This removes native hit testing during canvas use but requires continuous GPU readback, encoding, +freshness policy, memory, and activation transitions for all unfocused browsing. The defect is +sequence-local, so the permanent cost and interaction model are disproportionate. Rejected. + +### Patch Electron or install a platform event tap + +This could control scroll targeting below the application layer but creates a platform-specific +runtime fork and ongoing upgrade burden. It remains a fallback only if the bounded native-sink +contract fails on a supported platform. Rejected for the current implementation. + +### Use synchronous preload arbitration plus a frozen frame and emulated 4 DIP native sink + +The frame that can cancel page delivery chooses the owner once. Canvas-owned sequences replace the +moving native visual with a local screenshot. Browser-origin sequences keep the exact child target +in a cursor-local physical sink while device emulation preserves its original logical viewport. +Owner-origin sequences remove the child before it crosses the pointer. Selected. + +## Decision Outcome + +### Synchronous focus-aware ownership + +A self-contained registered `frame` preload installs a trusted, non-passive capture listener. On +the first wheel after 250 ms idle it synchronously requests `page | canvas` from main, caches the +decision and generation, and sends later sequence updates asynchronously. Top frame and nested +iframe events share the tab-scoped decision. + +| Initial live-page condition | Owner | +|---|---| +| `Ctrl/Meta` wheel or pinch-shaped input | Canvas | +| Full navigation override active | Canvas | +| Wheel capture On | Canvas | +| Wheel capture Key and binding active | Canvas | +| Browser not logically focused | Canvas | +| Browser focused, Off | Page | +| Browser focused, Key with binding released | Page | + +Page-owned input is not cancelled or relayed. Canvas-owned input is synchronously cancelled in +the frame and relayed exactly once after validation. Malformed or stale payloads fail closed. The +preload remains self-contained so its registered sandbox entry does not depend on a bundler shared +chunk; a contract test fixes its local idle value to the main-process 250 ms constant. + +Logical Browser focus is assigned by native pointer down or delayed hover and is cleared by click +outside widgets or Browser lifecycle removal. Selection remains separate. Placeholder/summary +content has no live page and remains canvas-owned. + +### Bidirectional sequence continuity + +For a Browser-origin canvas sequence, main performs the following before returning from the +synchronous ownership request: + +1. Start the tab-scoped owner sequence at an owner-DIP point. +2. Preserve the page's current logical viewport with Electron device emulation. +3. Capture or reuse the active tab's frozen frame. +4. Resize the physical native target to a 4 DIP cursor-local sink. +5. Show the frozen frame in the moving Browser card while the same native target continues to + receive the sequence through the frame preload. + +For a sequence starting in the owner renderer, the renderer synchronously arms main before +applying camera intent. As soon as the moving Browser bounds, expanded by a 4 DIP guard, would +cover the cursor, the collision latches and main removes the native surface from hit testing before +applying intersecting bounds. Subsequent wheel reaches the owner DOM frozen surface. Both paths +share the same 250 ms idle boundary and lifecycle reset. + +The first frame is captured locally from the active tab. Captures are coalesced and generation- +guarded; stale tab results are discarded. JPEG quality is 70 with a 1.5 MiB limit and progressive +downscaling. Capture failure reuses the previous frame for that tab or the normal viewport +background. Active freeze does not recapture for every camera or bounds update; one deferred +refresh runs after native restoration. + +Viewport state is explicit: + +```ts +type BrowserViewportSurface = "native" | "placeholder" | "hidden"; +``` + +`native -> placeholder` does not end an active canvas sequence or restore the native view under +the cursor. Only `hidden` and real lifecycle boundaries reset immediately. Resets cover blur, +owner hide/close, tab switch, navigation, crash, tab destruction, Browser close, and service +disposal. + +Restoration clears pointer relays, removes the sink decision, synchronizes normal native layout, +restores device emulation, and only then deactivates the DOM frozen frame. This ordering prevents +an empty visual gap and leaves one effective hit surface. + +### Pointer and cursor routing + +Full-navigation Browser drag is cancelled before normal Browser focus and remains canvas-owned +through mouse up/cancel even if the binding is released. `grab` and latched `grabbing` cursor state +are generation-guarded inside Browser content. + +While a frozen frame is standing in for a page, ordinary click, selection, and context-menu input +is forwarded as one complete native pointer sequence to the Browser. While the cursor-local sink +is active, a new pointer sequence is resolved to the restored Browser or owner based on current +geometry and remains on that target through completion. Full navigation always takes precedence +and leaves pointer input on the canvas. + +The public wheel relay is intentionally compact: + +```ts +interface BrowserCanvasWheelEvent { + tabId: string; + clientX: number; + clientY: number; + deltaX: number; + deltaY: number; + ctrlKey: boolean; + metaKey: boolean; +} +``` + +Capture mode, focus, and override state are authoritative in main and are not repeated in each +relay payload. + +## Invariants + +1. A wheel owner is selected once per 250 ms sequence and does not change mid-sequence. +2. Page-owned input is neither cancelled nor relayed; canvas-owned input is cancelled before page + delivery and relayed at most once. +3. Browser-origin canvas momentum keeps the original native target in a 4 DIP sink while its + logical viewport remains unchanged. +4. Owner-origin canvas momentum removes the broad native hit target before it covers the cursor. +5. The frozen representation follows current canvas geometry; the sink remains cursor-local. +6. Placeholder transitions do not end a sequence; real hidden/lifecycle transitions do. +7. Full-navigation pointer ownership dominates wheel-only policy and frozen/sink forwarding. +8. Mouse wheel, trackpad, pinch, and Magic Mouse use the same wheel-class mechanism without + physical-device inference. +9. Cached frame and asynchronous cursor/capture work cannot outlive their generation or tab. + +## Consequences and Mitigations + +- Canvas pan and zoom remain continuous across a native Browser boundary in both directions. +- A focused Browser retains native plain-wheel page scrolling under Off or released Key. +- During a canvas-owned Browser-origin sequence, the visual page is a frozen frame for at most the + idle interval after the last event. This is the accepted tradeoff for stable native targeting. +- The 4 DIP native sink and device emulation add Electron-specific state. Dedicated controllers + isolate this from tab/navigation/download orchestration and are unit-tested through narrow host + ports. +- `BrowserService` remains the Electron lifecycle composition root because tab identity, native + view mounting, navigation, downloads, and session policy share one `WebContents` lifecycle; it + delegates all canvas sequence, freeze, pointer, and cursor state to the dedicated controllers. +- Screenshot capture has bounded CPU, GPU readback, encoding, and memory work and is not a + permanent rendering mode. +- If a future Electron version changes child-view hit testing, device emulation, or registered + frame preloads, the integration smoke and live boundary matrix must be rerun before removing the + workaround. + +## Validation and Confidence + +Unit tests cover focus/capture ownership, 250 ms latch, stale generation, coordinate conversion, +both axes and modifiers, collision geometry in every direction, 4 DIP guard and sink, capture +fallback, viewport preservation, restoration order, pointer/cursor lifecycle, placeholder +transitions, and all lifecycle resets. The Electron smoke is the required integration boundary for +registered preload installation, real `sendInputEvent`, page `scrollY`, canvas relay, nested +frames, freeze IPC, and non-empty capture. + +Live macOS testing confirmed the final causal chain and result: focused page scroll works; +Browser-to-canvas and canvas-to-Browser pan remain continuous; pinch/Command-scroll zoom remains +canvas-owned; and zoom through the placeholder threshold does not terminate the sequence. This is +high confidence for macOS. Windows Precision Touchpad and Linux/libinput use the same contracts but +have not received the same live release matrix, so confidence for those platforms is medium until +that matrix is completed. + +The decision is falsified if a canvas-owned sequence stops at a native boundary, a page-owned +sequence changes canvas camera, a native sink changes page layout or scroll state, a stale frame +appears for another tab, or native and frozen surfaces are simultaneously absent after reset. + +## References + +- [Electron registered preload scripts](https://www.electronjs.org/docs/latest/api/session#sesregisterpreloadscriptpreload) +- [Electron `ipcRenderer.sendSync`](https://www.electronjs.org/docs/latest/api/ipc-renderer#ipcrenderersendsyncchannel-args) +- [Electron issue #32751](https://github.com/electron/electron/issues/32751) +- [Electron 43.2.0 macOS native hit testing](https://github.com/electron/electron/blob/v43.2.0/shell/browser/native_window_mac.mm#L111-L148) diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..d32842c --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,26 @@ +# Architecture Decision Records + +This directory contains durable records of significant architectural decisions. + +## Naming + +Use `ADR-YYYYMMDD-.md` unless the repository establishes a stronger +local convention. + +## Lifecycle + +Use `Proposed`, `Accepted`, `Deprecated`, and `Superseded` as decision states. Accepted ADRs are +append-only historical records: when reality changes, write a successor and link both records +instead of rewriting the old rationale. + +## Required Content + +An ADR captures one significant decision, its context, considered alternatives, concrete +rejection reasons, the chosen outcome, consequences, mitigations, and invariants. A description +of implementation without a consequential choice is not an ADR. Missing rationale is an explicit +`TODO:`, never an invented best-practice explanation. + +## Placement + +System-wide decisions live here. A repository may establish module-local or infrastructure ADR +directories when ownership is narrower; project instructions always override this fallback. diff --git a/docs/getting-started.md b/docs/getting-started.md index f0f7d59..e86f2f0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -33,16 +33,17 @@ The **YOLO** profile disables provider safety prompts where the provider support ## Terminal input and controls -- Press any live terminal card to select it. The selected card receives xterm keyboard focus immediately, so typing is sent to its PTY without a second press inside the text area. -- Press empty canvas to clear the selection, keyboard focus, and visible outline. -- **Settings → Controls → Focus on hover** can select the terminal under the pointer and clear it after leaving. The same delay applies in both directions: slow `500ms`, normal `250ms`, or fast `80ms`. It is off by default. -- Terminal scrolling and canvas zoom have independent wheel-direction settings. By default, wheel-down scrolls down in a live terminal, while canvas zoom keeps the original CanvasTTY direction. +- Press any live terminal card to select and focus it. Input focus is independent from selection, so future multi-selection can keep one unambiguous keyboard and wheel target. +- Click outside every widget to clear input focus. Clicking a decorative or action-only widget does not make it the wheel target. +- **Settings → Controls → Focus on hover** moves input focus after the pointer rests on a focusable widget for slow `500ms`, normal `250ms`, or fast `80ms`. Leaving only cancels a pending transfer; assigned focus remains and selection is unchanged. It is off by default. +- On a fresh profile, ordinary scroll over canvas, unfocused widgets, and non-focusable widgets pans on both axes; pinch and `Cmd/Ctrl + scroll` zoom around the pointer. A focused input widget keeps plain wheel/pinch in Off or released-Key mode. This includes a live Browser page, so it scrolls natively while focused; an unfocused Browser, On, an active Key binding, pinch, and `Cmd/Ctrl + scroll` route to the canvas. Browser summary/placeholder surfaces always route to the canvas. **Settings → Controls → Use scroll wheel to zoom** restores ordinary wheel zoom. Fresh profiles use Key with `Command` on macOS or `Ctrl` elsewhere. The separate full canvas navigation override defaults to `Option` on macOS and `Alt` elsewhere, accepts standalone Command/Ctrl, captures drag as well as wheel/pinch, and shows the hand cursor while held. +- Terminal scrolling and canvas navigation have independent wheel-direction settings. Canvas inversion applies to both pan axes and to ordinary wheel zoom. - `Shift+Enter` sends a modified Enter sequence to insert a line break in compatible agent prompts without submitting. `Enter` keeps its normal PTY behavior. - With terminal text selected, `Ctrl+C`/`Ctrl+Shift+C` or `Cmd+C` copies it. Paste with `Ctrl+Shift+V`, `Cmd+V`, or `Shift+Insert`. Plain `Ctrl+C` without a selection remains the PTY interrupt. ## Browser controls and activity -- The browser follows the same configured click-selection and hover-focus behavior as terminal cards. Clicking empty canvas clears the active application. +- The browser follows the same independent selection and input-focus rules as terminal cards. Native page hover transfers focus after the configured delay; leaving the page does not clear it. In Off or released-Key mode, that focus also lets plain wheel scroll the live page. Pinch and `Cmd/Ctrl + scroll` still zoom the canvas. A page can also scroll through scrollbar drag, keyboard input, or site controls. - Use the trusted tab strip and navigation bar for HTTP(S) pages. Hiding the card preserves tabs; **Close all** removes them after confirmation. - **Settings → Browser** controls agent access and tab restore and shows recent downloads and command activity. - **Clear browser data** removes tabs, site data, cache, auth cache, staged uploads, and the current download list. It deliberately keeps the persistent redacted audit log. diff --git a/docs/getting-started.ru.md b/docs/getting-started.ru.md index b120496..3a9ab3f 100644 --- a/docs/getting-started.ru.md +++ b/docs/getting-started.ru.md @@ -33,16 +33,17 @@ npm run dev ## Ввод и управление терминалом -- Нажмите на любую живую карточку терминала, чтобы выбрать её. Выбранная карточка сразу получает клавиатурный фокус xterm, поэтому ввод отправляется в PTY без второго клика внутри текстовой области. -- Нажатие по пустому канвасу снимает выбор, клавиатурный фокус и видимую рамку. -- **Настройки → Управление → Автофокус при наведении** выбирает терминал под курсором и снимает выбор после ухода. В обе стороны используется одна задержка: медленно `500ms`, обычно `250ms` или быстро `80ms`. По умолчанию автофокус выключен. -- Направления колеса для прокрутки терминала и зума канваса настраиваются независимо. По умолчанию колесо вниз прокручивает живой терминал вниз, а канвас сохраняет прежнее направление зума CanvasTTY. +- Нажмите на любую живую карточку терминала, чтобы выбрать и сфокусировать её. Input focus не зависит от selection, поэтому будущий multi-selection сохранит одну однозначную цель для keyboard и wheel. +- Клик вне всех виджетов сбрасывает input focus. Клик по декоративному или action-only виджету не делает его целью wheel. +- **Настройки → Управление → Автофокус при наведении** переносит input focus после задержки: медленно `500ms`, обычно `250ms` или быстро `80ms`. Уход только отменяет ещё не сработавший перенос; назначенный focus сохраняется, а selection не меняется. По умолчанию автофокус выключен. +- В свежем профиле обычный scroll над canvas, unfocused-виджетами и нефокусируемыми виджетами перемещает canvas по обеим осям, а pinch и `Cmd/Ctrl + scroll` масштабируют вокруг указателя. Focused input-виджет оставляет обычный wheel/pinch себе в Off или при отпущенном Key. Это относится и к live Browser page: в focus она прокручивается нативно; unfocused Browser, On, активный Key binding, pinch и `Cmd/Ctrl + scroll` передают события канвасу. Browser summary/placeholder всегда передаёт их канвасу. **Настройки → Управление → Зум обычным скроллом** возвращает wheel zoom. Свежий профиль использует Key с `Command` на macOS или `Ctrl` на остальных платформах. Отдельный полный navigation override по умолчанию использует `Option`/`Alt`, разрешает одиночный Command/Ctrl, дополнительно перехватывает drag и при удержании показывает курсор-руку. +- Направления колеса для прокрутки терминала и навигации канваса настраиваются независимо. Canvas inversion применяется к обеим осям pan и к обычному wheel zoom. - `Shift+Enter` отправляет модифицированный Enter и в совместимых промптах агента вставляет перенос строки без отправки. Обычный `Enter` сохраняет штатное поведение PTY. - Выделенный текст копируется через `Ctrl+C`/`Ctrl+Shift+C` или `Cmd+C`. Вставка: `Ctrl+Shift+V`, `Cmd+V` или `Shift+Insert`. Обычный `Ctrl+C` без выделения остаётся прерыванием PTY. ## Управление браузером и действия -- Браузер использует те же настроенные click-selection и hover-focus правила, что и terminal cards. Клик по пустому канвасу снимает активное приложение. +- Browser использует те же независимые правила selection и input focus, что и terminal cards. Hover над native page переносит focus после настроенной задержки; уход со страницы его не сбрасывает. В Off или при отпущенном Key этот focus также позволяет обычному wheel прокручивать live page. Pinch и `Cmd/Ctrl + scroll` по-прежнему масштабируют canvas. Страница также прокручивается через scrollbar drag, клавиатуру или элементы сайта. - Доверенные tab strip и navigation bar работают с HTTP(S)-страницами. Скрытие карточки сохраняет вкладки; **Закрыть все** удаляет их после подтверждения. - **Настройки → Браузер** управляют доступом агентов и восстановлением вкладок и показывают недавние загрузки и команды. - **Очистить данные браузера** удаляет вкладки, site data, cache, auth cache, staged uploads и текущий список загрузок, но намеренно сохраняет постоянный очищенный audit log. diff --git a/docs/getting-started.zh-CN.md b/docs/getting-started.zh-CN.md index 542aff9..a7fe5bb 100644 --- a/docs/getting-started.zh-CN.md +++ b/docs/getting-started.zh-CN.md @@ -33,16 +33,17 @@ npm run dev ## 终端输入与控制 -- 点击任意实时终端卡片即可选中它。选中后,卡片立即获得 xterm 键盘焦点,因此无需再次点击文本区域,输入就会发送到对应 PTY。 -- 点击空白画布会清除选中状态、键盘焦点和可见边框。 -- **设置 → 控制 → 悬停时聚焦** 可以在指针停留后选中终端,并在指针离开后取消选中。进入和离开使用相同延迟:慢速 `500ms`、正常 `250ms`、快速 `80ms`。默认关闭。 -- 终端滚动与画布缩放的滚轮方向可以独立设置。默认情况下,滚轮向下会让实时终端向下滚动,画布缩放则保持 CanvasTTY 原有方向。 +- 点击任意实时终端卡片即可选中并聚焦它。Input focus 与 selection 相互独立,因此未来加入 multi-selection 后仍能保持唯一明确的 keyboard 与 wheel 目标。 +- 点击所有 widget 之外会清除 input focus。点击装饰性或仅执行 action 的 widget 不会让它成为 wheel 目标。 +- **设置 → 控制 → 悬停时聚焦** 会在延迟后转移 input focus:慢速 `500ms`、正常 `250ms`、快速 `80ms`。离开只会取消尚未触发的转移;已分配的 focus 会保留,selection 不变。默认关闭。 +- 新配置中,在 canvas、unfocused widget 和不可聚焦 widget 上的普通 scroll 会沿两个轴移动 canvas,pinch 与 `Cmd/Ctrl + scroll` 会以指针为中心缩放。Focused input widget 在 Off 或 Key 未按下时保留普通 wheel/pinch;这也适用于 live Browser page,因此获得 focus 后页面会原生滚动。Unfocused Browser、On、激活的 Key binding、pinch 与 `Cmd/Ctrl + scroll` 会交给 canvas。Browser summary/placeholder 始终交给 canvas。**设置 → 控制 → Use scroll wheel to zoom** 可恢复普通 wheel zoom。新配置默认使用 Key,在 macOS 为 `Command`,其他平台为 `Ctrl`。独立的完整 canvas navigation override 默认使用 `Option`/`Alt`,允许单独 Command/Ctrl,还会接管 drag,并在按住时显示手形 cursor。 +- 终端滚动与画布导航的滚轮方向可以独立设置。Canvas inversion 同时作用于两个 pan 轴和普通 wheel zoom。 - `Shift+Enter` 会发送带修饰符的 Enter,在兼容的智能体 prompt 中插入换行而不提交;普通 `Enter` 保持原有 PTY 行为。 - 选中终端文字后,使用 `Ctrl+C`/`Ctrl+Shift+C` 或 `Cmd+C` 复制;使用 `Ctrl+Shift+V`、`Cmd+V` 或 `Shift+Insert` 粘贴。没有选中文字时,普通 `Ctrl+C` 仍是 PTY 中断。 ## 浏览器控制与活动 -- 浏览器与终端卡片遵循相同的点击选中和悬停聚焦设置。点击空白画布会清除活动应用。 +- Browser 与 Terminal card 遵循相同且相互独立的 selection 与 input-focus 规则。悬停 native page 会在配置的延迟后转移 focus;离开页面不会清除它。在 Off 或 Key 未按下时,该 focus 也允许普通 wheel 原生滚动 live page。Pinch 与 `Cmd/Ctrl + scroll` 仍会缩放 canvas。页面也可通过 scrollbar drag、键盘或站点控件滚动。 - 使用可信标签栏与导航栏访问 HTTP(S) 页面。隐藏卡片会保留标签页;确认后使用 **全部关闭** 才会移除它们。 - **设置 → 浏览器** 控制智能体访问和标签页恢复,并显示最近下载和命令活动。 - **清除浏览器数据** 会删除标签页、网站数据、cache、auth cache、暂存上传和当前下载列表,但会刻意保留持久化脱敏审计日志。 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 2b0bfea..455345e 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ rollupOptions: { input: { index: resolve("src/preload/index.ts"), + browser: resolve("src/preload/browser.ts"), plugin: resolve("src/preload/plugin.ts") }, output: { diff --git a/src/main/index.ts b/src/main/index.ts index 3324014..1040a26 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,6 +10,8 @@ import { PluginManager } from "./services/PluginManager"; import { PluginMediaService } from "./services/PluginMediaService"; import { PluginSecretsService } from "./services/PluginSecretsService"; import { BrowserService } from "./services/BrowserService"; +import { CanvasNavigationInputController } from "./services/CanvasNavigationOverride"; +import { activeCanvasWheelBinding } from "../shared/canvasNavigation"; import { runBrowserElectronSmoke } from "./services/browser/BrowserElectronSmoke"; import { runProviderElectronSmoke, @@ -53,13 +55,6 @@ protocol.registerSchemesAsPrivileged([ } ]); -// macOS native occlusion can defer a child WebContentsView's CDP input ACK for -// several seconds even when that view disables renderer background throttling. -// Agent-controlled tabs must remain responsive while the canvas is covered. -if (process.platform === "darwin") { - app.commandLine.appendSwitch("disable-backgrounding-occluded-windows"); -} - let mainWindow: BrowserWindow | null = null; let terminalManager: TerminalManager | null = null; let limitsService: LimitsService | null = null; @@ -67,6 +62,7 @@ let pluginManager: PluginManager | null = null; let pluginMediaService: PluginMediaService | null = null; let pluginSecretsService: PluginSecretsService | null = null; let browserService: BrowserService | null = null; +let canvasNavigationInput: CanvasNavigationInputController | null = null; let agentGateway: AgentGateway | null = null; let agentBrowserBridge: AgentBrowserBridge | null = null; let agentBrowserHelper: StdioHelperLaunch | null = null; @@ -102,6 +98,11 @@ async function createWindow(): Promise { const currentUrl = window.webContents.getURL(); if (currentUrl && url !== currentUrl) event.preventDefault(); }); + canvasNavigationInput?.attach(window.webContents); + window.on("blur", () => { + canvasNavigationInput?.reset(); + browserService?.cancelCanvasNavigationGesture(); + }); await window.loadURL(startupPageUrl({ locale: app.getLocale(), isMacOS: process.platform === "darwin" })); @@ -121,14 +122,34 @@ async function initializeServices(): Promise { const settings = new SettingsStore(userDataPath, app.getLocale()); await settings.load(); + canvasNavigationInput = new CanvasNavigationInputController( + { + wheelBinding: activeCanvasWheelBinding( + settings.get().canvasWheelCaptureMode, + settings.get().canvasWheelOverride + ), + navigationBinding: settings.get().canvasNavigationOverride + }, + (state) => { + browserService?.setCanvasNavigationActive(state.navigationActive); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC.canvasNavigationOverrideState, state); + } + } + ); + if (mainWindow && !mainWindow.isDestroyed()) canvasNavigationInput.attach(mainWindow.webContents); + browserService = new BrowserService(() => mainWindow, { userDataPath, restoreTabs: settings.get().browserRestoreTabs, + canvasWheelCaptureMode: settings.get().canvasWheelCaptureMode, + canvasNavigationInput, ...(process.env.CANVASTTY_BROWSER_SMOKE_URL ? { downloadRoot: join(userDataPath, "browser-smoke-downloads") } : {}) }); await browserService.ready(); + browserService.setCanvasNavigationActive(canvasNavigationInput.active); if (supportsAgentGatewayPlatform()) { const runtimeDirectory = join(userDataPath, "browser", "runtime"); @@ -197,6 +218,16 @@ async function initializeServices(): Promise { applyBrowserSettings: (next) => { agentBrowserBridge?.setEnabled(next.browserAgentAccess); browserService?.setRestoreTabs(next.browserRestoreTabs); + browserService?.cancelCanvasNavigationGesture(); + browserService?.setCanvasWheelCaptureMode(next.canvasWheelCaptureMode); + canvasNavigationInput?.setBindings({ + wheelBinding: activeCanvasWheelBinding(next.canvasWheelCaptureMode, next.canvasWheelOverride), + navigationBinding: next.canvasNavigationOverride + }); + }, + setCanvasNavigationShortcutCapture: (active) => { + if (active) browserService?.cancelCanvasNavigationGesture(); + canvasNavigationInput?.setShortcutCaptureActive(active); }, openPluginWindow, closePluginWindows, diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 69c795e..cc7bbf3 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -39,6 +39,7 @@ interface Dependencies { browser: BrowserService; getMainWindow(): BrowserWindow | null; applyBrowserSettings(settings: AppSettings): void; + setCanvasNavigationShortcutCapture(active: boolean): void; openPluginWindow(pluginId: string, contributionId: string): Promise; closePluginWindows(pluginId: string): void; requestPluginLauncher(provider: ProviderId): void; @@ -56,6 +57,7 @@ export function registerIpc({ browser, getMainWindow, applyBrowserSettings, + setCanvasNavigationShortcutCapture, openPluginWindow, closePluginWindows, requestPluginLauncher, @@ -73,6 +75,21 @@ export function registerIpc({ applyBrowserSettings(next); return next; }); + ipcMain.on(IPC.canvasNavigationShortcutCapture, (event, active: boolean) => { + assertMainRenderer(event, getMainWindow); + if (typeof active !== "boolean") return; + setCanvasNavigationShortcutCapture(active); + }); + ipcMain.on(IPC.canvasNavigationOwnerWheel, (event, input: unknown) => { + assertMainRenderer(event, getMainWindow); + browser.beginRendererWheelSequence(input); + event.returnValue = true; + }); + ipcMain.on(IPC.canvasNavigationPointerGesture, (event, active: boolean) => { + assertMainRenderer(event, getMainWindow); + if (typeof active !== "boolean") return; + browser.setRendererCanvasGestureActive(active); + }); ipcMain.handle(IPC.dialogPickDirectory, async (event, defaultPath?: string) => { const owner = BrowserWindow.fromWebContents(event.sender); @@ -395,10 +412,21 @@ export function registerIpc({ assertMainRenderer(event, getMainWindow); browser.focus(); }); + ipcMain.on(IPC.browserSetInputFocused, (event, focused: unknown) => { + assertMainRenderer(event, getMainWindow); + browser.setInputFocused(focused === true); + event.returnValue = true; + }); ipcMain.on(IPC.browserSetViewport, (event, bounds) => { assertMainRenderer(event, getMainWindow); browser.setViewport(bounds); }); + ipcMain.on(IPC.browserPageWheelDecision, (event, input: unknown) => { + event.returnValue = browser.decidePageWheel(event.sender, input); + }); + ipcMain.on(IPC.browserPageWheel, (event, input: unknown) => { + browser.handlePageWheel(event.sender, input); + }); ipcMain.handle(IPC.terminalList, () => terminals.list()); ipcMain.handle(IPC.terminalCreate, (_event, request: CreateSessionRequest) => terminals.create(request)); diff --git a/src/main/services/BrowserService.ts b/src/main/services/BrowserService.ts index fac4f2a..6efb03c 100644 --- a/src/main/services/BrowserService.ts +++ b/src/main/services/BrowserService.ts @@ -5,6 +5,7 @@ import { app, BrowserWindow, nativeImage, + screen, session, View, WebContentsView @@ -15,7 +16,6 @@ import type { BrowserActivityEvent, BrowserActor, BrowserCanvasPointerEvent, - BrowserCanvasWheelEvent, BrowserCommand, BrowserDialogSnapshot, BrowserDownloadSnapshot, @@ -24,13 +24,25 @@ import type { BrowserSnapshot, BrowserTabSnapshot, BrowserTabStatus, - BrowserViewportBounds + BrowserViewportBounds, + CanvasWheelCaptureMode } from "../../shared/contracts.ts"; import { IPC } from "../../shared/contracts.ts"; import { AgentRegistry } from "./browser/AgentRegistry.ts"; +import type { CanvasNavigationInputController } from "./CanvasNavigationOverride.ts"; import { BrowserAutomationService, type BrowserPointerResult } from "./browser/BrowserAutomationService.ts"; +import { + browserVisibleRectangle +} from "./browser/BrowserCanvasFreeze.ts"; +import { + BrowserCanvasCursorController, + browserCanvasNavigationCursor +} from "./browser/BrowserCanvasCursor.ts"; +import { BrowserCanvasGestureController } from "./browser/BrowserCanvasGestureController.ts"; +import { BrowserCanvasPointerRouter } from "./browser/BrowserCanvasPointerRouter.ts"; +import { BrowserCanvasSinkViewportController } from "./browser/BrowserCanvasSinkViewport.ts"; import { BrowserAuditStore } from "./browser/BrowserAuditStore.ts"; -import { toCanvasWheelDeltaY } from "./browser/BrowserCanvasWheel.ts"; +import type { BrowserWheelDecision } from "./browser/BrowserCanvasWheel.ts"; import { normalizeBrowserViewportBounds } from "./browser/BrowserViewport.ts"; import { BrowserCore, type BrowserCoreHost, type BrowserCoreTab } from "./browser/BrowserCore.ts"; import { BrowserKernelError } from "./browser/BrowserErrors.ts"; @@ -60,6 +72,8 @@ interface BrowserTab { crashState: string | null; favicon: string | null; lastSafeUrl: string; + canvasCursor: BrowserCanvasCursorController; + canvasSinkViewport: BrowserCanvasSinkViewportController; } interface DownloadWaiter { @@ -77,33 +91,38 @@ export interface BrowserServiceOptions { downloadRoot?: string; uploadRoots?: readonly string[]; restoreTabs?: boolean; + canvasWheelCaptureMode?: CanvasWheelCaptureMode; now?: () => number; + canvasNavigationInput?: CanvasNavigationInputController; } export class BrowserService { readonly core: BrowserCore; private readonly getOwner: () => BrowserWindow | null; private readonly now: () => number; + private readonly canvasNavigationInput: CanvasNavigationInputController | null; private readonly tabs = new Map(); private readonly store: BrowserStore; private readonly policy: BrowserPolicyService; private readonly audit: BrowserAuditStore; private readonly automation = new BrowserAutomationService(); private readonly agents: AgentRegistry; + private readonly canvasGestures: BrowserCanvasGestureController; + private readonly canvasPointers: BrowserCanvasPointerRouter; private readonly clipView = new View(); private readonly readyPromise: Promise; private readonly downloadWaiters = new Set(); private readonly observedOwners = new WeakSet(); private readonly presenceTimer: NodeJS.Timeout; private browserSession: Session | null = null; + private browserPagePreloadId: string | null = null; private activeTabId: string | null = null; private viewport: BrowserViewportBounds = { x: 0, y: 0, width: 0, height: 0, - visible: false, - captureCanvasWheel: false, + surface: "hidden", showAgentPresence: false }; private persisted: PersistedBrowserState = { version: 1, tabs: [], activeTabId: null }; @@ -121,6 +140,7 @@ export class BrowserService { constructor(getOwner: () => BrowserWindow | null, options: BrowserServiceOptions = {}) { this.getOwner = getOwner; this.now = options.now ?? Date.now; + this.canvasNavigationInput = options.canvasNavigationInput ?? null; const userDataPath = options.userDataPath ?? app.getPath("userData"); const downloadRoot = join(options.downloadRoot ?? join(app.getPath("downloads"), "CanvasTTY"), randomUUID()); this.restoreTabsEnabled = options.restoreTabs ?? true; @@ -132,6 +152,49 @@ export class BrowserService { }); this.audit = new BrowserAuditStore(userDataPath, { now: this.now }); this.agents = new AgentRegistry(this.now); + this.canvasGestures = new BrowserCanvasGestureController({ + getOwner: () => this.getOwner(), + getViewport: () => this.viewport, + getActiveTab: () => this.activeTabId ? this.tabs.get(this.activeTabId) ?? null : null, + getTab: (tabId) => this.tabs.get(tabId), + isVisible: () => this.visible, + isDisposed: () => this.disposed, + getOverrideState: () => ({ + wheelActive: this.canvasNavigationInput?.wheelActive ?? false, + navigationActive: this.canvasNavigationInput?.active ?? false + }), + getCursorScreenPoint: () => screen.getCursorScreenPoint(), + requestSurfaceSync: () => this.syncViews(), + beforeSequenceEnd: () => this.canvasPointers.cancelSequenceRelays(), + shouldDeferIdleEnd: () => this.canvasPointers.hasFreezePointerRelay, + sendWheel: (payload) => { + const owner = this.getOwner(); + if (owner && !owner.isDestroyed()) owner.webContents.send(IPC.browserCanvasWheel, payload); + }, + sendFreezeFrame: (payload) => { + const owner = this.getOwner(); + if (owner && !owner.isDestroyed()) owner.webContents.send(IPC.browserCanvasFreezeFrame, payload); + } + }, { + captureMode: options.canvasWheelCaptureMode ?? "key", + now: this.now + }); + this.canvasPointers = new BrowserCanvasPointerRouter({ + getOwner: () => this.getOwner(), + getViewport: () => this.viewport, + getTab: (tabId) => this.tabs.get(tabId), + getTabs: () => this.tabs.values(), + getNativeWheelSink: () => this.canvasGestures.activeNativeSink, + getFrozenTabId: () => this.canvasGestures.frozenTabId, + isFreezeActive: () => this.canvasGestures.isFreezeActive, + isNavigationOverrideActive: () => this.canvasNavigationInput?.active ?? false, + getCursorScreenPoint: () => screen.getCursorScreenPoint(), + endWheelSequence: () => this.canvasGestures.endSequence(), + sendNavigationPointer: (payload) => { + const owner = this.getOwner(); + if (owner && !owner.isDestroyed()) owner.webContents.send(IPC.browserCanvasNavigationPointer, payload); + } + }); const host: BrowserCoreHost = { getSnapshot: () => this.getState(), @@ -199,6 +262,9 @@ export class BrowserService { await this.readyPromise; if (this.disposed) return; await this.persistRuntime(); + this.canvasPointers.cancelNavigationGesture(); + this.invalidateCanvasSequence(false); + this.canvasGestures.setInputFocused(false); this.visible = false; this.hideClipView(); this.destroyPresenceWindow(); @@ -206,12 +272,32 @@ export class BrowserService { } focus(): void { - if (!this.visible || !this.viewport.visible || !this.activeTabId) return; + if (!this.visible || this.viewport.surface !== "native" || !this.activeTabId) return; const active = this.tabs.get(this.activeTabId); if (!active || active.view.webContents.isDestroyed()) return; active.view.webContents.focus(); } + setInputFocused(focused: boolean): void { + this.canvasGestures.setInputFocused(focused); + } + + setCanvasWheelCaptureMode(mode: CanvasWheelCaptureMode): void { + this.canvasGestures.setCaptureMode(mode); + } + + decidePageWheel(sender: WebContents, input: unknown): BrowserWheelDecision { + return this.canvasGestures.decidePageWheel(sender, input); + } + + handlePageWheel(sender: WebContents, input: unknown): void { + this.canvasGestures.handlePageWheel(sender, input); + } + + beginRendererWheelSequence(input: unknown): void { + this.canvasGestures.beginRendererSequence(input); + } + async setRestoreTabs(enabled: boolean): Promise { await this.readyPromise; this.restoreTabsEnabled = enabled; @@ -229,8 +315,15 @@ export class BrowserService { const draining = this.core.shutdown(); await this.persistRuntime().catch(() => undefined); this.visible = false; + this.canvasGestures.setInputFocused(false); + this.canvasGestures.endSequence(false); + this.canvasGestures.clear(); clearInterval(this.presenceTimer); this.destroyRuntimeTabs(); + if (this.browserSession && this.browserPagePreloadId) { + this.browserSession.unregisterPreloadScript(this.browserPagePreloadId); + this.browserPagePreloadId = null; + } this.hideClipView(); this.destroyPresenceWindow(); for (const waiter of this.downloadWaiters) { @@ -334,10 +427,27 @@ export class BrowserService { setViewport(bounds: BrowserViewportBounds): void { const normalized = normalizeBrowserViewportBounds(bounds); if (!normalized) return; + const previous = this.viewport; this.viewport = normalized; + if (normalized.surface === "hidden") { + this.canvasPointers.cancelNavigationGesture(); + } + this.canvasGestures.viewportChanged(previous, normalized); this.syncViews(); } + setCanvasNavigationActive(active: boolean): void { + this.canvasPointers.setNavigationActive(active); + } + + setRendererCanvasGestureActive(active: boolean): void { + this.canvasPointers.setRendererGestureActive(active); + } + + cancelCanvasNavigationGesture(): void { + this.canvasPointers.cancelNavigationGesture(); + } + setAgentPresences(values: readonly AgentPresenceSnapshot[]): void { this.agents.replace(values); this.presenceChanged(); @@ -392,6 +502,7 @@ export class BrowserService { } const normalized = this.policy.assertNavigationUrl(url); const tab = this.createRuntimeTab(randomUUID(), normalized); + this.invalidateCanvasSequence(false); this.activeTabId = tab.id; await this.persistRuntime(); this.syncViews(); @@ -403,10 +514,13 @@ export class BrowserService { private async hostActivateTab(tabId: string): Promise { await this.ensureRuntime(); const tab = this.requireTab(tabId); + this.canvasPointers.cancelNavigationGesture(); + this.invalidateCanvasSequence(false); this.activeTabId = tab.id; await this.persistRuntime(); this.syncViews(); - if (this.viewport.visible && !tab.view.webContents.isDestroyed()) tab.view.webContents.focus(); + this.canvasGestures.refreshFrame(); + if (this.viewport.surface === "native" && !tab.view.webContents.isDestroyed()) tab.view.webContents.focus(); this.emit(); return this.getState(); } @@ -414,6 +528,9 @@ export class BrowserService { private async hostCloseTab(tabId: string): Promise { await this.ensureRuntime(); const tab = this.requireTab(tabId); + if (this.activeTabId === tabId) { + this.invalidateCanvasSequence(false); + } this.tabs.delete(tabId); this.destroyTab(tab); this.pendingDialogs.delete(tabId); @@ -507,9 +624,12 @@ export class BrowserService { documentRevision: initialRevision, crashState: null, favicon: null, - lastSafeUrl: url + lastSafeUrl: url, + canvasCursor: new BrowserCanvasCursorController(view.webContents), + canvasSinkViewport: new BrowserCanvasSinkViewportController(view.webContents) }; this.tabs.set(id, tab); + tab.canvasCursor.set(browserCanvasNavigationCursor(this.canvasNavigationInput?.active ?? false, false)); this.bindTab(tab); void this.automation.register(id, view.webContents, tab.documentRevision, (dialog) => { if (dialog && this.tabs.has(id)) this.pendingDialogs.set(id, dialog); @@ -521,6 +641,7 @@ export class BrowserService { private bindTab(tab: BrowserTab): void { const contents = tab.view.webContents; + this.canvasNavigationInput?.attach(contents); contents.setWindowOpenHandler((details) => { const decision = this.policy.popup(details.url, details.disposition, this.tabs.size); if (decision.action === "deny" || !decision.url) return { action: "deny" }; @@ -554,9 +675,14 @@ export class BrowserService { }); contents.on("will-attach-webview", (event) => event.preventDefault()); contents.on("before-mouse-event", (event, mouse) => { - if (!this.viewport.visible || this.activeTabId !== tab.id) return; + const nativeSink = this.canvasGestures.activeNativeSink?.tabId === tab.id + ? this.canvasGestures.activeNativeSink + : null; + if ((this.viewport.surface !== "native" && !nativeSink) || this.activeTabId !== tab.id) return; const owner = this.getOwner(); if (!owner || owner.isDestroyed()) return; + this.canvasGestures.observeBrowserWheel(tab.id, mouse); + if (this.canvasPointers.handleBrowserMouse(tab, owner, event, mouse)) return; const pointerType = mouse.type === "mouseDown" && mouse.button === "left" ? "down" @@ -568,7 +694,10 @@ export class BrowserService { ? "leave" : null; if (pointerType) { - if (pointerType === "down") contents.focus(); + if (pointerType === "down") { + contents.focus(); + this.setInputFocused(true); + } this.pointerTabId = pointerType === "leave" ? null : tab.id; const payload: BrowserCanvasPointerEvent = { tabId: tab.id, @@ -580,17 +709,6 @@ export class BrowserService { owner.webContents.send(IPC.browserCanvasPointer, payload); } - if (mouse.type !== "mouseWheel" || !this.viewport.captureCanvasWheel) return; - const deltaY = toCanvasWheelDeltaY(mouse as Electron.MouseWheelInputEvent); - if (deltaY === null) return; - const payload: BrowserCanvasWheelEvent = { - tabId: tab.id, - clientX: this.viewport.x + mouse.x, - clientY: this.viewport.y + mouse.y, - deltaY - }; - event.preventDefault(); - owner.webContents.send(IPC.browserCanvasWheel, payload); }); contents.on("login", (event, _details, _authInfo, callback) => { event.preventDefault(); @@ -602,6 +720,9 @@ export class BrowserService { }); contents.on("did-start-navigation", (_details, _url, isInPlace, isMainFrame) => { if (!isMainFrame) return; + if (!isInPlace && this.activeTabId === tab.id) { + this.invalidateCanvasSequence(); + } tab.loading = true; tab.status = "loading"; tab.crashState = null; @@ -616,8 +737,10 @@ export class BrowserService { contents.on("did-stop-loading", () => { tab.loading = false; if (tab.status !== "crashed" && tab.status !== "error") tab.status = "ready"; + if (this.activeTabId === tab.id) this.canvasGestures.refreshFrame(); this.emit(); }); + contents.on("did-finish-load", () => tab.canvasCursor.refresh()); contents.on("did-fail-load", (_event, errorCode, _errorDescription, _url, isMainFrame) => { if (!isMainFrame || errorCode === -3) return; tab.loading = false; @@ -656,12 +779,22 @@ export class BrowserService { this.emit(); }); contents.on("render-process-gone", (_event, details) => { + this.canvasPointers.cancelTab(tab.id); + if (this.activeTabId === tab.id) { + this.invalidateCanvasSequence(false); + } tab.loading = false; tab.status = "crashed"; tab.crashState = details.reason; this.emit(); }); contents.on("destroyed", () => { + tab.canvasCursor.dispose(); + tab.canvasSinkViewport.dispose(); + this.canvasPointers.cancelTab(tab.id); + if (this.activeTabId === tab.id) { + this.invalidateCanvasSequence(false); + } if (!this.tabs.has(tab.id)) return; tab.loading = false; tab.status = "crashed"; @@ -674,6 +807,10 @@ export class BrowserService { if (this.browserSession) return; const browserSession = session.fromPartition(BROWSER_PARTITION); this.browserSession = browserSession; + this.browserPagePreloadId = browserSession.registerPreloadScript({ + type: "frame", + filePath: join(__dirname, "../preload/browser.cjs") + }); browserSession.setPermissionCheckHandler((_contents, permission, requestingOrigin) => ( this.policy.permission(permission, requestingOrigin) )); @@ -847,6 +984,12 @@ export class BrowserService { } private destroyTab(tab: BrowserTab): void { + this.canvasPointers.cancelTab(tab.id); + if (this.activeTabId === tab.id) { + this.invalidateCanvasSequence(false); + } + tab.canvasCursor.dispose(); + tab.canvasSinkViewport.dispose(); this.automation.unregister(tab.id); this.clipView.removeChildView(tab.view); if (this.clipTabId === tab.id) this.clipTabId = null; @@ -863,29 +1006,41 @@ export class BrowserService { } this.observeOwner(owner); const content = owner.getContentBounds(); - const left = Math.max(0, this.viewport.x); - const top = Math.max(0, this.viewport.y); - const right = Math.min(content.width, this.viewport.x + this.viewport.width); - const bottom = Math.min(content.height, this.viewport.y + this.viewport.height); - const show = this.visible && this.viewport.visible && right > left && bottom > top; + const visibleRectangle = browserVisibleRectangle(this.viewport, content); + const left = visibleRectangle?.x ?? 0; + const top = visibleRectangle?.y ?? 0; + const right = left + (visibleRectangle?.width ?? 0); + const bottom = top + (visibleRectangle?.height ?? 0); const active = this.activeTabId ? this.tabs.get(this.activeTabId) : undefined; - if (!show || !active || active.view.webContents.isDestroyed()) { + if (!active || active.view.webContents.isDestroyed()) { this.hideClipView(); this.syncPresenceOverlay(null); return; } - for (const tab of this.tabs.values()) { - if (tab.id !== active.id) tab.view.setVisible(false); - } - if (this.clipTabId !== active.id) { - if (this.clipTabId) { - const previous = this.tabs.get(this.clipTabId); - if (previous) this.clipView.removeChildView(previous.view); + const canvasSurface = this.canvasGestures.surfaceDecision(active.id, visibleRectangle, content); + if (canvasSurface.kind !== "normal") { + if (this.presenceWindow && !this.presenceWindow.isDestroyed()) this.presenceWindow.hide(); + if (canvasSurface.kind === "sink") { + this.mountClipTab(owner, active); + this.clipView.setBounds(canvasSurface.layout.clip); + active.view.setBounds(canvasSurface.layout.view); + active.view.setVisible(true); + this.clipView.setVisible(true); + } else { + this.clipView.setVisible(false); } - this.clipView.addChildView(active.view); - this.clipTabId = active.id; + return; + } + + const show = this.visible && this.viewport.surface === "native" && visibleRectangle !== null; + if (!show) { + this.hideClipView(); + this.syncPresenceOverlay(null); + return; } + + this.mountClipTab(owner, active); this.clipView.setBounds({ x: left, y: top, width: right - left, height: bottom - top }); this.applyPageScale(active); active.view.setBounds({ @@ -896,11 +1051,30 @@ export class BrowserService { }); active.view.setVisible(true); this.clipView.setVisible(true); + this.syncPresenceOverlay({ owner, tabId: active.id, left, top, right, bottom }); + } + + private invalidateCanvasSequence(sync = true): void { + this.canvasGestures.endSequence(sync); + this.canvasGestures.invalidateCapture(); + } + + private mountClipTab(owner: BrowserWindow, active: BrowserTab): void { + for (const tab of this.tabs.values()) { + if (tab.id !== active.id) tab.view.setVisible(false); + } + if (this.clipTabId !== active.id) { + if (this.clipTabId) { + const previous = this.tabs.get(this.clipTabId); + if (previous) this.clipView.removeChildView(previous.view); + } + this.clipView.addChildView(active.view); + this.clipTabId = active.id; + } if (this.clipOwnerId !== owner.id) { owner.contentView.addChildView(this.clipView); this.clipOwnerId = owner.id; } - this.syncPresenceOverlay({ owner, tabId: active.id, left, top, right, bottom }); } private applyPageScale(tab: BrowserTab): void { @@ -927,8 +1101,21 @@ export class BrowserService { owner.on("maximize", sync); owner.on("unmaximize", sync); owner.on("show", sync); - owner.on("hide", sync); - owner.once("closed", () => this.destroyPresenceWindow()); + owner.on("hide", () => { + this.canvasGestures.endSequence(false); + sync(); + }); + owner.on("blur", () => this.canvasGestures.endSequence()); + owner.webContents.on("before-mouse-event", (event, mouse) => { + if (mouse.type === "mouseWheel") { + this.canvasGestures.beginOwnerSequence({ x: mouse.x, y: mouse.y }); + } + this.canvasPointers.handleOwnerMouse(event, mouse, owner); + }); + owner.once("closed", () => { + this.canvasGestures.endSequence(false); + this.destroyPresenceWindow(); + }); } private syncPresenceOverlay( diff --git a/src/main/services/CanvasNavigationOverride.ts b/src/main/services/CanvasNavigationOverride.ts new file mode 100644 index 0000000..7ec96d3 --- /dev/null +++ b/src/main/services/CanvasNavigationOverride.ts @@ -0,0 +1,257 @@ +import type { Event, Input, WebContents } from "electron"; +import { + canvasNavigationModifierFromKey, + isCanvasNavigationModifierActive, + isCanvasNavigationBindingActive, + isCanvasNavigationBindingKey, + normalizeCanvasNavigationInputKey, + parseCanvasNavigationBinding +} from "../../shared/canvasNavigation.ts"; + +export interface CanvasNavigationKeyboardInput { + type: "keyDown" | "keyUp"; + key: string; + code?: string; + alt: boolean; + control: boolean; + meta: boolean; + shift: boolean; +} + +export interface CanvasNavigationOverrideTransition { + active: boolean; + changed: boolean; + reserved: boolean; +} + +export interface CanvasNavigationBindings { + wheelBinding: string | null; + navigationBinding: string | null; +} + +export interface CanvasNavigationOverrideState { + wheelActive: boolean; + navigationActive: boolean; +} + +export function shouldPreventCanvasNavigationInput( + input: Pick, + transition: CanvasNavigationOverrideTransition +): boolean { + return transition.reserved && canvasNavigationModifierFromKey(input.key) === null; +} + +export class CanvasNavigationOverrideTracker { + private binding: string | null; + private suspended = false; + private isActive = false; + private readonly pressedKeys = new Set(); + private modifiers = { altKey: false, ctrlKey: false, metaKey: false, shiftKey: false }; + + constructor(binding: string | null) { + this.binding = binding; + } + + get active(): boolean { + return this.isActive; + } + + get shouldCaptureMenuShortcuts(): boolean { + const parsed = parseCanvasNavigationBinding(this.binding); + if (!parsed || !parsed.modifiers.every((modifier) => isCanvasNavigationModifierActive(this.modifiers, modifier))) { + return false; + } + return parsed.key !== null || parsed.modifiers.includes("Alt"); + } + + setBinding(binding: string | null): CanvasNavigationOverrideTransition { + this.binding = binding; + return this.clearState(); + } + + setSuspended(suspended: boolean): CanvasNavigationOverrideTransition { + this.suspended = suspended; + return this.clearState(); + } + + update(input: CanvasNavigationKeyboardInput): CanvasNavigationOverrideTransition { + if (this.suspended) return { active: false, changed: false, reserved: false }; + const previous = this.isActive; + const modifier = canvasNavigationModifierFromKey(input.key); + this.modifiers = { + altKey: input.alt, + ctrlKey: input.control, + metaKey: input.meta, + shiftKey: input.shift + }; + if (modifier !== null) { + const pressed = input.type === "keyDown"; + if (modifier === "Alt") this.modifiers.altKey = pressed; + else if (modifier === "Ctrl") this.modifiers.ctrlKey = pressed; + else if (modifier === "Meta") this.modifiers.metaKey = pressed; + else this.modifiers.shiftKey = pressed; + } + const key = normalizeCanvasNavigationInputKey(input.key, input.code); + let reserved = modifier !== null && isCanvasNavigationBindingKey(input.key, this.binding); + if (modifier === null && key && isCanvasNavigationBindingKey(key, this.binding)) { + if (input.type === "keyDown") { + const alreadyOwned = this.pressedKeys.has(key); + const completesBinding = isCanvasNavigationBindingActive({ + ...this.modifiers, + pressedKeys: new Set([key]) + }, this.binding); + if (alreadyOwned || completesBinding) { + this.pressedKeys.add(key); + reserved = true; + } + } else { + reserved = this.pressedKeys.delete(key); + } + } + this.isActive = isCanvasNavigationBindingActive({ + ...this.modifiers, + pressedKeys: this.pressedKeys + }, this.binding); + return { + active: this.isActive, + changed: previous !== this.isActive, + reserved + }; + } + + reset(): CanvasNavigationOverrideTransition { + return this.clearState(); + } + + private clearState(): CanvasNavigationOverrideTransition { + const changed = this.isActive; + this.isActive = false; + this.pressedKeys.clear(); + this.modifiers = { altKey: false, ctrlKey: false, metaKey: false, shiftKey: false }; + return { active: false, changed, reserved: false }; + } + +} + +export class CanvasNavigationInputController { + private readonly wheelTracker: CanvasNavigationOverrideTracker; + private readonly navigationTracker: CanvasNavigationOverrideTracker; + private readonly attachedContents = new Set(); + private readonly onActiveChange: (state: CanvasNavigationOverrideState) => void; + private menuShortcutContents: WebContents | null = null; + + constructor( + bindings: CanvasNavigationBindings, + onActiveChange: (state: CanvasNavigationOverrideState) => void + ) { + this.wheelTracker = new CanvasNavigationOverrideTracker(bindings.wheelBinding); + this.navigationTracker = new CanvasNavigationOverrideTracker(bindings.navigationBinding); + this.onActiveChange = onActiveChange; + } + + get active(): boolean { + return this.navigationTracker.active; + } + + get wheelActive(): boolean { + return this.wheelTracker.active; + } + + attach(contents: WebContents): void { + if (this.attachedContents.has(contents)) return; + this.attachedContents.add(contents); + contents.on("before-input-event", (event, input) => this.handleInput(contents, event, input)); + contents.once("destroyed", () => { + this.attachedContents.delete(contents); + if (this.menuShortcutContents === contents) this.menuShortcutContents = null; + this.resetTrackers(); + }); + } + + setBindings(bindings: CanvasNavigationBindings): void { + this.releaseMenuShortcuts(); + const previous = this.state(); + this.wheelTracker.setBinding(bindings.wheelBinding); + this.navigationTracker.setBinding(bindings.navigationBinding); + this.emitIfChanged(previous); + } + + setShortcutCaptureActive(active: boolean): void { + this.releaseMenuShortcuts(); + const previous = this.state(); + this.wheelTracker.setSuspended(active); + this.navigationTracker.setSuspended(active); + this.emitIfChanged(previous); + } + + reset(): void { + this.releaseMenuShortcuts(); + this.resetTrackers(); + } + + private handleInput(contents: WebContents, event: Event, input: Input): void { + if (input.type !== "keyDown" && input.type !== "keyUp") return; + const keyboardInput = { + type: input.type, + key: input.key, + code: input.code, + alt: input.alt, + control: input.control, + meta: input.meta, + shift: input.shift + } satisfies CanvasNavigationKeyboardInput; + const previous = this.state(); + const wheelTransition = this.wheelTracker.update(keyboardInput); + const navigationTransition = this.navigationTracker.update(keyboardInput); + const shouldCaptureMenuShortcuts = this.wheelTracker.shouldCaptureMenuShortcuts + || this.navigationTracker.shouldCaptureMenuShortcuts; + this.setMenuShortcutCapture(contents, shouldCaptureMenuShortcuts); + const shouldPrevent = shouldPreventCanvasNavigationInput(input, { + active: wheelTransition.active || navigationTransition.active, + changed: wheelTransition.changed || navigationTransition.changed, + reserved: wheelTransition.reserved || navigationTransition.reserved + }); + if (shouldPrevent) event.preventDefault(); + this.emitIfChanged(previous); + } + + private releaseMenuShortcuts(): void { + if (this.menuShortcutContents && !this.menuShortcutContents.isDestroyed()) { + this.menuShortcutContents.setIgnoreMenuShortcuts(false); + } + this.menuShortcutContents = null; + } + + private setMenuShortcutCapture(contents: WebContents, active: boolean): void { + if (this.menuShortcutContents !== contents) this.releaseMenuShortcuts(); + if (active) { + if (this.menuShortcutContents !== contents) contents.setIgnoreMenuShortcuts(true); + this.menuShortcutContents = contents; + } else if (this.menuShortcutContents === contents) { + this.releaseMenuShortcuts(); + } + } + + private state(): CanvasNavigationOverrideState { + return { + wheelActive: this.wheelTracker.active, + navigationActive: this.navigationTracker.active + }; + } + + private resetTrackers(): void { + const previous = this.state(); + this.wheelTracker.reset(); + this.navigationTracker.reset(); + this.emitIfChanged(previous); + } + + private emitIfChanged(previous: CanvasNavigationOverrideState): void { + const current = this.state(); + if (current.wheelActive !== previous.wheelActive + || current.navigationActive !== previous.navigationActive) { + this.onActiveChange(current); + } + } + +} diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index 2f9d10c..0da5585 100644 --- a/src/main/services/PluginManager.ts +++ b/src/main/services/PluginManager.ts @@ -41,6 +41,16 @@ const MAX_PACKAGE_BYTES = 25 * 1024 * 1024; const MAX_ASSET_BYTES = 8 * 1024 * 1024; const MAX_STORAGE_BYTES = 64 * 1024; const MAX_MANIFEST_BYTES = 128 * 1024; +const PLUGIN_INPUT_BRIDGE_URL = "canvastty-plugin://host/input-bridge.js"; + +export function injectPluginInputBridge(html: string): string { + if (html.includes(PLUGIN_INPUT_BRIDGE_URL)) return html; + const script = ``; + const head = /]*)?>/i.exec(html); + if (!head || head.index === undefined) return `${script}${html}`; + const offset = head.index + head[0].length; + return `${html.slice(0, offset)}${script}${html.slice(offset)}`; +} const PLUGIN_PERMISSIONS = new Set([ "storage", @@ -365,6 +375,12 @@ export class PluginManager { headers: resourceHeaders("application/javascript; charset=utf-8", false, false) }); } + if (url.hostname === "host" && url.pathname === "/input-bridge.js") { + return new Response(PLUGIN_INPUT_BRIDGE_SOURCE, { + status: 200, + headers: resourceHeaders("application/javascript; charset=utf-8", false, false) + }); + } const plugin = activePlugin(this.requireEnabledPlugin(url.hostname)); const relativePath = decodeAssetPath(url.pathname); @@ -373,7 +389,10 @@ export class PluginManager { const metadata = await stat(path); if (!metadata.isFile() || metadata.size > MAX_ASSET_BYTES) return response("Plugin asset is unavailable.", 404); const content = await readFile(path); - return new Response(content, { + const body = extname(path).toLowerCase() === ".html" + ? injectPluginInputBridge(content.toString("utf8")) + : content; + return new Response(body, { status: 200, headers: resourceHeaders( mimeType(path), @@ -1338,3 +1357,42 @@ const PLUGIN_SDK_SOURCE = `(() => { }); addEventListener("DOMContentLoaded", () => post({ type: "ready" }), { once: true }); })();`; + +const PLUGIN_INPUT_BRIDGE_SOURCE = `(() => { + if (parent === window) return; + let captureWheel = false; + addEventListener("message", (event) => { + const message = event.data; + if (event.source !== parent || !message || message.source !== "canvastty-host") return; + if (message.type === "canvas-input-policy") captureWheel = message.captureWheel === true; + }); + addEventListener("wheel", (event) => { + if (!captureWheel) return; + event.preventDefault(); + event.stopImmediatePropagation(); + parent.postMessage({ + source: "canvastty-plugin", + type: "canvas-wheel", + clientX: event.clientX, + clientY: event.clientY, + deltaX: event.deltaX, + deltaY: event.deltaY, + deltaMode: event.deltaMode, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + altKey: event.altKey, + shiftKey: event.shiftKey + }, "*"); + }, { capture: true, passive: false }); + addEventListener("pointerdown", () => { + parent.postMessage({ source: "canvastty-plugin", type: "canvas-focus" }, "*"); + }, { capture: true }); + addEventListener("pointerover", (event) => { + if (event.relatedTarget !== null) return; + parent.postMessage({ source: "canvastty-plugin", type: "canvas-hover", active: true }, "*"); + }, { capture: true }); + addEventListener("pointerout", (event) => { + if (event.relatedTarget !== null) return; + parent.postMessage({ source: "canvastty-plugin", type: "canvas-hover", active: false }, "*"); + }, { capture: true }); +})();`; diff --git a/src/main/services/SettingsStore.ts b/src/main/services/SettingsStore.ts index 44440f1..a645bde 100644 --- a/src/main/services/SettingsStore.ts +++ b/src/main/services/SettingsStore.ts @@ -5,6 +5,7 @@ import type { AgentProviderId, AppSettings, BrowserCanvasState, + CanvasWheelCaptureMode, CanvasPatternId, EdgePanSpeed, FocusActivation, @@ -25,6 +26,12 @@ import { HOME_GRID_MIN_COLUMNS, HOME_GRID_MIN_ROWS } from "../../shared/contracts.ts"; +import { + canvasNavigationPlatform, + defaultCanvasWheelBinding, + normalizeCanvasOverrideBinding, + type CanvasNavigationPlatform +} from "../../shared/canvasNavigation.ts"; const LOCALES = new Set(["ru", "en"]); const PALETTES = new Set(["sage", "lilac", "night"]); @@ -34,23 +41,38 @@ const AGENT_PROVIDERS = new Set(["codex", "claude", "kimi"]); const EDGE_PAN_SPEEDS = new Set(["slow", "normal", "fast"]); const ZOOM_SENSITIVITIES = new Set(["slow", "normal", "fast"]); const FOCUS_ACTIVATIONS = new Set(["off", "single", "double"]); +const CANVAS_WHEEL_CAPTURE_MODES = new Set(["off", "always", "key"]); const SHORTCUT_MODIFIERS = new Set(["Ctrl", "Alt", "Shift", "Meta"]); const DEFAULT_SHORTCUTS: ShortcutBindings = { home: "Home", renameWindow: "F2" }; export class SettingsStore { private readonly filePath: string; + private readonly platform: CanvasNavigationPlatform; private value: AppSettings; + private hasPersistedLegacyWheelCapture = false; private writeQueue = Promise.resolve(); - constructor(userDataPath: string, systemLocale: string) { + constructor(userDataPath: string, systemLocale: string, platform: string = process.platform) { this.filePath = join(userDataPath, "settings.json"); - this.value = createDefaults(systemLocale); + this.platform = canvasNavigationPlatform(platform); + this.value = createDefaults(systemLocale, this.platform); } async load(): Promise { try { const raw = await readFile(this.filePath, "utf8"); - this.value = normalizeSettings(JSON.parse(raw), this.value); + const candidate: unknown = JSON.parse(raw); + const source = candidate && typeof candidate === "object" ? candidate as Record : {}; + this.hasPersistedLegacyWheelCapture = Object.hasOwn(source, "zoomOverApplications"); + const needsMigration = !("useScrollWheelToZoom" in source) + || !("canvasNavigationOverride" in source) + || !("canvasWheelOverride" in source) + || !("canvasWheelCaptureMode" in source); + this.value = normalizeSettings(candidate, { + ...this.value, + useScrollWheelToZoom: true + }, this.platform); + if (needsMigration) await this.persist(); } catch (error) { if (isMissingFile(error)) { await this.persist(); @@ -67,13 +89,23 @@ export class SettingsStore { } async update(patch: Partial): Promise { - this.value = normalizeSettings({ ...this.value, ...patch }, this.value); + if (patch.canvasWheelCaptureMode !== undefined) this.hasPersistedLegacyWheelCapture = true; + const nextPatch = patch.canvasWheelCaptureMode === "key" + && patch.canvasWheelOverride === undefined + && this.value.canvasWheelOverride === null + ? { ...patch, canvasWheelOverride: defaultCanvasWheelBinding(this.platform) } + : patch; + this.value = normalizeSettings({ ...this.value, ...nextPatch }, this.value, this.platform); await this.persist(); return this.get(); } private persist(): Promise { - const snapshot = JSON.stringify(this.value, null, 2); + const persistedValue: Partial & { zoomOverApplications?: boolean } = { ...this.value }; + if (this.hasPersistedLegacyWheelCapture) { + persistedValue.zoomOverApplications = this.value.canvasWheelCaptureMode === "always"; + } + const snapshot = JSON.stringify(persistedValue, null, 2); const temporaryPath = `${this.filePath}.tmp`; const write = this.writeQueue.catch(() => undefined).then(async () => { @@ -87,7 +119,7 @@ export class SettingsStore { } } -function createDefaults(systemLocale: string): AppSettings { +function createDefaults(systemLocale: string, platform: CanvasNavigationPlatform): AppSettings { return { locale: systemLocale.toLowerCase().startsWith("ru") ? "ru" : "en", palette: "sage", @@ -98,7 +130,10 @@ function createDefaults(systemLocale: string): AppSettings { edgePan: false, edgePanSpeed: "normal", zoomSensitivity: "normal", - zoomOverApplications: true, + useScrollWheelToZoom: false, + canvasWheelCaptureMode: "key", + canvasWheelOverride: defaultCanvasWheelBinding(platform), + canvasNavigationOverride: "Alt", focusActivation: "off", hoverFocus: false, hoverFocusSpeed: "normal", @@ -118,12 +153,16 @@ function createDefaults(systemLocale: string): AppSettings { }; } -export function normalizeSettings(candidate: unknown, fallback: AppSettings): AppSettings { +export function normalizeSettings( + candidate: unknown, + fallback: AppSettings, + platform: CanvasNavigationPlatform = canvasNavigationPlatform(process.platform) +): AppSettings { if (!candidate || typeof candidate !== "object") { return fallback; } - const source = candidate as Partial; + const source = candidate as Partial & { zoomOverApplications?: unknown }; const mediaPath = source.mediaPath === null || typeof source.mediaPath === "string" ? source.mediaPath : fallback.mediaPath; @@ -133,6 +172,16 @@ export function normalizeSettings(candidate: unknown, fallback: AppSettings): Ap ) : fallback.acknowledgedDangerousProfiles; const shortcuts = normalizeShortcuts(source.shortcuts, fallback.shortcuts); + const navigationOverrideCandidate = source.canvasNavigationOverride === undefined + ? fallback.canvasNavigationOverride + : source.canvasNavigationOverride; + const canvasNavigationOverride = navigationOverrideCandidate === null + ? null + : normalizeCanvasOverrideBinding( + navigationOverrideCandidate, + Object.values(shortcuts) + ); + const wheelCapture = normalizeCanvasWheelCapture(source, fallback, platform, Object.values(shortcuts)); const homeGridSize = normalizeHomeGridSize( source.homeGridSize, fallback.homeGridSize ?? DEFAULT_HOME_GRID_SIZE @@ -165,9 +214,12 @@ export function normalizeSettings(candidate: unknown, fallback: AppSettings): Ap zoomSensitivity: ZOOM_SENSITIVITIES.has(source.zoomSensitivity as ZoomSensitivity) ? source.zoomSensitivity as ZoomSensitivity : fallback.zoomSensitivity, - zoomOverApplications: typeof source.zoomOverApplications === "boolean" - ? source.zoomOverApplications - : fallback.zoomOverApplications, + useScrollWheelToZoom: typeof source.useScrollWheelToZoom === "boolean" + ? source.useScrollWheelToZoom + : fallback.useScrollWheelToZoom, + canvasWheelCaptureMode: wheelCapture.mode, + canvasWheelOverride: wheelCapture.binding, + canvasNavigationOverride, focusActivation: FOCUS_ACTIVATIONS.has(source.focusActivation as FocusActivation) ? source.focusActivation as FocusActivation : fallback.focusActivation, @@ -201,6 +253,50 @@ export function normalizeSettings(candidate: unknown, fallback: AppSettings): Ap }; } +function normalizeCanvasWheelCapture( + source: Partial & { zoomOverApplications?: unknown }, + fallback: AppSettings, + platform: CanvasNavigationPlatform, + actionShortcuts: readonly string[] +): { mode: CanvasWheelCaptureMode; binding: string | null } { + const hasMode = Object.hasOwn(source, "canvasWheelCaptureMode"); + const requestedMode = CANVAS_WHEEL_CAPTURE_MODES.has(source.canvasWheelCaptureMode as CanvasWheelCaptureMode) + ? source.canvasWheelCaptureMode as CanvasWheelCaptureMode + : null; + const hasBinding = Object.hasOwn(source, "canvasWheelOverride"); + const rawBinding = source.canvasWheelOverride; + const normalizedSourceBinding = rawBinding === null || rawBinding === undefined + ? null + : normalizeCanvasOverrideBinding(rawBinding, actionShortcuts); + const invalidSourceBinding = hasBinding && rawBinding !== null && rawBinding !== undefined + && normalizedSourceBinding === null; + + if (hasMode && requestedMode !== null) { + const binding = hasBinding ? normalizedSourceBinding : fallback.canvasWheelOverride; + if (requestedMode === "key" && binding === null) return { mode: "off", binding: null }; + return { mode: requestedMode, binding }; + } + + if (typeof source.zoomOverApplications === "boolean") { + if (source.zoomOverApplications) { + return { + mode: "always", + binding: normalizedSourceBinding ?? defaultCanvasWheelBinding(platform) + }; + } + return normalizedSourceBinding === null + ? { mode: "off", binding: null } + : { mode: "key", binding: normalizedSourceBinding }; + } + + if (invalidSourceBinding) return { mode: "off", binding: null }; + const binding = normalizedSourceBinding ?? fallback.canvasWheelOverride ?? defaultCanvasWheelBinding(platform); + const mode = requestedMode ?? fallback.canvasWheelCaptureMode; + return mode === "key" && binding === null + ? { mode: "off", binding: null } + : { mode, binding }; +} + export function normalizeHomeLayout( candidate: unknown, fallback: readonly HomeWidgetPlacement[] = DEFAULT_HOME_LAYOUT, diff --git a/src/main/services/browser/BrowserCanvasCursor.ts b/src/main/services/browser/BrowserCanvasCursor.ts new file mode 100644 index 0000000..f585753 --- /dev/null +++ b/src/main/services/browser/BrowserCanvasCursor.ts @@ -0,0 +1,79 @@ +export type BrowserCanvasCursor = "grab" | "grabbing" | null; + +export interface BrowserCanvasCursorContents { + isDestroyed(): boolean; + insertCSS(css: string, options: { cssOrigin: "user" }): Promise; + removeInsertedCSS(key: string): Promise; +} + +export function browserCanvasNavigationCursor( + navigationActive: boolean, + dragOwnedByCanvas: boolean +): BrowserCanvasCursor { + if (dragOwnedByCanvas) return "grabbing"; + return navigationActive ? "grab" : null; +} + +export class BrowserCanvasCursorController { + private readonly contents: BrowserCanvasCursorContents; + private desired: BrowserCanvasCursor = null; + private generation = 0; + private insertedKey: string | null = null; + private disposed = false; + + constructor(contents: BrowserCanvasCursorContents) { + this.contents = contents; + } + + set(cursor: BrowserCanvasCursor): void { + if (this.disposed || this.desired === cursor) return; + this.desired = cursor; + void this.apply(); + } + + refresh(): void { + if (this.disposed || this.desired === null) return; + void this.apply(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.desired = null; + this.generation += 1; + this.removeCurrent(); + } + + private async apply(): Promise { + const generation = ++this.generation; + const cursor = this.desired; + this.removeCurrent(); + if (cursor === null || this.contents.isDestroyed()) return; + + try { + const key = await this.contents.insertCSS( + `html, body, * { cursor: ${cursor} !important; }`, + { cssOrigin: "user" } + ); + if ( + this.disposed + || generation !== this.generation + || cursor !== this.desired + || this.contents.isDestroyed() + ) { + void this.contents.removeInsertedCSS(key).catch(() => undefined); + return; + } + this.insertedKey = key; + } catch { + // A navigation or destroyed tab can invalidate an in-flight CSS insertion. + } + } + + private removeCurrent(): void { + const key = this.insertedKey; + this.insertedKey = null; + if (key === null || this.contents.isDestroyed()) return; + void this.contents.removeInsertedCSS(key).catch(() => undefined); + } +} diff --git a/src/main/services/browser/BrowserCanvasFreeze.ts b/src/main/services/browser/BrowserCanvasFreeze.ts new file mode 100644 index 0000000..105766e --- /dev/null +++ b/src/main/services/browser/BrowserCanvasFreeze.ts @@ -0,0 +1,231 @@ +import type { Point, Size } from "../../../shared/contracts.ts"; + +export const BROWSER_CANVAS_FREEZE_GUARD_DIP = 4; +export const BROWSER_CANVAS_NATIVE_WHEEL_SINK_SIZE_DIP = 4; +export const BROWSER_CANVAS_WHEEL_IDLE_MS = 250; +export const BROWSER_CANVAS_FREEZE_MAX_BYTES = Math.floor(1.5 * 1024 * 1024); + +export interface BrowserCanvasRectangle extends Point, Size {} + +interface BrowserCanvasWheelSequenceState { + active: boolean; + collisionLatched: boolean; + lastWheelAt: number; + pointer: Point | null; +} + +export interface BrowserCanvasFreezeCaptureToken { + tabId: string; + generation: number; +} + +export interface BrowserCanvasNativeWheelSink { + tabId: string; + pointer: Point; + viewport: BrowserCanvasRectangle; +} + +export interface BrowserCanvasNativeWheelSinkLayout { + clip: BrowserCanvasRectangle; + view: BrowserCanvasRectangle; +} + +export interface BrowserCanvasFreezeImage { + getSize(): Size; + resize(options: { width: number; height: number; quality?: "good" }): BrowserCanvasFreezeImage; + toJPEG(quality: number): Buffer; +} + +export class BrowserCanvasWheelSequence { + private state: BrowserCanvasWheelSequenceState = { + active: false, + collisionLatched: false, + lastWheelAt: 0, + pointer: null + }; + + begin(pointer: Point, now: number): { started: boolean } { + const started = !this.state.active; + this.state = { + active: true, + collisionLatched: started ? false : this.state.collisionLatched, + lastWheelAt: now, + pointer: { ...pointer } + }; + return { started }; + } + + shouldFreeze(rectangle: BrowserCanvasRectangle | null): boolean { + if (!this.state.active) return false; + if (this.state.collisionLatched) return true; + if (!rectangle || !this.state.pointer) return false; + if (!pointInsideExpandedRectangle(this.state.pointer, rectangle, BROWSER_CANVAS_FREEZE_GUARD_DIP)) { + return false; + } + this.state = { ...this.state, collisionLatched: true }; + return true; + } + + expired(now: number): boolean { + return this.state.active && now - this.state.lastWheelAt >= BROWSER_CANVAS_WHEEL_IDLE_MS; + } + + end(): boolean { + if (!this.state.active && !this.state.collisionLatched && this.state.pointer === null) return false; + this.state = { + ...this.state, + active: false, + collisionLatched: false, + lastWheelAt: 0, + pointer: null + }; + return true; + } +} + +export class BrowserCanvasFreezeFrameStore { + private captureGeneration = 0; + private latestRequest: BrowserCanvasFreezeCaptureToken | null = null; + private frame: { tabId: string; dataUrl: string } | null = null; + + beginCapture(tabId: string): BrowserCanvasFreezeCaptureToken { + const token = { tabId, generation: ++this.captureGeneration }; + this.latestRequest = token; + return token; + } + + commitCapture(token: BrowserCanvasFreezeCaptureToken, dataUrl: string): { tabId: string; dataUrl: string } | null { + if (!this.isCurrent(token)) return null; + this.latestRequest = null; + this.frame = { tabId: token.tabId, dataUrl }; + return { ...this.frame }; + } + + failCapture(token: BrowserCanvasFreezeCaptureToken): void { + if (this.isCurrent(token)) this.latestRequest = null; + } + + invalidateCapture(): void { + this.latestRequest = null; + this.captureGeneration += 1; + } + + frameFor(tabId: string): string | null { + return this.frame?.tabId === tabId ? this.frame.dataUrl : null; + } + + clear(): void { + this.latestRequest = null; + this.frame = null; + this.captureGeneration += 1; + } + + private isCurrent(token: BrowserCanvasFreezeCaptureToken): boolean { + return this.latestRequest?.tabId === token.tabId + && this.latestRequest.generation === token.generation; + } +} + +export function browserVisibleRectangle( + viewport: BrowserCanvasRectangle, + content: Size +): BrowserCanvasRectangle | null { + const left = Math.max(0, viewport.x); + const top = Math.max(0, viewport.y); + const right = Math.min(content.width, viewport.x + viewport.width); + const bottom = Math.min(content.height, viewport.y + viewport.height); + if (right <= left || bottom <= top) return null; + return { x: left, y: top, width: right - left, height: bottom - top }; +} + +export function createBrowserCanvasNativeWheelSink( + tabId: string, + viewport: BrowserCanvasRectangle, + pointer: Point +): BrowserCanvasNativeWheelSink | null { + if ( + !tabId + || !rectangleIsFinite(viewport) + || viewport.width <= 0 + || viewport.height <= 0 + || !pointIsFinite(pointer) + || !pointInsideRectangle(pointer, viewport) + ) return null; + return { + tabId, + pointer: { ...pointer }, + viewport: { ...viewport } + }; +} + +export function browserCanvasNativeWheelSinkLayout( + sink: BrowserCanvasNativeWheelSink, + content: Size +): BrowserCanvasNativeWheelSinkLayout | null { + if (!sizeIsFinite(content) || content.width <= 0 || content.height <= 0) return null; + const contentWidth = Math.floor(content.width); + const contentHeight = Math.floor(content.height); + if ( + contentWidth <= 0 + || contentHeight <= 0 + || !pointInsideRectangle(sink.pointer, { x: 0, y: 0, width: contentWidth, height: contentHeight }) + ) return null; + + const width = Math.min(BROWSER_CANVAS_NATIVE_WHEEL_SINK_SIZE_DIP, contentWidth); + const height = Math.min(BROWSER_CANVAS_NATIVE_WHEEL_SINK_SIZE_DIP, contentHeight); + const x = Math.min( + contentWidth - width, + Math.max(0, Math.floor(sink.pointer.x) - Math.floor(width / 2)) + ); + const y = Math.min( + contentHeight - height, + Math.max(0, Math.floor(sink.pointer.y) - Math.floor(height / 2)) + ); + return { + clip: { x, y, width, height }, + view: { x: 0, y: 0, width, height } + }; +} + +export function encodeBrowserCanvasFreezeFrame(image: BrowserCanvasFreezeImage): string | null { + let current = image; + let bytes = current.toJPEG(70); + for (let attempt = 0; bytes.byteLength > BROWSER_CANVAS_FREEZE_MAX_BYTES && attempt < 4; attempt += 1) { + const size = current.getSize(); + const scale = Math.min(0.85, Math.sqrt(BROWSER_CANVAS_FREEZE_MAX_BYTES / bytes.byteLength) * 0.9); + current = current.resize({ + width: Math.max(160, Math.floor(size.width * scale)), + height: Math.max(100, Math.floor(size.height * scale)), + quality: "good" + }); + bytes = current.toJPEG([65, 50, 35, 25][attempt] ?? 25); + } + if (bytes.byteLength === 0 || bytes.byteLength > BROWSER_CANVAS_FREEZE_MAX_BYTES) return null; + return `data:image/jpeg;base64,${bytes.toString("base64")}`; +} + +function pointInsideExpandedRectangle(point: Point, rectangle: BrowserCanvasRectangle, guard: number): boolean { + return point.x >= rectangle.x - guard + && point.y >= rectangle.y - guard + && point.x < rectangle.x + rectangle.width + guard + && point.y < rectangle.y + rectangle.height + guard; +} + +function pointInsideRectangle(point: Point, rectangle: BrowserCanvasRectangle): boolean { + return point.x >= rectangle.x + && point.y >= rectangle.y + && point.x < rectangle.x + rectangle.width + && point.y < rectangle.y + rectangle.height; +} + +function pointIsFinite(point: Point): boolean { + return Number.isFinite(point.x) && Number.isFinite(point.y); +} + +function sizeIsFinite(size: Size): boolean { + return Number.isFinite(size.width) && Number.isFinite(size.height); +} + +function rectangleIsFinite(rectangle: BrowserCanvasRectangle): boolean { + return pointIsFinite(rectangle) && sizeIsFinite(rectangle); +} diff --git a/src/main/services/browser/BrowserCanvasGestureController.ts b/src/main/services/browser/BrowserCanvasGestureController.ts new file mode 100644 index 0000000..089b7ec --- /dev/null +++ b/src/main/services/browser/BrowserCanvasGestureController.ts @@ -0,0 +1,348 @@ +import type { BrowserWindow, WebContents, WebContentsView } from "electron"; +import type { + BrowserCanvasFreezeFrameEvent, + BrowserCanvasWheelEvent, + BrowserViewportBounds, + CanvasWheelCaptureMode, + Point, + Size +} from "../../../shared/contracts.ts"; +import { + BROWSER_CANVAS_WHEEL_IDLE_MS, + BrowserCanvasFreezeFrameStore, + BrowserCanvasWheelSequence, + browserCanvasNativeWheelSinkLayout, + createBrowserCanvasNativeWheelSink, + encodeBrowserCanvasFreezeFrame, + type BrowserCanvasNativeWheelSink, + type BrowserCanvasNativeWheelSinkLayout, + type BrowserCanvasRectangle +} from "./BrowserCanvasFreeze.ts"; +import type { BrowserCanvasSinkViewportController } from "./BrowserCanvasSinkViewport.ts"; +import { + BrowserPageWheelSequence, + browserPageWheelClientPoint, + browserWheelOwner, + toCanvasPageWheelInput, + type BrowserWheelDecision +} from "./BrowserCanvasWheel.ts"; + +export interface BrowserCanvasGestureTab { + id: string; + view: WebContentsView; + canvasSinkViewport: BrowserCanvasSinkViewportController; +} + +export interface BrowserCanvasGestureHost { + getOwner(): BrowserWindow | null; + getViewport(): BrowserViewportBounds; + getActiveTab(): BrowserCanvasGestureTab | null; + getTab(tabId: string): BrowserCanvasGestureTab | undefined; + isVisible(): boolean; + isDisposed(): boolean; + getOverrideState(): { wheelActive: boolean; navigationActive: boolean }; + getCursorScreenPoint(): Point; + requestSurfaceSync(): void; + beforeSequenceEnd(): void; + shouldDeferIdleEnd(): boolean; + sendWheel(payload: BrowserCanvasWheelEvent): void; + sendFreezeFrame(payload: BrowserCanvasFreezeFrameEvent): void; +} + +export type BrowserCanvasSurfaceDecision = + | { kind: "normal" } + | { kind: "frozen" } + | { kind: "sink"; layout: BrowserCanvasNativeWheelSinkLayout }; + +export class BrowserCanvasGestureController { + private readonly host: BrowserCanvasGestureHost; + private readonly now: () => number; + private readonly pageSequence = new BrowserPageWheelSequence(); + private readonly ownerSequence = new BrowserCanvasWheelSequence(); + private readonly frameStore = new BrowserCanvasFreezeFrameStore(); + private captureMode: CanvasWheelCaptureMode; + private inputFocused = false; + private browserWheelPoint: { tabId: string; point: Point; observedAt: number } | null = null; + private sequenceTimer: NodeJS.Timeout | null = null; + private capturePromise: Promise | null = null; + private captureQueued = false; + private captureAfterSequence = false; + private freezeActive = false; + private freezeTabId: string | null = null; + private freezeEventGeneration = 0; + private nativeSink: BrowserCanvasNativeWheelSink | null = null; + + constructor( + host: BrowserCanvasGestureHost, + options: { captureMode: CanvasWheelCaptureMode; now?: () => number } + ) { + this.host = host; + this.captureMode = options.captureMode; + this.now = options.now ?? Date.now; + } + + get activeNativeSink(): BrowserCanvasNativeWheelSink | null { + return this.nativeSink; + } + + get frozenTabId(): string | null { + return this.freezeTabId; + } + + get isFreezeActive(): boolean { + return this.freezeActive; + } + + setInputFocused(focused: boolean): void { + this.inputFocused = focused; + } + + setCaptureMode(mode: CanvasWheelCaptureMode): void { + this.captureMode = mode; + } + + decidePageWheel(sender: WebContents, input: unknown): BrowserWheelDecision { + const failClosed = { generation: 0, owner: "canvas" } satisfies BrowserWheelDecision; + const wheel = toCanvasPageWheelInput(input); + const viewport = this.host.getViewport(); + const tab = this.host.getActiveTab(); + if (!wheel || !this.host.isVisible() || viewport.surface !== "native" || !tab) return failClosed; + if (tab.view.webContents !== sender || sender.isDestroyed()) return failClosed; + const owner = this.host.getOwner(); + if (!owner || owner.isDestroyed()) return failClosed; + const overrides = this.host.getOverrideState(); + const decision = this.pageSequence.decide(browserWheelOwner({ + surface: viewport.surface, + focused: this.inputFocused, + captureMode: this.captureMode, + wheelOverrideActive: overrides.wheelActive, + canvasOverrideActive: overrides.navigationActive, + ctrlKey: wheel.ctrlKey, + metaKey: wheel.metaKey + }), this.now()); + if (decision.owner === "canvas") { + this.beginOwnerSequence(this.wheelClientPoint(tab.id, owner, input), true); + } + return decision; + } + + handlePageWheel(sender: WebContents, input: unknown): void { + const viewport = this.host.getViewport(); + const tab = this.host.getActiveTab(); + if (!this.host.isVisible() || viewport.surface === "hidden" || !tab) return; + if (tab.view.webContents !== sender || sender.isDestroyed()) return; + const wheel = toCanvasPageWheelInput(input); + if (!wheel) return; + const generation = pageWheelGeneration(input); + const owner = generation === null ? null : this.pageSequence.touch(generation, this.now()); + if (owner === null || owner === "page") return; + const ownerWindow = this.host.getOwner(); + if (!ownerWindow || ownerWindow.isDestroyed()) return; + const clientPoint = this.wheelClientPoint(tab.id, ownerWindow, input); + this.beginOwnerSequence(clientPoint); + this.host.sendWheel({ + tabId: tab.id, + clientX: clientPoint.x, + clientY: clientPoint.y, + ...wheel + }); + } + + beginRendererSequence(input: unknown): void { + if (!input || typeof input !== "object" || Array.isArray(input)) return; + const values = input as Record; + if (!Number.isFinite(values.clientX) || !Number.isFinite(values.clientY)) return; + const owner = this.host.getOwner(); + if (!owner || owner.isDestroyed()) return; + const content = owner.getContentBounds(); + const clientX = values.clientX as number; + const clientY = values.clientY as number; + if (clientX < 0 || clientY < 0 || clientX >= content.width || clientY >= content.height) return; + this.beginOwnerSequence({ x: clientX, y: clientY }); + } + + observeBrowserWheel(tabId: string, mouse: Electron.MouseInputEvent): void { + if (mouse.type !== "mouseWheel") return; + const viewport = this.host.getViewport(); + const sink = this.nativeSink?.tabId === tabId ? this.nativeSink : null; + this.browserWheelPoint = { + tabId, + point: sink ? { ...sink.pointer } : { x: viewport.x + mouse.x, y: viewport.y + mouse.y }, + observedAt: this.now() + }; + } + + beginOwnerSequence(point: Point, preserveNativeTarget = false): void { + const viewport = this.host.getViewport(); + const tab = this.host.getActiveTab(); + if (!this.host.isVisible() || viewport.surface === "hidden" || !tab) return; + if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return; + const transition = this.ownerSequence.begin(point, this.now()); + this.scheduleEnd(); + if (transition.started) { + const proposedSink = preserveNativeTarget + ? createBrowserCanvasNativeWheelSink(tab.id, viewport, point) + : null; + const sinkTab = proposedSink ? this.host.getTab(proposedSink.tabId) : undefined; + this.nativeSink = proposedSink && sinkTab?.canvasSinkViewport.preserve(proposedSink.viewport) + ? proposedSink + : null; + this.refreshFrame(); + } + this.host.requestSurfaceSync(); + } + + endSequence(sync = true): void { + if (this.sequenceTimer) { + clearTimeout(this.sequenceTimer); + this.sequenceTimer = null; + } + this.host.beforeSequenceEnd(); + const frozenTabId = this.freezeTabId; + const wasFrozen = this.freezeActive; + const nativeSinkTabId = this.nativeSink?.tabId ?? null; + this.ownerSequence.end(); + this.pageSequence.reset(); + this.nativeSink = null; + if (sync && !this.host.isDisposed()) this.host.requestSurfaceSync(); + if (nativeSinkTabId) this.host.getTab(nativeSinkTabId)?.canvasSinkViewport.restore(); + if (wasFrozen && frozenTabId && this.freezeTabId === frozenTabId) { + this.freezeActive = false; + this.freezeTabId = null; + this.sendFreezeFrame(frozenTabId, false, this.frameStore.frameFor(frozenTabId)); + } + if (this.captureAfterSequence) { + this.captureAfterSequence = false; + if (sync && !this.host.isDisposed() && this.host.getViewport().surface === "native") this.refreshFrame(); + } + } + + invalidateCapture(): void { + this.frameStore.invalidateCapture(); + } + + clear(): void { + this.frameStore.clear(); + } + + viewportChanged(previous: BrowserViewportBounds, next: BrowserViewportBounds): void { + if (next.surface === "hidden") { + this.inputFocused = false; + this.endSequence(false); + this.invalidateCapture(); + return; + } + if (next.surface === "native" && ( + previous.width !== next.width + || previous.height !== next.height + || previous.canvasScale !== next.canvasScale + )) { + if (this.freezeActive) this.captureAfterSequence = true; + else this.refreshFrame(); + } + } + + surfaceDecision( + tabId: string, + visibleRectangle: BrowserCanvasRectangle | null, + ownerSize: Size + ): BrowserCanvasSurfaceDecision { + if (!this.ownerSequence.shouldFreeze(visibleRectangle)) return { kind: "normal" }; + this.activateFreezeFrame(tabId); + const layout = this.nativeSink?.tabId === tabId + ? browserCanvasNativeWheelSinkLayout(this.nativeSink, ownerSize) + : null; + return layout ? { kind: "sink", layout } : { kind: "frozen" }; + } + + refreshFrame(): void { + const viewport = this.host.getViewport(); + const tab = this.host.getActiveTab(); + if (this.host.isDisposed() || !this.host.isVisible() || viewport.surface !== "native" || !tab) return; + if (tab.view.webContents.isDestroyed()) return; + if (this.capturePromise) { + this.captureQueued = true; + return; + } + const token = this.frameStore.beginCapture(tab.id); + const capture = (async (): Promise => { + try { + const image = await tab.view.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true }); + const dataUrl = encodeBrowserCanvasFreezeFrame(image); + if (!dataUrl) { + this.frameStore.failCapture(token); + return; + } + const frame = this.frameStore.commitCapture(token, dataUrl); + if (!frame) return; + this.sendFreezeFrame(frame.tabId, this.freezeActive && this.freezeTabId === frame.tabId, frame.dataUrl); + } catch { + this.frameStore.failCapture(token); + } + })(); + this.capturePromise = capture; + void capture.finally(() => { + if (this.capturePromise === capture) this.capturePromise = null; + if (!this.captureQueued) return; + this.captureQueued = false; + this.refreshFrame(); + }); + } + + private scheduleEnd(): void { + if (this.sequenceTimer) clearTimeout(this.sequenceTimer); + this.sequenceTimer = setTimeout(() => { + this.sequenceTimer = null; + if (!this.ownerSequence.expired(this.now())) { + this.scheduleEnd(); + return; + } + this.endIdleSequence(); + }, BROWSER_CANVAS_WHEEL_IDLE_MS); + } + + private endIdleSequence(): void { + if (this.host.shouldDeferIdleEnd()) { + this.scheduleEnd(); + return; + } + this.endSequence(); + } + + private activateFreezeFrame(tabId: string): void { + if (this.freezeActive && this.freezeTabId === tabId) return; + this.freezeActive = true; + this.freezeTabId = tabId; + this.sendFreezeFrame(tabId, true, this.frameStore.frameFor(tabId)); + } + + private sendFreezeFrame(tabId: string, active: boolean, dataUrl: string | null): void { + this.host.sendFreezeFrame({ + tabId, + generation: ++this.freezeEventGeneration, + active, + dataUrl + }); + } + + private wheelClientPoint(tabId: string, owner: BrowserWindow, input: unknown): Point { + const contentBounds = owner.getContentBounds(); + const eventPoint = browserPageWheelClientPoint(input, { + ownerScreenBounds: contentBounds, + viewport: this.host.getViewport() + }); + if (eventPoint) return eventPoint; + const nativePoint = this.browserWheelPoint; + if (nativePoint?.tabId === tabId && this.now() - nativePoint.observedAt < BROWSER_CANVAS_WHEEL_IDLE_MS) { + return { ...nativePoint.point }; + } + const pointer = this.host.getCursorScreenPoint(); + return { x: pointer.x - contentBounds.x, y: pointer.y - contentBounds.y }; + } +} + +function pageWheelGeneration(value: unknown): number | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const generation = (value as Record).generation; + return Number.isInteger(generation) && (generation as number) > 0 ? generation as number : null; +} diff --git a/src/main/services/browser/BrowserCanvasPointerRouter.ts b/src/main/services/browser/BrowserCanvasPointerRouter.ts new file mode 100644 index 0000000..820a0d0 --- /dev/null +++ b/src/main/services/browser/BrowserCanvasPointerRouter.ts @@ -0,0 +1,442 @@ +import type { BrowserWindow, WebContentsView } from "electron"; +import type { + BrowserCanvasNavigationPointerEvent, + BrowserViewportBounds, + Point +} from "../../../shared/contracts.ts"; +import type { BrowserCanvasNativeWheelSink } from "./BrowserCanvasFreeze.ts"; +import { + BrowserCanvasCursorController, + browserCanvasNavigationCursor +} from "./BrowserCanvasCursor.ts"; +import { browserCanvasNavigationPointerType } from "./BrowserCanvasWheel.ts"; + +export interface BrowserCanvasPointerTab { + id: string; + view: WebContentsView; + canvasCursor: BrowserCanvasCursorController; +} + +export interface BrowserCanvasPointerRouterHost { + getOwner(): BrowserWindow | null; + getViewport(): BrowserViewportBounds; + getTab(tabId: string): BrowserCanvasPointerTab | undefined; + getTabs(): Iterable; + getNativeWheelSink(): BrowserCanvasNativeWheelSink | null; + getFrozenTabId(): string | null; + isFreezeActive(): boolean; + isNavigationOverrideActive(): boolean; + getCursorScreenPoint(): Point; + endWheelSequence(): void; + sendNavigationPointer(payload: BrowserCanvasNavigationPointerEvent): void; +} + +interface PointerRelay { + tabId: string; + button: NonNullable; + clickCount: number; + lastClient: Point; +} + +interface NativeSinkPointerRelay extends PointerRelay { + target: "browser" | "owner"; +} + +export class BrowserCanvasPointerRouter { + private readonly host: BrowserCanvasPointerRouterHost; + private canvasDragTabId: string | null = null; + private rendererGestureActive = false; + private navigationActive = false; + private freezePointerRelay: PointerRelay | null = null; + private nativeSinkPointerRelay: NativeSinkPointerRelay | null = null; + + constructor(host: BrowserCanvasPointerRouterHost) { + this.host = host; + } + + get hasFreezePointerRelay(): boolean { + return this.freezePointerRelay !== null; + } + + setNavigationActive(active: boolean): void { + if (this.navigationActive === active) return; + this.navigationActive = active; + this.syncCursors(); + } + + setRendererGestureActive(active: boolean): void { + if (this.rendererGestureActive === active) return; + this.rendererGestureActive = active; + this.syncCursors(); + } + + cancelNavigationGesture(): void { + const hadRendererGesture = this.rendererGestureActive; + this.rendererGestureActive = false; + if (this.canvasDragTabId !== null) this.cancelCanvasDrag(this.canvasDragTabId); + else if (hadRendererGesture) this.syncCursors(); + } + + cancelTab(tabId: string): void { + this.cancelCanvasDrag(tabId); + if (this.freezePointerRelay?.tabId === tabId) this.cancelFreezePointerRelay(); + if (this.nativeSinkPointerRelay?.tabId === tabId) this.cancelNativeSinkPointerRelay(); + } + + cancelSequenceRelays(): void { + this.cancelFreezePointerRelay(); + this.cancelNativeSinkPointerRelay(); + } + + handleBrowserMouse( + tab: BrowserCanvasPointerTab, + owner: BrowserWindow, + event: Electron.Event, + mouse: Electron.MouseInputEvent + ): boolean { + const sink = this.host.getNativeWheelSink(); + const activeSink = sink?.tabId === tab.id ? sink : null; + const mouseClientPoint = activeSink + ? this.ownerPointerClientPoint(owner, activeSink.pointer, mouse) + : { x: this.host.getViewport().x + mouse.x, y: this.host.getViewport().y + mouse.y }; + const pointerType = browserCanvasNavigationPointerType( + mouse, + this.host.isNavigationOverrideActive(), + this.canvasDragTabId === tab.id || this.rendererGestureActive + ); + if (pointerType) { + if (pointerType === "down") { + this.canvasDragTabId = tab.id; + this.syncCursors(); + } + if (pointerType === "up" || pointerType === "cancel") { + this.canvasDragTabId = null; + this.rendererGestureActive = false; + this.syncCursors(); + } + event.preventDefault(); + this.host.sendNavigationPointer({ + tabId: tab.id, + type: pointerType, + clientX: mouseClientPoint.x, + clientY: mouseClientPoint.y + }); + return true; + } + if ((this.canvasDragTabId === tab.id || this.rendererGestureActive) && mouse.type === "mouseLeave") { + event.preventDefault(); + return true; + } + return this.relayNativeSinkFromBrowser(event, mouse, owner, tab); + } + + handleOwnerMouse( + event: Electron.Event, + mouse: Electron.MouseInputEvent, + owner: BrowserWindow + ): boolean { + if (this.relayNativeSinkFromOwner(event, mouse, owner)) return true; + if (this.relayFreezeFromOwner(event, mouse)) return true; + return this.relayCanvasDragFromOwner(event, mouse); + } + + private cancelCanvasDrag(tabId: string): void { + if (this.canvasDragTabId !== tabId) return; + this.canvasDragTabId = null; + this.syncCursors(); + this.host.sendNavigationPointer({ + tabId, + type: "cancel", + clientX: 0, + clientY: 0 + }); + } + + private syncCursors(): void { + for (const tab of this.host.getTabs()) { + tab.canvasCursor.set(browserCanvasNavigationCursor( + this.navigationActive, + this.canvasDragTabId === tab.id || this.rendererGestureActive + )); + } + } + + private relayFreezeFromOwner(event: Electron.Event, mouse: Electron.MouseInputEvent): boolean { + const relay = this.freezePointerRelay; + if (relay) { + if (!freezePointerEvent(mouse)) return false; + event.preventDefault(); + relay.lastClient = { x: mouse.x, y: mouse.y }; + if (mouse.type === "mouseLeave") { + this.cancelFreezePointerRelay(); + this.host.endWheelSequence(); + return true; + } + this.sendFreezePointerInput(relay.tabId, mouse, relay.button, relay.clickCount); + if (mouse.type === "mouseUp" && mouse.button === relay.button) { + this.freezePointerRelay = null; + this.host.endWheelSequence(); + } + return true; + } + if ( + this.navigationActive + || this.host.isNavigationOverrideActive() + || this.rendererGestureActive + || this.canvasDragTabId !== null + ) return false; + const frozenTabId = this.host.getFrozenTabId(); + if (!this.host.isFreezeActive() || frozenTabId === null || !this.pointInsideViewport(mouse)) return false; + if (mouse.type === "contextMenu") { + event.preventDefault(); + this.sendFreezePointerInput(frozenTabId, mouse, mouse.button ?? "right", mouse.clickCount ?? 1); + return true; + } + if (mouse.type !== "mouseDown" || mouse.button === undefined) return false; + const tab = this.host.getTab(frozenTabId); + if (!tab || tab.view.webContents.isDestroyed()) return false; + event.preventDefault(); + const clickCount = Math.max(1, mouse.clickCount ?? 1); + tab.view.webContents.focus(); + this.freezePointerRelay = { + tabId: tab.id, + button: mouse.button, + clickCount, + lastClient: { x: mouse.x, y: mouse.y } + }; + this.sendFreezePointerInput(tab.id, mouse, mouse.button, clickCount); + return true; + } + + private sendFreezePointerInput( + tabId: string, + mouse: Electron.MouseInputEvent, + button: PointerRelay["button"], + clickCount: number + ): void { + const tab = this.host.getTab(tabId); + if (!tab || tab.view.webContents.isDestroyed()) return; + const viewport = this.host.getViewport(); + sendPointerInput( + tab.view.webContents, + mouse, + { button, clickCount }, + { x: mouse.x - viewport.x, y: mouse.y - viewport.y } + ); + } + + private cancelFreezePointerRelay(): void { + const relay = this.freezePointerRelay; + if (!relay) return; + this.freezePointerRelay = null; + const tab = this.host.getTab(relay.tabId); + if (!tab || tab.view.webContents.isDestroyed()) return; + const viewport = this.host.getViewport(); + sendPointerInput( + tab.view.webContents, + { type: "mouseUp", modifiers: [] }, + relay, + { x: relay.lastClient.x - viewport.x, y: relay.lastClient.y - viewport.y } + ); + } + + private relayNativeSinkFromBrowser( + event: Electron.Event, + mouse: Electron.MouseInputEvent, + owner: BrowserWindow, + tab: BrowserCanvasPointerTab + ): boolean { + const relay = this.nativeSinkPointerRelay; + if (relay?.tabId === tab.id) { + if (relay.target === "browser") { + if (mouse.type === "mouseUp" && mouse.button === relay.button) this.nativeSinkPointerRelay = null; + return false; + } + if (!nativeWheelSinkPointerEvent(mouse)) return false; + event.preventDefault(); + const point = this.ownerPointerClientPoint(owner, relay.lastClient, mouse); + relay.lastClient = point; + this.sendPointerToOwner(owner, mouse, relay, point); + if (mouse.type === "mouseUp" && mouse.button === relay.button) this.nativeSinkPointerRelay = null; + return true; + } + + const sink = this.host.getNativeWheelSink(); + if ( + !sink + || sink.tabId !== tab.id + || (mouse.type !== "mouseDown" && mouse.type !== "contextMenu") + || mouse.button === undefined + ) return false; + + event.preventDefault(); + const point = this.ownerPointerClientPoint(owner, sink.pointer, mouse); + this.host.endWheelSequence(); + const target = this.host.getViewport().surface === "native" && this.pointInsideViewport(point) + ? "browser" + : "owner"; + const nextRelay: NativeSinkPointerRelay = { + tabId: tab.id, + target, + button: mouse.button, + clickCount: Math.max(1, mouse.clickCount ?? 1), + lastClient: point + }; + if (mouse.type === "mouseDown") this.nativeSinkPointerRelay = nextRelay; + if (target === "browser") this.sendPointerToBrowser(tab, mouse, nextRelay, point); + else this.sendPointerToOwner(owner, mouse, nextRelay, point); + return true; + } + + private relayNativeSinkFromOwner( + event: Electron.Event, + mouse: Electron.MouseInputEvent, + owner: BrowserWindow + ): boolean { + const relay = this.nativeSinkPointerRelay; + if (!relay || !nativeWheelSinkPointerEvent(mouse)) return false; + const point = this.ownerPointerClientPoint(owner, relay.lastClient, mouse); + relay.lastClient = point; + if (relay.target === "owner") { + if (mouse.type === "mouseUp" && mouse.button === relay.button) this.nativeSinkPointerRelay = null; + return false; + } + event.preventDefault(); + const tab = this.host.getTab(relay.tabId); + if (tab && !tab.view.webContents.isDestroyed()) this.sendPointerToBrowser(tab, mouse, relay, point); + if (mouse.type === "mouseUp" && mouse.button === relay.button) this.nativeSinkPointerRelay = null; + return true; + } + + private sendPointerToBrowser( + tab: BrowserCanvasPointerTab, + mouse: Electron.MouseInputEvent, + relay: NativeSinkPointerRelay, + point: Point + ): void { + const viewport = this.host.getViewport(); + sendPointerInput( + tab.view.webContents, + mouse, + relay, + { x: point.x - viewport.x, y: point.y - viewport.y } + ); + } + + private sendPointerToOwner( + owner: BrowserWindow, + mouse: Electron.MouseInputEvent, + relay: NativeSinkPointerRelay, + point: Point + ): void { + sendPointerInput(owner.webContents, mouse, relay, point); + } + + private cancelNativeSinkPointerRelay(): void { + const relay = this.nativeSinkPointerRelay; + if (!relay) return; + this.nativeSinkPointerRelay = null; + const owner = this.host.getOwner(); + if (!owner || owner.isDestroyed()) return; + const mouseUp: Electron.MouseInputEvent = { + type: "mouseUp", + x: relay.lastClient.x, + y: relay.lastClient.y, + button: relay.button, + clickCount: relay.clickCount, + modifiers: [] + }; + if (relay.target === "browser") { + const tab = this.host.getTab(relay.tabId); + if (tab && !tab.view.webContents.isDestroyed()) this.sendPointerToBrowser(tab, mouseUp, relay, relay.lastClient); + } else { + this.sendPointerToOwner(owner, mouseUp, relay, relay.lastClient); + } + } + + private ownerPointerClientPoint( + owner: BrowserWindow, + fallback: Point, + mouse?: Electron.MouseInputEvent + ): Point { + const globalX = mouse?.globalX; + const globalY = mouse?.globalY; + const pointer = typeof globalX === "number" && Number.isFinite(globalX) + && typeof globalY === "number" && Number.isFinite(globalY) + ? { x: globalX, y: globalY } + : this.host.getCursorScreenPoint(); + const content = owner.getContentBounds(); + const point = { x: pointer.x - content.x, y: pointer.y - content.y }; + return point.x >= 0 && point.y >= 0 && point.x < content.width && point.y < content.height + ? point + : { ...fallback }; + } + + private pointInsideViewport(point: Point): boolean { + const viewport = this.host.getViewport(); + return point.x >= viewport.x + && point.y >= viewport.y + && point.x < viewport.x + viewport.width + && point.y < viewport.y + viewport.height; + } + + private relayCanvasDragFromOwner( + event: Electron.Event, + mouse: Electron.MouseInputEvent + ): boolean { + const tabId = this.canvasDragTabId; + if (tabId === null) return false; + const type = mouse.type === "mouseMove" + ? "move" + : mouse.type === "mouseUp" && mouse.button === "left" + ? "up" + : mouse.type === "mouseLeave" + ? "cancel" + : null; + if (type === null) return false; + if (type === "up" || type === "cancel") { + this.canvasDragTabId = null; + this.syncCursors(); + } + event.preventDefault(); + this.host.sendNavigationPointer({ + tabId, + type, + clientX: mouse.x, + clientY: mouse.y + }); + return true; + } +} + +function freezePointerEvent(mouse: Electron.MouseInputEvent): boolean { + return mouse.type === "mouseMove" + || mouse.type === "mouseUp" + || mouse.type === "mouseLeave" + || mouse.type === "contextMenu"; +} + +function nativeWheelSinkPointerEvent(mouse: Electron.MouseInputEvent): boolean { + return mouse.type === "mouseDown" + || mouse.type === "mouseUp" + || mouse.type === "mouseMove" + || mouse.type === "mouseEnter" + || mouse.type === "mouseLeave" + || mouse.type === "contextMenu"; +} + +function sendPointerInput( + contents: { sendInputEvent(event: Electron.MouseInputEvent): void }, + mouse: Pick, + relay: Pick, + point: Point +): void { + contents.sendInputEvent({ + type: mouse.type, + x: Math.round(point.x), + y: Math.round(point.y), + button: relay.button, + clickCount: relay.clickCount, + modifiers: mouse.modifiers + }); +} diff --git a/src/main/services/browser/BrowserCanvasSinkViewport.ts b/src/main/services/browser/BrowserCanvasSinkViewport.ts new file mode 100644 index 0000000..2d357ee --- /dev/null +++ b/src/main/services/browser/BrowserCanvasSinkViewport.ts @@ -0,0 +1,60 @@ +import type { Size } from "../../../shared/contracts.ts"; + +interface BrowserCanvasSinkViewportContents { + isDestroyed(): boolean; + enableDeviceEmulation(parameters: Electron.Parameters): void; + disableDeviceEmulation(): void; +} + +export function browserCanvasDeviceEmulationParameters(viewport: Size): Electron.Parameters { + const width = Math.max(1, Math.round(viewport.width)); + const height = Math.max(1, Math.round(viewport.height)); + return { + screenPosition: "desktop", + screenSize: { width, height }, + viewPosition: { x: 0, y: 0 }, + deviceScaleFactor: 0, + viewSize: { width, height }, + scale: 1 + }; +} + +export class BrowserCanvasSinkViewportController { + private readonly contents: BrowserCanvasSinkViewportContents; + private preserved = false; + private disposed = false; + + constructor(contents: BrowserCanvasSinkViewportContents) { + this.contents = contents; + } + + preserve(viewport: Size): boolean { + if (this.disposed || this.contents.isDestroyed()) return false; + if (this.preserved) return true; + try { + this.contents.enableDeviceEmulation(browserCanvasDeviceEmulationParameters(viewport)); + this.preserved = true; + return true; + } catch { + return false; + } + } + + restore(): boolean { + if (!this.preserved) return true; + this.preserved = false; + if (this.disposed || this.contents.isDestroyed()) return false; + try { + this.contents.disableDeviceEmulation(); + return true; + } catch { + return false; + } + } + + dispose(): void { + if (this.disposed) return; + this.restore(); + this.disposed = true; + } +} diff --git a/src/main/services/browser/BrowserCanvasWheel.ts b/src/main/services/browser/BrowserCanvasWheel.ts index f86c3bb..dbe2f66 100644 --- a/src/main/services/browser/BrowserCanvasWheel.ts +++ b/src/main/services/browser/BrowserCanvasWheel.ts @@ -1,18 +1,185 @@ -const FALLBACK_WHEEL_STEP = 100; +import type { + BrowserViewportSurface, + CanvasWheelCaptureMode, + Point, + Size +} from "../../../shared/contracts.ts"; +import { shouldCanvasOwnWheel } from "../../../shared/canvasNavigation.ts"; +import { BROWSER_CANVAS_WHEEL_IDLE_MS } from "./BrowserCanvasFreeze.ts"; + const MAX_WHEEL_DELTA = 1_200; +const LINE_DELTA_CSS_PIXELS = 16; +export type BrowserWheelOwner = "page" | "canvas"; + +export interface BrowserWheelDecision { + generation: number; + owner: BrowserWheelOwner; +} + +export interface BrowserWheelOwnershipInput { + surface: BrowserViewportSurface; + focused: boolean; + captureMode: CanvasWheelCaptureMode; + wheelOverrideActive: boolean; + canvasOverrideActive: boolean; + ctrlKey: boolean; + metaKey: boolean; +} + +export interface CanvasPageWheelInput { + deltaX: number; + deltaY: number; + ctrlKey: boolean; + metaKey: boolean; +} + +export interface BrowserPageWheelPointContext { + ownerScreenBounds: Point & Size; + viewport: Point & Size; +} + +export type BrowserCanvasNavigationPointerType = "down" | "move" | "up" | "cancel"; + +export function browserWheelOwner(input: BrowserWheelOwnershipInput): BrowserWheelOwner { + return shouldCanvasOwnWheel({ + overFocusedWidget: input.focused, + captureMode: input.captureMode, + wheelOverrideActive: input.wheelOverrideActive, + navigationOverrideActive: input.canvasOverrideActive, + forceCanvas: input.surface !== "native" || input.ctrlKey || input.metaKey + }) ? "canvas" : "page"; +} + +export class BrowserPageWheelSequence { + private generation = 0; + private owner: BrowserWheelOwner | null = null; + private lastWheelAt = 0; + + decide(requestedOwner: BrowserWheelOwner, now: number): BrowserWheelDecision { + if (this.owner !== null && now - this.lastWheelAt < BROWSER_CANVAS_WHEEL_IDLE_MS) { + this.lastWheelAt = now; + return { generation: this.generation, owner: this.owner }; + } + this.generation += 1; + this.owner = requestedOwner; + this.lastWheelAt = now; + return { generation: this.generation, owner: requestedOwner }; + } + + touch(generation: number, now: number): BrowserWheelOwner | null { + if ( + this.owner === null + || generation !== this.generation + || now - this.lastWheelAt >= BROWSER_CANVAS_WHEEL_IDLE_MS + ) return null; + this.lastWheelAt = now; + return this.owner; + } + + reset(): void { + this.owner = null; + this.lastWheelAt = 0; + } +} + +export function browserCanvasNavigationPointerType( + input: { type: string; button?: string }, + canvasOverrideActive: boolean, + gestureOwnedByCanvas: boolean +): BrowserCanvasNavigationPointerType | null { + if (!gestureOwnedByCanvas) { + return input.type === "mouseDown" && input.button === "left" && canvasOverrideActive ? "down" : null; + } + if (input.type === "mouseEnter" || input.type === "mouseMove") return "move"; + if (input.type === "mouseUp" && input.button === "left") return "up"; + return null; +} + +export function toCanvasPageWheelInput(value: unknown): CanvasPageWheelInput | null { + if (!isRecord(value)) return null; + const deltaX = finiteNumber(value.deltaX); + const deltaY = finiteNumber(value.deltaY); + const deltaMode = finiteNumber(value.deltaMode); + const viewportWidth = finiteNumber(value.viewportWidth); + const viewportHeight = finiteNumber(value.viewportHeight); + const ctrlKey = booleanValue(value.ctrlKey); + const metaKey = booleanValue(value.metaKey); + if ( + deltaX === null || deltaY === null || deltaMode === null + || viewportWidth === null || viewportHeight === null + || ctrlKey === null || metaKey === null + || !Number.isInteger(deltaMode) || deltaMode < 0 || deltaMode > 2 + || viewportWidth <= 0 || viewportHeight <= 0 + ) return null; + + const scaleX = deltaMode === 1 ? LINE_DELTA_CSS_PIXELS : deltaMode === 2 ? viewportWidth : 1; + const scaleY = deltaMode === 1 ? LINE_DELTA_CSS_PIXELS : deltaMode === 2 ? viewportHeight : 1; + const normalizedX = clampDelta(deltaX * scaleX); + const normalizedY = clampDelta(deltaY * scaleY); + if (normalizedX === 0 && normalizedY === 0) return null; + return { deltaX: normalizedX, deltaY: normalizedY, ctrlKey, metaKey }; +} + +export function browserPageWheelClientPoint( + value: unknown, + context: BrowserPageWheelPointContext +): Point | null { + if (!isRecord(value) || !rectangleIsFinite(context.ownerScreenBounds) || !rectangleIsFinite(context.viewport)) { + return null; + } + + const screenX = finiteNumber(value.screenX); + const screenY = finiteNumber(value.screenY); + if (screenX !== null && screenY !== null) { + const point = { + x: screenX - context.ownerScreenBounds.x, + y: screenY - context.ownerScreenBounds.y + }; + if (pointInsideSize(point, context.ownerScreenBounds)) return point; + } + + if (value.topFrame !== true) return null; + const clientX = finiteNumber(value.clientX); + const clientY = finiteNumber(value.clientY); + const viewportWidth = finiteNumber(value.viewportWidth); + const viewportHeight = finiteNumber(value.viewportHeight); + if ( + clientX === null || clientY === null + || viewportWidth === null || viewportHeight === null + || clientX < 0 || clientY < 0 + || viewportWidth <= 0 || viewportHeight <= 0 + || clientX >= viewportWidth || clientY >= viewportHeight + || context.viewport.width <= 0 || context.viewport.height <= 0 + ) return null; + return { + x: context.viewport.x + clientX * context.viewport.width / viewportWidth, + y: context.viewport.y + clientY * context.viewport.height / viewportHeight + }; +} + +function clampDelta(delta: number): number { + return Math.max(-MAX_WHEEL_DELTA, Math.min(MAX_WHEEL_DELTA, delta)); +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function booleanValue(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} -export interface NativeBrowserWheelDelta { - deltaY?: number; - wheelTicksY?: number; +function rectangleIsFinite(value: Point & Size): boolean { + return Number.isFinite(value.x) + && Number.isFinite(value.y) + && Number.isFinite(value.width) + && Number.isFinite(value.height); } -/** Converts Electron's positive-up wheel delta to the DOM positive-down convention. */ -export function toCanvasWheelDeltaY(input: NativeBrowserWheelDelta): number | null { - const nativeDelta = Number.isFinite(input.deltaY) && input.deltaY !== 0 - ? input.deltaY! - : Number.isFinite(input.wheelTicksY) - ? input.wheelTicksY! * FALLBACK_WHEEL_STEP - : 0; - if (nativeDelta === 0) return null; - return Math.max(-MAX_WHEEL_DELTA, Math.min(MAX_WHEEL_DELTA, -nativeDelta)); +function pointInsideSize(point: Point, size: Size): boolean { + return point.x >= 0 && point.y >= 0 && point.x < size.width && point.y < size.height; } diff --git a/src/main/services/browser/BrowserElectronSmoke.ts b/src/main/services/browser/BrowserElectronSmoke.ts index 13bb13b..97604d6 100644 --- a/src/main/services/browser/BrowserElectronSmoke.ts +++ b/src/main/services/browser/BrowserElectronSmoke.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { BrowserWindow, webContents } from "electron"; import type { BrowserActor, BrowserCommand, BrowserElementRef, BrowserResult } from "../../../shared/contracts.ts"; import type { BrowserService } from "../BrowserService.ts"; @@ -18,7 +19,7 @@ export async function runBrowserElectronSmoke( } const uploadPath = join(userDataPath, "fixture-upload.txt"); await writeFile(uploadPath, "CanvasTTY upload fixture", { mode: 0o600 }); - service.setViewport({ x: 0, y: 0, width: 820, height: 620, visible: true, canvasScale: 1 }); + service.setViewport({ x: 0, y: 0, width: 820, height: 620, surface: "native", canvasScale: 1 }); const actor: Extract = { kind: "agent", @@ -51,15 +52,19 @@ export async function runBrowserElectronSmoke( if (!tabId) throw new Error("Browser smoke did not create a tab."); await execute("browser_list_tabs"); await waitUntil(async () => service.getState().tabs.find((tab) => tab.id === tabId)?.status === "ready"); + await assertFocusAwarePhysicalWheel(service, `${origin}/`); + await assertBrowserOriginPanCrossesBoundary(service, `${origin}/`); + await assertOwnerWheelFreezesCrossingBrowser(service); + await assertRendererPanCrossesBrowser(service, `${origin}/`); - service.setViewport({ x: 0, y: 0, width: 410, height: 310, visible: true, canvasScale: 0.5 }); + service.setViewport({ x: 0, y: 0, width: 410, height: 310, surface: "native", canvasScale: 0.5 }); await execute("browser_wait_for", { tabId, condition: "text", value: "Viewport width: 820", timeoutMs: 4_000 }); - service.setViewport({ x: 0, y: 0, width: 820, height: 620, visible: true, canvasScale: 1 }); + service.setViewport({ x: 0, y: 0, width: 820, height: 620, surface: "native", canvasScale: 1 }); await execute("browser_wait_for", { tabId, condition: "text", @@ -215,6 +220,359 @@ export async function runBrowserElectronSmoke( } } +async function assertFocusAwarePhysicalWheel(service: BrowserService, url: string): Promise { + const contents = webContents.getAllWebContents().find((candidate) => candidate.getURL() === url); + const owner = BrowserWindow.getAllWindows().find((candidate) => candidate.getParentWindow() === null); + if (!contents || !owner) throw new Error("Browser smoke could not resolve the wheel ownership WebContents."); + await owner.webContents.executeJavaScript(` + globalThis.__canvasttyWheelRelays = []; + globalThis.__canvasttyWheelRelayOff = window.canvasTTY.browser.onCanvasWheel((event) => { + globalThis.__canvasttyWheelRelays.push(event); + }); + void 0; + `); + try { + service.setCanvasWheelCaptureMode("off"); + service.setInputFocused(true); + service.setViewport({ x: 0, y: 0, width: 820, height: 620, surface: "native", canvasScale: 1 }); + await waitForWheelIdle(); + const pageScrollY = await sendWheelUntilPageScrolls(contents, { x: 700, y: 300 }); + if (pageScrollY === 400) throw new Error("Focused Browser plain wheel did not scroll the page."); + if (await wheelRelayCount(owner) !== 0) { + throw new Error("Focused Browser plain wheel unexpectedly created a canvas relay."); + } + + await waitForWheelIdle(); + service.setInputFocused(false); + await contents.executeJavaScript("window.scrollTo(0, 400)"); + contents.sendInputEvent({ type: "mouseWheel", x: 700, y: 300, deltaX: 0, deltaY: -120 }); + await waitUntil(async () => await wheelRelayCount(owner) === 1); + const unfocusedScrollY = await contents.executeJavaScript("window.scrollY"); + if (unfocusedScrollY !== 400) { + throw new Error(`Unfocused Browser wheel leaked to page scrolling: scrollY=${String(unfocusedScrollY)}.`); + } + + await waitForWheelIdle(); + service.setInputFocused(true); + service.setViewport({ x: 0, y: 0, width: 820, height: 620, surface: "native", canvasScale: 1 }); + await contents.executeJavaScript("window.scrollTo(0, 400)"); + contents.sendInputEvent({ + type: "mouseWheel", + x: 700, + y: 300, + deltaX: 0, + deltaY: -120, + modifiers: [process.platform === "darwin" ? "meta" : "control"] + }); + await waitUntil(async () => await wheelRelayCount(owner) === 2); + const modifiedScrollY = await contents.executeJavaScript("window.scrollY"); + if (modifiedScrollY !== 400) { + throw new Error(`Modified Browser wheel leaked to page scrolling: scrollY=${String(modifiedScrollY)}.`); + } + const modifiedRelay = await owner.webContents.executeJavaScript( + "globalThis.__canvasttyWheelRelays?.at(-1) ?? null" + ) as { ctrlKey?: boolean; metaKey?: boolean } | null; + if (!modifiedRelay || (!modifiedRelay.ctrlKey && !modifiedRelay.metaKey)) { + throw new Error(`Modified Browser wheel lost its zoom modifier: ${JSON.stringify(modifiedRelay)}.`); + } + } finally { + service.setInputFocused(false); + service.setCanvasWheelCaptureMode("key"); + await waitForWheelIdle(); + service.setViewport({ x: 0, y: 0, width: 820, height: 620, surface: "native", canvasScale: 1 }); + await contents.executeJavaScript("window.scrollTo(0, 0)").catch(() => undefined); + await owner.webContents.executeJavaScript(` + globalThis.__canvasttyWheelRelayOff?.(); + delete globalThis.__canvasttyWheelRelayOff; + delete globalThis.__canvasttyWheelRelays; + `).catch(() => undefined); + } +} + +async function assertBrowserOriginPanCrossesBoundary(service: BrowserService, url: string): Promise { + const contents = webContents.getAllWebContents().find((candidate) => candidate.getURL() === url); + const owner = BrowserWindow.getAllWindows().find((candidate) => candidate.getParentWindow() === null); + if (!contents || !owner) throw new Error("Browser smoke could not resolve the Browser-origin pan WebContents."); + await owner.webContents.executeJavaScript(` + globalThis.__canvasttyBrowserOriginFreezeEvents = []; + globalThis.__canvasttyBrowserOriginWheelRelays = []; + globalThis.__canvasttyBrowserOriginFreezeOff = window.canvasTTY.browser.onCanvasFreezeFrame((event) => { + globalThis.__canvasttyBrowserOriginFreezeEvents.push({ + active: event.active, + generation: event.generation, + dataUrlLength: event.dataUrl?.length ?? 0 + }); + }); + globalThis.__canvasttyBrowserOriginWheelOff = window.canvasTTY.browser.onCanvasWheel((event) => { + globalThis.__canvasttyBrowserOriginWheelRelays.push(event); + }); + void 0; + `); + try { + service.setCanvasWheelCaptureMode("off"); + service.setInputFocused(false); + service.setViewport({ x: 200, y: 120, width: 300, height: 240, surface: "native", canvasScale: 1 }); + await waitUntil(async () => owner.webContents.executeJavaScript( + "globalThis.__canvasttyBrowserOriginFreezeEvents?.some((event) => event.dataUrlLength > 0) === true" + )); + await waitForWheelIdle(); + await contents.executeJavaScript(` + window.scrollTo(0, 400); + globalThis.__canvasttySinkResizeCount = 0; + globalThis.__canvasttySinkResizeListener = () => { globalThis.__canvasttySinkResizeCount += 1; }; + window.addEventListener('resize', globalThis.__canvasttySinkResizeListener); + void 0; + `); + const initialPageMetrics = await browserPageMetrics(contents); + contents.sendInputEvent({ type: "mouseWheel", x: 100, y: 40, deltaX: 12, deltaY: 18 }); + await waitUntil(async () => owner.webContents.executeJavaScript( + "globalThis.__canvasttyBrowserOriginFreezeEvents?.some((event) => event.active) === true" + )); + assertStableSinkPageMetrics(initialPageMetrics, await browserPageMetrics(contents), "native sink activation"); + service.setViewport({ x: 520, y: 320, width: 300, height: 240, surface: "native", canvasScale: 1 }); + await new Promise((resolve) => setTimeout(resolve, 100)); + contents.sendInputEvent({ type: "mouseWheel", x: 2, y: 2, deltaX: 10, deltaY: 14 }); + await new Promise((resolve) => setTimeout(resolve, 100)); + contents.sendInputEvent({ type: "mouseWheel", x: 2, y: 2, deltaX: 8, deltaY: 12 }); + await waitUntil(async () => await wheelRelayCount(owner, "__canvasttyBrowserOriginWheelRelays") === 3); + assertStableSinkPageMetrics( + initialPageMetrics, + await browserPageMetrics(contents), + "continued native sink wheel sequence" + ); + await new Promise((resolve) => setTimeout(resolve, 120)); + const endedTooEarly = await owner.webContents.executeJavaScript(` + (() => { + const events = globalThis.__canvasttyBrowserOriginFreezeEvents ?? []; + const active = events.findIndex((event) => event.active); + return active >= 0 && events.slice(active + 1).some((event) => !event.active); + })() + `); + if (endedTooEarly) throw new Error("Browser-origin canvas sequence ended at the old native boundary."); + await waitUntil(async () => owner.webContents.executeJavaScript(` + (() => { + const events = globalThis.__canvasttyBrowserOriginFreezeEvents ?? []; + const active = events.findIndex((event) => event.active); + return active >= 0 && events.slice(active + 1).some((event) => !event.active); + })() + `)); + assertStableSinkPageMetrics( + initialPageMetrics, + await browserPageMetrics(contents), + "native sink restoration" + ); + + service.setViewport({ x: 200, y: 120, width: 150, height: 120, surface: "native", canvasScale: 0.5 }); + await new Promise((resolve) => setTimeout(resolve, 50)); + await contents.executeJavaScript(` + window.scrollTo(0, 400); + globalThis.__canvasttySinkResizeCount = 0; + `); + const scaledPageMetrics = await browserPageMetrics(contents); + contents.sendInputEvent({ type: "mouseWheel", x: 50, y: 40, deltaX: 6, deltaY: 9 }); + await waitUntil(async () => await wheelRelayCount(owner, "__canvasttyBrowserOriginWheelRelays") === 4); + assertStableSinkPageMetrics( + scaledPageMetrics, + await browserPageMetrics(contents), + "scaled native sink activation" + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + contents.sendInputEvent({ type: "mouseWheel", x: 2, y: 2, deltaX: 5, deltaY: 7 }); + await waitUntil(async () => await wheelRelayCount(owner, "__canvasttyBrowserOriginWheelRelays") === 5); + await waitForWheelIdle(); + assertStableSinkPageMetrics( + scaledPageMetrics, + await browserPageMetrics(contents), + "scaled native sink restoration" + ); + } finally { + service.setCanvasWheelCaptureMode("key"); + await waitForWheelIdle(); + service.setViewport({ x: 0, y: 0, width: 820, height: 620, surface: "native", canvasScale: 1 }); + await contents.executeJavaScript(` + if (globalThis.__canvasttySinkResizeListener) { + window.removeEventListener('resize', globalThis.__canvasttySinkResizeListener); + } + delete globalThis.__canvasttySinkResizeListener; + delete globalThis.__canvasttySinkResizeCount; + window.scrollTo(0, 0); + `).catch(() => undefined); + await owner.webContents.executeJavaScript(` + globalThis.__canvasttyBrowserOriginFreezeOff?.(); + globalThis.__canvasttyBrowserOriginWheelOff?.(); + delete globalThis.__canvasttyBrowserOriginFreezeOff; + delete globalThis.__canvasttyBrowserOriginWheelOff; + delete globalThis.__canvasttyBrowserOriginFreezeEvents; + delete globalThis.__canvasttyBrowserOriginWheelRelays; + `).catch(() => undefined); + } +} + +interface BrowserPageMetrics { + width: number; + height: number; + scrollY: number; + resizeCount: number; +} + +async function browserPageMetrics(contents: Electron.WebContents): Promise { + return await contents.executeJavaScript(`({ + width: window.innerWidth, + height: window.innerHeight, + scrollY: window.scrollY, + resizeCount: globalThis.__canvasttySinkResizeCount ?? 0 + })`) as BrowserPageMetrics; +} + +function assertStableSinkPageMetrics( + expected: BrowserPageMetrics, + actual: BrowserPageMetrics, + phase: string +): void { + if ( + actual.width !== expected.width + || actual.height !== expected.height + || actual.scrollY !== expected.scrollY + || actual.resizeCount !== expected.resizeCount + ) { + throw new Error( + `Browser page metrics changed during ${phase}: expected=${JSON.stringify(expected)}, actual=${JSON.stringify(actual)}.` + ); + } +} + +async function assertRendererPanCrossesBrowser(service: BrowserService, url: string): Promise { + const contents = webContents.getAllWebContents().find((candidate) => candidate.getURL() === url); + const owner = BrowserWindow.getAllWindows().find((candidate) => candidate.getParentWindow() === null); + if (!contents || !owner) throw new Error("Browser smoke could not resolve the pan boundary WebContents."); + await owner.webContents.executeJavaScript(` + globalThis.__canvasttyPanBoundaryEvents = []; + globalThis.__canvasttyPanBoundaryOff = window.canvasTTY.browser.onCanvasNavigationPointer((event) => { + globalThis.__canvasttyPanBoundaryEvents.push(event.type); + }); + void 0; + `); + try { + service.setRendererCanvasGestureActive(true); + contents.sendInputEvent({ type: "mouseMove", x: 400, y: 300 }); + contents.sendInputEvent({ type: "mouseUp", button: "left", x: 400, y: 300, clickCount: 1 }); + await waitUntil(async () => owner.webContents.executeJavaScript( + "globalThis.__canvasttyPanBoundaryEvents?.includes('up') === true" + )); + const events = await owner.webContents.executeJavaScript( + "globalThis.__canvasttyPanBoundaryEvents ?? []" + ) as unknown; + if (!Array.isArray(events) || !events.includes("move") || events.at(-1) !== "up") { + throw new Error(`Renderer pan did not cross the native Browser boundary: ${JSON.stringify(events)}.`); + } + } finally { + service.setRendererCanvasGestureActive(false); + await owner.webContents.executeJavaScript(` + globalThis.__canvasttyPanBoundaryOff?.(); + delete globalThis.__canvasttyPanBoundaryOff; + delete globalThis.__canvasttyPanBoundaryEvents; + `).catch(() => undefined); + } +} + +async function assertOwnerWheelFreezesCrossingBrowser(service: BrowserService): Promise { + const owner = BrowserWindow.getAllWindows().find((candidate) => candidate.getParentWindow() === null); + if (!owner) throw new Error("Browser smoke could not resolve the owner window for freeze validation."); + await owner.webContents.executeJavaScript(` + globalThis.__canvasttyFreezeEvents = []; + globalThis.__canvasttyFreezeOff = window.canvasTTY.browser.onCanvasFreezeFrame((event) => { + globalThis.__canvasttyFreezeEvents.push({ + active: event.active, + generation: event.generation, + dataUrlLength: event.dataUrl?.length ?? 0 + }); + }); + void 0; + `); + try { + const initialViewport = { + x: 500, + y: 200, + width: 200, + height: 200, + surface: "native" as const, + canvasScale: 1 + }; + service.setViewport(initialViewport); + await waitUntil(async () => owner.webContents.executeJavaScript( + "globalThis.__canvasttyFreezeEvents?.some((event) => event.dataUrlLength > 0) === true" + )); + + owner.webContents.sendInputEvent({ + type: "mouseWheel", + x: 400, + y: 300, + deltaX: 8, + deltaY: 12 + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + service.setViewport({ ...initialViewport, x: 403 }); + await waitUntil(async () => owner.webContents.executeJavaScript( + "globalThis.__canvasttyFreezeEvents?.some((event) => event.active && event.dataUrlLength > 0) === true" + )); + service.setViewport({ ...initialViewport, x: 403, surface: "placeholder" }); + await new Promise((resolve) => setTimeout(resolve, 80)); + service.setViewport({ ...initialViewport, x: 403, surface: "native" }); + await new Promise((resolve) => setTimeout(resolve, 80)); + const endedDuringPlaceholder = await owner.webContents.executeJavaScript(` + (() => { + const events = globalThis.__canvasttyFreezeEvents ?? []; + const activeIndex = events.findIndex((event) => event.active && event.dataUrlLength > 0); + return activeIndex >= 0 && events.slice(activeIndex + 1).some((event) => !event.active); + })() + `); + if (endedDuringPlaceholder) { + throw new Error("Native/placeholder transition ended the active canvas wheel sequence."); + } + await waitUntil(async () => owner.webContents.executeJavaScript(` + (() => { + const events = globalThis.__canvasttyFreezeEvents ?? []; + const activeIndex = events.findIndex((event) => event.active && event.dataUrlLength > 0); + return activeIndex >= 0 && events.slice(activeIndex + 1).some((event) => !event.active); + })() + `)); + } finally { + service.setViewport({ x: 0, y: 0, width: 820, height: 620, surface: "native", canvasScale: 1 }); + await owner.webContents.executeJavaScript(` + globalThis.__canvasttyFreezeOff?.(); + delete globalThis.__canvasttyFreezeOff; + delete globalThis.__canvasttyFreezeEvents; + `).catch(() => undefined); + } +} + +async function sendWheelUntilPageScrolls( + contents: Electron.WebContents, + point: { x: number; y: number } +): Promise { + for (const deltaY of [-120, 120]) { + await contents.executeJavaScript("window.scrollTo(0, 400)"); + contents.sendInputEvent({ type: "mouseWheel", ...point, deltaX: 0, deltaY }); + for (let attempt = 0; attempt < 20; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 25)); + const scrollY = await contents.executeJavaScript("window.scrollY") as number; + if (scrollY !== 400) return scrollY; + } + } + return 400; +} + +async function wheelRelayCount( + owner: BrowserWindow, + key: "__canvasttyWheelRelays" | "__canvasttyBrowserOriginWheelRelays" = "__canvasttyWheelRelays" +): Promise { + return owner.webContents.executeJavaScript(`globalThis.${key}?.length ?? 0`) as Promise; +} + +async function waitForWheelIdle(): Promise { + await new Promise((resolve) => setTimeout(resolve, 320)); +} + async function waitUntil(check: () => Promise): Promise { const deadline = Date.now() + READY_TIMEOUT_MS; let lastError: unknown; diff --git a/src/main/services/browser/BrowserViewport.ts b/src/main/services/browser/BrowserViewport.ts index a208590..659996b 100644 --- a/src/main/services/browser/BrowserViewport.ts +++ b/src/main/services/browser/BrowserViewport.ts @@ -7,6 +7,9 @@ export function normalizeBrowserViewportBounds(value: unknown): BrowserViewportB if (!value || typeof value !== "object") return null; const bounds = value as BrowserViewportBounds; if (![bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite)) return null; + if (bounds.surface !== "native" && bounds.surface !== "placeholder" && bounds.surface !== "hidden") { + return null; + } const width = Math.max(0, bounds.width); const height = Math.max(0, bounds.height); @@ -23,9 +26,8 @@ export function normalizeBrowserViewportBounds(value: unknown): BrowserViewportB y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top), - visible: bounds.visible === true, + surface: bounds.surface, ...(canvasScale === undefined ? {} : { canvasScale }), - captureCanvasWheel: bounds.captureCanvasWheel === true, showAgentPresence: bounds.showAgentPresence === true }; } diff --git a/src/preload/browser.ts b/src/preload/browser.ts new file mode 100644 index 0000000..d62013a --- /dev/null +++ b/src/preload/browser.ts @@ -0,0 +1,55 @@ +import { ipcRenderer } from "electron"; + +const BROWSER_PAGE_WHEEL_CHANNEL = "browser:page-wheel"; +const BROWSER_PAGE_WHEEL_DECISION_CHANNEL = "browser:page-wheel-decision"; +const BROWSER_PAGE_WHEEL_IDLE_MS = 250; +type BrowserWheelDecision = { + generation: number; + owner: "page" | "canvas"; +}; + +let wheelDecision: (BrowserWheelDecision & { lastEventAt: number }) | null = null; + +window.addEventListener("wheel", (event) => { + if (!event.isTrusted) return; + const now = performance.now(); + const input = { + deltaX: event.deltaX, + deltaY: event.deltaY, + deltaMode: event.deltaMode, + clientX: event.clientX, + clientY: event.clientY, + screenX: event.screenX, + screenY: event.screenY, + topFrame: window === window.top, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }; + if (!wheelDecision || now - wheelDecision.lastEventAt >= BROWSER_PAGE_WHEEL_IDLE_MS) { + const value = ipcRenderer.sendSync(BROWSER_PAGE_WHEEL_DECISION_CHANNEL, input); + wheelDecision = isBrowserWheelDecision(value) + ? { ...value, lastEventAt: now } + : { generation: 0, owner: "canvas", lastEventAt: now }; + } else { + wheelDecision.lastEventAt = now; + } + const decision = wheelDecision; + if (decision.owner === "canvas") { + event.preventDefault(); + event.stopImmediatePropagation(); + } + ipcRenderer.send(BROWSER_PAGE_WHEEL_CHANNEL, { + ...input, + generation: decision.generation + }); +}, { capture: true, passive: false }); + +function isBrowserWheelDecision(value: unknown): value is BrowserWheelDecision { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const decision = value as Record; + return Number.isInteger(decision.generation) + && (decision.generation as number) > 0 + && (decision.owner === "page" || decision.owner === "canvas"); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 8b71ba0..cd2b4b5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,11 +2,14 @@ import { contextBridge, ipcRenderer } from "electron"; import type { AppSettings, BrowserActivityStateEvent, + BrowserCanvasFreezeFrameEvent, + BrowserCanvasNavigationPointerEvent, BrowserCanvasPointerEvent, BrowserCanvasWheelEvent, BrowserCommand, BrowserStateEvent, BrowserViewportBounds, + CanvasNavigationOverrideStateEvent, CanvasTTYApi, CreateSessionRequest, PluginCanvasRequest, @@ -90,11 +93,30 @@ const api: CanvasTTYApi = { getActivity: (sinceSequence?: number) => ipcRenderer.invoke(IPC.browserGetActivity, sinceSequence), clearData: () => ipcRenderer.invoke(IPC.browserClearData), focus: () => ipcRenderer.send(IPC.browserFocus), + setInputFocused: (focused: boolean) => { + ipcRenderer.sendSync(IPC.browserSetInputFocused, focused); + }, setViewport: (bounds: BrowserViewportBounds) => ipcRenderer.send(IPC.browserSetViewport, bounds), onState: (listener: (event: BrowserStateEvent) => void) => subscribe(IPC.browserState, listener), onActivity: (listener: (event: BrowserActivityStateEvent) => void) => subscribe(IPC.browserActivity, listener), onCanvasWheel: (listener: (event: BrowserCanvasWheelEvent) => void) => subscribe(IPC.browserCanvasWheel, listener), - onCanvasPointer: (listener: (event: BrowserCanvasPointerEvent) => void) => subscribe(IPC.browserCanvasPointer, listener) + onCanvasFreezeFrame: (listener: (event: BrowserCanvasFreezeFrameEvent) => void) => ( + subscribe(IPC.browserCanvasFreezeFrame, listener) + ), + onCanvasPointer: (listener: (event: BrowserCanvasPointerEvent) => void) => subscribe(IPC.browserCanvasPointer, listener), + onCanvasNavigationPointer: (listener: (event: BrowserCanvasNavigationPointerEvent) => void) => ( + subscribe(IPC.browserCanvasNavigationPointer, listener) + ) + }, + canvasNavigation: { + armOwnerWheelSequence: (clientX: number, clientY: number) => { + ipcRenderer.sendSync(IPC.canvasNavigationOwnerWheel, { clientX, clientY }); + }, + setShortcutCaptureActive: (active: boolean) => ipcRenderer.send(IPC.canvasNavigationShortcutCapture, active), + setPointerGestureActive: (active: boolean) => ipcRenderer.send(IPC.canvasNavigationPointerGesture, active), + onOverrideState: (listener: (event: CanvasNavigationOverrideStateEvent) => void) => ( + subscribe(IPC.canvasNavigationOverrideState, listener) + ) }, terminal: { list: () => ipcRenderer.invoke(IPC.terminalList), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a38cd19..4d57fde 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -50,7 +50,10 @@ const FALLBACK_SETTINGS: AppSettings = { edgePan: false, edgePanSpeed: "normal", zoomSensitivity: "normal", - zoomOverApplications: true, + useScrollWheelToZoom: false, + canvasWheelCaptureMode: "key", + canvasWheelOverride: window.canvasTTY.window.isMacOS ? "Meta" : "Ctrl", + canvasNavigationOverride: "Alt", focusActivation: "off", hoverFocus: false, hoverFocusSpeed: "normal", @@ -684,8 +687,6 @@ export function App(): React.JSX.Element { setActiveSessionId(null); setBrowserSelected(false); }} - onDeselectSession={(id) => setActiveSessionId((current) => current === id ? null : current)} - onDeselectBrowser={() => setBrowserSelected(false)} onRenameSession={renameSession} onRenameEnd={() => setRenamingSessionId(null)} onSessionBoundsChange={changeSessionBounds} diff --git a/src/renderer/src/features/browser/BrowserCard.tsx b/src/renderer/src/features/browser/BrowserCard.tsx index a00846a..a1aa2bf 100644 --- a/src/renderer/src/features/browser/BrowserCard.tsx +++ b/src/renderer/src/features/browser/BrowserCard.tsx @@ -1,12 +1,13 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { AgentPresenceSnapshot, + BrowserCanvasFreezeFrameEvent, BrowserCanvasState, BrowserDownloadSnapshot, BrowserSnapshot, BrowserTabSnapshot, + BrowserViewportSurface, CameraState, - EdgePanSpeed, FocusActivation, LocaleId, Point, @@ -15,8 +16,9 @@ import type { import { BROWSER_PROVIDER_COLORS } from "../../../../shared/contracts"; import { UiIcon } from "../../components/UiIcon"; import { t } from "../../lib/i18n"; -import { HOVER_FOCUS_DELAYS, shouldActivateCanvasFromClick } from "../workspace/focus"; +import { shouldActivateCanvasFromClick } from "../workspace/focus"; import { snapMove, snapResize, type ResizeDirection } from "../workspace/snap"; +import { browserCanvasWidgetId } from "../workspace/canvasWidgetFocus"; interface BrowserCardProps { browser: BrowserSnapshot; @@ -27,16 +29,15 @@ interface BrowserCardProps { visible: boolean; snapEnabled: boolean; focusActivation: FocusActivation; - hoverFocus: boolean; - hoverFocusSpeed: EdgePanSpeed; + focused: boolean; selected: boolean; - zoomOverApplications: boolean; showAgentPresence: boolean; snapTargets: readonly SessionBounds[]; onBoundsChange(bounds: BrowserCanvasState): void; onActivate(): void; onSelect(): void; - onDeselect(): void; + onWidgetFocus(): void; + onWidgetHoverChange(active: boolean): void; onClose(): void; onError(message: string): void; } @@ -64,23 +65,21 @@ export function BrowserCard({ visible, snapEnabled, focusActivation, - hoverFocus, - hoverFocusSpeed, + focused, selected, - zoomOverApplications, showAgentPresence, snapTargets, onBoundsChange, onActivate, onSelect, - onDeselect, + onWidgetFocus, + onWidgetHoverChange, onClose, onError }: BrowserCardProps): React.JSX.Element { const dragState = useRef(null); const resizeState = useRef(null); const viewport = useRef(null); - const hoverFocusTimer = useRef(null); const addressFocused = useRef(false); const [position, setPosition] = useState(bounds.position); const [size, setSize] = useState(bounds.size); @@ -89,6 +88,7 @@ export function BrowserCard({ const [address, setAddress] = useState(activeTab?.url ?? ""); const [panel, setPanel] = useState(null); const [dialogPrompt, setDialogPrompt] = useState(""); + const [freezeFrame, setFreezeFrame] = useState(null); const summaryMode = zoom < 0.5; const summaryScale = summaryMode ? Math.min(2.5, Math.max(1, 0.5 / zoom)) : 1; const activeAgents = useMemo( @@ -109,6 +109,12 @@ export function BrowserCard({ && browser.pendingDialog === null && !pageUnavailable && activeTab !== null; + const surface: BrowserViewportSurface = !visible ? "hidden" : nativeViewVisible ? "native" : "placeholder"; + const freezeFrameVisible = freezeFrame?.active === true + && freezeFrame.tabId === activeTab?.id; + const freezeFrameDataUrl = freezeFrame && freezeFrame.tabId === activeTab?.id + ? freezeFrame.dataUrl + : null; useEffect(() => { liveBounds.current = bounds; @@ -124,8 +130,8 @@ export function BrowserCard({ setDialogPrompt(browser.pendingDialog?.defaultPrompt ?? ""); }, [browser.pendingDialog?.defaultPrompt, browser.pendingDialog?.openedAt]); - const viewportState = useRef({ nativeViewVisible, zoom, zoomOverApplications, showAgentPresence }); - viewportState.current = { nativeViewVisible, zoom, zoomOverApplications, showAgentPresence }; + const viewportState = useRef({ surface, zoom, showAgentPresence }); + viewportState.current = { surface, zoom, showAgentPresence }; const reportViewport = useCallback((): void => { const element = viewport.current; @@ -137,30 +143,15 @@ export function BrowserCard({ y: rect.top, width: rect.width, height: rect.height, - visible: state.nativeViewVisible, + surface: state.surface, canvasScale: state.zoom, - captureCanvasWheel: state.zoomOverApplications, showAgentPresence: state.showAgentPresence }); }, []); useLayoutEffect(() => { - if (nativeViewVisible) { - reportViewport(); - return; - } - const rect = viewport.current?.getBoundingClientRect(); - window.canvasTTY.browser.setViewport({ - x: rect?.left ?? 0, - y: rect?.top ?? 0, - width: rect?.width ?? 0, - height: rect?.height ?? 0, - visible: false, - canvasScale: zoom, - captureCanvasWheel: zoomOverApplications, - showAgentPresence - }); - }, [camera.x, camera.y, nativeViewVisible, position, reportViewport, showAgentPresence, size, zoom, zoomOverApplications]); + reportViewport(); + }, [camera.x, camera.y, position, reportViewport, showAgentPresence, size, surface, zoom]); useLayoutEffect(() => { const element = viewport.current; @@ -175,73 +166,55 @@ export function BrowserCard({ }, [reportViewport]); useEffect(() => () => { - if (hoverFocusTimer.current !== null) window.clearTimeout(hoverFocusTimer.current); window.canvasTTY.browser.setViewport({ x: 0, y: 0, width: 0, height: 0, - visible: false, + surface: "hidden", canvasScale: 1, - captureCanvasWheel: false, showAgentPresence: false }); }, []); - const clearHoverFocusTimer = useCallback((): void => { - if (hoverFocusTimer.current === null) return; - window.clearTimeout(hoverFocusTimer.current); - hoverFocusTimer.current = null; - }, []); + useLayoutEffect(() => { + window.canvasTTY.browser.setInputFocused(focused); + }, [focused]); - const scheduleHoverFocus = useCallback((): void => { - clearHoverFocusTimer(); - if (!hoverFocus || selected) return; - hoverFocusTimer.current = window.setTimeout(() => { - hoverFocusTimer.current = null; - onSelect(); - window.canvasTTY.browser.focus(); - }, HOVER_FOCUS_DELAYS[hoverFocusSpeed]); - }, [clearHoverFocusTimer, hoverFocus, hoverFocusSpeed, onSelect, selected]); - - const scheduleHoverBlur = useCallback((): void => { - clearHoverFocusTimer(); - if (!hoverFocus || !selected) return; - hoverFocusTimer.current = window.setTimeout(() => { - hoverFocusTimer.current = null; - onDeselect(); - }, HOVER_FOCUS_DELAYS[hoverFocusSpeed]); - }, [clearHoverFocusTimer, hoverFocus, hoverFocusSpeed, onDeselect, selected]); + useEffect(() => () => window.canvasTTY.browser.setInputFocused(false), []); useEffect(() => window.canvasTTY.browser.onCanvasPointer((event) => { if (event.tabId !== browser.activeTabId) return; if (event.type === "enter") { - scheduleHoverFocus(); + onWidgetHoverChange(true); return; } if (event.type === "leave") { - scheduleHoverBlur(); + onWidgetHoverChange(false); return; } if (event.type === "down") { - clearHoverFocusTimer(); + onWidgetFocus(); onSelect(); window.canvasTTY.browser.focus(); return; } if (shouldActivateCanvasFromClick(focusActivation, event.clickCount)) onActivate(); - }), [browser.activeTabId, clearHoverFocusTimer, focusActivation, onActivate, onSelect, scheduleHoverBlur, scheduleHoverFocus]); + }), [browser.activeTabId, focusActivation, onActivate, onSelect, onWidgetFocus, onWidgetHoverChange]); - useEffect(() => { - if (hoverFocus) return; - clearHoverFocusTimer(); - }, [clearHoverFocusTimer, hoverFocus]); + useEffect(() => window.canvasTTY.browser.onCanvasFreezeFrame((event) => { + setFreezeFrame((current) => { + if (current && event.generation <= current.generation) return current; + const cachedDataUrl = current?.tabId === event.tabId ? current.dataUrl : null; + return { ...event, dataUrl: event.dataUrl ?? cachedDataUrl }; + }); + }), []); useEffect(() => { - if (!selected || !nativeViewVisible) return; + if (!focused || !nativeViewVisible) return; const frame = requestAnimationFrame(() => window.canvasTTY.browser.focus()); return () => cancelAnimationFrame(frame); - }, [nativeViewVisible, selected]); + }, [focused, nativeViewVisible]); const startDrag = (event: React.PointerEvent): void => { if ((event.target as HTMLElement).closest("button, input, [data-browser-action]")) return; @@ -384,15 +357,18 @@ export function BrowserCard({
{ - clearHoverFocusTimer(); + onWidgetFocus(); onSelect(); }} - onPointerEnter={scheduleHoverFocus} - onPointerLeave={scheduleHoverBlur} + onPointerEnter={() => onWidgetHoverChange(true)} + onPointerLeave={() => onWidgetHoverChange(false)} onClick={activateCard} onDoubleClick={activateCardDouble} style={{ @@ -507,7 +483,21 @@ export function BrowserCard({ -
+
+ {freezeFrameDataUrl && ( + + )} +
{!activeTab && (
@@ -532,6 +522,7 @@ export function BrowserCard({ onClick={activateSummary} onDoubleClick={activateSummaryDouble} data-focus-activation={focusActivation} + data-browser-canvas-wheel-owner="canvas" aria-label={t(locale, "browser")} > diff --git a/src/renderer/src/features/home/HomeZone.tsx b/src/renderer/src/features/home/HomeZone.tsx index ff2f292..4a03ca9 100644 --- a/src/renderer/src/features/home/HomeZone.tsx +++ b/src/renderer/src/features/home/HomeZone.tsx @@ -24,6 +24,7 @@ import { AGENT_PROVIDERS, PROVIDERS } from "../../lib/providers"; import { sessionStatusIcon, sessionStatusLabel } from "../../lib/sessionStatus"; import { HomeMediaWidget } from "./HomeMediaWidget"; import { PluginFrame } from "../plugins/PluginFrame"; +import type { PluginCanvasWheelInput } from "../plugins/pluginInputBridge"; import { HOME_GRID_COLUMN_STEP, HOME_GRID_ROW_STEP, @@ -42,6 +43,7 @@ import { type HomeLimitRow, type LimitsLoadState } from "./homeModel"; +import { homeCanvasWidgetId } from "../workspace/canvasWidgetFocus"; interface HomeZoneProps { settings: AppSettings; @@ -61,6 +63,11 @@ interface HomeZoneProps { onLayoutChange(layout: HomeWidgetPlacement[]): void; onGridSizeChange(gridSize: HomeGridSize): void; onPluginError(message: string): void; + captureCanvasWheelOverWidgets: boolean; + focusedWidgetId: string | null; + onWidgetFocus(id: string): void; + onWidgetHoverChange(id: string, active: boolean): void; + onPluginCanvasWheel(event: PluginCanvasWheelInput): void; } interface LayoutPointerState { @@ -98,7 +105,12 @@ export function HomeZone({ onRemoveMedia, onLayoutChange, onGridSizeChange, - onPluginError + onPluginError, + captureCanvasWheelOverWidgets, + focusedWidgetId, + onWidgetFocus, + onWidgetHoverChange, + onPluginCanvasWheel }: HomeZoneProps): React.JSX.Element { const locale = settings.locale; const [now, setNow] = useState(() => new Date()); @@ -338,6 +350,7 @@ export function HomeZone({ candidate.id === match?.[2] && candidate.kind === "home-widget" )); if (plugin && contribution) { + const canvasWidgetId = homeCanvasWidgetId(widgetId); return (
onWidgetFocus(canvasWidgetId)} + onHoverChange={(active) => onWidgetHoverChange(canvasWidgetId, active)} onOpenLauncher={openPluginLauncher} onError={onPluginError} /> @@ -396,6 +413,8 @@ export function HomeZone({
plugin.enabled + && plugin.manifest.id === pluginId + && plugin.manifest.contributions.some((contribution) => ( + contribution.id === contributionId && contribution.kind === "home-widget" + ))); +} + function ClockWidget({ locale, now }: { locale: LocaleId; now: Date }): React.JSX.Element { const formatLocale = locale === "ru" ? "ru-RU" : "en-GB"; const time = now.toLocaleTimeString(formatLocale, { hour: "2-digit", minute: "2-digit" }); diff --git a/src/renderer/src/features/plugins/PluginCanvasCard.tsx b/src/renderer/src/features/plugins/PluginCanvasCard.tsx index f2a51de..bdc0a05 100644 --- a/src/renderer/src/features/plugins/PluginCanvasCard.tsx +++ b/src/renderer/src/features/plugins/PluginCanvasCard.tsx @@ -16,6 +16,8 @@ import { t } from "../../lib/i18n"; import { snapMove, snapResize, type ResizeDirection } from "../workspace/snap"; import { constrainPluginResize } from "./pluginBounds"; import { PluginFrame } from "./PluginFrame"; +import type { PluginCanvasWheelInput } from "./pluginInputBridge"; +import { pluginCanvasWidgetId } from "../workspace/canvasWidgetFocus"; interface PluginCanvasCardProps { instance: PluginCanvasInstance; @@ -33,6 +35,10 @@ interface PluginCanvasCardProps { onDispose(id: string): void; onOpenLauncher(provider: ProviderId): void; onError(message: string): void; + captureCanvasWheelOverWidgets: boolean; + onWidgetFocus(): void; + onWidgetHoverChange(active: boolean): void; + onCanvasWheel(event: PluginCanvasWheelInput): void; } interface DragState { @@ -62,7 +68,11 @@ export function PluginCanvasCard({ onBoundsChange, onDispose, onOpenLauncher, - onError + onError, + captureCanvasWheelOverWidgets, + onWidgetFocus, + onWidgetHoverChange, + onCanvasWheel }: PluginCanvasCardProps): React.JSX.Element { const dragState = useRef(null); const resizeState = useRef(null); @@ -175,6 +185,8 @@ export function PluginCanvasCard({
void>>(); @@ -21,6 +26,10 @@ interface PluginFrameProps { limits: LimitsSnapshot | null; canvasInstanceId?: string; className?: string; + captureCanvasWheelOverWidgets: boolean; + onCanvasWheel(event: PluginCanvasWheelInput): void; + onFocus(): void; + onHoverChange(active: boolean): void; onOpenLauncher(provider: ProviderId): void; onError(message: string): void; } @@ -42,6 +51,10 @@ export function PluginFrame({ limits, canvasInstanceId, className, + captureCanvasWheelOverWidgets, + onCanvasWheel, + onFocus, + onHoverChange, onOpenLauncher, onError }: PluginFrameProps): React.JSX.Element { @@ -73,6 +86,38 @@ export function PluginFrame({ if (event.source !== frame.current?.contentWindow || !isRecord(event.data)) return; const message = event.data as PluginMessage; if (message.source !== "canvastty-plugin") return; + const input = pluginCanvasFocusInput(event.data); + if (input?.type === "focus") { + onFocus(); + return; + } + if (input?.type === "hover") { + if (input.active) { + onHoverChange(true); + } else { + const pluginFrame = frame.current; + requestAnimationFrame(() => { + const widget = pluginFrame?.closest("[data-canvas-widget-id]"); + if (!widget?.matches(":hover")) onHoverChange(false); + }); + } + return; + } + if (message.type === "canvas-wheel") { + if (!captureCanvasWheelOverWidgets) return; + const pluginFrame = frame.current; + const bounds = pluginFrame?.getBoundingClientRect(); + const wheel = bounds && pluginFrame ? pluginCanvasWheelInput(event.data, { + left: bounds.left, + top: bounds.top, + width: bounds.width, + height: bounds.height, + layoutWidth: pluginFrame.clientWidth, + layoutHeight: pluginFrame.clientHeight + }) : null; + if (wheel) onCanvasWheel(wheel); + return; + } if (message.type === "ready") { postToFrame(frame.current, { source: "canvastty-host", type: "context", value: context }); return; @@ -111,12 +156,16 @@ export function PluginFrame({ }; window.addEventListener("message", receive); return () => window.removeEventListener("message", receive); - }, [canvasInstanceId, context, limits, onError, onOpenLauncher, plugin, sessions]); + }, [canvasInstanceId, captureCanvasWheelOverWidgets, context, limits, onCanvasWheel, onError, onFocus, onHoverChange, onOpenLauncher, plugin, sessions]); useEffect(() => { postToFrame(frame.current, { source: "canvastty-host", type: "context", value: context }); }, [context]); + useEffect(() => { + postCanvasInputPolicy(frame.current, captureCanvasWheelOverWidgets); + }, [captureCanvasWheelOverWidgets]); + useEffect(() => subscribeStorage(plugin.manifest.id, (key, value) => { postToFrame(frame.current, { source: "canvastty-host", type: "storage-change", key, value }); }), [plugin.manifest.id]); @@ -129,7 +178,10 @@ export function PluginFrame({ title={`${plugin.manifest.name}: ${contribution.title}`} sandbox="allow-scripts" referrerPolicy="no-referrer" - onLoad={() => postToFrame(frame.current, { source: "canvastty-host", type: "context", value: context })} + onLoad={() => { + postToFrame(frame.current, { source: "canvastty-host", type: "context", value: context }); + postCanvasInputPolicy(frame.current, captureCanvasWheelOverWidgets); + }} /> ); } @@ -279,6 +331,10 @@ function postToFrame(frame: HTMLIFrameElement | null, message: object): void { frame?.contentWindow?.postMessage(message, "*"); } +function postCanvasInputPolicy(frame: HTMLIFrameElement | null, captureWheel: boolean): void { + postToFrame(frame, { source: "canvastty-host", type: "canvas-input-policy", captureWheel }); +} + function subscribeStorage(pluginId: string, listener: (key: string, value: unknown) => void): () => void { const listeners = storageListeners.get(pluginId) ?? new Set(); listeners.add(listener); diff --git a/src/renderer/src/features/plugins/pluginInputBridge.ts b/src/renderer/src/features/plugins/pluginInputBridge.ts new file mode 100644 index 0000000..65b9917 --- /dev/null +++ b/src/renderer/src/features/plugins/pluginInputBridge.ts @@ -0,0 +1,90 @@ +export interface PluginCanvasWheelInput { + clientX: number; + clientY: number; + deltaX: number; + deltaY: number; + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; + shiftKey: boolean; +} + +export type PluginCanvasFocusInput = + | { type: "focus" } + | { type: "hover"; active: boolean }; + +export function pluginCanvasFocusInput(message: unknown): PluginCanvasFocusInput | null { + if (!isRecord(message) || message.source !== "canvastty-plugin") return null; + if (message.type === "canvas-focus") { + return hasExactKeys(message, ["source", "type"]) ? { type: "focus" } : null; + } + if (message.type === "canvas-hover" && typeof message.active === "boolean") { + return hasExactKeys(message, ["source", "type", "active"]) + ? { type: "hover", active: message.active } + : null; + } + return null; +} + +export function pluginCanvasWheelInput( + message: unknown, + frame: { + left: number; + top: number; + width: number; + height: number; + layoutWidth: number; + layoutHeight: number; + } +): PluginCanvasWheelInput | null { + if (!isRecord(message) || message.source !== "canvastty-plugin" || message.type !== "canvas-wheel") return null; + if (!hasExactKeys(message, [ + "source", "type", "clientX", "clientY", "deltaX", "deltaY", "deltaMode", + "ctrlKey", "metaKey", "altKey", "shiftKey" + ])) return null; + if (![frame.left, frame.top, frame.width, frame.height, frame.layoutWidth, frame.layoutHeight] + .every(Number.isFinite)) return null; + if (frame.width <= 0 || frame.height <= 0 || frame.layoutWidth <= 0 || frame.layoutHeight <= 0) return null; + const clientX = finiteNumber(message.clientX); + const clientY = finiteNumber(message.clientY); + const deltaX = finiteNumber(message.deltaX); + const deltaY = finiteNumber(message.deltaY); + const deltaMode = finiteNumber(message.deltaMode); + if (clientX === null || clientY === null || deltaX === null || deltaY === null || deltaMode === null) return null; + if (clientX < 0 || clientY < 0 || clientX > frame.layoutWidth || clientY > frame.layoutHeight) return null; + if (!Number.isInteger(deltaMode) || deltaMode < 0 || deltaMode > 2) return null; + const { ctrlKey, metaKey, altKey, shiftKey } = message; + if (typeof ctrlKey !== "boolean" || typeof metaKey !== "boolean" + || typeof altKey !== "boolean" || typeof shiftKey !== "boolean") return null; + const scaleX = deltaMode === 1 ? 16 : deltaMode === 2 ? frame.width : 1; + const scaleY = deltaMode === 1 ? 16 : deltaMode === 2 ? frame.height : 1; + const coordinateScaleX = frame.width / frame.layoutWidth; + const coordinateScaleY = frame.height / frame.layoutHeight; + return { + clientX: frame.left + clientX * coordinateScaleX, + clientY: frame.top + clientY * coordinateScaleY, + deltaX: clamp(deltaX * scaleX), + deltaY: clamp(deltaY * scaleY), + ctrlKey, + metaKey, + altKey, + shiftKey + }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasExactKeys(value: Record, expected: readonly string[]): boolean { + const actual = Object.keys(value); + return actual.length === expected.length && expected.every((key) => Object.hasOwn(value, key)); +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function clamp(value: number): number { + return Math.min(1_200, Math.max(-1_200, value)); +} diff --git a/src/renderer/src/features/settings/CanvasNavigationShortcutEditor.tsx b/src/renderer/src/features/settings/CanvasNavigationShortcutEditor.tsx new file mode 100644 index 0000000..389b5b8 --- /dev/null +++ b/src/renderer/src/features/settings/CanvasNavigationShortcutEditor.tsx @@ -0,0 +1,188 @@ +import { useEffect, useRef, useState } from "react"; +import type { LocaleId } from "../../../../shared/contracts"; +import { + activeCanvasNavigationModifiers, + canvasOverrideBindingConflicts, + canvasNavigationModifierFromKey, + formatCanvasNavigationBinding, + normalizeCanvasNavigationInputKey, + normalizeCanvasOverrideBinding, + type CanvasNavigationModifier +} from "../../../../shared/canvasNavigation"; +import { t } from "../../lib/i18n"; +import { displayCanvasNavigationBinding } from "../../lib/shortcuts"; + +interface CanvasNavigationShortcutEditorProps { + open: boolean; + locale: LocaleId; + label: string; + binding: string | null; + actionShortcuts: readonly string[]; + allowDisable?: boolean; + onCaptureStart(): void; + onChange(binding: string | null): Promise; +} + +export function CanvasNavigationShortcutEditor({ + open, + locale, + label, + binding, + actionShortcuts, + allowDisable = true, + onCaptureStart, + onChange +}: CanvasNavigationShortcutEditorProps): React.JSX.Element { + const [capturing, setCapturing] = useState(false); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(null); + const modifiersRef = useRef>(new Set()); + const commitInFlight = useRef(false); + const isMacOS = window.canvasTTY.window.isMacOS; + + useEffect(() => { + if (open || !capturing) return; + window.canvasTTY.canvasNavigation.setShortcutCaptureActive(false); + modifiersRef.current.clear(); + setPreview(null); + setError(null); + setCapturing(false); + }, [capturing, open]); + + useEffect(() => { + const active = open && capturing; + window.canvasTTY.canvasNavigation.setShortcutCaptureActive(active); + return () => { + if (active) window.canvasTTY.canvasNavigation.setShortcutCaptureActive(false); + }; + }, [capturing, open]); + + const stopCapture = (): void => { + window.canvasTTY.canvasNavigation.setShortcutCaptureActive(false); + modifiersRef.current.clear(); + setPreview(null); + setError(null); + setCapturing(false); + }; + + const beginCapture = (): void => { + if (commitInFlight.current) return; + onCaptureStart(); + window.canvasTTY.canvasNavigation.setShortcutCaptureActive(true); + modifiersRef.current.clear(); + setPreview(null); + setError(null); + setCapturing(true); + }; + + const save = async (key: string | null): Promise => { + if (commitInFlight.current) return; + const candidate = formatCanvasNavigationBinding({ modifiers: [...modifiersRef.current], key }); + const normalized = normalizeCanvasOverrideBinding(candidate); + if (!normalized) { + modifiersRef.current.clear(); + setPreview(null); + setError(t(locale, "canvasOverrideShortcutInvalid")); + return; + } + if (actionShortcuts.some((shortcut) => canvasOverrideBindingConflicts(normalized, shortcut))) { + modifiersRef.current.clear(); + setPreview(null); + setError(t(locale, "shortcutConflict")); + return; + } + + commitInFlight.current = true; + stopCapture(); + try { + await onChange(normalized); + } finally { + commitInFlight.current = false; + } + }; + + const handleKeyDown = (event: React.KeyboardEvent): void => { + if (!capturing) return; + event.preventDefault(); + event.stopPropagation(); + if (event.key === "Escape") { + stopCapture(); + return; + } + + const modifier = canvasNavigationModifierFromKey(event.key); + if (modifier) { + modifiersRef.current.add(modifier); + setPreview(displayCanvasNavigationBinding( + formatCanvasNavigationBinding({ modifiers: [...modifiersRef.current], key: null }), + isMacOS + )); + return; + } + + for (const eventModifier of activeCanvasNavigationModifiers(event)) { + modifiersRef.current.add(eventModifier); + } + const key = normalizeCanvasNavigationInputKey(event.key, event.code); + if (!key || modifiersRef.current.size === 0) { + setError(t(locale, "canvasOverrideShortcutInvalid")); + return; + } + setPreview(displayCanvasNavigationBinding( + formatCanvasNavigationBinding({ modifiers: [...modifiersRef.current], key }), + isMacOS + )); + void save(key); + }; + + const handleKeyUp = (event: React.KeyboardEvent): void => { + if (!capturing) return; + event.preventDefault(); + event.stopPropagation(); + if (!canvasNavigationModifierFromKey(event.key) || modifiersRef.current.size === 0) return; + void save(null); + }; + + return ( + <> +
+
+ {label} +
+ + {allowDisable && binding !== null && ( + + )} +
+
+
+ {capturing &&

{t(locale, "canvasNavigationCaptureHint")}

} + {error &&

{error}

} + + ); +} diff --git a/src/renderer/src/features/settings/SettingsPanel.tsx b/src/renderer/src/features/settings/SettingsPanel.tsx index f242d9b..aaf683d 100644 --- a/src/renderer/src/features/settings/SettingsPanel.tsx +++ b/src/renderer/src/features/settings/SettingsPanel.tsx @@ -6,6 +6,7 @@ import type { BrowserDownloadSnapshot, BrowserSnapshot, CanvasPatternId, + CanvasWheelCaptureMode, EdgePanSpeed, FocusActivation, InstalledPlugin, @@ -18,11 +19,16 @@ import type { ZoomSensitivity } from "../../../../shared/contracts"; import { BROWSER_PROVIDER_COLORS } from "../../../../shared/contracts"; +import { + canvasOverrideBindingConflicts, + defaultCanvasWheelBinding +} from "../../../../shared/canvasNavigation"; import { UiIcon } from "../../components/UiIcon"; import { shortcutFromKeyboardEvent } from "../../lib/shortcuts"; import { t } from "../../lib/i18n"; import { PluginSettingsSection } from "../plugins/PluginSettingsSection"; import { HomeAppearanceSettings } from "../home/HomeAppearanceSettings"; +import { CanvasNavigationShortcutEditor } from "./CanvasNavigationShortcutEditor"; type SettingsSection = "general" | "appearance" | "controls" | "browser" | "plugins"; @@ -78,6 +84,12 @@ export function SettingsPanel({ } }, [open]); + useEffect(() => { + if (section === "controls") return; + setShortcutError(null); + setCapturing(null); + }, [section]); + useEffect(() => { if (!open || section !== "browser") return; let active = true; @@ -137,7 +149,12 @@ export function SettingsPanel({ const conflict = Object.entries(settings.shortcuts).find( ([candidateAction, value]) => candidateAction !== action && value.toLowerCase() === shortcut.toLowerCase() ); - if (conflict) { + const conflictsWithNavigation = settings.canvasNavigationOverride !== null + && canvasOverrideBindingConflicts(settings.canvasNavigationOverride, shortcut); + const conflictsWithWheel = settings.canvasWheelCaptureMode === "key" + && settings.canvasWheelOverride !== null + && canvasOverrideBindingConflicts(settings.canvasWheelOverride, shortcut); + if (conflict || conflictsWithNavigation || conflictsWithWheel) { setShortcutError(t(locale, "shortcutConflict")); return; } @@ -147,6 +164,22 @@ export function SettingsPanel({ setCapturing(null); }; + const changeCanvasWheelCaptureMode = (mode: CanvasWheelCaptureMode): void => { + if (mode === "key" && settings.canvasWheelOverride === null) { + void onChange({ + canvasWheelCaptureMode: mode, + canvasWheelOverride: defaultCanvasWheelBinding(window.canvasTTY.window.isMacOS ? "darwin" : "other") + }); + return; + } + void onChange({ canvasWheelCaptureMode: mode }); + }; + + const canvasOverrideBindingsMatch = settings.canvasWheelCaptureMode === "key" + && settings.canvasWheelOverride !== null + && settings.canvasNavigationOverride !== null + && settings.canvasWheelOverride === settings.canvasNavigationOverride; + return (
{ if (event.target === event.currentTarget) onClose(); @@ -267,13 +300,59 @@ export function SettingsPanel({ /> void onChange({ zoomOverApplications: value === "on" })} + onChange={(value) => void onChange({ useScrollWheelToZoom: value === "on" })} + /> + + + changeCanvasWheelCaptureMode(value as CanvasWheelCaptureMode)} + /> + {settings.canvasWheelCaptureMode === "key" && ( + { + setCapturing(null); + setShortcutError(null); + }} + onChange={(canvasWheelOverride) => onChange({ canvasWheelOverride })} + /> + )} + {canvasOverrideBindingsMatch && ( +

{t(locale, "canvasOverrideBindingsMatch")}

+ )} +
+ + { + setCapturing(null); + setShortcutError(null); + }} + onChange={(canvasNavigationOverride) => onChange({ canvasNavigationOverride })} /> diff --git a/src/renderer/src/features/terminal/TerminalCard.tsx b/src/renderer/src/features/terminal/TerminalCard.tsx index c52714e..3511397 100644 --- a/src/renderer/src/features/terminal/TerminalCard.tsx +++ b/src/renderer/src/features/terminal/TerminalCard.tsx @@ -5,7 +5,6 @@ import type { LocaleId, PaletteId, Point, - EdgePanSpeed, FocusActivation, SessionBounds, SessionSnapshot @@ -28,8 +27,9 @@ import { snapMove, snapResize } from "../workspace/snap"; -import { HOVER_FOCUS_DELAYS, shouldActivateCanvasFromClick } from "../workspace/focus"; +import { shouldActivateCanvasFromClick } from "../workspace/focus"; import type { ResizeDirection } from "../workspace/snap"; +import { terminalCanvasWidgetId } from "../workspace/canvasWidgetFocus"; interface TerminalCardProps { session: SessionSnapshot; @@ -38,16 +38,15 @@ interface TerminalCardProps { zoom: number; snapEnabled: boolean; focusActivation: FocusActivation; - hoverFocus: boolean; - hoverFocusSpeed: EdgePanSpeed; invertTerminalWheel: boolean; - zoomOverApplications: boolean; + captureCanvasWheelOverWidgets: boolean; + focused: boolean; + focusChangeSource: "explicit" | "hover"; selected: boolean; renaming: boolean; snapTargets: readonly SessionBounds[]; onActivate(session: SessionSnapshot): void; onSelect(id: string): void; - onDeselect(id: string): void; onRename(id: string, title: string): Promise; onRenameEnd(): void; onBoundsChange(id: string, bounds: SessionBounds): void; @@ -76,16 +75,15 @@ export function TerminalCard({ zoom, snapEnabled, focusActivation, - hoverFocus, - hoverFocusSpeed, invertTerminalWheel, - zoomOverApplications, + captureCanvasWheelOverWidgets, + focused, + focusChangeSource, selected, renaming, snapTargets, onActivate, onSelect, - onDeselect, onRename, onRenameEnd, onBoundsChange, @@ -96,17 +94,14 @@ export function TerminalCard({ const terminalRef = useRef(null); const renameInput = useRef(null); const renameInFlight = useRef(false); - const hoverFocusTimer = useRef(null); - const hoverFocusTransition = useRef<"focus" | "blur" | null>(null); - const hoverSelected = useRef(false); const suppressFocusReport = useRef(false); const sessionExited = useRef(session.exitCode !== null); sessionExited.current = session.exitCode !== null; const restartAction = useRef<() => Promise>(async () => undefined); const invertTerminalWheelRef = useRef(invertTerminalWheel); invertTerminalWheelRef.current = invertTerminalWheel; - const zoomOverApplicationsRef = useRef(zoomOverApplications); - zoomOverApplicationsRef.current = zoomOverApplications; + const captureCanvasWheelRef = useRef(captureCanvasWheelOverWidgets); + captureCanvasWheelRef.current = captureCanvasWheelOverWidgets; const dragState = useRef(null); const resizeState = useRef(null); const [position, setPosition] = useState(session.position); @@ -197,7 +192,7 @@ export function TerminalCard({ ? attachTerminalMouseCoordinateAdapter( screen, () => invertTerminalWheelRef.current ? -1 : 1, - () => zoomOverApplicationsRef.current + () => captureCanvasWheelRef.current ) : () => undefined; terminalRef.current = terminal; @@ -215,7 +210,7 @@ export function TerminalCard({ resizeObserver.observe(host); const input = terminal.onData((data) => { - // Hover focus routes keyboard input locally; it is not an OS focus change for the agent TUI. + // Hover focus routes keyboard input locally without reporting a synthetic focus transition to the TUI. if (suppressFocusReport.current && (data === TERMINAL_FOCUS_IN || data === TERMINAL_FOCUS_OUT)) return; window.canvasTTY.terminal.input(session.id, data); }); @@ -244,27 +239,14 @@ export function TerminalCard({ useEffect(() => { const terminal = terminalRef.current; if (!terminal) return; - const transition = hoverFocusTransition.current; - suppressFocusReport.current = transition !== null || (!selected && hoverSelected.current); - if (selected && !renaming && !summaryMode) terminal.focus(); - else if (!selected) { + suppressFocusReport.current = focusChangeSource === "hover"; + if (focused && !renaming && !summaryMode) terminal.focus(); + else if (!focused) { terminal.blur(); renameInput.current?.blur(); - hoverSelected.current = false; } suppressFocusReport.current = false; - hoverFocusTransition.current = null; - }, [renaming, selected, summaryMode]); - - useEffect(() => () => { - if (hoverFocusTimer.current !== null) window.clearTimeout(hoverFocusTimer.current); - }, []); - - useEffect(() => { - if (hoverFocus || hoverFocusTimer.current === null) return; - window.clearTimeout(hoverFocusTimer.current); - hoverFocusTimer.current = null; - }, [hoverFocus]); + }, [focusChangeSource, focused, renaming, summaryMode]); const bindRenameInput = useCallback((input: HTMLInputElement | null): void => { renameInput.current = input; @@ -375,30 +357,6 @@ export function TerminalCard({ onActivate(session); }; - const scheduleHoverFocus = (): void => { - if (hoverFocusTimer.current !== null) window.clearTimeout(hoverFocusTimer.current); - hoverFocusTimer.current = null; - if (!hoverFocus || selected) return; - hoverFocusTimer.current = window.setTimeout(() => { - hoverFocusTimer.current = null; - hoverSelected.current = true; - hoverFocusTransition.current = "focus"; - onSelect(session.id); - }, HOVER_FOCUS_DELAYS[hoverFocusSpeed]); - }; - - const scheduleHoverBlur = (): void => { - if (hoverFocusTimer.current !== null) window.clearTimeout(hoverFocusTimer.current); - hoverFocusTimer.current = null; - if (!hoverFocus || !selected) return; - hoverFocusTimer.current = window.setTimeout(() => { - hoverFocusTimer.current = null; - hoverFocusTransition.current = "blur"; - hoverSelected.current = false; - onDeselect(session.id); - }, HOVER_FOCUS_DELAYS[hoverFocusSpeed]); - }; - const commitRename = async (): Promise => { if (renameInFlight.current) return; const title = renameInput.current?.value.trim() ?? ""; @@ -419,21 +377,17 @@ export function TerminalCard({
{ - if (hoverFocusTimer.current !== null) window.clearTimeout(hoverFocusTimer.current); - hoverFocusTimer.current = null; - if (hoverFocusTransition.current === "focus") hoverSelected.current = false; - hoverFocusTransition.current = null; onSelect(session.id); if (!renaming && !summaryMode && !(event.target as HTMLElement).closest("button, input")) { terminalRef.current?.focus(); } }} - onPointerEnter={scheduleHoverFocus} - onPointerLeave={scheduleHoverBlur} onClick={activateCard} onDoubleClick={activateCardDouble} style={{ diff --git a/src/renderer/src/features/terminal/terminalMouseCoordinates.ts b/src/renderer/src/features/terminal/terminalMouseCoordinates.ts index 9e6ca67..bd26f6e 100644 --- a/src/renderer/src/features/terminal/terminalMouseCoordinates.ts +++ b/src/renderer/src/features/terminal/terminalMouseCoordinates.ts @@ -103,11 +103,11 @@ export function attachTerminalMouseCoordinateAdapter( }; for (const type of MOUSE_EVENT_TYPES) ownerDocument.addEventListener(type, handleMouseEvent, true); - ownerDocument.addEventListener("wheel", handleWheelEvent, { capture: true, passive: false }); + screen.addEventListener("wheel", handleWheelEvent, { capture: true, passive: false }); return () => { for (const type of MOUSE_EVENT_TYPES) ownerDocument.removeEventListener(type, handleMouseEvent, true); - ownerDocument.removeEventListener("wheel", handleWheelEvent, true); + screen.removeEventListener("wheel", handleWheelEvent, true); }; } diff --git a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx index e2f1a04..65f4f59 100644 --- a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx +++ b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useRef } from "react"; import type { AgentProviderId, AppSettings, @@ -9,20 +9,27 @@ import type { HomeWidgetPlacement, InstalledPlugin, LimitsSnapshot, - Point, SessionBounds, SessionSnapshot } from "../../../../shared/contracts"; import { HomeZone } from "../home/HomeZone"; -import { EDGE_PAN_SPEEDS, edgePanVelocity } from "./edgePan"; -import { wheelZoomFactor } from "./zoom"; import { TerminalCard } from "../terminal/TerminalCard"; import { PluginCanvasCard } from "../plugins/PluginCanvasCard"; import { UiIcon } from "../../components/UiIcon"; import { t } from "../../lib/i18n"; +import { displayCanvasNavigationBinding } from "../../lib/shortcuts"; import type { LimitsLoadState } from "../home/homeModel"; import { homeGridPixelSize, homeLayoutFitsGrid } from "../home/homeLayout"; import { BrowserCard } from "../browser/BrowserCard"; +import { + browserCanvasWidgetId, + canvasWidgetTarget, + pluginCanvasWidgetId, + terminalCanvasWidgetId +} from "./canvasWidgetFocus"; +import { useCanvasPointerNavigation } from "./useCanvasPointerNavigation"; +import { useCanvasWheelNavigation } from "./useCanvasWheelNavigation"; +import { useCanvasWidgetFocus } from "./useCanvasWidgetFocus"; interface WorkspaceCanvasProps { settings: AppSettings; @@ -48,8 +55,6 @@ interface WorkspaceCanvasProps { onSelectSession(id: string): void; onSelectBrowser(): void; onClearCanvasSelection(): void; - onDeselectSession(id: string): void; - onDeselectBrowser(): void; onRenameSession(id: string, title: string): Promise; onRenameEnd(): void; onRequestMedia(): Promise; @@ -70,12 +75,6 @@ interface WorkspaceCanvasProps { onCloseBrowser(): void; } -interface PanState { - pointerId: number; - startClient: Point; - startCamera: CameraState; -} - export function WorkspaceCanvas({ settings, mediaData, @@ -100,8 +99,6 @@ export function WorkspaceCanvas({ onSelectSession, onSelectBrowser, onClearCanvasSelection, - onDeselectSession, - onDeselectBrowser, onRenameSession, onRenameEnd, onRequestMedia, @@ -122,118 +119,48 @@ export function WorkspaceCanvas({ onCloseBrowser }: WorkspaceCanvasProps): React.JSX.Element { const viewport = useRef(null); - const panState = useRef(null); - const [panning, setPanning] = useState(false); const cameraRef = useRef(camera); cameraRef.current = camera; - const settingsRef = useRef(settings); - settingsRef.current = settings; - const edgePointer = useRef(null); - const edgeFrame = useRef(null); - const edgeLastTime = useRef(0); - - useEffect(() => () => { - if (edgeFrame.current !== null) cancelAnimationFrame(edgeFrame.current); - }, []); - - const edgePanStep = (time: number): void => { - edgeFrame.current = null; - const pointer = edgePointer.current; - if (!pointer || panState.current || !settingsRef.current.edgePan) return; - const bounds = viewport.current?.getBoundingClientRect(); - if (!bounds) return; - const hovered = document.elementFromPoint(pointer.x, pointer.y); - if (hovered?.closest('[data-interactive="true"]')) return; - const velocity = edgePanVelocity(pointer, bounds, { - maxSpeed: EDGE_PAN_SPEEDS[settingsRef.current.edgePanSpeed] - }); - if (!velocity) return; - const dt = edgeLastTime.current === 0 ? 0 : Math.min(0.05, (time - edgeLastTime.current) / 1000); - edgeLastTime.current = time; - onCameraChange({ - ...cameraRef.current, - x: cameraRef.current.x + velocity.x * dt, - y: cameraRef.current.y + velocity.y * dt - }); - edgeFrame.current = requestAnimationFrame(edgePanStep); - }; - - const trackEdgePointer = (event: React.PointerEvent): void => { - if (!settings.edgePan) { - edgePointer.current = null; - return; - } - edgePointer.current = { x: event.clientX, y: event.clientY }; - if (edgeFrame.current === null) { - edgeLastTime.current = 0; - edgeFrame.current = requestAnimationFrame(edgePanStep); - } - }; - - const startPan = (event: React.PointerEvent): void => { - if (event.button !== 0 || (event.target as HTMLElement).closest('[data-interactive="true"]')) return; - event.currentTarget.setPointerCapture(event.pointerId); - panState.current = { - pointerId: event.pointerId, - startClient: { x: event.clientX, y: event.clientY }, - startCamera: camera - }; - setPanning(true); - }; - - const pan = (event: React.PointerEvent): void => { - const state = panState.current; - if (!state || state.pointerId !== event.pointerId) return; - onCameraChange({ - ...camera, - x: state.startCamera.x + event.clientX - state.startClient.x, - y: state.startCamera.y + event.clientY - state.startClient.y - }); - }; - - const endPan = (event: React.PointerEvent): void => { - if (panState.current?.pointerId !== event.pointerId) return; - panState.current = null; - setPanning(false); - }; - - const zoomAt = useCallback((clientX: number, clientY: number, nextZoom: number): void => { - const bounds = viewport.current?.getBoundingClientRect(); - if (!bounds) return; - const currentCamera = cameraRef.current; - const localX = clientX - bounds.left; - const localY = clientY - bounds.top; - const worldX = (localX - currentCamera.x) / currentCamera.zoom; - const worldY = (localY - currentCamera.y) / currentCamera.zoom; - onCameraChange({ - zoom: nextZoom, - x: localX - worldX * nextZoom, - y: localY - worldY * nextZoom - }); + const commitCamera = useCallback((next: CameraState): void => { + cameraRef.current = next; + onCameraChange(next); }, [onCameraChange]); - const zoomFromWheel = useCallback((clientX: number, clientY: number, wheelDeltaY: number): void => { - const currentSettings = settingsRef.current; - const deltaY = currentSettings.invertCanvasWheel ? -wheelDeltaY : wheelDeltaY; - const nextZoom = clamp( - cameraRef.current.zoom * wheelZoomFactor(deltaY, currentSettings.zoomSensitivity), - 0.2, - 1.35 - ); - zoomAt(clientX, clientY, nextZoom); - }, [zoomAt]); - - useEffect(() => window.canvasTTY.browser.onCanvasWheel((event) => { - if (!settingsRef.current.zoomOverApplications) return; - zoomFromWheel(event.clientX, event.clientY, event.deltaY); - }), [zoomFromWheel]); - - const zoomBy = (factor: number): void => { - const bounds = viewport.current?.getBoundingClientRect(); - if (!bounds) return; - const nextZoom = clamp(cameraRef.current.zoom * factor, 0.2, 1.35); - zoomAt(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2, nextZoom); - }; + const focusController = useCanvasWidgetFocus({ + viewport, + settings, + activeSessionId, + browserSelected, + widgetTreeVersion: [ + browserViewVisible ? "browser-visible" : "browser-hidden", + settings.browserCanvas ? "browser-card" : "no-browser-card", + sessions.map((session) => session.id).join(","), + plugins.map((plugin) => [ + plugin.manifest.id, + plugin.enabled ? "enabled" : "disabled", + plugin.manifest.contributions.map((contribution) => contribution.id).join(",") + ].join(":")).join(";"), + settings.pluginCanvas.map((instance) => instance.id).join(","), + settings.homeLayout.map((placement) => placement.widgetId).join(",") + ].join("|") + }); + const wheelNavigation = useCanvasWheelNavigation({ + viewport, + settings, + cameraRef, + widgetFocusRef: focusController.stateRef, + commitCamera + }); + const pointerNavigation = useCanvasPointerNavigation({ + viewport, + settings, + cameraRef, + canvasOverrideActiveRef: wheelNavigation.canvasOverrideActiveRef, + commitCamera + }); + const widgetFocus = focusController.state; + const routeWidgetWheelToCanvas = wheelNavigation.routeWidgetWheelToCanvas; + const canvasOverrideActive = wheelNavigation.canvasOverrideActive; const homeBounds: SessionBounds = { position: { x: 0, y: 0 }, @@ -244,32 +171,26 @@ export function WorkspaceCanvas({ return (
{ + if (pointerNavigation.handlePointerDownCapture(event)) return; + const target = canvasWidgetTarget(event.target); + if (target.focusableWidgetId !== null) { + focusController.cancelHover(); + focusController.focus(target.focusableWidgetId, "explicit"); + } if (!(event.target as HTMLElement).closest(".terminal-card, .browser-card")) onClearCanvasSelection(); }} - onPointerDown={startPan} - onPointerMove={(event) => { - pan(event); - trackEdgePointer(event); - }} - onPointerUp={endPan} - onPointerCancel={endPan} - onPointerLeave={() => { - edgePointer.current = null; - }} - onWheelCapture={(event) => { - if (!settings.zoomOverApplications || !isApplicationWheelTarget(event.target)) return; - event.preventDefault(); - event.stopPropagation(); - zoomFromWheel(event.clientX, event.clientY, event.deltaY); - }} - onWheel={(event) => { - if (settings.zoomOverApplications && isApplicationWheelTarget(event.target)) return; - if ((event.target as HTMLElement).closest('[data-wheel-owner="local"]')) return; - event.preventDefault(); - zoomFromWheel(event.clientX, event.clientY, event.deltaY); + onClickCapture={(event) => { + if (!pointerNavigation.handleClickCapture(event)) focusController.handleClick(event); }} + onPointerOverCapture={focusController.handlePointerOver} + onPointerOutCapture={focusController.handlePointerOut} + onPointerDown={pointerNavigation.handlePointerDown} + onPointerMove={pointerNavigation.handlePointerMove} + onPointerUp={pointerNavigation.handlePointerEnd} + onPointerCancel={pointerNavigation.handlePointerEnd} + onPointerLeave={pointerNavigation.handlePointerLeave} >
{ + if (settings.browserCanvas) focusController.focusBrowser(); + onOpenBrowser(); + }} + onFocusSession={(session) => { + focusController.focus(terminalCanvasWidgetId(session.id), "explicit"); + onFocusSession(session); + }} onRequestMedia={onRequestMedia} onRemoveMedia={onRemoveMedia} onLayoutChange={onHomeLayoutChange} onGridSizeChange={onHomeGridSizeChange} onPluginError={onPluginError} + captureCanvasWheelOverWidgets={routeWidgetWheelToCanvas} + focusedWidgetId={widgetFocus.id} + onWidgetFocus={(id) => { + focusController.cancelHover(); + focusController.focus(id, "explicit"); + }} + onWidgetHoverChange={(id, active) => { + if (active) focusController.scheduleHover(id); + else focusController.cancelHover(id); + }} + onPluginCanvasWheel={wheelNavigation.applyCanvasWheel} />
({ position: candidate.position, size: candidate.size })), ...(settings.browserCanvas ? [settings.browserCanvas] : []) ]} - onActivate={onFocusSession} + onActivate={(selectedSession) => { + focusController.focus(terminalCanvasWidgetId(selectedSession.id), "explicit"); + onFocusSession(selectedSession); + }} onSelect={onSelectSession} - onDeselect={onDeselectSession} onRename={onRenameSession} onRenameEnd={onRenameEnd} onBoundsChange={onSessionBoundsChange} @@ -352,11 +292,24 @@ export function WorkspaceCanvas({ .map((candidate) => ({ position: candidate.position, size: candidate.size })), ...(settings.browserCanvas ? [settings.browserCanvas] : []) ]} - onActivate={() => onFocusPluginCanvas(instance.id)} + onActivate={() => { + focusController.focus(pluginCanvasWidgetId(instance.id), "explicit"); + onFocusPluginCanvas(instance.id); + }} onBoundsChange={onPluginCanvasBoundsChange} onDispose={onDisposePluginCanvas} onOpenLauncher={(provider) => provider === "terminal" ? onOpenTerminal() : onOpenAgent(provider)} onError={onPluginError} + captureCanvasWheelOverWidgets={routeWidgetWheelToCanvas || widgetFocus.id !== pluginCanvasWidgetId(instance.id)} + onWidgetFocus={() => { + focusController.cancelHover(); + focusController.focus(pluginCanvasWidgetId(instance.id), "explicit"); + }} + onWidgetHoverChange={(active) => { + if (active) focusController.scheduleHover(pluginCanvasWidgetId(instance.id)); + else focusController.cancelHover(pluginCanvasWidgetId(instance.id)); + }} + onCanvasWheel={wheelNavigation.applyCanvasWheel} /> ); })} @@ -370,10 +323,8 @@ export function WorkspaceCanvas({ visible={browserViewVisible && !homeEditing} snapEnabled={settings.snapToGrid} focusActivation={settings.focusActivation} - hoverFocus={settings.hoverFocus} - hoverFocusSpeed={settings.hoverFocusSpeed} + focused={widgetFocus.id === browserCanvasWidgetId} selected={browserSelected} - zoomOverApplications={settings.zoomOverApplications} showAgentPresence={settings.browserShowAgentPresence} snapTargets={[ homeBounds, @@ -381,9 +332,13 @@ export function WorkspaceCanvas({ ...settings.pluginCanvas.map((candidate) => ({ position: candidate.position, size: candidate.size })) ]} onBoundsChange={onBrowserBoundsChange} - onActivate={onFocusBrowser} + onActivate={() => { + focusController.focusBrowser(); + onFocusBrowser(); + }} onSelect={onSelectBrowser} - onDeselect={onDeselectBrowser} + onWidgetFocus={focusController.focusBrowser} + onWidgetHoverChange={focusController.hoverBrowser} onClose={onCloseBrowser} onError={onPluginError} /> @@ -407,24 +362,33 @@ export function WorkspaceCanvas({
- - + +
{settings.showShortcutHints && ( )}
); } - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -function isApplicationWheelTarget(target: EventTarget | null): boolean { - return target instanceof HTMLElement - && target.closest('[data-canvas-zoom-surface="application"]') !== null; -} diff --git a/src/renderer/src/features/workspace/canvasWidgetFocus.ts b/src/renderer/src/features/workspace/canvasWidgetFocus.ts new file mode 100644 index 0000000..fbfab0b --- /dev/null +++ b/src/renderer/src/features/workspace/canvasWidgetFocus.ts @@ -0,0 +1,47 @@ +export const browserCanvasWidgetId = "browser"; + +export interface CanvasWidgetTarget { + isWidget: boolean; + focusableWidgetId: string | null; +} + +export function terminalCanvasWidgetId(sessionId: string): string { + return `terminal:${sessionId}`; +} + +export function pluginCanvasWidgetId(instanceId: string): string { + return `plugin-canvas:${instanceId}`; +} + +export function homeCanvasWidgetId(widgetId: string): string { + return `home:${widgetId}`; +} + +export function canvasWidgetFocusAfterClick( + current: string | null, + target: CanvasWidgetTarget +): string | null { + if (target.focusableWidgetId !== null) return target.focusableWidgetId; + return target.isWidget ? current : null; +} + +export function canvasWidgetTarget(target: EventTarget | null): CanvasWidgetTarget { + if (!(target instanceof Element)) return { isWidget: false, focusableWidgetId: null }; + const widget = target.closest("[data-canvas-widget-id]"); + if (!widget) return { isWidget: false, focusableWidgetId: null }; + const widgetId = widget.dataset.canvasWidgetId; + return { + isWidget: true, + focusableWidgetId: widget.dataset.canvasWidgetFocusable === "true" && widgetId + ? widgetId + : null + }; +} + +export function isFocusedCanvasWidgetTarget( + target: EventTarget | null, + focusedWidgetId: string | null +): boolean { + if (focusedWidgetId === null) return false; + return canvasWidgetTarget(target).focusableWidgetId === focusedWidgetId; +} diff --git a/src/renderer/src/features/workspace/useCanvasPointerNavigation.ts b/src/renderer/src/features/workspace/useCanvasPointerNavigation.ts new file mode 100644 index 0000000..e1eb6af --- /dev/null +++ b/src/renderer/src/features/workspace/useCanvasPointerNavigation.ts @@ -0,0 +1,224 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { MutableRefObject, RefObject } from "react"; +import type { AppSettings, CameraState, Point } from "../../../../shared/contracts"; +import { EDGE_PAN_SPEEDS, edgePanVelocity } from "./edgePan"; + +interface PanState { + pointerId: number; + startClient: Point; + startCamera: CameraState; + moved: boolean; + suppressClick: boolean; +} + +interface NativePanState { + tabId: string; + startClient: Point; + startCamera: CameraState; +} + +interface UseCanvasPointerNavigationOptions { + viewport: RefObject; + settings: AppSettings; + cameraRef: MutableRefObject; + canvasOverrideActiveRef: RefObject; + commitCamera(camera: CameraState): void; +} + +export interface CanvasPointerNavigationController { + panning: boolean; + handlePointerDownCapture(event: React.PointerEvent): boolean; + handleClickCapture(event: React.MouseEvent): boolean; + handlePointerDown(event: React.PointerEvent): void; + handlePointerMove(event: React.PointerEvent): void; + handlePointerEnd(event: React.PointerEvent): void; + handlePointerLeave(): void; +} + +export function useCanvasPointerNavigation({ + viewport, + settings, + cameraRef, + canvasOverrideActiveRef, + commitCamera +}: UseCanvasPointerNavigationOptions): CanvasPointerNavigationController { + const panState = useRef(null); + const nativePanState = useRef(null); + const suppressClick = useRef(false); + const [panning, setPanning] = useState(false); + const settingsRef = useRef(settings); + settingsRef.current = settings; + const edgePointer = useRef(null); + const edgeFrame = useRef(null); + const edgeLastTime = useRef(0); + + const panTo = useCallback((clientX: number, clientY: number): void => { + const state = panState.current; + if (!state) return; + if (Math.abs(clientX - state.startClient.x) > 3 || Math.abs(clientY - state.startClient.y) > 3) { + state.moved = true; + } + commitCamera({ + ...state.startCamera, + x: state.startCamera.x + clientX - state.startClient.x, + y: state.startCamera.y + clientY - state.startClient.y + }); + }, [commitCamera]); + + const finishPan = useCallback((): void => { + const state = panState.current; + if (!state) return; + if (state.moved || state.suppressClick) { + suppressClick.current = true; + window.setTimeout(() => { suppressClick.current = false; }, 0); + } + const element = viewport.current; + if (element?.hasPointerCapture(state.pointerId)) element.releasePointerCapture(state.pointerId); + panState.current = null; + window.canvasTTY.canvasNavigation.setPointerGestureActive(false); + setPanning(false); + }, [viewport]); + + const resetPan = useCallback((): void => { + const pointerId = panState.current?.pointerId; + const element = viewport.current; + if (pointerId !== undefined && element?.hasPointerCapture(pointerId)) element.releasePointerCapture(pointerId); + if (panState.current) window.canvasTTY.canvasNavigation.setPointerGestureActive(false); + panState.current = null; + nativePanState.current = null; + setPanning(false); + }, [viewport]); + + useEffect(() => { + window.addEventListener("blur", resetPan); + return () => window.removeEventListener("blur", resetPan); + }, [resetPan]); + + useEffect(() => () => { + if (edgeFrame.current !== null) cancelAnimationFrame(edgeFrame.current); + if (panState.current) window.canvasTTY.canvasNavigation.setPointerGestureActive(false); + }, []); + + useEffect(() => window.canvasTTY.browser.onCanvasNavigationPointer((event) => { + if (panState.current && event.type !== "down") { + if (event.type === "move") panTo(event.clientX, event.clientY); + else finishPan(); + return; + } + if (event.type === "down") { + nativePanState.current = { + tabId: event.tabId, + startClient: { x: event.clientX, y: event.clientY }, + startCamera: cameraRef.current + }; + setPanning(true); + return; + } + const state = nativePanState.current; + if (!state || state.tabId !== event.tabId) return; + if (event.type === "move") { + commitCamera({ + ...state.startCamera, + x: state.startCamera.x + event.clientX - state.startClient.x, + y: state.startCamera.y + event.clientY - state.startClient.y + }); + return; + } + nativePanState.current = null; + setPanning(false); + }), [cameraRef, commitCamera, finishPan, panTo]); + + const startPan = useCallback((event: React.PointerEvent, override = false): void => { + const blocked = event.button !== 0 + || (!override && (event.target as HTMLElement).closest('[data-interactive="true"]') !== null); + if (blocked) return; + if (override) { + event.preventDefault(); + event.stopPropagation(); + } + event.currentTarget.setPointerCapture(event.pointerId); + panState.current = { + pointerId: event.pointerId, + startClient: { x: event.clientX, y: event.clientY }, + startCamera: cameraRef.current, + moved: false, + suppressClick: override + }; + window.canvasTTY.canvasNavigation.setPointerGestureActive(true); + setPanning(true); + }, [cameraRef]); + + const edgePanStep = useCallback((time: number): void => { + edgeFrame.current = null; + const pointer = edgePointer.current; + if (!pointer || panState.current || nativePanState.current || !settingsRef.current.edgePan) return; + const bounds = viewport.current?.getBoundingClientRect(); + if (!bounds) return; + const hovered = document.elementFromPoint(pointer.x, pointer.y); + if (hovered?.closest('[data-interactive="true"]')) return; + const velocity = edgePanVelocity(pointer, bounds, { + maxSpeed: EDGE_PAN_SPEEDS[settingsRef.current.edgePanSpeed] + }); + if (!velocity) return; + const dt = edgeLastTime.current === 0 ? 0 : Math.min(0.05, (time - edgeLastTime.current) / 1000); + edgeLastTime.current = time; + commitCamera({ + ...cameraRef.current, + x: cameraRef.current.x + velocity.x * dt, + y: cameraRef.current.y + velocity.y * dt + }); + edgeFrame.current = requestAnimationFrame(edgePanStep); + }, [cameraRef, commitCamera, viewport]); + + const handlePointerDownCapture = useCallback((event: React.PointerEvent): boolean => { + if (!canvasOverrideActiveRef.current || !isCanvasWidgetTarget(event.target)) return false; + startPan(event, true); + return true; + }, [canvasOverrideActiveRef, startPan]); + + const handleClickCapture = useCallback((event: React.MouseEvent): boolean => { + if (!suppressClick.current) return false; + suppressClick.current = false; + event.preventDefault(); + event.stopPropagation(); + return true; + }, []); + + const handlePointerMove = useCallback((event: React.PointerEvent): void => { + const state = panState.current; + if (state?.pointerId === event.pointerId) panTo(event.clientX, event.clientY); + if (!settingsRef.current.edgePan) { + edgePointer.current = null; + return; + } + edgePointer.current = { x: event.clientX, y: event.clientY }; + if (edgeFrame.current === null) { + edgeLastTime.current = 0; + edgeFrame.current = requestAnimationFrame(edgePanStep); + } + }, [edgePanStep, panTo]); + + const handlePointerEnd = useCallback((event: React.PointerEvent): void => { + if (panState.current?.pointerId === event.pointerId) finishPan(); + }, [finishPan]); + + const handlePointerLeave = useCallback((): void => { + edgePointer.current = null; + }, []); + + return { + panning, + handlePointerDownCapture, + handleClickCapture, + handlePointerDown: startPan, + handlePointerMove, + handlePointerEnd, + handlePointerLeave + }; +} + +function isCanvasWidgetTarget(target: EventTarget | null): boolean { + return target instanceof Element && Boolean(target.closest( + '[data-wheel-owner="local"], [data-interactive="true"], [data-canvas-zoom-surface="application"]' + )); +} diff --git a/src/renderer/src/features/workspace/useCanvasWheelNavigation.ts b/src/renderer/src/features/workspace/useCanvasWheelNavigation.ts new file mode 100644 index 0000000..8ba4b4c --- /dev/null +++ b/src/renderer/src/features/workspace/useCanvasWheelNavigation.ts @@ -0,0 +1,163 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { MutableRefObject, RefObject } from "react"; +import type { AppSettings, CameraState, Point } from "../../../../shared/contracts"; +import { + canvasWheelIntent, + normalizeCanvasWheelDeltas, + shouldCanvasOwnWheel, + type CanvasWheelDeltas +} from "../../../../shared/canvasNavigation"; +import { isFocusedCanvasWidgetTarget } from "./canvasWidgetFocus"; +import type { CanvasWidgetFocusState } from "./useCanvasWidgetFocus"; + +export interface CanvasWheelInput extends CanvasWheelDeltas { + clientX: number; + clientY: number; + ctrlKey: boolean; + metaKey: boolean; +} + +interface UseCanvasWheelNavigationOptions { + viewport: RefObject; + settings: AppSettings; + cameraRef: MutableRefObject; + widgetFocusRef: RefObject; + commitCamera(camera: CameraState): void; +} + +export interface CanvasWheelNavigationController { + canvasOverrideActive: boolean; + canvasOverrideActiveRef: RefObject; + routeWidgetWheelToCanvas: boolean; + applyCanvasWheel(event: CanvasWheelInput): void; + zoomBy(factor: number): void; +} + +export function useCanvasWheelNavigation({ + viewport, + settings, + cameraRef, + widgetFocusRef, + commitCamera +}: UseCanvasWheelNavigationOptions): CanvasWheelNavigationController { + const [wheelOverrideActive, setWheelOverrideActive] = useState(false); + const wheelOverrideActiveRef = useRef(false); + const [canvasOverrideActive, setCanvasOverrideActive] = useState(false); + const canvasOverrideActiveRef = useRef(false); + const settingsRef = useRef(settings); + settingsRef.current = settings; + const panFrame = useRef(null); + const pendingPan = useRef({ x: 0, y: 0 }); + + const zoomAt = useCallback((clientX: number, clientY: number, nextZoom: number): void => { + const bounds = viewport.current?.getBoundingClientRect(); + if (!bounds) return; + const camera = cameraRef.current; + const localX = clientX - bounds.left; + const localY = clientY - bounds.top; + const worldX = (localX - camera.x) / camera.zoom; + const worldY = (localY - camera.y) / camera.zoom; + commitCamera({ + zoom: nextZoom, + x: localX - worldX * nextZoom, + y: localY - worldY * nextZoom + }); + }, [cameraRef, commitCamera, viewport]); + + const flushPan = useCallback((): void => { + if (panFrame.current !== null) { + cancelAnimationFrame(panFrame.current); + panFrame.current = null; + } + const delta = pendingPan.current; + if (delta.x === 0 && delta.y === 0) return; + pendingPan.current = { x: 0, y: 0 }; + commitCamera({ + ...cameraRef.current, + x: cameraRef.current.x - delta.x, + y: cameraRef.current.y - delta.y + }); + }, [cameraRef, commitCamera]); + + const applyCanvasWheel = useCallback((event: CanvasWheelInput): void => { + window.canvasTTY.canvasNavigation.armOwnerWheelSequence(event.clientX, event.clientY); + const intent = canvasWheelIntent(event, event, settingsRef.current); + if (intent.kind === "pan") { + pendingPan.current.x += intent.deltaX; + pendingPan.current.y += intent.deltaY; + if (panFrame.current === null) panFrame.current = requestAnimationFrame(flushPan); + return; + } + flushPan(); + zoomAt(event.clientX, event.clientY, clamp(cameraRef.current.zoom * intent.factor, 0.2, 1.35)); + }, [cameraRef, flushPan, zoomAt]); + + useEffect(() => () => { + if (panFrame.current !== null) cancelAnimationFrame(panFrame.current); + }, []); + + useEffect(() => window.canvasTTY.canvasNavigation.onOverrideState(({ wheelActive, navigationActive }) => { + wheelOverrideActiveRef.current = wheelActive; + canvasOverrideActiveRef.current = navigationActive; + viewport.current?.classList.toggle("workspace--canvas-override", navigationActive); + setWheelOverrideActive(wheelActive); + setCanvasOverrideActive(navigationActive); + }), [viewport]); + + useEffect(() => { + const element = viewport.current; + if (!element) return; + const handleWheel = (event: WheelEvent): void => { + const browserFreezeOwned = event.target instanceof Element + && event.target.closest('[data-browser-canvas-wheel-owner="canvas"]') !== null; + const ownedByCanvas = browserFreezeOwned || shouldCanvasOwnWheel({ + overFocusedWidget: isFocusedCanvasWidgetTarget(event.target, widgetFocusRef.current.id), + captureMode: settingsRef.current.canvasWheelCaptureMode, + wheelOverrideActive: wheelOverrideActiveRef.current, + navigationOverrideActive: canvasOverrideActiveRef.current + }); + if (!ownedByCanvas) return; + event.preventDefault(); + event.stopPropagation(); + const bounds = element.getBoundingClientRect(); + applyCanvasWheel({ + clientX: event.clientX, + clientY: event.clientY, + ...normalizeCanvasWheelDeltas(event.deltaX, event.deltaY, event.deltaMode, bounds), + ctrlKey: event.ctrlKey, + metaKey: event.metaKey + }); + }; + element.addEventListener("wheel", handleWheel, { capture: true, passive: false }); + return () => element.removeEventListener("wheel", handleWheel, true); + }, [applyCanvasWheel, viewport, widgetFocusRef]); + + useEffect(() => window.canvasTTY.browser.onCanvasWheel(applyCanvasWheel), [applyCanvasWheel]); + + const zoomBy = useCallback((factor: number): void => { + const bounds = viewport.current?.getBoundingClientRect(); + if (!bounds) return; + zoomAt( + bounds.left + bounds.width / 2, + bounds.top + bounds.height / 2, + clamp(cameraRef.current.zoom * factor, 0.2, 1.35) + ); + }, [cameraRef, viewport, zoomAt]); + + return { + canvasOverrideActive, + canvasOverrideActiveRef, + routeWidgetWheelToCanvas: shouldCanvasOwnWheel({ + overFocusedWidget: true, + captureMode: settings.canvasWheelCaptureMode, + wheelOverrideActive, + navigationOverrideActive: canvasOverrideActive + }), + applyCanvasWheel, + zoomBy + }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} diff --git a/src/renderer/src/features/workspace/useCanvasWidgetFocus.ts b/src/renderer/src/features/workspace/useCanvasWidgetFocus.ts new file mode 100644 index 0000000..837759c --- /dev/null +++ b/src/renderer/src/features/workspace/useCanvasWidgetFocus.ts @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { RefObject } from "react"; +import type { AppSettings } from "../../../../shared/contracts"; +import { HOVER_FOCUS_DELAYS } from "./focus"; +import { + browserCanvasWidgetId, + canvasWidgetFocusAfterClick, + canvasWidgetTarget, + terminalCanvasWidgetId +} from "./canvasWidgetFocus"; + +export interface CanvasWidgetFocusState { + id: string | null; + source: "explicit" | "hover"; +} + +interface UseCanvasWidgetFocusOptions { + viewport: RefObject; + settings: AppSettings; + activeSessionId: string | null; + browserSelected: boolean; + widgetTreeVersion: string; +} + +export interface CanvasWidgetFocusController { + state: CanvasWidgetFocusState; + stateRef: RefObject; + focus(id: string | null, source: CanvasWidgetFocusState["source"]): void; + cancelHover(id?: string): void; + scheduleHover(id: string): void; + focusBrowser(): void; + hoverBrowser(active: boolean): void; + handleClick(event: React.MouseEvent): void; + handlePointerOver(event: React.PointerEvent): void; + handlePointerOut(event: React.PointerEvent): void; +} + +export function useCanvasWidgetFocus({ + viewport, + settings, + activeSessionId, + browserSelected, + widgetTreeVersion +}: UseCanvasWidgetFocusOptions): CanvasWidgetFocusController { + const [state, setState] = useState({ id: null, source: "explicit" }); + const stateRef = useRef(state); + stateRef.current = state; + const settingsRef = useRef(settings); + settingsRef.current = settings; + const hoverTimer = useRef<{ id: string; timer: number } | null>(null); + + const focus = useCallback((id: string | null, source: CanvasWidgetFocusState["source"]): void => { + setState((current) => current.id === id && current.source === source ? current : { id, source }); + }, []); + + const cancelHover = useCallback((id?: string): void => { + const pending = hoverTimer.current; + if (!pending || (id !== undefined && pending.id !== id)) return; + window.clearTimeout(pending.timer); + hoverTimer.current = null; + }, []); + + const scheduleHover = useCallback((id: string): void => { + cancelHover(); + if (!settingsRef.current.hoverFocus || stateRef.current.id === id) return; + hoverTimer.current = { + id, + timer: window.setTimeout(() => { + hoverTimer.current = null; + focus(id, "hover"); + }, HOVER_FOCUS_DELAYS[settingsRef.current.hoverFocusSpeed]) + }; + }, [cancelHover, focus]); + + const focusBrowser = useCallback((): void => { + cancelHover(); + focus(browserCanvasWidgetId, "explicit"); + }, [cancelHover, focus]); + + const hoverBrowser = useCallback((active: boolean): void => { + if (active) scheduleHover(browserCanvasWidgetId); + else cancelHover(browserCanvasWidgetId); + }, [cancelHover, scheduleHover]); + + useEffect(() => () => cancelHover(), [cancelHover]); + + useEffect(() => { + if (!settings.hoverFocus) cancelHover(); + }, [cancelHover, settings.hoverFocus]); + + useEffect(() => { + if (activeSessionId !== null) focus(terminalCanvasWidgetId(activeSessionId), "explicit"); + }, [activeSessionId, focus]); + + useEffect(() => { + if (browserSelected) focus(browserCanvasWidgetId, "explicit"); + }, [browserSelected, focus]); + + useEffect(() => { + if (state.id === null) return; + const widgets = viewport.current?.querySelectorAll("[data-canvas-widget-id]"); + const exists = widgets && Array.from(widgets).some((widget) => widget.dataset.canvasWidgetId === state.id); + if (!exists) focus(null, "explicit"); + }, [focus, state.id, viewport, widgetTreeVersion]); + + const handleClick = useCallback((event: React.MouseEvent): void => { + const target = canvasWidgetTarget(event.target); + setState((current) => { + const nextId = canvasWidgetFocusAfterClick(current.id, target); + return nextId === current.id ? current : { id: nextId, source: "explicit" }; + }); + }, []); + + const handlePointerOver = useCallback((event: React.PointerEvent): void => { + const target = canvasWidgetTarget(event.target).focusableWidgetId; + const previous = canvasWidgetTarget(event.relatedTarget).focusableWidgetId; + if (target !== null && target !== previous) scheduleHover(target); + }, [scheduleHover]); + + const handlePointerOut = useCallback((event: React.PointerEvent): void => { + const target = canvasWidgetTarget(event.target).focusableWidgetId; + const next = canvasWidgetTarget(event.relatedTarget).focusableWidgetId; + if (target !== null && target !== next) cancelHover(target); + }, [cancelHover]); + + return { + state, + stateRef, + focus, + cancelHover, + scheduleHover, + focusBrowser, + hoverBrowser, + handleClick, + handlePointerOver, + handlePointerOut + }; +} diff --git a/src/renderer/src/features/workspace/zoom.ts b/src/renderer/src/features/workspace/zoom.ts deleted file mode 100644 index 22235fc..0000000 --- a/src/renderer/src/features/workspace/zoom.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ZoomSensitivity } from "../../../../shared/contracts"; - -const WHEEL_ZOOM_BASE = 0.0012; - -export const ZOOM_SENSITIVITY_FACTORS: Record = { - slow: 0.5, - normal: 1, - fast: 2 -}; - -/** Wheel/pinch zoom step scaled by the user's sensitivity preference. */ -export function wheelZoomFactor(deltaY: number, sensitivity: ZoomSensitivity): number { - return Math.exp(-deltaY * WHEEL_ZOOM_BASE * ZOOM_SENSITIVITY_FACTORS[sensitivity]); -} diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index d2611d3..591ddb6 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -158,15 +158,26 @@ const ru = { edgePanDescription: "Автоматически сдвигает канвас, когда курсор задерживается у края окна над свободным пространством.", edgePanSpeed: "Скорость движения", zoomSensitivity: "Чувствительность зума", - zoomOverApplications: "Зум поверх приложений", - zoomOverApplicationsDescription: "Масштабирует канвас колесом или трекпадом над терминалом и браузером вместо прокрутки их содержимого.", + useScrollWheelToZoom: "Зум обычным скроллом", + useScrollWheelToZoomDescription: "Если выключено, обычный скролл перемещает канвас, а Ctrl/Command + скролл и pinch масштабируют.", + canvasWheelCapture: "Перехват wheel/pinch над виджетами", + canvasWheelCaptureDescription: "Off оставляет обычный wheel/pinch focused input-виджету, включая live Browser page. On и активный Key передают события канвасу; pinch и Ctrl/Command + скролл над Browser всегда масштабируют canvas. Клики и drag не перехватываются.", + canvasWheelOverride: "Комбинация wheel/pinch", + canvasWheelOverrideHint: "Wheel/pinch поверх виджетов", + canvasOverrideShortcutInvalid: "Нужен модификатор или комбинация с модификатором.", + canvasNavigationOverride: "Перехват навигации канвасом", + canvasNavigationOverrideDescription: "Пока комбинация удерживается, wheel, pinch и drag над любым виджетом принадлежат канвасу.", + canvasNavigationCaptureHint: "Удержите модификатор или нажмите модификатор с обычной клавишей. Escape отменяет запись.", + canvasNavigationOverrideHint: "Навигация поверх виджетов", + canvasOverrideBindingsMatch: "Комбинации совпадают: полный перехват имеет приоритет и также забирает drag.", + disabled: "Отключено", terminalWheelDirection: "Направление колеса терминала", canvasWheelDirection: "Направление колеса канваса", wheelNormal: "Обычное", wheelInverted: "Обратное", focusActivation: "Фокус и приближение по клику", hoverFocus: "Автофокус при наведении", - hoverFocusDescription: "Выбирает терминал под курсором и снимает выбор после ухода с той же задержкой.", + hoverFocusDescription: "После задержки переносит input-фокус на виджет под курсором. Уход не сбрасывает фокус и не меняет selection.", hoverFocusSpeed: "Скорость автофокуса", single: "Один клик", double: "Двойной клик", @@ -209,7 +220,7 @@ const ru = { statusNeedsApproval: "Требуется решение", statusDone: "Завершено", statusFailed: "Ошибка", - moveHint: "Тяните пустой канвас · колесо масштабирует", + moveHint: "Тяните пустой канвас · Ctrl/Command + скролл масштабирует", loading: "Готовим рабочее пространство…" } as const; @@ -371,15 +382,26 @@ const en: Record = { edgePanDescription: "Moves the canvas automatically while the pointer rests near a window edge over empty space.", edgePanSpeed: "Edge pan speed", zoomSensitivity: "Zoom sensitivity", - zoomOverApplications: "Zoom over applications", - zoomOverApplicationsDescription: "Zooms the canvas with the wheel or trackpad over terminals and the browser instead of scrolling their content.", + useScrollWheelToZoom: "Use scroll wheel to zoom", + useScrollWheelToZoomDescription: "When off, plain scrolling pans the canvas while Ctrl/Command + scroll and pinch zoom.", + canvasWheelCapture: "Wheel/pinch capture over widgets", + canvasWheelCaptureDescription: "Off leaves plain wheel/pinch with the focused input widget, including a live Browser page. On and an active Key route it to the canvas; Browser pinch and Ctrl/Command + scroll always zoom the canvas. Clicks and drag are not captured.", + canvasWheelOverride: "Wheel/pinch shortcut", + canvasWheelOverrideHint: "Wheel/pinch over widgets", + canvasOverrideShortcutInvalid: "Use a modifier or a modifier-based chord.", + canvasNavigationOverride: "Canvas navigation override", + canvasNavigationOverrideDescription: "While held, wheel, pinch, and drag over every widget belong to the canvas.", + canvasNavigationCaptureHint: "Hold a modifier, or press a modifier with a regular key. Escape cancels capture.", + canvasNavigationOverrideHint: "Navigate over widgets", + canvasOverrideBindingsMatch: "The shortcuts match: full navigation takes priority and also captures drag.", + disabled: "Disabled", terminalWheelDirection: "Terminal wheel direction", canvasWheelDirection: "Canvas wheel direction", wheelNormal: "Normal", wheelInverted: "Inverted", focusActivation: "Focus and zoom on click", hoverFocus: "Focus on hover", - hoverFocusDescription: "Selects the terminal under the pointer and clears it after leaving with the same delay.", + hoverFocusDescription: "After the delay, moves input focus to the widget under the pointer. Leaving keeps focus and selection is unchanged.", hoverFocusSpeed: "Hover focus speed", single: "Single click", double: "Double click", @@ -422,7 +444,7 @@ const en: Record = { statusNeedsApproval: "Needs approval", statusDone: "Done", statusFailed: "Failed", - moveHint: "Drag the empty canvas · wheel to zoom", + moveHint: "Drag the empty canvas · Ctrl/Command + scroll to zoom", loading: "Preparing your workspace…" }; diff --git a/src/renderer/src/lib/shortcuts.ts b/src/renderer/src/lib/shortcuts.ts index cf3700f..ec21c18 100644 --- a/src/renderer/src/lib/shortcuts.ts +++ b/src/renderer/src/lib/shortcuts.ts @@ -1,4 +1,8 @@ -const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]); +import { + activeCanvasNavigationModifiers, + canvasNavigationModifierFromKey, + normalizeCanvasNavigationInputKey +} from "../../../shared/canvasNavigation.ts"; interface ShortcutEvent { key: string; @@ -9,17 +13,11 @@ interface ShortcutEvent { } export function shortcutFromKeyboardEvent(event: ShortcutEvent): string | null { - if (MODIFIER_KEYS.has(event.key)) return null; - const key = normalizeKey(event.key); + if (canvasNavigationModifierFromKey(event.key) !== null) return null; + const key = normalizeCanvasNavigationInputKey(event.key); if (!key) return null; - return [ - event.ctrlKey ? "Ctrl" : null, - event.altKey ? "Alt" : null, - event.shiftKey ? "Shift" : null, - event.metaKey ? "Meta" : null, - key - ].filter(Boolean).join("+"); + return [...activeCanvasNavigationModifiers(event), key].join("+"); } export function matchesShortcut(event: ShortcutEvent, shortcut: string): boolean { @@ -34,13 +32,11 @@ export function isRenameInputTarget(target: EventTarget | null): boolean { return target instanceof Element && Boolean(target.closest('[data-terminal-rename="true"]')); } -function normalizeKey(key: string): string | null { - if (key === " ") return "Space"; - if (key.length === 1) return key.toUpperCase(); - if (/^F(?:[1-9]|1\d|2[0-4])$/.test(key)) return key; - if (new Set([ - "Home", "End", "PageUp", "PageDown", "Enter", "Escape", "Tab", - "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Delete", "Insert", "Backspace" - ]).has(key)) return key; - return null; +export function displayCanvasNavigationBinding(binding: string, isMacOS: boolean): string { + if (!isMacOS) return binding; + return binding.split("+").map((part) => { + if (part === "Alt") return "Option"; + if (part === "Meta") return "Command"; + return part; + }).join("+"); } diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index c6cbb6f..c78eb3e 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -44,7 +44,10 @@ button { border: 0; } cursor: grab; isolation: isolate; } -.workspace--panning { cursor: grabbing; } +.workspace--canvas-override, +.workspace--canvas-override * { cursor: grab !important; } +.workspace--panning, +.workspace--panning * { cursor: grabbing !important; } .workspace__scene { position: absolute; inset: 0; width: 1px; height: 1px; transform-origin: 0 0; will-change: transform; } .workspace__windows { position: absolute; left: 0; top: 0; } .workspace__windows--hidden { visibility: hidden; pointer-events: none; } @@ -79,6 +82,7 @@ button { border: 0; } .plugin-widget { background: var(--surface); } .plugin-widget--unavailable { display: grid; place-items: center; padding: 18px; color: var(--text-muted-on-dark); font: 750 12px/1.4 var(--font-mono); overflow-wrap: anywhere; } .plugin-frame { width: 100%; height: 100%; display: block; border: 0; background: transparent; } +.workspace--canvas-override .plugin-frame { pointer-events: none; } .mini-media { position: relative; color: white; background: var(--pink); isolation: isolate; } .mini-media--custom { background-color: var(--surface-soft); } @@ -264,6 +268,7 @@ button { border: 0; } .browser-card__downloads--active .ui-icon { animation: browser-download 1.2s ease-in-out infinite alternate; } @keyframes browser-download { to { transform: translateY(4px); } } .browser-card__viewport { position: absolute; inset: 140px 8px 8px; overflow: hidden; background: #272934; } +.browser-card__freeze-frame { width: 100%; height: 100%; display: block; object-fit: fill; pointer-events: none; user-select: none; } .browser-card__summary { position: absolute; inset: 54px 0 0; z-index: 10; width: 100%; height: calc(100% - 54px); display: none; align-items: center; justify-content: center; overflow: hidden; padding: 24px; color: white; background: #272934; cursor: zoom-in; } .browser-card__summary[data-focus-activation="off"] { cursor: pointer; } .browser-card__summary-content { width: var(--summary-content-width); max-width: calc(100% - 48px); display: flex; flex-direction: column; align-items: center; gap: 9px; transform: scale(var(--summary-scale, 1)); text-align: center; } @@ -362,10 +367,15 @@ button { border: 0; } .shortcut-editor { display: grid; gap: 8px; } .shortcut-editor__row { min-height: 54px; padding: 8px 9px 8px 14px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; border-radius: 12px; background: rgba(255,255,255,.4); } .shortcut-editor__row > span { overflow: hidden; font-size: 13px; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; } +.shortcut-editor__actions { display: flex; align-items: center; gap: 7px; } .shortcut-editor__key { min-width: 88px; min-height: 36px; padding: 7px 11px; border: 1px solid rgba(45,45,56,.12); border-radius: 9px; color: var(--text-dark); background: rgba(255,255,255,.58); cursor: pointer; font: 850 12px var(--font-mono); } .shortcut-editor__key:hover, .shortcut-editor__key--capturing { color: white; background: var(--surface); } .shortcut-editor__key--capturing { box-shadow: 0 0 0 3px color-mix(in srgb, var(--surface) 20%, transparent); } +.shortcut-editor__clear { min-height: 36px; padding: 7px 10px; border: 1px solid rgba(45,45,56,.1); border-radius: 9px; color: var(--text-muted); background: rgba(255,255,255,.38); cursor: pointer; font: 800 11px var(--font-sans); } +.shortcut-editor__clear:hover { color: var(--text-dark); background: rgba(255,255,255,.7); } +.shortcut-editor__capture-hint { margin: 9px 2px 0; color: var(--text-muted); font-size: 12px; line-height: 1.35; } .shortcut-editor__error { margin: 9px 0 0; color: #8f3939; font-size: 12px; font-weight: 750; line-height: 1.35; } +.shortcut-editor__warning { margin: 9px 0 0; color: #7b5b19; font-size: 12px; font-weight: 750; line-height: 1.35; } .setting-group__heading-row { margin-bottom: 9px; display: flex; align-items: center; justify-content: space-between; gap: 12px; } .setting-group__heading-row h3 { margin: 0; } .browser-settings__list { max-height: 248px; display: grid; gap: 7px; overflow-y: auto; scrollbar-gutter: stable; scrollbar-width: thin; } diff --git a/src/shared/canvasNavigation.ts b/src/shared/canvasNavigation.ts new file mode 100644 index 0000000..cf98f99 --- /dev/null +++ b/src/shared/canvasNavigation.ts @@ -0,0 +1,265 @@ +import type { CanvasWheelCaptureMode, ZoomSensitivity } from "./contracts.ts"; + +export type CanvasNavigationPlatform = "darwin" | "other"; + +export interface CanvasNavigationKeyState { + altKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + shiftKey: boolean; + pressedKeys?: ReadonlySet; +} + +export interface CanvasWheelOwnershipInput { + overFocusedWidget: boolean; + captureMode: CanvasWheelCaptureMode; + wheelOverrideActive: boolean; + navigationOverrideActive: boolean; + forceCanvas?: boolean; +} + +export interface ParsedCanvasNavigationBinding { + modifiers: readonly CanvasNavigationModifier[]; + key: string | null; +} + +export type CanvasNavigationModifier = "Ctrl" | "Alt" | "Shift" | "Meta"; + +export interface CanvasWheelDeltas { + deltaX: number; + deltaY: number; +} + +export type CanvasWheelIntent = + | { kind: "pan"; deltaX: number; deltaY: number } + | { kind: "zoom"; factor: number; source: "modifier" | "wheel" }; + +export interface CanvasWheelIntentSettings { + invertCanvasWheel: boolean; + useScrollWheelToZoom: boolean; + zoomSensitivity: ZoomSensitivity; +} + +const MODIFIER_ORDER: readonly CanvasNavigationModifier[] = ["Ctrl", "Alt", "Shift", "Meta"]; +const MODIFIER_SET = new Set(MODIFIER_ORDER); +const LINE_DELTA_PIXELS = 16; +const MIN_MODIFIER_ZOOM_FACTOR = 0.75; +const MAX_MODIFIER_ZOOM_FACTOR = 1.25; +const WHEEL_ZOOM_BASE = 0.0012; +export const ZOOM_SENSITIVITY_FACTORS: Record = { + slow: 0.5, + normal: 1, + fast: 2 +}; +const NAMED_KEYS = new Set([ + "Home", "End", "PageUp", "PageDown", "Space", "Enter", "Escape", "Tab", + "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Delete", "Insert", "Backspace" +]); + +export function canvasNavigationPlatform(platform: string): CanvasNavigationPlatform { + return platform === "darwin" ? "darwin" : "other"; +} + +export function defaultCanvasWheelBinding(platform: CanvasNavigationPlatform): "Meta" | "Ctrl" { + return platform === "darwin" ? "Meta" : "Ctrl"; +} + +export function activeCanvasWheelBinding( + mode: CanvasWheelCaptureMode, + binding: string | null +): string | null { + return mode === "key" ? binding : null; +} + +export function parseCanvasNavigationBinding(value: unknown): ParsedCanvasNavigationBinding | null { + if (typeof value !== "string" || value.length === 0 || value.length > 40) return null; + const parts = value.split("+"); + if (parts.some((part) => part.length === 0) || new Set(parts).size !== parts.length) return null; + + const modifiers: CanvasNavigationModifier[] = []; + let key: string | null = null; + for (const [index, part] of parts.entries()) { + if (isCanvasNavigationModifier(part)) { + if (key !== null) return null; + modifiers.push(part); + continue; + } + if (index !== parts.length - 1 || key !== null) return null; + key = normalizeCanvasNavigationKey(part); + if (!key) return null; + } + if (modifiers.length === 0) return null; + + return { + modifiers: MODIFIER_ORDER.filter((modifier) => modifiers.includes(modifier)), + key + }; +} + +export function normalizeCanvasOverrideBinding( + value: unknown, + actionShortcuts: readonly string[] = [] +): string | null { + const parsed = parseCanvasNavigationBinding(value); + if (!parsed) return null; + const canonical = formatCanvasNavigationBinding(parsed); + return actionShortcuts.some((shortcut) => canvasOverrideBindingConflicts(canonical, shortcut)) + ? null + : canonical; +} + +export function formatCanvasNavigationBinding(binding: ParsedCanvasNavigationBinding): string { + return [...binding.modifiers, binding.key].filter((part): part is string => part !== null).join("+"); +} + +export function isCanvasNavigationBindingActive( + state: CanvasNavigationKeyState, + binding: string | null +): boolean { + if (binding === null) return false; + const parsed = parseCanvasNavigationBinding(binding); + if (!parsed) return false; + if (parsed.modifiers.some((modifier) => !isCanvasNavigationModifierActive(state, modifier))) return false; + return parsed.key === null || state.pressedKeys?.has(parsed.key) === true; +} + +export function isCanvasNavigationBindingKey(key: string, binding: string | null): boolean { + if (binding === null) return false; + const parsed = parseCanvasNavigationBinding(binding); + if (!parsed) return false; + const modifier = canvasNavigationModifierFromKey(key); + if (modifier) return parsed.modifiers.includes(modifier); + return parsed.key === normalizeCanvasNavigationKey(key); +} + +export function normalizeCanvasNavigationInputKey(key: string, code?: string): string | null { + if (typeof code === "string") { + const letter = /^Key([A-Z])$/.exec(code); + if (letter) return letter[1]; + const digit = /^(?:Digit|Numpad)([0-9])$/.exec(code); + if (digit) return digit[1]; + const codedKey = normalizeCanvasNavigationKey(code); + if (codedKey) return codedKey; + } + return normalizeCanvasNavigationKey(key); +} + +export function canvasOverrideBindingConflicts(binding: string, shortcut: string): boolean { + const parsedBinding = parseCanvasNavigationBinding(binding); + const parsedShortcut = parseShortcut(shortcut); + if (!parsedBinding || !parsedShortcut) return false; + if (parsedBinding.key === null) return false; + if (parsedBinding.modifiers.some((modifier) => !parsedShortcut.modifiers.includes(modifier))) return false; + return parsedBinding.key === parsedShortcut.key; +} + +export function normalizeCanvasWheelDeltas( + deltaX: number, + deltaY: number, + deltaMode: number, + viewport: { width: number; height: number } +): CanvasWheelDeltas { + const safeX = Number.isFinite(deltaX) ? deltaX : 0; + const safeY = Number.isFinite(deltaY) ? deltaY : 0; + if (deltaMode === 1) { + return { deltaX: safeX * LINE_DELTA_PIXELS, deltaY: safeY * LINE_DELTA_PIXELS }; + } + if (deltaMode === 2) { + return { + deltaX: safeX * Math.max(0, viewport.width), + deltaY: safeY * Math.max(0, viewport.height) + }; + } + return { deltaX: safeX, deltaY: safeY }; +} + +export function canvasWheelIntent( + deltas: CanvasWheelDeltas, + modifiers: { ctrlKey: boolean; metaKey: boolean }, + settings: CanvasWheelIntentSettings +): CanvasWheelIntent { + if (modifiers.ctrlKey || modifiers.metaKey) { + return { + kind: "zoom", + factor: clamp(Math.exp(-deltas.deltaY / 100), MIN_MODIFIER_ZOOM_FACTOR, MAX_MODIFIER_ZOOM_FACTOR), + source: "modifier" + }; + } + if (settings.useScrollWheelToZoom) { + const deltaY = settings.invertCanvasWheel ? -deltas.deltaY : deltas.deltaY; + return { + kind: "zoom", + factor: wheelZoomFactor(deltaY, settings.zoomSensitivity), + source: "wheel" + }; + } + const multiplier = settings.invertCanvasWheel ? -1 : 1; + return { + kind: "pan", + deltaX: deltas.deltaX * multiplier, + deltaY: deltas.deltaY * multiplier + }; +} + +export function shouldCanvasOwnWheel(input: CanvasWheelOwnershipInput): boolean { + return input.forceCanvas === true + || !input.overFocusedWidget + || input.navigationOverrideActive + || input.captureMode === "always" + || (input.captureMode === "key" && input.wheelOverrideActive); +} + +export function wheelZoomFactor(deltaY: number, sensitivity: ZoomSensitivity): number { + return Math.exp(-deltaY * WHEEL_ZOOM_BASE * ZOOM_SENSITIVITY_FACTORS[sensitivity]); +} + +export function canvasNavigationModifierFromKey(key: string): CanvasNavigationModifier | null { + if (key === "Control" || key === "Ctrl") return "Ctrl"; + if (key === "Alt") return "Alt"; + if (key === "Shift") return "Shift"; + if (key === "Meta") return "Meta"; + return null; +} + +export function activeCanvasNavigationModifiers( + state: Pick +): CanvasNavigationModifier[] { + return MODIFIER_ORDER.filter((modifier) => isCanvasNavigationModifierActive(state, modifier)); +} + +export function isCanvasNavigationModifierActive( + state: Pick, + modifier: CanvasNavigationModifier +): boolean { + if (modifier === "Ctrl") return state.ctrlKey; + if (modifier === "Alt") return state.altKey; + if (modifier === "Shift") return state.shiftKey; + return state.metaKey; +} + +export function normalizeCanvasNavigationKey(key: string): string | null { + if (key === " ") return "Space"; + if (key.length === 1 && /[A-Za-z0-9]/.test(key)) return key.toUpperCase(); + if (/^F(?:[1-9]|1\d|2[0-4])$/.test(key)) return key; + return NAMED_KEYS.has(key) ? key : null; +} + +function parseShortcut(shortcut: string): { modifiers: readonly CanvasNavigationModifier[]; key: string } | null { + const parts = shortcut.split("+"); + const key = normalizeCanvasNavigationKey(parts.at(-1) ?? ""); + if (!key) return null; + const modifiers: CanvasNavigationModifier[] = []; + for (const modifier of parts.slice(0, -1)) { + if (!isCanvasNavigationModifier(modifier)) return null; + modifiers.push(modifier); + } + return { modifiers, key }; +} + +function isCanvasNavigationModifier(value: string): value is CanvasNavigationModifier { + return MODIFIER_SET.has(value); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index b89ad38..eb5e28c 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -8,6 +8,8 @@ export type LocaleId = "ru" | "en"; export type MediaFit = "cover" | "contain"; export type EdgePanSpeed = "slow" | "normal" | "fast"; export type ZoomSensitivity = "slow" | "normal" | "fast"; +export type CanvasWheelCaptureMode = "off" | "always" | "key"; +export type BrowserViewportSurface = "native" | "placeholder" | "hidden"; export type FocusActivation = "off" | "single" | "double"; export type ShortcutAction = "home" | "renameWindow"; @@ -93,7 +95,10 @@ export interface AppSettings { edgePan: boolean; edgePanSpeed: EdgePanSpeed; zoomSensitivity: ZoomSensitivity; - zoomOverApplications: boolean; + useScrollWheelToZoom: boolean; + canvasWheelCaptureMode: CanvasWheelCaptureMode; + canvasWheelOverride: string | null; + canvasNavigationOverride: string | null; focusActivation: FocusActivation; hoverFocus: boolean; hoverFocusSpeed: EdgePanSpeed; @@ -314,9 +319,8 @@ export interface BrowserCanvasState extends SessionBounds {} export interface BrowserViewportBounds extends Size { x: number; y: number; - visible: boolean; + surface: BrowserViewportSurface; canvasScale?: number; - captureCanvasWheel?: boolean; showAgentPresence?: boolean; } @@ -404,7 +408,29 @@ export interface BrowserCanvasWheelEvent { tabId: string; clientX: number; clientY: number; + deltaX: number; deltaY: number; + ctrlKey: boolean; + metaKey: boolean; +} + +export interface BrowserCanvasFreezeFrameEvent { + tabId: string; + generation: number; + active: boolean; + dataUrl: string | null; +} + +export interface BrowserCanvasNavigationPointerEvent { + tabId: string; + type: "down" | "move" | "up" | "cancel"; + clientX: number; + clientY: number; +} + +export interface CanvasNavigationOverrideStateEvent { + wheelActive: boolean; + navigationActive: boolean; } export interface BrowserCanvasPointerEvent { @@ -687,11 +713,20 @@ export interface CanvasTTYApi { getActivity(sinceSequence?: number): Promise; clearData(): Promise; focus(): void; + setInputFocused(focused: boolean): void; setViewport(bounds: BrowserViewportBounds): void; onState(listener: (event: BrowserStateEvent) => void): () => void; onActivity(listener: (event: BrowserActivityStateEvent) => void): () => void; onCanvasWheel(listener: (event: BrowserCanvasWheelEvent) => void): () => void; + onCanvasFreezeFrame(listener: (event: BrowserCanvasFreezeFrameEvent) => void): () => void; onCanvasPointer(listener: (event: BrowserCanvasPointerEvent) => void): () => void; + onCanvasNavigationPointer(listener: (event: BrowserCanvasNavigationPointerEvent) => void): () => void; + }; + canvasNavigation: { + armOwnerWheelSequence(clientX: number, clientY: number): void; + setShortcutCaptureActive(active: boolean): void; + setPointerGestureActive(active: boolean): void; + onOverrideState(listener: (event: CanvasNavigationOverrideStateEvent) => void): () => void; }; terminal: { list(): Promise; @@ -765,11 +800,20 @@ export const IPC = { browserGetActivity: "browser:get-activity", browserClearData: "browser:clear-data", browserFocus: "browser:focus", + browserSetInputFocused: "browser:set-input-focused", browserSetViewport: "browser:set-viewport", browserState: "browser:state", browserActivity: "browser:activity", + browserPageWheelDecision: "browser:page-wheel-decision", + browserPageWheel: "browser:page-wheel", browserCanvasWheel: "browser:canvas-wheel", + browserCanvasFreezeFrame: "browser:canvas-freeze-frame", browserCanvasPointer: "browser:canvas-pointer", + browserCanvasNavigationPointer: "browser:canvas-navigation-pointer", + canvasNavigationShortcutCapture: "canvas-navigation:shortcut-capture", + canvasNavigationOwnerWheel: "canvas-navigation:owner-wheel", + canvasNavigationPointerGesture: "canvas-navigation:pointer-gesture", + canvasNavigationOverrideState: "canvas-navigation:override-state", terminalList: "terminal:list", terminalCreate: "terminal:create", terminalRestart: "terminal:restart", diff --git a/tests/app-shortcuts.test.mjs b/tests/app-shortcuts.test.mjs index fb16fd1..de11fea 100644 --- a/tests/app-shortcuts.test.mjs +++ b/tests/app-shortcuts.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + displayCanvasNavigationBinding, matchesShortcut, shortcutFromKeyboardEvent } from "../src/renderer/src/lib/shortcuts.ts"; @@ -32,3 +33,8 @@ test("matches shortcuts without casing drift", () => { assert.equal(matchesShortcut(keyEvent("h", { ctrlKey: true }), "Ctrl+H"), true); assert.equal(matchesShortcut(keyEvent("h", { ctrlKey: true }), "Alt+H"), false); }); + +test("displays platform-neutral canvas navigation bindings with macOS key names", () => { + assert.equal(displayCanvasNavigationBinding("Ctrl+Alt+Meta+Space", true), "Ctrl+Option+Command+Space"); + assert.equal(displayCanvasNavigationBinding("Ctrl+Alt", false), "Ctrl+Alt"); +}); diff --git a/tests/browser-canvas-controllers.test.mjs b/tests/browser-canvas-controllers.test.mjs new file mode 100644 index 0000000..e6d291d --- /dev/null +++ b/tests/browser-canvas-controllers.test.mjs @@ -0,0 +1,304 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { BrowserCanvasGestureController } from "../src/main/services/browser/BrowserCanvasGestureController.ts"; +import { BrowserCanvasPointerRouter } from "../src/main/services/browser/BrowserCanvasPointerRouter.ts"; + +function wheelInput(overrides = {}) { + return { + deltaX: 2, + deltaY: 8, + deltaMode: 0, + viewportWidth: 800, + viewportHeight: 600, + ctrlKey: false, + metaKey: false, + screenX: 240, + screenY: 280, + topFrame: true, + clientX: 140, + clientY: 160, + ...overrides + }; +} + +function gestureHarness() { + let now = 1_000; + let focused = false; + let captureMode = "off"; + let overrides = { wheelActive: false, navigationActive: false }; + let viewport = { + x: 100, + y: 100, + width: 800, + height: 600, + surface: "native", + showAgentPresence: false + }; + const trace = []; + const contents = { + isDestroyed: () => false, + capturePage: async () => { throw new Error("capture unavailable in unit test"); } + }; + const sink = { + preserve: () => { + trace.push("sink:preserve"); + return true; + }, + restore: () => { + trace.push("sink:restore"); + return true; + } + }; + const tab = { id: "tab-1", view: { webContents: contents }, canvasSinkViewport: sink }; + const owner = { + isDestroyed: () => false, + getContentBounds: () => ({ x: 100, y: 120, width: 1_200, height: 900 }) + }; + const controller = new BrowserCanvasGestureController({ + getOwner: () => owner, + getViewport: () => viewport, + getActiveTab: () => tab, + getTab: (tabId) => tabId === tab.id ? tab : undefined, + isVisible: () => true, + isDisposed: () => false, + getOverrideState: () => overrides, + getCursorScreenPoint: () => ({ x: 140, y: 180 }), + requestSurfaceSync: () => trace.push("surface:sync"), + beforeSequenceEnd: () => trace.push("pointer:cancel"), + shouldDeferIdleEnd: () => false, + sendWheel: (payload) => trace.push(["wheel", payload]), + sendFreezeFrame: (payload) => trace.push(["freeze", payload]) + }, { captureMode, now: () => now }); + + return { + controller, + contents, + trace, + setNow(value) { now = value; }, + setFocused(value) { + focused = value; + controller.setInputFocused(focused); + }, + setCaptureMode(value) { + captureMode = value; + controller.setCaptureMode(captureMode); + }, + setOverrides(value) { overrides = value; }, + setViewport(value) { + const previous = viewport; + viewport = { ...viewport, ...value }; + controller.viewportChanged(previous, viewport); + } + }; +} + +test("BrowserCanvasGestureController latches focus-aware page ownership until reset", () => { + const harness = gestureHarness(); + harness.setFocused(true); + harness.setCaptureMode("off"); + + const first = harness.controller.decidePageWheel(harness.contents, wheelInput()); + assert.deepEqual(first, { generation: 1, owner: "page" }); + + harness.setFocused(false); + harness.setCaptureMode("always"); + harness.setNow(1_100); + assert.deepEqual( + harness.controller.decidePageWheel(harness.contents, wheelInput({ metaKey: true })), + { generation: 1, owner: "page" } + ); + + harness.controller.endSequence(false); + harness.setNow(1_400); + const next = harness.controller.decidePageWheel(harness.contents, wheelInput({ metaKey: true })); + assert.deepEqual(next, { generation: 2, owner: "canvas" }); + harness.controller.endSequence(false); +}); + +test("BrowserCanvasGestureController owns freeze, sink, relay, and restoration order", () => { + const harness = gestureHarness(); + harness.setFocused(false); + + const decision = harness.controller.decidePageWheel(harness.contents, wheelInput()); + assert.equal(decision.owner, "canvas"); + assert.equal(harness.controller.activeNativeSink?.tabId, "tab-1"); + assert.equal( + harness.controller.surfaceDecision("tab-1", { x: 100, y: 100, width: 800, height: 600 }, { width: 1_200, height: 900 }).kind, + "sink" + ); + + harness.controller.handlePageWheel(harness.contents, { ...wheelInput(), generation: decision.generation }); + const relay = harness.trace.find((entry) => Array.isArray(entry) && entry[0] === "wheel"); + assert.deepEqual(relay?.[1], { + tabId: "tab-1", + clientX: 140, + clientY: 160, + deltaX: 2, + deltaY: 8, + ctrlKey: false, + metaKey: false + }); + + harness.trace.length = 0; + harness.controller.endSequence(); + assert.deepEqual(harness.trace.slice(0, 4).map((entry) => Array.isArray(entry) ? entry[0] : entry), [ + "pointer:cancel", + "surface:sync", + "sink:restore", + "freeze" + ]); + assert.equal(harness.controller.activeNativeSink, null); + assert.equal(harness.controller.isFreezeActive, false); +}); + +test("BrowserCanvasGestureController preserves a canvas sequence across placeholder viewport state", () => { + const harness = gestureHarness(); + const decision = harness.controller.decidePageWheel(harness.contents, wheelInput()); + assert.equal(decision.owner, "canvas"); + harness.setViewport({ surface: "placeholder" }); + harness.controller.handlePageWheel(harness.contents, { ...wheelInput(), generation: decision.generation }); + assert.ok(harness.trace.some((entry) => Array.isArray(entry) && entry[0] === "wheel")); + harness.controller.endSequence(false); +}); + +function pointerHarness() { + const cursors = []; + const ownerEvents = []; + const browserEvents = []; + const navigationEvents = []; + let navigationOverrideActive = false; + let nativeSink = null; + let freezeActive = false; + let frozenTabId = null; + const contents = { + isDestroyed: () => false, + focus: () => undefined, + sendInputEvent: (event) => browserEvents.push(event) + }; + const tab = { + id: "tab-1", + view: { webContents: contents }, + canvasCursor: { set: (cursor) => cursors.push(cursor) } + }; + const owner = { + isDestroyed: () => false, + getContentBounds: () => ({ x: 100, y: 100, width: 1_000, height: 800 }), + webContents: { sendInputEvent: (event) => ownerEvents.push(event) } + }; + const router = new BrowserCanvasPointerRouter({ + getOwner: () => owner, + getViewport: () => ({ x: 200, y: 150, width: 600, height: 400, surface: "native", showAgentPresence: false }), + getTab: (tabId) => tabId === tab.id ? tab : undefined, + getTabs: () => [tab], + getNativeWheelSink: () => nativeSink, + getFrozenTabId: () => frozenTabId, + isFreezeActive: () => freezeActive, + isNavigationOverrideActive: () => navigationOverrideActive, + getCursorScreenPoint: () => ({ x: 340, y: 300 }), + endWheelSequence: () => navigationEvents.push({ sequenceEnded: true }), + sendNavigationPointer: (payload) => navigationEvents.push(payload) + }); + return { + router, + tab, + owner, + cursors, + ownerEvents, + browserEvents, + navigationEvents, + setNavigationOverrideActive(value) { navigationOverrideActive = value; }, + setNativeSink(value) { nativeSink = value; }, + setFreeze(value, tabId = "tab-1") { + freezeActive = value; + frozenTabId = value ? tabId : null; + } + }; +} + +function nativeEvent() { + let prevented = false; + return { + event: { preventDefault: () => { prevented = true; } }, + prevented: () => prevented + }; +} + +test("BrowserCanvasPointerRouter latches full-override drag and cursor across surfaces", () => { + const harness = pointerHarness(); + harness.setNavigationOverrideActive(true); + harness.router.setNavigationActive(true); + assert.equal(harness.cursors.at(-1), "grab"); + + const down = nativeEvent(); + assert.equal(harness.router.handleBrowserMouse(harness.tab, harness.owner, down.event, { + type: "mouseDown", + button: "left", + x: 20, + y: 30, + modifiers: [] + }), true); + assert.equal(down.prevented(), true); + assert.equal(harness.cursors.at(-1), "grabbing"); + assert.equal(harness.navigationEvents.at(-1).type, "down"); + + harness.setNavigationOverrideActive(false); + harness.router.setNavigationActive(false); + const move = nativeEvent(); + assert.equal(harness.router.handleOwnerMouse(move.event, { + type: "mouseMove", + x: 310, + y: 260, + modifiers: [] + }, harness.owner), true); + assert.equal(move.prevented(), true); + assert.equal(harness.navigationEvents.at(-1).type, "move"); + + const up = nativeEvent(); + assert.equal(harness.router.handleOwnerMouse(up.event, { + type: "mouseUp", + button: "left", + x: 315, + y: 265, + modifiers: [] + }, harness.owner), true); + assert.equal(harness.navigationEvents.at(-1).type, "up"); + assert.equal(harness.cursors.at(-1), null); +}); + +test("BrowserCanvasPointerRouter forwards ordinary frozen clicks without stealing full override", () => { + const harness = pointerHarness(); + harness.setFreeze(true); + const down = nativeEvent(); + assert.equal(harness.router.handleOwnerMouse(down.event, { + type: "mouseDown", + button: "left", + x: 240, + y: 200, + clickCount: 1, + modifiers: ["shift"] + }, harness.owner), true); + assert.equal(down.prevented(), true); + assert.equal(harness.browserEvents.at(-1).type, "mouseDown"); + + const up = nativeEvent(); + assert.equal(harness.router.handleOwnerMouse(up.event, { + type: "mouseUp", + button: "left", + x: 245, + y: 205, + modifiers: ["shift"] + }, harness.owner), true); + assert.equal(harness.browserEvents.at(-1).type, "mouseUp"); + + harness.setNavigationOverrideActive(true); + harness.router.setNavigationActive(true); + const overrideDown = nativeEvent(); + assert.equal(harness.router.handleOwnerMouse(overrideDown.event, { + type: "mouseDown", + button: "left", + x: 250, + y: 210, + modifiers: [] + }, harness.owner), false); + assert.equal(overrideDown.prevented(), false); +}); diff --git a/tests/browser-canvas-cursor.test.mjs b/tests/browser-canvas-cursor.test.mjs new file mode 100644 index 0000000..28526b9 --- /dev/null +++ b/tests/browser-canvas-cursor.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + BrowserCanvasCursorController, + browserCanvasNavigationCursor +} from "../src/main/services/browser/BrowserCanvasCursor.ts"; + +function deferred() { + let resolve; + const promise = new Promise((next) => { resolve = next; }); + return { promise, resolve }; +} + +test("native Browser cursor follows only full navigation ownership and latches during drag", () => { + assert.equal(browserCanvasNavigationCursor(false, false), null); + assert.equal(browserCanvasNavigationCursor(true, false), "grab"); + assert.equal(browserCanvasNavigationCursor(false, true), "grabbing"); + assert.equal(browserCanvasNavigationCursor(true, true), "grabbing"); +}); + +test("native Browser cursor CSS uses user origin and rejects stale async insertion", async () => { + const insertions = []; + const removals = []; + const contents = { + isDestroyed: () => false, + insertCSS(css, options) { + const pending = deferred(); + insertions.push({ css, options, pending }); + return pending.promise; + }, + async removeInsertedCSS(key) { + removals.push(key); + } + }; + const controller = new BrowserCanvasCursorController(contents); + controller.set("grab"); + controller.set("grabbing"); + assert.equal(insertions.length, 2); + assert.match(insertions[0].css, /cursor: grab !important/); + assert.match(insertions[1].css, /cursor: grabbing !important/); + assert.deepEqual(insertions[1].options, { cssOrigin: "user" }); + + insertions[1].pending.resolve("current-key"); + await Promise.resolve(); + insertions[0].pending.resolve("stale-key"); + await Promise.resolve(); + await Promise.resolve(); + assert.deepEqual(removals, ["stale-key"]); + + controller.set(null); + await Promise.resolve(); + assert.deepEqual(removals, ["stale-key", "current-key"]); +}); + +test("native Browser cursor reapplies after navigation and cleans up on dispose", async () => { + let nextKey = 0; + const removals = []; + const contents = { + isDestroyed: () => false, + async insertCSS() { + nextKey += 1; + return `key-${nextKey}`; + }, + async removeInsertedCSS(key) { + removals.push(key); + } + }; + const controller = new BrowserCanvasCursorController(contents); + controller.set("grab"); + await Promise.resolve(); + controller.refresh(); + await Promise.resolve(); + await Promise.resolve(); + assert.ok(removals.includes("key-1")); + controller.dispose(); + await Promise.resolve(); + assert.ok(removals.includes("key-2")); +}); diff --git a/tests/browser-canvas-freeze.test.mjs b/tests/browser-canvas-freeze.test.mjs new file mode 100644 index 0000000..daa2bcb --- /dev/null +++ b/tests/browser-canvas-freeze.test.mjs @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + BROWSER_CANVAS_FREEZE_GUARD_DIP, + BROWSER_CANVAS_NATIVE_WHEEL_SINK_SIZE_DIP, + BROWSER_CANVAS_WHEEL_IDLE_MS, + BrowserCanvasFreezeFrameStore, + BrowserCanvasWheelSequence, + browserCanvasNativeWheelSinkLayout, + createBrowserCanvasNativeWheelSink, + browserVisibleRectangle, + encodeBrowserCanvasFreezeFrame +} from "../src/main/services/browser/BrowserCanvasFreeze.ts"; + +test("wheel sequence freezes before the logged Browser boundary crosses the pointer", () => { + const sequence = new BrowserCanvasWheelSequence(); + sequence.begin({ x: 1156, y: 479 }, 1_000); + + assert.equal(BROWSER_CANVAS_FREEZE_GUARD_DIP, 4); + assert.equal(BROWSER_CANVAS_WHEEL_IDLE_MS, 250); + assert.equal(sequence.shouldFreeze({ x: 324, y: 283, width: 830, height: 434 }), true); + assert.equal(sequence.shouldFreeze({ x: 0, y: 0, width: 10, height: 10 }), true); + + assert.equal(sequence.end(), true); + assert.equal(sequence.shouldFreeze({ x: 324, y: 283, width: 830, height: 434 }), false); + assert.equal(sequence.end(), false); +}); + +test("wheel collision uses the visible window-clipped rectangle and latches across direction changes", () => { + const sequence = new BrowserCanvasWheelSequence(); + sequence.begin({ x: 8, y: 100 }, 0); + const clipped = browserVisibleRectangle( + { x: -30, y: 20, width: 42, height: 200 }, + { width: 800, height: 600 } + ); + assert.deepEqual(clipped, { x: 0, y: 20, width: 12, height: 200 }); + assert.equal(sequence.shouldFreeze(clipped), true); + assert.equal(sequence.shouldFreeze({ x: 500, y: 500, width: 10, height: 10 }), true); + + sequence.end(); + sequence.begin({ x: 400, y: 300 }, 100); + assert.equal(sequence.shouldFreeze({ x: 0, y: 0, width: 20, height: 20 }), false); + assert.equal(sequence.shouldFreeze({ x: 397, y: 0, width: 2, height: 600 }), true); +}); + +test("wheel collision handles every pan direction and the expanded boundary", () => { + const rectangles = [ + { x: 104, y: 20, width: 80, height: 160 }, + { x: 16, y: 104, width: 160, height: 80 }, + { x: 104, y: 104, width: 80, height: 80 }, + { x: 16, y: 20, width: 81, height: 81 } + ]; + for (const rectangle of rectangles) { + const sequence = new BrowserCanvasWheelSequence(); + sequence.begin({ x: 100, y: 100 }, 0); + assert.equal(sequence.shouldFreeze(rectangle), true); + } + + const outside = new BrowserCanvasWheelSequence(); + outside.begin({ x: 100, y: 100 }, 0); + assert.equal(outside.shouldFreeze({ x: 105, y: 20, width: 80, height: 160 }), false); +}); + +test("wheel sequence refreshes its pointer and reports expiration from the last event", () => { + const sequence = new BrowserCanvasWheelSequence(); + const first = sequence.begin({ x: 10, y: 20 }, 1_000); + const second = sequence.begin({ x: 30, y: 40 }, 1_120); + + assert.equal(first.started, true); + assert.equal(second.started, false); + assert.equal(sequence.shouldFreeze({ x: 28, y: 38, width: 4, height: 4 }), true); + assert.equal(sequence.expired(1_369), false); + assert.equal(sequence.expired(1_370), true); +}); + +test("Browser-origin canvas wheel shrinks the native surface to the pinned hit-test sink", () => { + const sink = createBrowserCanvasNativeWheelSink( + "tab-a", + { x: 318, y: 261, width: 830, height: 433 }, + { x: 681, y: 415 } + ); + + assert.ok(sink); + assert.equal(BROWSER_CANVAS_NATIVE_WHEEL_SINK_SIZE_DIP, 4); + assert.deepEqual(browserCanvasNativeWheelSinkLayout(sink, { width: 1_440, height: 900 }), { + clip: { x: 679, y: 413, width: 4, height: 4 }, + view: { x: 0, y: 0, width: 4, height: 4 } + }); +}); + +test("native wheel sink clamps to owner edges and rejects points outside the pinned Browser viewport", () => { + const nearEdge = createBrowserCanvasNativeWheelSink( + "tab-a", + { x: -10, y: -20, width: 120, height: 100 }, + { x: 1, y: 1 } + ); + assert.ok(nearEdge); + assert.deepEqual(browserCanvasNativeWheelSinkLayout(nearEdge, { width: 80, height: 60 }), { + clip: { x: 0, y: 0, width: 4, height: 4 }, + view: { x: 0, y: 0, width: 4, height: 4 } + }); + + assert.equal(createBrowserCanvasNativeWheelSink( + "tab-a", + { x: 20, y: 20, width: 100, height: 80 }, + { x: 10, y: 10 } + ), null); + assert.equal(browserCanvasNativeWheelSinkLayout(nearEdge, { width: 0, height: 60 }), null); +}); + +test("freeze frame store rejects stale captures and preserves the last frame for the same tab", () => { + const store = new BrowserCanvasFreezeFrameStore(); + const oldRequest = store.beginCapture("tab-a"); + const currentRequest = store.beginCapture("tab-a"); + + assert.equal(store.commitCapture(oldRequest, "data:image/jpeg;base64,old"), null); + assert.deepEqual(store.commitCapture(currentRequest, "data:image/jpeg;base64,current"), { + tabId: "tab-a", + dataUrl: "data:image/jpeg;base64,current" + }); + assert.equal(store.frameFor("tab-a"), "data:image/jpeg;base64,current"); + assert.equal(store.frameFor("tab-b"), null); + + const failedRequest = store.beginCapture("tab-a"); + store.failCapture(failedRequest); + assert.equal(store.frameFor("tab-a"), "data:image/jpeg;base64,current"); + + const invalidatedRequest = store.beginCapture("tab-a"); + store.invalidateCapture(); + assert.equal(store.commitCapture(invalidatedRequest, "data:image/jpeg;base64,stale"), null); + assert.equal(store.frameFor("tab-a"), "data:image/jpeg;base64,current"); +}); + +test("freeze frame encoding bounds oversized JPEG output", () => { + const calls = []; + const image = { + getSize: () => ({ width: 2_000, height: 1_000 }), + toJPEG: (quality) => { + calls.push({ type: "jpeg", quality }); + return Buffer.alloc(quality === 70 ? 2 * 1024 * 1024 : 200_000, quality); + }, + resize: ({ width, height }) => { + calls.push({ type: "resize", width, height }); + return { + getSize: () => ({ width, height }), + toJPEG: image.toJPEG, + resize: image.resize + }; + } + }; + + const dataUrl = encodeBrowserCanvasFreezeFrame(image); + assert.match(dataUrl ?? "", /^data:image\/jpeg;base64,/); + assert.ok(calls.some((call) => call.type === "resize")); + assert.ok(Buffer.from((dataUrl ?? "").split(",")[1] ?? "", "base64").byteLength <= 1.5 * 1024 * 1024); +}); + +test("freeze frame encoding repeatedly shrinks incompressible captures", () => { + const makeImage = (width, height) => ({ + getSize: () => ({ width, height }), + toJPEG: () => Buffer.alloc(Math.ceil(width * height), 1), + resize: ({ width: nextWidth, height: nextHeight }) => makeImage(nextWidth, nextHeight) + }); + + const dataUrl = encodeBrowserCanvasFreezeFrame(makeImage(4_000, 3_000)); + assert.match(dataUrl ?? "", /^data:image\/jpeg;base64,/); + assert.ok(Buffer.from((dataUrl ?? "").split(",")[1] ?? "", "base64").byteLength <= 1.5 * 1024 * 1024); +}); diff --git a/tests/browser-canvas-integration.test.mjs b/tests/browser-canvas-integration.test.mjs index 2153049..f144de1 100644 --- a/tests/browser-canvas-integration.test.mjs +++ b/tests/browser-canvas-integration.test.mjs @@ -6,6 +6,7 @@ import { HOVER_FOCUS_DELAYS, shouldActivateCanvasFromClick } from "../src/render const browserCardPath = new URL("../src/renderer/src/features/browser/BrowserCard.tsx", import.meta.url); const browserServicePath = new URL("../src/main/services/BrowserService.ts", import.meta.url); const workspacePath = new URL("../src/renderer/src/features/workspace/WorkspaceCanvas.tsx", import.meta.url); +const focusHookPath = new URL("../src/renderer/src/features/workspace/useCanvasWidgetFocus.ts", import.meta.url); const iconPath = new URL("../src/renderer/src/components/UiIcon.tsx", import.meta.url); const stylesPath = new URL("../src/renderer/src/styles/app.css", import.meta.url); @@ -18,42 +19,37 @@ test("browser and terminal share canvas focus activation semantics", () => { assert.deepEqual(HOVER_FOCUS_DELAYS, { slow: 500, normal: 250, fast: 80 }); }); -test("browser native input participates in canvas selection and hover focus", async () => { - const [card, service, workspace] = await Promise.all([ +test("browser native input participates in selection and independent logical focus", async () => { + const [card, service, workspace, focusHook] = await Promise.all([ readFile(browserCardPath, "utf8"), readFile(browserServicePath, "utf8"), - readFile(workspacePath, "utf8") + readFile(workspacePath, "utf8"), + readFile(focusHookPath, "utf8") ]); assert.match(service, /contents\.on\("before-mouse-event"/); - assert.match(service, /if \(pointerType === "down"\) contents\.focus\(\)/); - assert.match(service, /IPC\.browserCanvasPointer/); - assert.match(service, /mouse\.type === "mouseMove" && this\.pointerTabId !== tab\.id/); + assert.match(service, /if \(pointerType === "down"\) \{[\s\S]*?contents\.focus\(\);[\s\S]*?this\.setInputFocused\(true\)/); assert.match(card, /window\.canvasTTY\.browser\.onCanvasPointer/); assert.match(card, /browser-card--selected/); assert.match(workspace, /selected=\{browserSelected\}/); assert.match(workspace, /focusActivation=\{settings\.focusActivation\}/); - assert.match(workspace, /hoverFocus=\{settings\.hoverFocus\}/); - assert.match(workspace, /closest\("\.terminal-card, \.browser-card"\)/); + assert.match(workspace, /focusController\.focusBrowser/); + assert.match(focusHook, /HOVER_FOCUS_DELAYS\[settingsRef\.current\.hoverFocusSpeed\]/); + assert.match(focusHook, /canvasWidgetFocusAfterClick/); }); -test("browser native view stays live while the canvas and card move", async () => { - const [card, service, workspace, styles] = await Promise.all([ +test("native Browser layout remains a BrowserService responsibility", async () => { + const [card, service, styles] = await Promise.all([ readFile(browserCardPath, "utf8"), readFile(browserServicePath, "utf8"), - readFile(workspacePath, "utf8"), readFile(stylesPath, "utf8") ]); assert.doesNotMatch(card, /canvasMoving|manipulating|browser-card__motion-surface/); - assert.doesNotMatch(workspace, /cameraMoving|canvasMoving=/); - assert.match(card, /\[camera\.x, camera\.y, nativeViewVisible, position, reportViewport/); - assert.doesNotMatch(card, /viewportFrame|requestAnimationFrame\(\(\) => \{\s*viewportFrame/); assert.match(card, /const rect = element\.getBoundingClientRect\(\);\s*const state = viewportState\.current;\s*window\.canvasTTY\.browser\.setViewport/); assert.match(service, /if \(this\.clipTabId !== active\.id\)/); assert.match(service, /this\.applyPageScale\(active\)/); assert.match(service, /contents\.setZoomFactor\(pageScale\)/); - assert.match(service, /contents\.on\("did-navigate",[\s\S]*?this\.applyPageScale\(tab\)/); assert.match(styles, /\.browser-card__viewport \{[^}]*inset: 140px 8px 8px;[^}]*background: #272934;/); }); @@ -80,5 +76,4 @@ test("browser window actions are separated from tab actions and use the Lucide g assert.match(card.slice(headerStart, tabsStart), /className="browser-card__hide"/); assert.doesNotMatch(card.slice(tabsStart, card.indexOf(" { + assert.deepEqual(browserCanvasDeviceEmulationParameters({ width: 830.4, height: 433.2 }), { + screenPosition: "desktop", + screenSize: { width: 830, height: 433 }, + viewPosition: { x: 0, y: 0 }, + deviceScaleFactor: 0, + viewSize: { width: 830, height: 433 }, + scale: 1 + }); + assert.deepEqual(browserCanvasDeviceEmulationParameters({ width: 0, height: -2 }), { + screenPosition: "desktop", + screenSize: { width: 1, height: 1 }, + viewPosition: { x: 0, y: 0 }, + deviceScaleFactor: 0, + viewSize: { width: 1, height: 1 }, + scale: 1 + }); +}); + +test("native sink preserves once and restores device emulation once", () => { + const state = harness(); + const controller = new BrowserCanvasSinkViewportController(state.contents); + + assert.equal(controller.preserve({ width: 830, height: 433 }), true); + assert.equal(controller.preserve({ width: 400, height: 300 }), true); + assert.equal(controller.restore(), true); + assert.equal(controller.restore(), true); + + assert.deepEqual(state.calls, [ + { type: "enable", parameters: browserCanvasDeviceEmulationParameters({ width: 830, height: 433 }) }, + { type: "disable" } + ]); +}); + +test("failed device emulation does not arm a native sink and may be retried", () => { + let attempts = 0; + const contents = { + isDestroyed: () => false, + enableDeviceEmulation() { + attempts += 1; + if (attempts === 1) throw new Error("unsupported"); + }, + disableDeviceEmulation() {} + }; + const controller = new BrowserCanvasSinkViewportController(contents); + + assert.equal(controller.preserve({ width: 830, height: 433 }), false); + assert.equal(controller.preserve({ width: 830, height: 433 }), true); + assert.equal(attempts, 2); +}); + +test("disposing an active native sink restores device emulation", () => { + const state = harness(); + const controller = new BrowserCanvasSinkViewportController(state.contents); + + assert.equal(controller.preserve({ width: 830, height: 433 }), true); + controller.dispose(); + controller.dispose(); + + assert.deepEqual(state.calls.map(({ type }) => type), ["enable", "disable"]); + assert.equal(controller.preserve({ width: 830, height: 433 }), false); +}); diff --git a/tests/browser-canvas-wheel.test.mjs b/tests/browser-canvas-wheel.test.mjs index d1ae972..d9e8582 100644 --- a/tests/browser-canvas-wheel.test.mjs +++ b/tests/browser-canvas-wheel.test.mjs @@ -1,14 +1,213 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { toCanvasWheelDeltaY } from "../src/main/services/browser/BrowserCanvasWheel.ts"; +import * as browserWheelModule from "../src/main/services/browser/BrowserCanvasWheel.ts"; -test("converts Electron positive-up wheel input to the DOM convention", () => { - assert.equal(toCanvasWheelDeltaY({ deltaY: 42 }), -42); - assert.equal(toCanvasWheelDeltaY({ deltaY: -42 }), 42); +const { + browserPageWheelClientPoint, + browserCanvasNavigationPointerType, + toCanvasPageWheelInput +} = browserWheelModule; + +test("native Browser wheel ownership follows focus, capture mode, overrides, and zoom intent", () => { + assert.equal(typeof browserWheelModule.browserWheelOwner, "function"); + const browserWheelOwner = browserWheelModule.browserWheelOwner; + const base = { + surface: "native", + focused: true, + captureMode: "off", + wheelOverrideActive: false, + canvasOverrideActive: false, + ctrlKey: false, + metaKey: false + }; + + assert.equal(browserWheelOwner(base), "page"); + assert.equal(browserWheelOwner({ ...base, captureMode: "key" }), "page"); + assert.equal(browserWheelOwner({ ...base, focused: false }), "canvas"); + assert.equal(browserWheelOwner({ ...base, captureMode: "always" }), "canvas"); + assert.equal(browserWheelOwner({ ...base, captureMode: "key", wheelOverrideActive: true }), "canvas"); + assert.equal(browserWheelOwner({ ...base, canvasOverrideActive: true }), "canvas"); + assert.equal(browserWheelOwner({ ...base, ctrlKey: true }), "canvas"); + assert.equal(browserWheelOwner({ ...base, metaKey: true }), "canvas"); + assert.equal(browserWheelOwner({ ...base, surface: "placeholder" }), "canvas"); + assert.equal(browserWheelOwner({ ...base, surface: "hidden" }), "canvas"); +}); + +test("Browser wheel ownership sequence latches one tab-scoped owner across frames until idle", () => { + assert.equal(typeof browserWheelModule.BrowserPageWheelSequence, "function"); + const sequence = new browserWheelModule.BrowserPageWheelSequence(); + + const first = sequence.decide("page", 1_000); + assert.deepEqual(first, { generation: 1, owner: "page" }); + // A new frame preload asks main again, but the shared tab sequence keeps the first owner. + assert.deepEqual(sequence.decide("canvas", 1_120), first); + assert.equal(sequence.touch(first.generation, 1_200), "page"); + assert.equal(sequence.touch(first.generation + 1, 1_210), null); + assert.deepEqual(sequence.decide("canvas", 1_451), { generation: 2, owner: "canvas" }); + + sequence.reset(); + assert.equal(sequence.touch(2, 1_452), null); + assert.deepEqual(sequence.decide("page", 1_453), { generation: 3, owner: "page" }); +}); + +test("browser page wheel input validates, normalizes delta modes, and clamps both axes", () => { + assert.deepEqual(toCanvasPageWheelInput({ + deltaX: 2, + deltaY: -3, + deltaMode: 1, + viewportWidth: 800, + viewportHeight: 600, + altKey: true, + ctrlKey: false, + metaKey: true, + shiftKey: false + }), { + deltaX: 32, + deltaY: -48, + ctrlKey: false, + metaKey: true + }); + assert.deepEqual(toCanvasPageWheelInput({ + deltaX: 2, + deltaY: -3, + deltaMode: 2, + viewportWidth: 800, + viewportHeight: 600, + altKey: false, + ctrlKey: true, + metaKey: false, + shiftKey: true + }), { + deltaX: 1_200, + deltaY: -1_200, + ctrlKey: true, + metaKey: false + }); +}); + +test("browser page pixel wheel preserves DOM direction and modifiers", () => { + assert.deepEqual(toCanvasPageWheelInput({ + deltaX: -12, + deltaY: 24, + deltaMode: 0, + viewportWidth: 800, + viewportHeight: 600, + altKey: false, + ctrlKey: true, + metaKey: false, + shiftKey: true + }), { + deltaX: -12, + deltaY: 24, + ctrlKey: true, + metaKey: false + }); +}); + +test("browser page wheel clamps pixel deltas on both axes", () => { + assert.deepEqual(toCanvasPageWheelInput({ + deltaX: 4_000, + deltaY: -4_000, + deltaMode: 0, + viewportWidth: 800, + viewportHeight: 600, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false + }), { + deltaX: 1_200, + deltaY: -1_200, + ctrlKey: false, + metaKey: false + }); +}); + +test("browser page wheel input fails closed for malformed, unsupported, and zero payloads", () => { + const valid = { + deltaX: 0, + deltaY: 1, + deltaMode: 0, + viewportWidth: 800, + viewportHeight: 600, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false + }; + assert.equal(toCanvasPageWheelInput({ ...valid, deltaMode: 3 }), null); + assert.equal(toCanvasPageWheelInput({ ...valid, deltaY: Number.NaN }), null); + assert.equal(toCanvasPageWheelInput({ ...valid, ctrlKey: "false" }), null); + assert.equal(toCanvasPageWheelInput({ ...valid, deltaY: 0 }), null); +}); + +test("browser page wheel rejects invalid page viewport dimensions", () => { + const input = { + deltaX: 0, + deltaY: 1, + deltaMode: 2, + viewportWidth: 800, + viewportHeight: 600, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false + }; + assert.equal(toCanvasPageWheelInput({ ...input, viewportWidth: 0 }), null); + assert.equal(toCanvasPageWheelInput({ ...input, viewportHeight: -1 }), null); +}); + +test("Browser wheel point prefers screen coordinates over scale-distorted page coordinates", () => { + assert.deepEqual(browserPageWheelClientPoint({ + screenX: 861, + screenY: 486, + clientX: 395, + clientY: 167, + topFrame: true, + viewportWidth: 902, + viewportHeight: 470 + }, { + ownerScreenBounds: { x: 180, y: 71, width: 1_440, height: 900 }, + viewport: { x: 318, y: 261, width: 830, height: 433 } + }), { x: 681, y: 415 }); +}); + +test("Browser wheel point scales a top-frame fallback into owner DIP coordinates", () => { + const point = browserPageWheelClientPoint({ + screenX: 0, + screenY: 0, + clientX: 395, + clientY: 167, + topFrame: true, + viewportWidth: 902, + viewportHeight: 470 + }, { + ownerScreenBounds: { x: 180, y: 71, width: 1_440, height: 900 }, + viewport: { x: 318, y: 261, width: 830, height: 433 } + }); + + assert.ok(point); + assert.ok(Math.abs(point.x - 681.470066518847) < 0.000_001); + assert.ok(Math.abs(point.y - 414.85319148936173) < 0.000_001); + assert.equal(browserPageWheelClientPoint({ + screenX: 0, + screenY: 0, + clientX: 10, + clientY: 10, + topFrame: false, + viewportWidth: 902, + viewportHeight: 470 + }, { + ownerScreenBounds: { x: 180, y: 71, width: 1_440, height: 900 }, + viewport: { x: 318, y: 261, width: 830, height: 433 } + }), null); }); -test("falls back to wheel ticks and bounds native browser deltas", () => { - assert.equal(toCanvasWheelDeltaY({ deltaY: 0, wheelTicksY: 1 }), -100); - assert.equal(toCanvasWheelDeltaY({ deltaY: 4_000 }), -1_200); - assert.equal(toCanvasWheelDeltaY({ deltaY: Number.NaN }), null); +test("native Browser override drag stays canvas-owned after the chord is released", () => { + assert.equal(browserCanvasNavigationPointerType({ type: "mouseDown", button: "left" }, true, false), "down"); + assert.equal(browserCanvasNavigationPointerType({ type: "mouseEnter" }, false, true), "move"); + assert.equal(browserCanvasNavigationPointerType({ type: "mouseMove" }, false, true), "move"); + assert.equal(browserCanvasNavigationPointerType({ type: "mouseUp", button: "left" }, false, true), "up"); + assert.equal(browserCanvasNavigationPointerType({ type: "mouseLeave" }, false, true), null); + assert.equal(browserCanvasNavigationPointerType({ type: "mouseDown", button: "left" }, false, false), null); }); diff --git a/tests/browser-viewport.test.mjs b/tests/browser-viewport.test.mjs index 7779dbf..843e5a7 100644 --- a/tests/browser-viewport.test.mjs +++ b/tests/browser-viewport.test.mjs @@ -8,18 +8,16 @@ test("browser viewport expands fractional edges instead of exposing compositor g y: 20.6, width: 100.2, height: 50.1, - visible: true, + surface: "native", canvasScale: 0.92, - captureCanvasWheel: true, showAgentPresence: true }), { x: 10, y: 20, width: 101, height: 51, - visible: true, + surface: "native", canvasScale: 0.92, - captureCanvasWheel: true, showAgentPresence: true }); }); @@ -32,15 +30,13 @@ test("browser viewport rejects invalid geometry and clamps negative sizes", () = y: 3.8, width: -20, height: -10, - visible: "yes", - captureCanvasWheel: 1 + surface: "hidden" }), { x: -3, y: 3, width: 0, height: 0, - visible: false, - captureCanvasWheel: false, + surface: "hidden", showAgentPresence: false }); }); @@ -51,7 +47,7 @@ test("browser viewport keeps page scaling inside Chromium's supported range", () y: 0, width: 400, height: 300, - visible: false, + surface: "placeholder", canvasScale: 0.2 })?.canvasScale, 0.5); assert.equal(normalizeBrowserViewportBounds({ @@ -59,7 +55,17 @@ test("browser viewport keeps page scaling inside Chromium's supported range", () y: 0, width: 400, height: 300, - visible: true, + surface: "native", canvasScale: 8 })?.canvasScale, 3); }); + +test("browser viewport rejects invalid surface state", () => { + assert.equal(normalizeBrowserViewportBounds({ + x: 0, + y: 0, + width: 400, + height: 300, + surface: "invalid" + }), null); +}); diff --git a/tests/canvas-navigation-override.test.mjs b/tests/canvas-navigation-override.test.mjs new file mode 100644 index 0000000..5884b91 --- /dev/null +++ b/tests/canvas-navigation-override.test.mjs @@ -0,0 +1,305 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; +import { + CanvasNavigationInputController, + CanvasNavigationOverrideTracker, + shouldPreventCanvasNavigationInput +} from "../src/main/services/CanvasNavigationOverride.ts"; + +const input = (type, key, modifiers = {}) => ({ + type, + key, + code: key === " " ? "Space" : key, + alt: false, + control: false, + meta: false, + shift: false, + ...modifiers +}); + +test("modifier-only override activates immediately and permits extra zoom modifiers", () => { + const tracker = new CanvasNavigationOverrideTracker("Alt"); + assert.deepEqual(tracker.update(input("keyDown", "Alt", { alt: true })), { + active: true, + changed: true, + reserved: true + }); + assert.deepEqual(tracker.update(input("keyDown", "Meta", { alt: true, meta: true })), { + active: true, + changed: false, + reserved: false + }); + assert.deepEqual(tracker.update(input("keyUp", "Alt", { meta: true })), { + active: false, + changed: true, + reserved: true + }); +}); + +test("owned modifiers keep their keyup observable while owned ordinary keys are prevented", () => { + assert.equal(shouldPreventCanvasNavigationInput(input("keyDown", "Alt"), { + active: true, + changed: true, + reserved: true + }), false); + assert.equal(shouldPreventCanvasNavigationInput(input("keyDown", " "), { + active: true, + changed: true, + reserved: true + }), true); +}); + +test("controller tracks wheel-only and full overrides independently without hiding modifier keyup", () => { + const contents = new EventEmitter(); + const ignored = []; + contents.isDestroyed = () => false; + contents.setIgnoreMenuShortcuts = (active) => ignored.push(active); + const activeStates = []; + const controller = new CanvasNavigationInputController({ + wheelBinding: "Meta", + navigationBinding: "Alt" + }, (state) => activeStates.push(state)); + controller.attach(contents); + + let prevented = false; + contents.emit("before-input-event", { preventDefault: () => { prevented = true; } }, input( + "keyDown", + "Meta", + { meta: true } + )); + assert.equal(prevented, false); + assert.deepEqual(ignored, []); + + contents.emit("before-input-event", { preventDefault: () => { prevented = true; } }, input( + "keyDown", + "h", + { code: "KeyH", meta: true } + )); + assert.equal(prevented, false); + + contents.emit("before-input-event", { preventDefault: () => { prevented = true; } }, input( + "keyUp", + "Meta" + )); + assert.equal(prevented, false); + assert.deepEqual(ignored, []); + assert.deepEqual(activeStates, [ + { wheelActive: true, navigationActive: false }, + { wheelActive: false, navigationActive: false } + ]); +}); + +test("standalone Alt and ordinary-key chords retain menu shortcut capture", () => { + const contents = new EventEmitter(); + const ignored = []; + contents.isDestroyed = () => false; + contents.setIgnoreMenuShortcuts = (active) => ignored.push(active); + const controller = new CanvasNavigationInputController({ + wheelBinding: null, + navigationBinding: "Alt" + }, () => undefined); + controller.attach(contents); + + contents.emit("before-input-event", { preventDefault: () => undefined }, input( + "keyDown", + "Alt", + { alt: true } + )); + contents.emit("before-input-event", { preventDefault: () => undefined }, input("keyUp", "Alt")); + assert.deepEqual(ignored, [true, false]); + + controller.setBindings({ wheelBinding: "Meta+Space", navigationBinding: null }); + contents.emit("before-input-event", { preventDefault: () => undefined }, input( + "keyDown", + "Meta", + { meta: true } + )); + contents.emit("before-input-event", { preventDefault: () => undefined }, input( + "keyDown", + " ", + { meta: true } + )); + contents.emit("before-input-event", { preventDefault: () => undefined }, input("keyUp", "Meta")); + assert.deepEqual(ignored, [true, false, true, false]); +}); + +test("modifier-only Meta full override does not swallow ordinary Command shortcuts", () => { + const contents = new EventEmitter(); + contents.isDestroyed = () => false; + contents.setIgnoreMenuShortcuts = () => undefined; + const controller = new CanvasNavigationInputController({ + wheelBinding: null, + navigationBinding: "Meta" + }, () => undefined); + controller.attach(contents); + + let prevented = false; + contents.emit("before-input-event", { preventDefault: () => { prevented = true; } }, input( + "keyDown", + "Meta", + { meta: true } + )); + assert.equal(controller.active, true); + assert.equal(prevented, false); + contents.emit("before-input-event", { preventDefault: () => { prevented = true; } }, input( + "keyDown", + "c", + { code: "KeyC", meta: true } + )); + assert.equal(prevented, false); +}); + +test("full override remains independent when both bindings are held", () => { + const contents = new EventEmitter(); + contents.isDestroyed = () => false; + contents.setIgnoreMenuShortcuts = () => undefined; + const states = []; + const controller = new CanvasNavigationInputController({ + wheelBinding: "Meta", + navigationBinding: "Alt" + }, (state) => states.push(state)); + controller.attach(contents); + + contents.emit("before-input-event", { preventDefault: () => undefined }, input( + "keyDown", + "Meta", + { meta: true } + )); + contents.emit("before-input-event", { preventDefault: () => undefined }, input( + "keyDown", + "Alt", + { meta: true, alt: true } + )); + contents.emit("before-input-event", { preventDefault: () => undefined }, input( + "keyUp", + "Meta", + { alt: true } + )); + + assert.deepEqual(states, [ + { wheelActive: true, navigationActive: false }, + { wheelActive: true, navigationActive: true }, + { wheelActive: false, navigationActive: true } + ]); + assert.equal(controller.wheelActive, false); + assert.equal(controller.active, true); +}); + +test("identical bindings activate both modes and full navigation remains available", () => { + const contents = new EventEmitter(); + contents.isDestroyed = () => false; + contents.setIgnoreMenuShortcuts = () => undefined; + const states = []; + const controller = new CanvasNavigationInputController({ + wheelBinding: "Alt", + navigationBinding: "Alt" + }, (state) => states.push(state)); + controller.attach(contents); + contents.emit("before-input-event", { preventDefault: () => undefined }, input( + "keyDown", + "Alt", + { alt: true } + )); + assert.deepEqual(states, [{ wheelActive: true, navigationActive: true }]); + assert.equal(controller.wheelActive, true); + assert.equal(controller.active, true); + controller.reset(); + assert.deepEqual(states.at(-1), { wheelActive: false, navigationActive: false }); +}); + +test("modifier keyup clears the released modifier even when Electron keeps it in the snapshot", () => { + const tracker = new CanvasNavigationOverrideTracker("Alt"); + tracker.update(input("keyDown", "Alt", { alt: true })); + assert.deepEqual(tracker.update(input("keyUp", "Alt", { alt: true })), { + active: false, + changed: true, + reserved: true + }); +}); + +test("modifier-key chord reserves its prefix and activates only when complete", () => { + const tracker = new CanvasNavigationOverrideTracker("Alt+Space"); + assert.deepEqual(tracker.update(input("keyDown", "Alt", { alt: true })), { + active: false, + changed: false, + reserved: true + }); + assert.deepEqual(tracker.update(input("keyDown", " ", { alt: true })), { + active: true, + changed: true, + reserved: true + }); + assert.deepEqual(tracker.update(input("keyUp", " ", { alt: true })), { + active: false, + changed: true, + reserved: true + }); +}); + +test("modifier-key chord does not reserve or arm its ordinary key without the modifiers", () => { + const tracker = new CanvasNavigationOverrideTracker("Alt+Space"); + assert.deepEqual(tracker.update(input("keyDown", " ")), { + active: false, + changed: false, + reserved: false + }); + assert.deepEqual(tracker.update(input("keyDown", "Alt", { alt: true })), { + active: false, + changed: false, + reserved: true + }); + assert.deepEqual(tracker.update(input("keyUp", " ", { alt: true })), { + active: false, + changed: false, + reserved: false + }); +}); + +test("an owned ordinary chord key remains reserved through keyup after modifier release", () => { + const tracker = new CanvasNavigationOverrideTracker("Alt+Space"); + tracker.update(input("keyDown", "Alt", { alt: true })); + tracker.update(input("keyDown", " ", { alt: true })); + tracker.update(input("keyUp", "Alt")); + assert.deepEqual(tracker.update(input("keyUp", " ")), { + active: false, + changed: false, + reserved: true + }); +}); + +test("ordinary chord keys use the physical key code across layouts", () => { + const tracker = new CanvasNavigationOverrideTracker("Alt+K"); + tracker.update(input("keyDown", "Alt", { alt: true })); + assert.deepEqual(tracker.update(input("keyDown", "˚", { code: "KeyK", alt: true })), { + active: true, + changed: true, + reserved: true + }); +}); + +test("blur reset and shortcut capture suspension clear active state", () => { + const tracker = new CanvasNavigationOverrideTracker("Alt"); + tracker.update(input("keyDown", "Alt", { alt: true })); + assert.deepEqual(tracker.reset(), { active: false, changed: true, reserved: false }); + + tracker.setSuspended(true); + assert.deepEqual(tracker.update(input("keyDown", "Alt", { alt: true })), { + active: false, + changed: false, + reserved: false + }); + tracker.setSuspended(false); + assert.equal(tracker.active, false); +}); + +test("changing a binding resets pressed state instead of activating it retroactively", () => { + const tracker = new CanvasNavigationOverrideTracker("Alt"); + tracker.update(input("keyDown", "Alt", { alt: true })); + assert.deepEqual(tracker.setBinding("Ctrl+Alt"), { + active: false, + changed: true, + reserved: false + }); + assert.equal(tracker.active, false); +}); diff --git a/tests/canvas-navigation-shortcut.test.mjs b/tests/canvas-navigation-shortcut.test.mjs new file mode 100644 index 0000000..cb7a175 --- /dev/null +++ b/tests/canvas-navigation-shortcut.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + activeCanvasWheelBinding, + canvasOverrideBindingConflicts, + isCanvasNavigationBindingActive, + normalizeCanvasOverrideBinding, + normalizeCanvasNavigationInputKey, + parseCanvasNavigationBinding, + shouldCanvasOwnWheel +} from "../src/shared/canvasNavigation.ts"; + +test("accepts modifier-only and modifier-based chords but rejects bare keys", () => { + assert.deepEqual(parseCanvasNavigationBinding("Alt"), { modifiers: ["Alt"], key: null }); + assert.deepEqual(parseCanvasNavigationBinding("Alt+Ctrl+Space"), { + modifiers: ["Ctrl", "Alt"], + key: "Space" + }); + assert.equal(parseCanvasNavigationBinding("Space"), null); +}); + +test("wheel capture modes keep Off, On, Key, and full navigation ownership distinct", () => { + assert.equal(activeCanvasWheelBinding("off", "Meta"), null); + assert.equal(activeCanvasWheelBinding("always", "Meta"), null); + assert.equal(activeCanvasWheelBinding("key", "Meta"), "Meta"); + const ownership = (captureMode, wheelOverrideActive, navigationOverrideActive) => shouldCanvasOwnWheel({ + overFocusedWidget: true, + captureMode, + wheelOverrideActive, + navigationOverrideActive + }); + assert.equal(ownership("off", true, false), false); + assert.equal(ownership("always", false, false), true); + assert.equal(ownership("key", false, false), false); + assert.equal(ownership("key", true, false), true); + assert.equal(ownership("off", false, true), true); +}); + +test("normalizes order and accepts the platform zoom modifier by itself", () => { + assert.equal(normalizeCanvasOverrideBinding("Alt+Ctrl"), "Ctrl+Alt"); + assert.equal(normalizeCanvasOverrideBinding("Meta"), "Meta"); + assert.equal(normalizeCanvasOverrideBinding("Ctrl"), "Ctrl"); + assert.equal(normalizeCanvasOverrideBinding("Ctrl+Alt"), "Ctrl+Alt"); +}); + +test("extra modifiers keep a configured override active", () => { + assert.equal(isCanvasNavigationBindingActive({ + altKey: true, + ctrlKey: false, + metaKey: true, + shiftKey: false + }, "Alt"), true); + assert.equal(isCanvasNavigationBindingActive({ + altKey: true, + ctrlKey: true, + metaKey: false, + shiftKey: false, + pressedKeys: new Set(["Space"]) + }, "Alt+Space"), true); +}); + +test("modifier-only overrides coexist with action shortcuts while ordinary chords conflict", () => { + assert.equal(canvasOverrideBindingConflicts("Alt", "Alt+R"), false); + assert.equal(canvasOverrideBindingConflicts("Alt+K", "Ctrl+Alt+K"), true); + assert.equal(canvasOverrideBindingConflicts("Alt+K", "Alt+R"), false); + assert.equal(canvasOverrideBindingConflicts("Meta", "Meta+H"), false); + assert.equal(canvasOverrideBindingConflicts("Meta+Space", "Meta+Space"), true); + assert.equal(normalizeCanvasOverrideBinding("Alt", ["Alt+R"]), "Alt"); + assert.equal(normalizeCanvasOverrideBinding("Meta+H", ["Meta+H"]), null); +}); + +test("normalizes ordinary chord keys by physical code across keyboard layouts", () => { + assert.equal(normalizeCanvasNavigationInputKey("ф", "KeyA"), "A"); + assert.equal(normalizeCanvasNavigationInputKey("˚", "KeyK"), "K"); + assert.equal(normalizeCanvasNavigationInputKey(" ", "Space"), "Space"); +}); diff --git a/tests/canvas-navigation.test.mjs b/tests/canvas-navigation.test.mjs new file mode 100644 index 0000000..96d315f --- /dev/null +++ b/tests/canvas-navigation.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + canvasWheelIntent, + normalizeCanvasWheelDeltas, + shouldCanvasOwnWheel +} from "../src/shared/canvasNavigation.ts"; + +const settings = { + invertCanvasWheel: false, + useScrollWheelToZoom: false, + zoomSensitivity: "normal" +}; + +test("normalizes pixel, line, and page wheel deltas on both axes", () => { + assert.deepEqual(normalizeCanvasWheelDeltas(2, -3, 0, { width: 800, height: 600 }), { + deltaX: 2, + deltaY: -3 + }); + assert.deepEqual(normalizeCanvasWheelDeltas(2, -3, 1, { width: 800, height: 600 }), { + deltaX: 32, + deltaY: -48 + }); + assert.deepEqual(normalizeCanvasWheelDeltas(2, -3, 2, { width: 800, height: 600 }), { + deltaX: 1_600, + deltaY: -1_800 + }); +}); + +test("ordinary scroll pans in screen space and inversion flips both axes", () => { + assert.deepEqual( + canvasWheelIntent({ deltaX: 12.5, deltaY: -8 }, { ctrlKey: false, metaKey: false }, settings), + { kind: "pan", deltaX: 12.5, deltaY: -8 } + ); + assert.deepEqual( + canvasWheelIntent( + { deltaX: 12.5, deltaY: -8 }, + { ctrlKey: false, metaKey: false }, + { ...settings, invertCanvasWheel: true } + ), + { kind: "pan", deltaX: -12.5, deltaY: 8 } + ); +}); + +test("plain wheel zoom retains legacy sensitivity and direction", () => { + const intent = canvasWheelIntent( + { deltaX: 40, deltaY: 100 }, + { ctrlKey: false, metaKey: false }, + { ...settings, useScrollWheelToZoom: true } + ); + assert.equal(intent.kind, "zoom"); + assert.equal(intent.source, "wheel"); + assert.ok(Math.abs(intent.factor - Math.exp(-0.12)) < 1e-12); +}); + +test("ctrl or meta wheel uses clamped pinch mapping independently of wheel preferences", () => { + const zoomSettings = { + invertCanvasWheel: true, + useScrollWheelToZoom: true, + zoomSensitivity: "fast" + }; + assert.deepEqual( + canvasWheelIntent({ deltaX: 0, deltaY: 100 }, { ctrlKey: true, metaKey: false }, zoomSettings), + { kind: "zoom", factor: 0.75, source: "modifier" } + ); + const meta = canvasWheelIntent( + { deltaX: 0, deltaY: -10 }, + { ctrlKey: false, metaKey: true }, + zoomSettings + ); + assert.equal(meta.kind, "zoom"); + assert.ok(Math.abs(meta.factor - Math.exp(0.1)) < 1e-12); +}); + +test("a wheel override can route modifier scroll to focal zoom over a widget", () => { + assert.equal(shouldCanvasOwnWheel({ + overFocusedWidget: true, + captureMode: "key", + wheelOverrideActive: true, + navigationOverrideActive: false + }), true); + assert.deepEqual( + canvasWheelIntent( + { deltaX: 0, deltaY: 100 }, + { ctrlKey: false, metaKey: true }, + settings + ), + { kind: "zoom", factor: 0.75, source: "modifier" } + ); +}); + +test("only the focused input widget interrupts canvas wheel navigation", () => { + assert.equal(shouldCanvasOwnWheel({ + overFocusedWidget: false, + captureMode: "off", + wheelOverrideActive: false, + navigationOverrideActive: false + }), true); + assert.equal(shouldCanvasOwnWheel({ + overFocusedWidget: true, + captureMode: "off", + wheelOverrideActive: false, + navigationOverrideActive: false + }), false); + assert.equal(shouldCanvasOwnWheel({ + overFocusedWidget: true, + captureMode: "off", + wheelOverrideActive: false, + navigationOverrideActive: true + }), true); + assert.equal(shouldCanvasOwnWheel({ + overFocusedWidget: true, + captureMode: "always", + wheelOverrideActive: false, + navigationOverrideActive: false + }), true); + assert.equal(shouldCanvasOwnWheel({ + overFocusedWidget: true, + captureMode: "key", + wheelOverrideActive: true, + navigationOverrideActive: false + }), true); +}); diff --git a/tests/canvas-widget-focus.test.mjs b/tests/canvas-widget-focus.test.mjs new file mode 100644 index 0000000..1250d2e --- /dev/null +++ b/tests/canvas-widget-focus.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + browserCanvasWidgetId, + canvasWidgetFocusAfterClick, + homeCanvasWidgetId, + pluginCanvasWidgetId, + terminalCanvasWidgetId +} from "../src/renderer/src/features/workspace/canvasWidgetFocus.ts"; + +test("canvas widget ids are stable across each focusable input surface", () => { + assert.equal(terminalCanvasWidgetId("pty-1"), "terminal:pty-1"); + assert.equal(pluginCanvasWidgetId("canvas-1"), "plugin-canvas:canvas-1"); + assert.equal(homeCanvasWidgetId("core.sessions"), "home:core.sessions"); + assert.equal(browserCanvasWidgetId, "browser"); +}); + +test("only a click outside every widget clears logical input focus", () => { + assert.equal(canvasWidgetFocusAfterClick("terminal:one", { + isWidget: true, + focusableWidgetId: "browser" + }), "browser"); + assert.equal(canvasWidgetFocusAfterClick("terminal:one", { + isWidget: true, + focusableWidgetId: null + }), "terminal:one"); + assert.equal(canvasWidgetFocusAfterClick("terminal:one", { + isWidget: false, + focusableWidgetId: null + }), null); +}); diff --git a/tests/plugin-input-bridge.test.mjs b/tests/plugin-input-bridge.test.mjs new file mode 100644 index 0000000..87dae5b --- /dev/null +++ b/tests/plugin-input-bridge.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + pluginCanvasFocusInput, + pluginCanvasWheelInput +} from "../src/renderer/src/features/plugins/pluginInputBridge.ts"; + +const frame = { + left: 100, + top: 50, + width: 600, + height: 400, + layoutWidth: 600, + layoutHeight: 400 +}; +const message = { + source: "canvastty-plugin", + type: "canvas-wheel", + clientX: 20, + clientY: 30, + deltaX: 2, + deltaY: -3, + deltaMode: 1, + ctrlKey: true, + metaKey: false, + altKey: true, + shiftKey: false +}; + +test("validates and converts plugin wheel relay data to bounded host pixels", () => { + assert.deepEqual(pluginCanvasWheelInput(message, frame), { + clientX: 120, + clientY: 80, + deltaX: 32, + deltaY: -48, + ctrlKey: true, + metaKey: false, + altKey: true, + shiftKey: false + }); + assert.deepEqual(pluginCanvasWheelInput({ ...message, deltaX: 10_000, deltaMode: 0 }, frame), { + clientX: 120, + clientY: 80, + deltaX: 1_200, + deltaY: -3, + ctrlKey: true, + metaKey: false, + altKey: true, + shiftKey: false + }); +}); + +test("rejects spoofable malformed or out-of-frame plugin messages", () => { + assert.equal(pluginCanvasWheelInput({ ...message, source: "plugin" }, frame), null); + assert.equal(pluginCanvasWheelInput({ ...message, clientX: 700 }, frame), null); + assert.equal(pluginCanvasWheelInput({ ...message, deltaY: Number.NaN }, frame), null); + assert.equal(pluginCanvasWheelInput({ ...message, ctrlKey: "yes" }, frame), null); + assert.equal(pluginCanvasWheelInput({ ...message, deltaMode: 3 }, frame), null); + assert.equal(pluginCanvasWheelInput({ ...message, injected: true }, frame), null); +}); + +test("maps iframe layout coordinates through the canvas visual transform", () => { + const transformedFrame = { + left: 100, + top: 50, + width: 300, + height: 200, + layoutWidth: 600, + layoutHeight: 400 + }; + assert.deepEqual(pluginCanvasWheelInput({ + ...message, + clientX: 500, + clientY: 300, + deltaMode: 0 + }, transformedFrame), { + clientX: 350, + clientY: 200, + deltaX: 2, + deltaY: -3, + ctrlKey: true, + metaKey: false, + altKey: true, + shiftKey: false + }); +}); + +test("accepts only exact plugin focus and hover bridge messages", () => { + assert.deepEqual(pluginCanvasFocusInput({ + source: "canvastty-plugin", + type: "canvas-focus" + }), { type: "focus" }); + assert.deepEqual(pluginCanvasFocusInput({ + source: "canvastty-plugin", + type: "canvas-hover", + active: true + }), { type: "hover", active: true }); + assert.deepEqual(pluginCanvasFocusInput({ + source: "canvastty-plugin", + type: "canvas-hover", + active: false + }), { type: "hover", active: false }); + assert.equal(pluginCanvasFocusInput({ + source: "canvastty-plugin", + type: "canvas-focus", + injected: true + }), null); + assert.equal(pluginCanvasFocusInput({ + source: "canvastty-plugin", + type: "canvas-hover", + active: "yes" + }), null); +}); diff --git a/tests/plugin-manager.test.mjs b/tests/plugin-manager.test.mjs index 6d3cdf1..77d0fe3 100644 --- a/tests/plugin-manager.test.mjs +++ b/tests/plugin-manager.test.mjs @@ -9,10 +9,20 @@ import { PluginManager, downloadGithubRepository, extractGithubTarball, + injectPluginInputBridge, normalizeGithubUrl, validatePluginManifest } from "../src/main/services/PluginManager.ts"; +test("injects one trusted input bridge before plugin scripts", () => { + const html = ""; + const injected = injectPluginInputBridge(html); + const bridge = "canvastty-plugin://host/input-bridge.js"; + assert.equal(injected.split(bridge).length - 1, 1); + assert.ok(injected.indexOf(bridge) < injected.indexOf("plugin.js")); + assert.equal(injectPluginInputBridge(injected), injected); +}); + const manifest = { apiVersion: 1, id: "com.example.studio-clock", @@ -132,6 +142,17 @@ test("previews, installs, serves, stores, disables, and uninstalls a static pack assert.equal(installed.enabled, true); assert.equal(manager.list().length, 1); + const inputBridge = await manager.protocolResponse("canvastty-plugin://host/input-bridge.js"); + assert.equal(inputBridge.status, 200); + const inputBridgeSource = await inputBridge.text(); + assert.match(inputBridgeSource, /addEventListener\("wheel"/); + assert.match(inputBridgeSource, /addEventListener\("pointerdown"/); + assert.match(inputBridgeSource, /type: "canvas-focus"/); + assert.match(inputBridgeSource, /type: "canvas-hover", active: true/); + assert.match(inputBridgeSource, /type: "canvas-hover", active: false/); + const pointerStart = inputBridgeSource.indexOf('addEventListener("pointerdown"'); + assert.doesNotMatch(inputBridgeSource.slice(pointerStart), /event\.preventDefault\(\)/); + await manager.storageSet(installed.manifest.id, "draft", { text: "real storage" }); assert.deepEqual(await manager.storageGet(installed.manifest.id, "draft"), { text: "real storage" }); @@ -139,7 +160,9 @@ test("previews, installs, serves, stores, disables, and uninstalls a static pack "canvastty-plugin://com.example.studio-kit/widgets/status.html" ); assert.equal(asset.status, 200); - assert.match(await asset.text(), /Session status/); + const assetHtml = await asset.text(); + assert.match(assetHtml, /Session status/); + assert.match(assetHtml, /canvastty-plugin:\/\/host\/input-bridge\.js/); assert.match(asset.headers.get("content-security-policy"), /connect-src 'none'/); const sdk = await manager.protocolResponse("canvastty-plugin://host/sdk.js"); @@ -542,7 +565,7 @@ test("setModules keeps the previous module set when activation fails", async () assert.deepEqual(installed.selectedModules, ["one"]); const oneAsset = await manager.protocolResponse("canvastty-plugin://com.example.rollback/one.html"); assert.equal(oneAsset.status, 200); - assert.equal(await oneAsset.text(), one.toString("utf8")); + assert.match(await oneAsset.text(), /

Module one<\/h1>/); // Integrity failure while activating a new selection: the old set survives. served.set("two.html", tamperedTwo); @@ -553,7 +576,7 @@ test("setModules keeps the previous module set when activation fails", async () assert.deepEqual(manager.list()[0].selectedModules, ["one"]); const intactAsset = await manager.protocolResponse("canvastty-plugin://com.example.rollback/one.html"); assert.equal(intactAsset.status, 200); - assert.equal(await intactAsset.text(), one.toString("utf8")); + assert.match(await intactAsset.text(), /

Module one<\/h1>/); assert.equal( (await manager.protocolResponse("canvastty-plugin://com.example.rollback/two.html")).status, 404 @@ -581,7 +604,7 @@ test("setModules keeps the previous module set when activation fails", async () "canvastty-plugin://com.example.rollback/one.html" ); assert.equal(reloadedAsset.status, 200); - assert.equal(await reloadedAsset.text(), one.toString("utf8")); + assert.match(await reloadedAsset.text(), /

Module one<\/h1>/); } finally { await reloaded.dispose(); } diff --git a/tests/settings-normalizer.test.mjs b/tests/settings-normalizer.test.mjs index dbcb285..a8586b8 100644 --- a/tests/settings-normalizer.test.mjs +++ b/tests/settings-normalizer.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -20,7 +20,10 @@ const fallback = { edgePan: true, edgePanSpeed: "normal", zoomSensitivity: "normal", - zoomOverApplications: false, + canvasWheelCaptureMode: "key", + useScrollWheelToZoom: false, + canvasWheelOverride: "Meta", + canvasNavigationOverride: "Alt", focusActivation: "off", hoverFocus: false, hoverFocusSpeed: "normal", @@ -50,7 +53,7 @@ test("keeps valid wheel, edge pan, zoom, and focus values", () => { edgePan: false, edgePanSpeed: "fast", zoomSensitivity: "slow", - zoomOverApplications: true, + canvasWheelCaptureMode: "always", hoverFocus: true, hoverFocusSpeed: "fast" }, @@ -61,7 +64,7 @@ test("keeps valid wheel, edge pan, zoom, and focus values", () => { assert.equal(normalized.edgePan, false); assert.equal(normalized.edgePanSpeed, "fast"); assert.equal(normalized.zoomSensitivity, "slow"); - assert.equal(normalized.zoomOverApplications, true); + assert.equal(normalized.canvasWheelCaptureMode, "always"); assert.equal(normalized.hoverFocus, true); assert.equal(normalized.hoverFocusSpeed, "fast"); }); @@ -74,7 +77,7 @@ test("falls back when edge pan and zoom values are garbage", () => { assert.equal(normalized.edgePan, fallback.edgePan); assert.equal(normalized.edgePanSpeed, fallback.edgePanSpeed); assert.equal(normalized.zoomSensitivity, fallback.zoomSensitivity); - assert.equal(normalized.zoomOverApplications, fallback.zoomOverApplications); + assert.equal(normalized.canvasWheelCaptureMode, fallback.canvasWheelCaptureMode); assert.equal(normalized.invertTerminalWheel, fallback.invertTerminalWheel); assert.equal(normalized.invertCanvasWheel, fallback.invertCanvasWheel); assert.equal(normalized.focusActivation, fallback.focusActivation); @@ -91,7 +94,7 @@ test("older settings files without the new keys inherit defaults", () => { assert.equal(normalized.edgePan, fallback.edgePan); assert.equal(normalized.edgePanSpeed, fallback.edgePanSpeed); assert.equal(normalized.zoomSensitivity, fallback.zoomSensitivity); - assert.equal(normalized.zoomOverApplications, fallback.zoomOverApplications); + assert.equal(normalized.canvasWheelCaptureMode, fallback.canvasWheelCaptureMode); assert.equal(normalized.invertTerminalWheel, fallback.invertTerminalWheel); assert.equal(normalized.invertCanvasWheel, fallback.invertCanvasWheel); assert.equal(normalized.hoverFocus, fallback.hoverFocus); @@ -103,7 +106,7 @@ test("a non-object candidate yields the fallback wholesale", () => { assert.equal(normalizeSettings("settings", fallback), fallback); }); -test("fresh installs keep edge automation off but allow escaping applications with canvas zoom", async () => { +test("fresh installs default to scroll pan and key-gated widget wheel input", async () => { const dir = await mkdtemp(join(tmpdir(), "canvastty-settings-")); try { const store = new SettingsStore(dir, "en"); @@ -111,7 +114,10 @@ test("fresh installs keep edge automation off but allow escaping applications wi assert.equal(store.get().edgePan, false); assert.equal(store.get().edgePanSpeed, "normal"); assert.equal(store.get().zoomSensitivity, "normal"); - assert.equal(store.get().zoomOverApplications, true); + assert.equal(store.get().canvasWheelCaptureMode, "key"); + assert.equal(store.get().useScrollWheelToZoom, false); + assert.equal(store.get().canvasWheelOverride, process.platform === "darwin" ? "Meta" : "Ctrl"); + assert.equal(store.get().canvasNavigationOverride, "Alt"); assert.equal(store.get().invertTerminalWheel, true); assert.equal(store.get().invertCanvasWheel, false); assert.equal(store.get().focusActivation, "off"); @@ -124,6 +130,165 @@ test("fresh installs keep edge automation off but allow escaping applications wi assert.equal(store.get().browserShowAgentPresence, true); assert.equal(store.get().browserRestoreTabs, true); assert.deepEqual(store.get().shortcuts, { home: "Home", renameWindow: "F2" }); + const persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); + assert.equal(Object.hasOwn(persisted, "zoomOverApplications"), false); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("fresh wheel capture binding follows the host platform", async () => { + const macDir = await mkdtemp(join(tmpdir(), "canvastty-settings-mac-wheel-")); + const otherDir = await mkdtemp(join(tmpdir(), "canvastty-settings-other-wheel-")); + try { + const mac = new SettingsStore(macDir, "en", "darwin"); + const other = new SettingsStore(otherDir, "en", "linux"); + await Promise.all([mac.load(), other.load()]); + assert.equal(mac.get().canvasWheelOverride, "Meta"); + assert.equal(other.get().canvasWheelOverride, "Ctrl"); + } finally { + await Promise.all([ + rm(macDir, { recursive: true, force: true }), + rm(otherDir, { recursive: true, force: true }) + ]); + } +}); + +test("existing settings migrate to wheel zoom and preserve legacy widget capture", async () => { + const dir = await mkdtemp(join(tmpdir(), "canvastty-settings-migration-")); + try { + await writeFile(join(dir, "settings.json"), JSON.stringify({ + locale: "en", + zoomOverApplications: false + }), "utf8"); + + const store = new SettingsStore(dir, "en"); + const migrated = await store.load(); + assert.equal(migrated.useScrollWheelToZoom, true); + assert.equal(migrated.canvasWheelCaptureMode, "off"); + assert.equal(migrated.canvasNavigationOverride, "Alt"); + assert.equal(migrated.canvasWheelOverride, null); + + const persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); + assert.equal(persisted.useScrollWheelToZoom, true); + assert.equal(persisted.zoomOverApplications, false); + assert.equal(persisted.canvasNavigationOverride, "Alt"); + assert.equal(persisted.canvasWheelOverride, null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("existing settings without the legacy key use key mode and keep the legacy key absent", async () => { + const dir = await mkdtemp(join(tmpdir(), "canvastty-settings-legacy-wheel-")); + try { + await writeFile(join(dir, "settings.json"), JSON.stringify({ locale: "ru" }), "utf8"); + const store = new SettingsStore(dir, "ru"); + const migrated = await store.load(); + assert.equal(migrated.useScrollWheelToZoom, true); + assert.equal(migrated.canvasWheelCaptureMode, "key"); + assert.equal(migrated.canvasWheelOverride, process.platform === "darwin" ? "Meta" : "Ctrl"); + + const persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); + assert.equal(Object.hasOwn(persisted, "zoomOverApplications"), false); + + await store.update({ palette: "night" }); + const updated = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); + assert.equal(Object.hasOwn(updated, "zoomOverApplications"), false); + + await store.update({ canvasWheelCaptureMode: "always" }); + const explicitlyEnabled = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); + assert.equal(explicitlyEnabled.zoomOverApplications, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("migration preserves an explicitly enabled legacy widget capture value", async () => { + const dir = await mkdtemp(join(tmpdir(), "canvastty-settings-legacy-wheel-enabled-")); + try { + await writeFile(join(dir, "settings.json"), JSON.stringify({ + locale: "en", + zoomOverApplications: true + }), "utf8"); + + const store = new SettingsStore(dir, "en"); + const migrated = await store.load(); + assert.equal(migrated.canvasWheelCaptureMode, "always"); + + const persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); + assert.equal(persisted.zoomOverApplications, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("legacy false with a valid wheel binding migrates to key mode", async () => { + const dir = await mkdtemp(join(tmpdir(), "canvastty-settings-legacy-wheel-key-")); + try { + await writeFile(join(dir, "settings.json"), JSON.stringify({ + zoomOverApplications: false, + canvasWheelOverride: "Alt" + }), "utf8"); + const store = new SettingsStore(dir, "en", "darwin"); + const migrated = await store.load(); + assert.equal(migrated.canvasWheelCaptureMode, "key"); + assert.equal(migrated.canvasWheelOverride, "Alt"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("selecting Key without a saved binding assigns the platform default", async () => { + const dir = await mkdtemp(join(tmpdir(), "canvastty-settings-wheel-key-default-")); + try { + await writeFile(join(dir, "settings.json"), JSON.stringify({ + zoomOverApplications: false, + canvasWheelOverride: null + }), "utf8"); + const store = new SettingsStore(dir, "en", "darwin"); + await store.load(); + assert.equal(store.get().canvasWheelCaptureMode, "off"); + await store.update({ canvasWheelCaptureMode: "key" }); + assert.equal(store.get().canvasWheelCaptureMode, "key"); + assert.equal(store.get().canvasWheelOverride, "Meta"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("invalid explicit key mode fails closed without replacing action shortcuts", () => { + const normalized = normalizeSettings({ + canvasWheelCaptureMode: "key", + canvasWheelOverride: "Meta+Space", + shortcuts: { home: "Meta+Space", renameWindow: "F2" } + }, fallback, "darwin"); + assert.equal(normalized.canvasWheelCaptureMode, "off"); + assert.equal(normalized.canvasWheelOverride, null); + assert.deepEqual(normalized.shortcuts, { home: "Meta+Space", renameWindow: "F2" }); +}); + +test("mode changes persist the compatible legacy boolean and preserve the hidden binding", async () => { + const dir = await mkdtemp(join(tmpdir(), "canvastty-settings-wheel-mode-")); + try { + const store = new SettingsStore(dir, "en", "darwin"); + await store.load(); + await store.update({ canvasWheelOverride: "Alt" }); + await store.update({ canvasWheelCaptureMode: "off" }); + assert.equal(store.get().canvasWheelOverride, "Alt"); + let persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); + assert.equal(persisted.zoomOverApplications, false); + + await store.update({ canvasWheelCaptureMode: "always" }); + assert.equal(store.get().canvasWheelOverride, "Alt"); + persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); + assert.equal(persisted.zoomOverApplications, true); + + await store.update({ canvasWheelCaptureMode: "key" }); + assert.equal(store.get().canvasWheelCaptureMode, "key"); + assert.equal(store.get().canvasWheelOverride, "Alt"); + persisted = JSON.parse(await readFile(join(dir, "settings.json"), "utf8")); + assert.equal(persisted.zoomOverApplications, false); } finally { await rm(dir, { recursive: true, force: true }); } @@ -202,6 +367,41 @@ test("conflicting or malformed shortcuts fall back together", () => { ); }); +test("normalization preserves action shortcuts and allows modifier-only navigation overrides", () => { + const conflict = normalizeSettings({ + shortcuts: { home: "Alt+H", renameWindow: "F2" }, + canvasNavigationOverride: "Alt" + }, fallback, "darwin"); + assert.deepEqual(conflict.shortcuts, { home: "Alt+H", renameWindow: "F2" }); + assert.equal(conflict.canvasNavigationOverride, "Alt"); + + const reserved = normalizeSettings({ canvasNavigationOverride: "Meta" }, fallback, "darwin"); + assert.equal(reserved.canvasNavigationOverride, "Meta"); + assert.equal(normalizeSettings({ canvasNavigationOverride: null }, fallback).canvasNavigationOverride, null); + + const migratedConflict = normalizeSettings({ + shortcuts: { home: "Alt+H", renameWindow: "F2" } + }, fallback, "darwin"); + assert.deepEqual(migratedConflict.shortcuts, { home: "Alt+H", renameWindow: "F2" }); + assert.equal(migratedConflict.canvasNavigationOverride, "Alt"); +}); + +test("both overrides accept zoom modifiers without swallowing ordinary shortcuts", () => { + const normalized = normalizeSettings({ + shortcuts: { home: "Meta+H", renameWindow: "F2" }, + canvasWheelOverride: "Meta", + canvasNavigationOverride: "Meta" + }, fallback, "darwin"); + assert.equal(normalized.canvasWheelOverride, "Meta"); + assert.equal(normalized.canvasNavigationOverride, "Meta"); + + const conflictingChord = normalizeSettings({ + shortcuts: { home: "Meta+Space", renameWindow: "F2" }, + canvasWheelOverride: "Meta+Space" + }, fallback, "darwin"); + assert.equal(conflictingChord.canvasWheelOverride, null); +}); + test("a saved edge pan preference survives normalization", () => { const normalized = normalizeSettings({ edgePan: true, edgePanSpeed: "fast" }, fallback); assert.equal(normalized.edgePan, true); diff --git a/tests/terminal-lifecycle.test.mjs b/tests/terminal-lifecycle.test.mjs index ee847e4..0b2930a 100644 --- a/tests/terminal-lifecycle.test.mjs +++ b/tests/terminal-lifecycle.test.mjs @@ -36,15 +36,16 @@ test("terminal mouse coordinates are adapted for a transformed canvas", async () const source = await readFile(terminalCardPath, "utf8"); assert.match(source, /attachTerminalMouseCoordinateAdapter\(\s*screen/); - assert.match(source, /zoomOverApplicationsRef\.current/); + assert.match(source, /captureCanvasWheelRef\.current/); assert.match(source, /data-canvas-zoom-surface="application"/); }); -test("selecting a terminal moves keyboard focus into the live xterm", async () => { +test("logical focus moves keyboard input independently of terminal selection", async () => { const source = await readFile(terminalCardPath, "utf8"); - assert.match(source, /if \(selected && !renaming && !summaryMode\) terminal\.focus\(\)/); - assert.match(source, /else if \(!selected\) \{\s*terminal\.blur\(\)/); + assert.match(source, /if \(focused && !renaming && !summaryMode\) terminal\.focus\(\)/); + assert.match(source, /else if \(!focused\) \{\s*terminal\.blur\(\)/); + assert.match(source, /terminal-card--selected/); }); test("terminal uses one block cursor instead of overlaying a bar on provider cursor cells", async () => { @@ -57,8 +58,7 @@ test("terminal uses one block cursor instead of overlaying a bar on provider cur test("programmatic hover focus does not leak focus reports into the agent TUI", async () => { const source = await readFile(terminalCardPath, "utf8"); - assert.match(source, /hoverFocusTransition\.current = "focus"/); - assert.match(source, /hoverFocusTransition\.current = "blur"/); + assert.match(source, /focusChangeSource === "hover"/); assert.match(source, /suppressFocusReport\.current/); assert.match(source, /TERMINAL_FOCUS_IN/); assert.match(source, /TERMINAL_FOCUS_OUT/); diff --git a/tests/zoom.test.mjs b/tests/zoom.test.mjs index ce6c6a1..1331391 100644 --- a/tests/zoom.test.mjs +++ b/tests/zoom.test.mjs @@ -3,7 +3,7 @@ import test from "node:test"; import { ZOOM_SENSITIVITY_FACTORS, wheelZoomFactor -} from "../src/renderer/src/features/workspace/zoom.ts"; +} from "../src/shared/canvasNavigation.ts"; test("normal sensitivity keeps the historical wheel step", () => { assert.ok(Math.abs(wheelZoomFactor(100, "normal") - Math.exp(-0.12)) < 1e-12);