mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
fix(tiles): the count menu closes when the keyboard leaves it
Found live: closing the grid with a click starts the single view's selection, which focuses its terminal when its replay lands, a few hundred milliseconds later. A right-click on Tiles in between opened the count menu with the keyboard in it, and the late focus then moved the keyboard into the terminal while the menu stayed open (3 of 3 tries), so the arrows, Enter or Escape meant for the menu went to the session's PTY instead (an Escape arrived there as an ESC byte). The menu now closes when the keyboard leaves it for another element, as any menu does. A focus going nowhere (a click on a button in Safari, which does not focus it) does not count, so a click on a count still picks it. Live afterwards: the menu either closes as the terminal takes the keyboard, or keeps it when the replay landed first; never open with the keyboard elsewhere. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -809,7 +809,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
|
|||||||
|
|
||||||
⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), the capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and BEFORE the Shift+Enter gate (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI). The arrow chords, focus and move, never apply in a text field other than xterm's own textarea (`isTextFieldTarget`), where shifted arrows select.
|
⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), the capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and BEFORE the Shift+Enter gate (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI). The arrow chords, focus and move, never apply in a text field other than xterm's own textarea (`isTextFieldTarget`), where shifted arrows select.
|
||||||
|
|
||||||
⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once with the remembered count of tiles (`codeman:tile-count`, 2, 4 or 6, default 6, owner decision 10; at most `_tileGridLimit().capacity`), on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive (an open split closes and is not seeded first: `openTileGrid(..., { mergeSplit: false })`), (b) else an open split's two sessions, (c) else the open sessions in tab order (no detached ones), the active session always included and focused; then `tileGridSetForCount` trims it from the end (the session to focus kept) or fills it from tab order to the count. A stored grid comes back with its tiles in their cells (`reformTileCells`: a count change is a shape change under `fitTileCells`, the added tiles fill the empty cells first): the count supersedes decision 8's "exactly the stored set", and only the page-load restore brings back exactly what was stored. Ctrl/Cmd+click with the grid closed opens the count in total, the clicked session among them and focused. Right-click (`oncontextmenu`, which also fires for Shift+F10 and the Menu key, and calls `preventDefault` for the browser menu) is the 2 / 4 / 6 count menu (`openTileCountMenu`); with the grid open a pick re-forms it (`_reformTileGrid`: the focused tile always stays, every joining tile is mounted and laid out before any connects, so each fits once). ⚠️ The menu owns its Escape: the global handler closes it alone and gives the keyboard back to the Tiles button, like the tab-group menu. ⚠️ Opening builds the tiles' terminals one per animation frame (`_connectTilesPaced`, the focused one first, a run token stops a stale run), so `openTileGrid` returns before they exist: a focus asked for meanwhile is handed over in `_connectTile` (`focusOnConnect`), never for `focus: false`, and `_connectTile` connects a tile once (`entry.connected`, reset by `_remountTile`). ⚠️ Motion (the grid's own, on by default): tiles enter staggered (`.tile--entering`, `--tile-enter-index`), a terminal stays transparent until its first capture lands (`.tile--revealing`, cleared by the load queue's idle, 15 s backstop), and the toggle's close leaves an inert cloned still copy (`_ghostTileGrid`, no xterm or socket) that holds until the single view's `selectSession` settles (at most 700 ms) and then fades; every keyframe animates opacity and transform only (FitAddon reads the untransformed box, so still one PTY resize per tile), nothing moves and no copy is made under `prefers-reduced-motion`, and `.main.webview-active` hides the copy.
|
⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once with the remembered count of tiles (`codeman:tile-count`, 2, 4 or 6, default 6, owner decision 10; at most `_tileGridLimit().capacity`), on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive (an open split closes and is not seeded first: `openTileGrid(..., { mergeSplit: false })`), (b) else an open split's two sessions, (c) else the open sessions in tab order (no detached ones), the active session always included and focused; then `tileGridSetForCount` trims it from the end (the session to focus kept) or fills it from tab order to the count. A stored grid comes back with its tiles in their cells (`reformTileCells`: a count change is a shape change under `fitTileCells`, the added tiles fill the empty cells first): the count supersedes decision 8's "exactly the stored set", and only the page-load restore brings back exactly what was stored. Ctrl/Cmd+click with the grid closed opens the count in total, the clicked session among them and focused. Right-click (`oncontextmenu`, which also fires for Shift+F10 and the Menu key, and calls `preventDefault` for the browser menu) is the 2 / 4 / 6 count menu (`openTileCountMenu`); with the grid open a pick re-forms it (`_reformTileGrid`: the focused tile always stays, every joining tile is mounted and laid out before any connects, so each fits once). ⚠️ The menu owns its Escape: the global handler closes it alone and gives the keyboard back to the Tiles button, like the tab-group menu. ⚠️ It closes when the keyboard leaves it for another element (a `focusout` with a `relatedTarget` outside it; a focus going nowhere, a Safari button click, does not count): closing the grid starts a selection that focuses the single view's terminal when its replay lands, and a menu left open behind that sent its arrows, Enter and Escape into the PTY. ⚠️ Opening builds the tiles' terminals one per animation frame (`_connectTilesPaced`, the focused one first, a run token stops a stale run), so `openTileGrid` returns before they exist: a focus asked for meanwhile is handed over in `_connectTile` (`focusOnConnect`), never for `focus: false`, and `_connectTile` connects a tile once (`entry.connected`, reset by `_remountTile`). ⚠️ Motion (the grid's own, on by default): tiles enter staggered (`.tile--entering`, `--tile-enter-index`), a terminal stays transparent until its first capture lands (`.tile--revealing`, cleared by the load queue's idle, 15 s backstop), and the toggle's close leaves an inert cloned still copy (`_ghostTileGrid`, no xterm or socket) that holds until the single view's `selectSession` settles (at most 700 ms) and then fades; every keyframe animates opacity and transform only (FitAddon reads the untransformed box, so still one PTY resize per tile), nothing moves and no copy is made under `prefers-reduced-motion`, and `.main.webview-active` hides the copy.
|
||||||
|
|
||||||
⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again.
|
⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again.
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ or settled a question the spec left open. The invariants as built are in
|
|||||||
Shift+F10 and the Menu key open it too (the browser's contextmenu event). Arrows move
|
Shift+F10 and the Menu key open it too (the browser's contextmenu event). Arrows move
|
||||||
over the counts that fit, Enter or Space picks, Escape closes it alone (the global
|
over the counts that fit, Enter or Space picks, Escape closes it alone (the global
|
||||||
Escape handler gives it the key first, like the tab-group menu) and puts the keyboard
|
Escape handler gives it the key first, like the tab-group menu) and puts the keyboard
|
||||||
back on the Tiles button, Tab and a click elsewhere close it. A pick is remembered per
|
back on the Tiles button, Tab, a click elsewhere and the keyboard leaving it for another
|
||||||
|
element close it (the single view a close starts focuses its terminal when its replay
|
||||||
|
lands; a menu left open behind that would send its keys there). A pick is remembered per
|
||||||
device in `codeman:tile-count` (`codeman:tile-grid` stays ids only) and opens that many
|
device in `codeman:tile-count` (`codeman:tile-grid` stays ids only) and opens that many
|
||||||
tiles; with the grid open it re-forms it (`_reformTileGrid`): the focused tile always
|
tiles; with the grid open it re-forms it (`_reformTileGrid`): the focused tile always
|
||||||
stays, the others leave from the end or join from tab order, filling empty cells first,
|
stays, the others leave from the end or join from tab order, filling empty cells first,
|
||||||
|
|||||||
@@ -677,6 +677,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.closeTileCountMenu();
|
this.closeTileCountMenu();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// The keyboard leaving the menu for something else closes it, as any menu:
|
||||||
|
// closing the grid starts a selection that focuses the single view's
|
||||||
|
// terminal when its replay lands, and a menu left open behind that would
|
||||||
|
// send the keys meant for it (arrows, Enter, Escape) into the terminal. A
|
||||||
|
// focus going nowhere (a click on a button in Safari, which does not focus
|
||||||
|
// it) does not count.
|
||||||
|
menu.addEventListener('focusout', (e) => {
|
||||||
|
const to = e.relatedTarget;
|
||||||
|
if (to && !menu.contains?.(to)) this.closeTileCountMenu({ refocus: false });
|
||||||
|
});
|
||||||
this._tileCountMenu = { menu, onOutside, onKey };
|
this._tileCountMenu = { menu, onOutside, onKey };
|
||||||
document.addEventListener('click', onOutside);
|
document.addEventListener('click', onOutside);
|
||||||
document.addEventListener('keydown', onKey, true);
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
|||||||
@@ -259,6 +259,24 @@ describe('the count menu', () => {
|
|||||||
expect(menu()).toBeNull();
|
expect(menu()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('closes when the keyboard leaves it for something else (a late focus of the single view), not for nothing', () => {
|
||||||
|
const app = gridApp();
|
||||||
|
open(app);
|
||||||
|
// Inside: moving between counts keeps it.
|
||||||
|
menu()!.dispatch('focusout', { relatedTarget: item(2) });
|
||||||
|
expect(menu()).not.toBeNull();
|
||||||
|
// Nowhere (a click on a button in Safari focuses nothing): kept.
|
||||||
|
menu()!.dispatch('focusout', { relatedTarget: null });
|
||||||
|
expect(menu()).not.toBeNull();
|
||||||
|
// Another element, the single view's terminal: closed, the keyboard left there.
|
||||||
|
const textarea = new FakeEl();
|
||||||
|
textarea.className = 'xterm-helper-textarea';
|
||||||
|
textarea.focus();
|
||||||
|
menu()!.dispatch('focusout', { relatedTarget: textarea });
|
||||||
|
expect(menu()).toBeNull();
|
||||||
|
expect(activeElement()).toBe(textarea);
|
||||||
|
});
|
||||||
|
|
||||||
it('a second right-click closes it; a click on Tiles closes it and toggles', () => {
|
it('a second right-click closes it; a click on Tiles closes it and toggles', () => {
|
||||||
const app = gridApp();
|
const app = gridApp();
|
||||||
open(app);
|
open(app);
|
||||||
|
|||||||
Reference in New Issue
Block a user