github-search-cli 9.9.0 → 9.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/HOW-TO.md CHANGED
@@ -141,8 +141,9 @@ ghfind "cli" --sort forks
141
141
  ghfind "cli" --sort best-match # GitHub's own relevance order
142
142
  ```
143
143
 
144
- In the TUI, `Space` opens the leader menu, where **Sort** cycles the same
145
- strategies without leaving the results.
144
+ In the TUI, `Esc` opens the leader menu (or click the <kbd>☰ menu</kbd> button in
145
+ the bottom-right corner), where **Sort** cycles the same strategies without
146
+ leaving the results.
146
147
 
147
148
  ---
148
149
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "github-search-cli",
3
- "version": "9.9.0",
4
- "description": "Search GitHub from your terminal — repo search, trending repos, npm package search, bookmarks, deep-dive, compare, export. Keyboard-driven TUI + pipeable CLI.",
3
+ "version": "9.10.0",
4
+ "description": "Search GitHub from your terminal — repo search, trending repos, npm package search, bookmarks, deep-dive, compare, export. Keyboard + mouse TUI + pipeable CLI.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1",
7
7
  "volta": {
@@ -0,0 +1,137 @@
1
+ # v9.10.0
2
+
3
+ ghfind's TUI has always been keyboard-only — and, quietly, half-broken for the
4
+ mouse. OpenTUI was already emitting SGR mouse tracking and hit-testing every
5
+ event into a target renderable. Nothing in ghfind consumed any of it. The
6
+ events were parsed, dispatched, and thrown away. This release makes the TUI
7
+ clickable, and fixes the two keyboard rough edges that were hiding behind it.
8
+
9
+ ## The TUI is now fully clickable
10
+
11
+ Click a result and the detail pane updates. Click it again and your browser
12
+ opens. Scroll to move through results. Click outside an overlay to close it.
13
+
14
+ Every list, card, tab and button responds: the results list, the landing mode
15
+ cards, trending tabs, the update dialog's buttons, and every overlay list
16
+ (history, bookmarks, saved searches, topics, export, share, and the command
17
+ menu itself).
18
+
19
+ The important detail is that clicks do not reimplement anything. A click calls
20
+ `setSelectedIndex`, which emits the same `selectionChanged` event `↑`/`↓`
21
+ already emitted, so the detail pane, the detail graph and the packages view all
22
+ work identically without a parallel code path. A double-click calls the same
23
+ activation path as `Enter`. There is no mouse branch to keep in sync — the
24
+ keyboard and the mouse converge on one implementation, which is also why they
25
+ cannot drift.
26
+
27
+ ## The wheel scrolled the wrong pane
28
+
29
+ Worth calling out, because it predates this release and was a real bug rather
30
+ than a missing feature.
31
+
32
+ When a scroll event hit nothing, OpenTUI falls back to delivering it to
33
+ whatever renderable currently holds focus. In ghfind that was almost always the
34
+ results list. So opening the README viewer and scrolling would move the
35
+ _result selection_ instead of the README — the one thing you were trying to do.
36
+
37
+ Every scrollable region now claims the wheel explicitly, and the list's wheel
38
+ handler steps the selection through `moveUp`/`moveDown`, so wheel navigation
39
+ and `↑`/`↓` navigation cannot diverge either.
40
+
41
+ ## Text that swallowed clicks
42
+
43
+ `TextRenderable` defaults to OpenTUI's `selectable: true`, and a left-press on
44
+ a selectable renderable is consumed as a text-selection drag _before_ the app's
45
+ own handlers run. The detail pane, comparison table, tab bar and other read-only
46
+ text are all `TextRenderable`s, so clicks landing on them did nothing at all.
47
+
48
+ They now opt out of text selection. The query input deliberately keeps it, so
49
+ clicking into the input still places your cursor.
50
+
51
+ ## A hover highlight that painted the list magenta
52
+
53
+ Small one, but it would have been immediately obvious to anyone using it.
54
+ OpenTUI's colour parser accepts `"transparent"`, CSS colour names, and 3/4/6/8
55
+ digit hex — nothing else. An `rgba(255,255,255,0.06)` hover tint failed that
56
+ check and fell through to its magenta fallback, so hovering a list would have
57
+ flashed it magenta. Now written as `#ffffff0f`.
58
+
59
+ Found because the test harness prints that warning. Worth noting the class of
60
+ bug: OpenTUI degrades an invalid colour to magenta rather than throwing, so this
61
+ fails silently and visibly at the same time.
62
+
63
+ ## `Space` opened the menu only if you had clicked something first
64
+
65
+ The subtlest problem in this release, and it was not caused by mouse support —
66
+ it was exposed by it.
67
+
68
+ The query input is focused by default. The command menu was bound to `Space`,
69
+ gated on the input _not_ being focused, so that spaces could still be typed into
70
+ a query. That gate meant `Space` opened the menu only when focus had moved
71
+ somewhere else. The one way focus moved was clicking a result row: OpenTUI's
72
+ auto-focus-on-press walks up from the pressed renderable to the nearest
73
+ focusable ancestor, finds the results list, and focuses it — blurring the input
74
+ as a side effect.
75
+
76
+ So on a fresh launch, `Space` typed a space. Click any row, and `Space` opened
77
+ the menu. Same key, same screen, different behaviour depending on invisible
78
+ state.
79
+
80
+ A regression test now pins this down from both directions: with `keepFocus` a row
81
+ click does not move focus, and without it one does.
82
+
83
+ The resolution is that **`Esc` opens the command menu**, and `Space` always
84
+ types. `Esc` already closed every overlay, so this adds a meaning rather than
85
+ replacing one. For anyone who prefers not to reach for `Esc`, there is now a
86
+ <kbd>☰ menu</kbd> button in the bottom-right corner — clickable, it floats above
87
+ the layout, and it hides while an overlay is open so it never sits on top of one.
88
+
89
+ ## Added
90
+
91
+ - Full mouse support: click to select, double-click to open, wheel to scroll,
92
+ click-outside to dismiss, hover feedback with a pointer cursor.
93
+ - <kbd>☰ menu</kbd> button in the bottom-right corner to open the command menu.
94
+ - A "Mouse" section in the in-app help, documenting every binding.
95
+ - README keybinding tables gained a mouse table; the command-menu references
96
+ across the README and `docs/HOW-TO.md` now say `Esc` instead of `Space`.
97
+
98
+ ## Fixed
99
+
100
+ - The scroll wheel scrolled the focused renderable rather than the pane under
101
+ the pointer, so long-pane scrolling moved the result selection instead.
102
+ - Non-interactive text consumed left-presses as text-selection drags, so clicks
103
+ over the detail pane and comparison table did nothing.
104
+ - The hover tint used an `rgba()` string that OpenTUI's colour parser rejects,
105
+ rendering the list magenta.
106
+ - `Space` could not open the command menu on a fresh launch, because it was
107
+ gated on focus that only moved after a mouse click.
108
+
109
+ ## Verification
110
+
111
+ The suite grew by 33 tests, to 657.
112
+
113
+ - **15 unit tests** on the click→row geometry: scroll offsets, multi-line rows
114
+ (overlay lists render a description line, so each option is two lines tall),
115
+ `itemSpacing`, clicks past the last row, unmeasured renderables, and empty
116
+ lists. OpenTUI keeps `scrollOffset` and `linesPerItem` private, so this
117
+ arithmetic is read through a single narrow cast — the tests are what keep that
118
+ cast honest.
119
+ - **12 integration tests** driving the real handlers with OpenTUI's mock mouse
120
+ against a live renderer: click-selects, double-click-opens, the double-click
121
+ window expiring, click-outside dismissal, row clicks _not_ leaking to the
122
+ enclosing panel, focus behaviour with and without `keepFocus`, and wheel
123
+ stepping including both ends of the list.
124
+ - **4 button tests** asserting the corner button actually lands in the
125
+ bottom-right, that hidden buttons neither render nor take clicks, and that its
126
+ click does not bubble.
127
+
128
+ The first two geometry tests failed on the run that wrote them, and caught a
129
+ real off-by-one: the empty area below a short list returned "no row" instead of
130
+ resolving to the last row, which would have dismissed an overlay instead of
131
+ selecting its final entry. That is the exact class of bug that would have been
132
+ very annoying and very hard to report.
133
+
134
+ `test`, `format:check`, `lint` (Biome) and `typecheck` (tsc) are all green, and
135
+ `bun run build` succeeds. The TUI was also driven end-to-end in a pty to confirm
136
+ the menu opens from both `Esc` and the corner button, since the mock mouse does
137
+ not exercise a real terminal's mouse reporting.
package/src/help.ts CHANGED
@@ -30,7 +30,7 @@ export function buildHelpSections(): HelpSection[] {
30
30
  { keys: "↑ ↓ / j k", action: "Move selection" },
31
31
  { keys: "Enter", action: "Open repo in browser" },
32
32
  { keys: "/", action: "Focus search input" },
33
- { keys: "Space", action: "Open command menu" },
33
+ { keys: "Esc", action: "Open command menu" },
34
34
  { keys: "? / Ctrl+H", action: "Toggle this help screen" },
35
35
  { keys: "q", action: "Quit" },
36
36
  ],
