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/CHANGELOG.md +51 -0
- package/README.md +38 -32
- package/dist/cli.js +450 -106
- package/docs/HOW-TO.md +3 -2
- package/package.json +2 -2
- package/release-notes/9.10.0.md +137 -0
- package/src/help.ts +21 -4
- package/src/tui/mouse.ts +349 -0
- package/src/tui.ts +378 -106
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, `
|
|
145
|
-
|
|
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.
|
|
4
|
-
"description": "Search GitHub from your terminal — repo search, trending repos, npm package search, bookmarks, deep-dive, compare, export. Keyboard
|
|
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: "
|
|
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: "
|
|
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 (
|
|
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",
|
package/src/tui/mouse.ts
ADDED
|
@@ -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;
|