Skip to content

Commit f04840e

Browse files
nybble73simonklee
andauthored
feat(solid): expose terminal focus events via hooks (#811)
Closes #766 The renderer already emits `"focus"` and `"blur"` events when the terminal window gains/loses focus (via DECSET 1004), but there's no way to consume them from the SolidJS layer. This adds three hooks following existing patterns: - `onFocus(callback)` / `onBlur(callback)` — callback style, mirrors `onResize` - `useTerminalFocus()` → `Accessor<boolean>` — signal style, mirrors `useTerminalDimensions` Use case: opencode plugins need to know if the user is looking at the terminal to decide whether to send OS notifications. Currently done via osascript polling — this replaces it with the event-driven signal the terminal already provides. --------- Co-authored-by: Simon Klee <hello@simonklee.dk>
1 parent 157193a commit f04840e

File tree

6 files changed

+149
-13
lines changed

6 files changed

+149
-13
lines changed

packages/core/src/renderer.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
473473
private _paletteDetectionPromise: Promise<TerminalColors> | null = null
474474
private _onDestroy?: () => void
475475
private _themeMode: ThemeMode | null = null
476+
private _terminalFocusState: boolean | null = null
476477

477478
private sequenceHandlers: ((sequence: string) => boolean)[] = []
478479
private prependedInputHandlers: ((sequence: string) => boolean)[] = []
@@ -1180,12 +1181,18 @@ export class CliRenderer extends EventEmitter implements RenderContext {
11801181
this.lib.restoreTerminalModes(this.rendererPtr)
11811182
this.shouldRestoreModesOnNextFocus = false
11821183
}
1183-
this.emit(CliRenderEvents.FOCUS)
1184+
if (this._terminalFocusState !== true) {
1185+
this._terminalFocusState = true
1186+
this.emit(CliRenderEvents.FOCUS)
1187+
}
11841188
return true
11851189
}
11861190
if (sequence === "\x1b[O") {
11871191
this.shouldRestoreModesOnNextFocus = true
1188-
this.emit(CliRenderEvents.BLUR)
1192+
if (this._terminalFocusState !== false) {
1193+
this._terminalFocusState = false
1194+
this.emit(CliRenderEvents.BLUR)
1195+
}
11891196
return true
11901197
}
11911198
return false

packages/core/src/tests/renderer.focus-restore.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,35 @@ describe("focus restore - terminal mode re-enable on focus-in", () => {
130130
expect(events).toEqual(["focus", "blur"])
131131
})
132132