@@ -65,6 +65,7 @@ export function buildHelpSections(): HelpSection[] {
65
65
  { keys: "1-5", action: "Switch tab: Today → Week → Month → …" },
66
66
  { keys: "← → / h l", action: "Navigate tabs" },
67
67
  ],
68
+ note: "Tabs are also clickable.",
68
69
  },
69
70
  {
70
71
  title: " Packages Mode ",
@@ -73,12 +74,12 @@ export function buildHelpSections(): HelpSection[] {
73
74
  { keys: "/", action: "Search npm packages" },
74
75
  { keys: "↑ ↓ / j k", action: "Move selection" },
75
76
  { keys: "Enter", action: "View package details" },
76
- { keys: "Space", action: "Open command menu" },
77
+ { keys: "Esc", action: "Open command menu" },
77
78
  ],
78
79
  note: "Search by name, description, or tags. Sort by downloads or score.",
79
80
  },
80
81
  {
81
- title: " Command Menu (Space)",
82
+ title: " Command Menu (Esc)",
82
83
  titleColor: "purple",
83
84
  rows: [
84
85
  { keys: "Sort", action: "Cycle sort: best-match → stars → …" },
@@ -103,7 +104,7 @@ export function buildHelpSections(): HelpSection[] {
103
104
  { keys: "History", action: "View search history" },
104
105
  { keys: "Help", action: "Show this help screen" },
105
106
  ],
106
- note: "Navigate menu with ↑ ↓, select with Enter, back with ← / Esc",
107
+ note: "Navigate menu with ↑ ↓, select with Enter, back with ← / Esc. Clicking a row runs it directly. Esc (or the ☰ menu button) opens this menu; Space types a space in the query.",
107
108
  },
108
109
  {
109
110
  title: " Bookmarks Page ",
@@ -116,6 +117,22 @@ export function buildHelpSections(): HelpSection[] {
116
117
  ],
117
118
  note: "A full page, not an overlay — Esc returns to the mode menu.",
118
119
  },
120
+ {
121
+ title: " Mouse ",
122
+ titleColor: "green",
123
+ rows: [
124
+ { keys: "Click", action: "Select a row / card / tab" },
125
+ { keys: "Double-click", action: "Open the repo or run the action" },
126
+ { keys: "Scroll wheel", action: "Move the selection / scroll a pane" },
127
+ { keys: "Click outside", action: "Dismiss an open overlay" },
128
+ { keys: "Click query box", action: "Focus the search input" },
129
+ {
130
+ keys: "☰ menu button",
131
+ action: "Open the command menu (bottom-right)",
132
+ },
133
+ ],
134
+ note: "Every list is fully clickable; keyboard and mouse are interchangeable.",
135
+ },
119
136
  {
120
137
  title: " Overlay Keys ",
121
138
  titleColor: "teal",
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Mouse support for the ghfind TUI.
3
+ *
4
+ * OpenTUI already emits SGR mouse tracking and hit-tests every event into a
5
+ * `MouseEvent` whose `target` is the deepest renderable under the pointer
6
+ * (`Renderable.processMouseEvent`). Nothing in the shell consumed them, so the
7
+ * renderer threw the events away. This module turns them into the same
8
+ * affordances the keyboard already offers.
9
+ *
10
+ * Design notes (all verified against @opentui/core 0.4.5):
11
+ *
12
+ * - `SelectRenderable` has no `onMouseEvent` override and no public row
13
+ * geometry, so `hitIndexForY` reads `scrollOffset` / `linesPerItem` through
14
+ * ONE narrow cast (see `selectRowGeometry`). If a future release renames
15
+ * those fields the helper degrades to "no row" — clicks no-op instead of
16
+ * throwing. Single choke point, easy to re-point.
17
+ * - `setSelectedIndex` / `selectCurrent` both emit `selectionChanged` /
18
+ * `itemSelected`, so clicks reuse the handlers the keyboard path already
19
+ * drives (`src/tui.ts`) with zero duplicated logic.
20
+ * - `dispatchMouseEvent` auto-focuses the nearest `focusable` ancestor on
21
+ * left-press *unless the event is `defaultPrevented`*. `keepFocus: true`
22
+ * calls `preventDefault()` so clicking a row never steals focus from the
23
+ * query input.
24
+ * - `processMouseEvent` bubbles to the parent unless `stopPropagation()` is
25
+ * called, so every row handler stops it — otherwise a click would also fire
26
+ * the enclosing panel's dismiss handler.
27
+ * - Every `TextRenderable` defaults to `selectable: true`, and a left-press on
28
+ * a selectable renderable is swallowed into a text-selection drag before the
29
+ * shell ever sees it. `isInteractiveText()` marks the ones that must opt out.
30
+ */
31
+ import type {
32
+ BoxRenderable,
33
+ CliRenderer,
34
+ SelectRenderable,
35
+ } from "@opentui/core";
36
+
37
+ /** Two clicks closer together than this count as a double-click. */
38
+ export const DOUBLE_CLICK_MS = 400;
39
+
40
+ /**
41
+ * Row geometry the hit test needs. `SelectRenderable` keeps all three fields
42
+ * private, so this is the structural shape `selectRowGeometry` projects onto.
43
+ */
44
+ export interface SelectRowGeometry {
45
+ /** Row's screen-space top edge (`Renderable.screenY`). */
46
+ readonly screenY: number;
47
+ /** Visible viewport height in rows. */
48
+ readonly height: number;
49
+ /** Current option count. */
50
+ readonly optionCount: number;
51
+ /** Index of the first visible row (private `scrollOffset`). */
52
+ readonly scrollOffset: number;
53
+ /** Row pitch in terminal lines (private `linesPerItem`). */
54
+ readonly linesPerItem: number;
55
+ }
56
+
57
+ /** Public stand-in used by the tests — no OpenTUI renderer required. */
58
+ export type SelectGeometryLike = SelectRowGeometry;
59
+
60
+ /**
61
+ * Map a screen-space y coordinate to an option index, or -1 when the click
62
+ * misses every row (empty list, click below the last row, click past the
63
+ * viewport). Pure — no renderer state, so the off-by-one surface at scroll
64
+ * boundaries is directly testable.
65
+ */
66
+ export function hitIndexForY(
67
+ geometry: SelectGeometryLike,
68
+ screenY: number,
69
+ ): number {
70
+ const pitch = geometry.linesPerItem;
71
+ // A zero/negative pitch means the renderable is unmeasured or malformed;
72
+ // treat every click as a miss rather than dividing by zero.
73
+ if (pitch <= 0) return -1;
74
+ if (geometry.optionCount <= 0) return -1;
75
+
76
+ const localY = screenY - geometry.screenY;
77
+ // Above the first row, or at/below the viewport's bottom edge.
78
+ if (localY < 0 || localY >= geometry.height) return -1;
79
+
80
+ const row = Math.floor(localY / pitch);
81
+ const index = geometry.scrollOffset + row;
82
+ // The last page of options is usually shorter than the viewport, leaving
83
+ // empty rows below it. Clamp those onto the final option rather than
84
+ // treating the click as a miss — a user aiming at the last row should land on
85
+ // it even when their pointer drifts a line or two.
86
+ const last = geometry.optionCount - 1;
87
+ if (index < 0) return 0;
88
+ return index > last ? last : index;
89
+ }
90
+
91
+ /**
92
+ * Project a `SelectRenderable` onto `SelectRowGeometry`.
93
+ *
94
+ * The single narrow cast in this module: `scrollOffset` and `linesPerItem` are
95
+ * declared `private` in OpenTUI's `.d.ts` but are plain instance fields at
96
+ * runtime (verified in `index.node.js`). A rename in a future release yields
97
+ * `undefined` → `pitch <= 0` → every click misses, which is a visible no-op
98
+ * rather than a crash.
99
+ */
100
+ export function selectRowGeometry(select: SelectRenderable): SelectRowGeometry {
101
+ const internal = select as unknown as {
102
+ scrollOffset?: number;
103
+ linesPerItem?: number;
104
+ };
105
+ return {
106
+ screenY: select.screenY,
107
+ height: select.height,
108
+ optionCount: select.options.length,
109
+ scrollOffset: internal.scrollOffset ?? 0,
110
+ linesPerItem: internal.linesPerItem ?? 0,
111
+ };
112
+ }
113
+
114
+ export interface ClickToSelectOptions {
115
+ /**
116
+ * Runs the list's Enter-equivalent (open the URL, run the export, ...).
117
+ * Called on the second click of a double-click, and only when the row is
118
+ * already selected.
119
+ */
120
+ onOpen?: () => void;
121
+ /**
122
+ * Called when a click lands on the list but resolves to no row — the empty
123
+ * area under the last option. Full-screen overlays use it to dismiss on a
124
+ * click below their content.
125
+ */
126
+ onClickMiss?: () => void;
127
+ /**
128
+ * `true` for the main results list: `preventDefault()` the press so OpenTUI
129
+ * does not auto-focus the list away from the query input. Overlays leave it
130
+ * `false` so ↑↓ / Enter keep working right after a click.
131
+ */
132
+ keepFocus?: boolean;
133
+ /** Rows per wheel notch. Defaults to 3. */
134
+ wheelStep?: number;
135
+ /** Called with the row index as the pointer moves over the list. */
136
+ onHoverRow?: (index: number) => void;
137
+ }
138
+
139
+ export interface AttachedSelect {
140
+ /** Forget the pending double-click (call when the list's options change). */
141
+ reset(): void;
142
+ /** Row index the pointer last selected, or -1. Exposed for tests/status. */
143
+ readonly lastHovered: number;
144
+ }
145
+
146
+ /**
147
+ * Wire click-to-select, double-click-to-open, wheel scrolling, and hover
148
+ * feedback onto one `SelectRenderable`.
149
+ */
150
+ export function attachClickToSelect(
151
+ renderer: CliRenderer,
152
+ select: SelectRenderable,
153
+ options: ClickToSelectOptions = {},
154
+ ): AttachedSelect {
155
+ const wheelStep = options.wheelStep ?? 3;
156
+ let lastClickIndex = -1;
157
+ let lastClickAt = 0;
158
+ let lastHovered = -1;
159
+
160
+ select.onMouseDown = (event) => {
161
+ // A click on the list must not also reach the enclosing overlay panel's
162
+ // dismiss handler, and must not steal focus from the query input.
163
+ event.stopPropagation();
164
+ if (options.keepFocus) event.preventDefault();
165
+
166
+ const index = hitIndexForY(selectRowGeometry(select), event.y);
167
+ if (index < 0) {
168
+ lastClickIndex = -1;
169
+ options.onClickMiss?.();
170
+ return;
171
+ }
172
+
173
+ const now = Date.now();
174
+ const alreadySelected = select.getSelectedIndex() === index;
175
+ const isDouble =
176
+ alreadySelected &&
177
+ index === lastClickIndex &&
178
+ now - lastClickAt < DOUBLE_CLICK_MS;
179
+
180
+ lastClickIndex = index;
181
+ lastClickAt = now;
182
+
183
+ if (isDouble) {
184
+ lastClickIndex = -1;
185
+ options.onOpen?.();
186
+ return;
187
+ }
188
+
189
+ // Emits `selectionChanged`, which the shell already uses to refresh the
190
+ // detail pane — the click path and the keyboard path converge here.
191
+ select.setSelectedIndex(index);
192
+ };
193
+
194
+ select.onMouseScroll = (event) => {
195
+ // Claim the wheel: without stopPropagation the renderer's focused-renderable
196
+ // fallback would also scroll whatever happens to hold focus.
197
+ event.stopPropagation();
198
+ const direction = event.scroll?.direction;
199
+ if (direction === "up") select.moveUp(wheelStep);
200
+ else if (direction === "down") select.moveDown(wheelStep);
201
+ // "left"/"right" belong to the scrollbar, not the list.
202
+ else return;
203
+ renderer.requestRender();
204
+ };
205
+
206
+ attachHoverHighlight(renderer, select, {
207
+ onHover: (index) => {
208
+ lastHovered = index;
209
+ options.onHoverRow?.(index);
210
+ },
211
+ // A stale row must never outlive the options it pointed at.
212
+ onLeave: () => {
213
+ lastHovered = -1;
214
+ },
215
+ });
216
+
217
+ return {
218
+ reset() {
219
+ lastClickIndex = -1;
220
+ lastClickAt = 0;
221
+ },
222
+ get lastHovered() {
223
+ return lastHovered;
224
+ },
225
+ };
226
+ }
227
+
228
+ export interface HoverOptions {
229
+ /** Row under the pointer, or -1 when the pointer left the list / missed. */
230
+ onHover?: (index: number) => void;
231
+ /** Called once when the pointer leaves the list. */
232
+ onLeave?: () => void;
233
+ }
234
+
235
+ /**
236
+ * Give a list a pointer cursor and a subtle background tint while hovered.
237
+ *
238
+ * A `SelectRenderable` can only tint its whole background (the selected row
239
+ * owns `selectedBackgroundColor`), so the hover state is a list-wide lift —
240
+ * enough to read as "this is live", without pretending to be per-row.
241
+ */
242
+ export function attachHoverHighlight(
243
+ renderer: CliRenderer,
244
+ select: SelectRenderable,
245
+ options: HoverOptions = {},
246
+ ): void {
247
+ const idleBackground = select.backgroundColor;
248
+ // OpenTUI's `parseColor` only understands "transparent", CSS colour names,
249
+ // and 3/4/6/8-digit hex — an `rgba(...)` string silently resolves to magenta.
250
+ // A white tint at 6% alpha is the 8-digit-hex form of that.
251
+ const hoverBackground = "#ffffff0f";
252
+ let hovered = -1;
253
+
254
+ select.onMouseOver = () => {
255
+ renderer.setMousePointer("pointer");
256
+ select.backgroundColor = hoverBackground;
257
+ renderer.requestRender();
258
+ };
259
+
260
+ select.onMouseOut = () => {
261
+ renderer.setMousePointer("default");
262
+ select.backgroundColor = idleBackground;
263
+ if (hovered !== -1) {
264
+ hovered = -1;
265
+ options.onLeave?.();
266
+ }
267
+ renderer.requestRender();
268
+ };
269
+
270
+ // Report the row under the pointer, resolved exactly like a click — but only
271
+ // when it changed, so a stationary pointer does not re-render every frame.
272
+ select.onMouseMove = (event) => {
273
+ const index = hitIndexForY(selectRowGeometry(select), event.y);
274
+ if (index === hovered) return;
275
+ hovered = index;
276
+ options.onHover?.(index);
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Dismiss a full-screen overlay when the click lands on the panel itself
282
+ * rather than on its content (border, padding, or a sibling region). Children
283
+ * that handle their own clicks call `stopPropagation()`, so this only fires for
284
+ * genuine outside-clicks.
285
+ */
286
+ export function attachClickOutside(
287
+ target: BoxRenderable,
288
+ onDismiss: () => void,
289
+ ): void {
290
+ target.onMouseDown = (event) => {
291
+ event.stopPropagation();
292
+ onDismiss();
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Make a non-list Box clickable: click runs `onActivate`, hovering swaps the
298
+ * border to the accent colour and shows a pointer cursor.
299
+ *
300
+ * `setBorder`/`getBorder` are passed in rather than imported so the unit tests
301
+ * can drive this with a plain object.
302
+ */
303
+ export function attachClickableBox(
304
+ renderer: CliRenderer,
305
+ box: BoxRenderable,
306
+ handlers: {
307
+ onActivate: () => void;
308
+ /** Border colour while not hovered. */
309
+ idleBorder: string;
310
+ /** Border colour while hovered / selected. */
311
+ activeBorder: string;
312
+ onStateChange?: () => void;
313
+ },
314
+ ): void {
315
+ box.onMouseDown = (event) => {
316
+ event.stopPropagation();
317
+ handlers.onActivate();
318
+ };
319
+ box.onMouseOver = () => {
320
+ renderer.setMousePointer("pointer");
321
+ box.borderColor = handlers.activeBorder;
322
+ handlers.onStateChange?.();
323
+ renderer.requestRender();
324
+ };
325
+ box.onMouseOut = () => {
326
+ renderer.setMousePointer("default");
327
+ box.borderColor = handlers.idleBorder;
328
+ handlers.onStateChange?.();
329
+ renderer.requestRender();
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Option-key that keeps a non-interactive `TextRenderable` from swallowing
335
+ * left-presses as a text-selection drag.
336
+ *
337
+ * Every `TextRenderable` (and `BoxRenderable`) defaults to `selectable: true`,
338
+ * and a left-press on a selectable renderable is turned into a selection drag
339
+ * *before* the shell's handlers run — so a click over such text does nothing.
340
+ * Pass this to every purely decorative renderable:
341
+ *
342
+ * ```ts
343
+ * new TextRenderable(renderer, { content: "", selectable: false })
344
+ * ```
345
+ *
346
+ * The query input keeps the default (`true`) so clicking into it still places
347
+ * the cursor.
348
+ */
349
+ export const NON_INTERACTIVE_TEXT = { selectable: false } as const;