133+
test("duplicate focus and blur sequences only emit transitions once", async () => {
134+
const events: string[] = []
135+
136+
renderer.on("focus", () => {
137+
events.push("focus")
138+
})
139+
140+
renderer.on("blur", () => {
141+
events.push("blur")
142+
})
143+
144+
renderer.stdin.emit("data", Buffer.from("\x1b[O"))
145+
clock.advance(15)
146+
renderer.stdin.emit("data", Buffer.from("\x1b[O"))
147+
clock.advance(15)
148+
149+
renderer.stdin.emit("data", Buffer.from("\x1b[I"))
150+
clock.advance(15)
151+
renderer.stdin.emit("data", Buffer.from("\x1b[I"))
152+
clock.advance(15)
153+
154+
renderer.stdin.emit("data", Buffer.from("\x1b[O"))
155+
clock.advance(15)
156+
renderer.stdin.emit("data", Buffer.from("\x1b[O"))
157+
clock.advance(15)
158+
159+
expect(events).toEqual(["blur", "focus", "blur"])
160+
})
161+
133162
test("focus events do not trigger keypress events", async () => {
134163
const keypresses: any[] = []
135164

packages/solid/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ Returns the current component catalogue that powers JSX tag lookup.
128128

129129
- `useRenderer()`
130130
- `onResize(callback)`
131+
- `onFocus(callback)`
132+
- `onBlur(callback)`
131133
- `useTerminalDimensions()`
132134
- `useKeyboard(handler, options?)`
133135
- `usePaste(handler)`

packages/solid/src/elements/hooks.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,30 @@ export const usePaste = (callback: (event: PasteEvent) => void) => {
105105
*/
106106
export const useKeyHandler = useKeyboard
107107

108+
export const onFocus = (callback: () => void) => {
109+
const renderer = useRenderer()
110+
111+
onMount(() => {
112+
renderer.on("focus", callback)
113+
})
114+
115+
onCleanup(() => {
116+
renderer.off("focus", callback)
117+
})
118+
}
119+
120+
export const onBlur = (callback: () => void) => {
121+
const renderer = useRenderer()
122+
123+
onMount(() => {
124+
renderer.on("blur", callback)
125+
})
126+
127+
onCleanup(() => {
128+
renderer.off("blur", callback)
129+
})
130+
}
131+
108132
export const useSelectionHandler = (callback: (selection: Selection) => void) => {
109133
const renderer = useRenderer()
110134

packages/solid/tests/events.test.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { testRender } from "../index.js"
33
import { createSignal } from "solid-js"
44
import { decodePasteBytes } from "@opentui/core"
55
import { createSpy } from "@opentui/core/testing"
6-
import { usePaste, useKeyboard } from "../src/elements/hooks.js"
6+
import { onBlur, onFocus, usePaste, useKeyboard } from "../src/elements/hooks.js"
77
import type { PasteEvent } from "@opentui/core"
88

99
let testSetup: Awaited<ReturnType<typeof testRender>>
@@ -348,6 +348,46 @@ describe("SolidJS Renderer Integration Tests", () => {
348348
expect(pastedText()).toBe("pasted content")
349349
})
350350

351+
it("should handle terminal focus hooks", async () => {
352+
const focusSpy = createSpy()
353+
const blurSpy = createSpy()
354+
const [status, setStatus] = createSignal("idle")
355+
356+
const TestComponent = () => {
357+
onFocus(() => {
358+
focusSpy()
359+
setStatus("focused")
360+
})
361+
362+
onBlur(() => {
363+
blurSpy()
364+
setStatus("blurred")
365+
})
366+
367+
return (
368+
<box>
369+
<text>Status: {status()}</text>
370+
</box>
371+
)
372+
}
373+
374+
testSetup = await testRender(() => <TestComponent />, { width: 30, height: 5 })
375+
376+
testSetup.renderer.stdin.emit("data", Buffer.from("\x1b[I"))
377+
await new Promise((resolve) => setTimeout(resolve, 10))
378+
379+
expect(focusSpy.callCount()).toBe(1)
380+
expect(blurSpy.callCount()).toBe(0)
381+
expect(status()).toBe("focused")
382+
383+
testSetup.renderer.stdin.emit("data", Buffer.from("\x1b[O"))
384+
await new Promise((resolve) => setTimeout(resolve, 10))
385+
386+
expect(focusSpy.callCount()).toBe(1)
387+
expect(blurSpy.callCount()).toBe(1)
388+
expect(status()).toBe("blurred")
389+
})
390+
351391
it("should handle global preventDefault for keyboard events", async () => {
352392
const inputSpy = createSpy()
353393
const globalHandlerSpy = createSpy()

packages/web/src/content/docs/bindings/solid.mdx

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,40 @@ const App = () => {
228228
}
229229
```
230230

231+
### `onFocus(callback)`
232+
233+
Run side effects when the terminal window gains focus.
234+
235+
```tsx
236+
import { onFocus } from "@opentui/solid"
237+
238+
const App = () => {
239+
onFocus(() => {
240+
console.log("Terminal focused")
241+
})
242+
243+
return <text>Switch away and back to trigger focus events</text>
244+
}
245+
```
246+
247+
### `onBlur(callback)`
248+
249+
Run side effects when the terminal window loses focus.
250+
251+
```tsx
252+
import { onBlur } from "@opentui/solid"
253+
254+
const App = () => {
255+
onBlur(() => {
256+
console.log("Terminal blurred")
257+
})
258+
259+
return <text>Switch away and back to trigger blur events</text>
260+
}
261+
```
262+
263+
These hooks listen for terminal focus-in/focus-out events when the terminal emulator supports them.
264+
231265
### `useTerminalDimensions()`
232266

233267
Get reactive terminal dimensions (returns a Solid signal).
@@ -410,13 +444,13 @@ render(App)
410444

411445
## Differences from React bindings
412446

413-
| Aspect | Solid | React |
414-
| ------------------ | ------------------------------------ | -------------------------------------- |
415-
| Render function | `render(() => <App />)` | `createRoot(renderer).render(<App />)` |
416-
| Component naming | snake_case (`ascii_font`) | kebab-case (`ascii-font`) |
417-
| State | `createSignal` | `useState` |
418-
| Effects | `onMount`, `onCleanup` | `useEffect` |
419-
| Resize hook | `onResize(callback)` | `useOnResize(callback)` |
420-
| Dimensions | Returns signal: `dimensions().width` | Returns object: `dimensions.width` |
421-
| Extra hooks | `usePaste`, `useSelectionHandler` | - |
422-
| Special components | `Portal`, `Dynamic` | - |
447+
| Aspect | Solid | React |
448+
| ------------------ | ------------------------------------------------------ | -------------------------------------- |
449+
| Render function | `render(() => <App />)` | `createRoot(renderer).render(<App />)` |
450+
| Component naming | snake_case (`ascii_font`) | kebab-case (`ascii-font`) |
451+
| State | `createSignal` | `useState` |
452+
| Effects | `onMount`, `onCleanup` | `useEffect` |
453+
| Resize hook | `onResize(callback)` | `useOnResize(callback)` |
454+
| Dimensions | Returns signal: `dimensions().width` | Returns object: `dimensions.width` |
455+
| Extra hooks | `onFocus`, `onBlur`, `usePaste`, `useSelectionHandler` | - |
456+
| Special components | `Portal`, `Dynamic` | - |

0 commit comments

Comments
 (0)