Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions packages/core/src/Renderable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ export abstract class Renderable extends BaseRenderable {

protected _focusable: boolean = false
protected _focused: boolean = false
protected _hasFocusedDescendant: boolean = false
protected keypressHandler: ((key: KeyEvent) => void) | null = null
protected pasteHandler: ((event: PasteEvent) => void) | null = null

Expand Down Expand Up @@ -407,12 +408,27 @@ export abstract class Renderable extends BaseRenderable {

this.ctx._internalKeyInput.onInternal("keypress", this.keypressHandler)
this.ctx._internalKeyInput.onInternal("paste", this.pasteHandler)
this.propagateFocusChange(true)
this.emit(RenderableEvents.FOCUSED)
}

protected propagateFocusChange(hasFocus: boolean): void {
let parent = this.parent
while (parent) {
if (parent._hasFocusedDescendant !== hasFocus) {
parent._hasFocusedDescendant = hasFocus
parent.markDirty()
}
parent = parent.parent
}

this.requestRender()
}

public blur(): void {
if (!this._focused || !this._focusable) return

this._ctx.blurRenderable(this)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realised earlier this week that renderer.focusedRenderable basically never gets reset. It's also not reset when the renderable is destroyed. So in the renderable destroy method it should probably be blurred as well. Would be cool to get this new blur behaviour for renderer level focus/blur test covered.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So "luckily" this change will help with the destroy. It calls blur when destroying.

Let me add test coverage though.

this._focused = false
this.requestRender()

Expand All @@ -426,13 +442,18 @@ export abstract class Renderable extends BaseRenderable {
this.pasteHandler = null
}

this.propagateFocusChange(false)
this.emit(RenderableEvents.BLURRED)
}

public get focused(): boolean {
return this._focused
}

public get hasFocusedDescendant(): boolean {
return this._hasFocusedDescendant
}

public get live(): boolean {
return this._live
}
Expand Down Expand Up @@ -1078,7 +1099,10 @@ export abstract class Renderable extends BaseRenderable {

try {
const widthMethod = this._ctx.widthMethod
this.frameBuffer = OptimizedBuffer.create(w, h, widthMethod, { respectAlpha: true, id: `framebuffer-${this.id}` })
this.frameBuffer = OptimizedBuffer.create(w, h, widthMethod, {
respectAlpha: true,
id: `framebuffer-${this.id}`,
})
} catch (error) {
console.error(`Failed to create frame buffer for ${this.id}:`, error)
this.frameBuffer = null
Expand Down Expand Up @@ -1394,7 +1418,12 @@ export abstract class Renderable extends BaseRenderable {
// Override this method to provide custom rendering
}

protected getScissorRect(): { x: number; y: number; width: number; height: number } {
protected getScissorRect(): {
x: number
y: number
width: number
height: number
} {
return {
x: this.buffered ? 0 : this.x,
y: this.buffered ? 0 : this.y,
Expand Down Expand Up @@ -1610,7 +1639,14 @@ export class RootRenderable extends Renderable {
private renderList: RenderCommand[] = []

constructor(ctx: RenderContext) {
super(ctx, { id: "__root__", zIndex: 0, visible: true, width: ctx.width, height: ctx.height, enableLayout: true })
super(ctx, {
id: "__root__",
zIndex: 0,
visible: true,
width: ctx.width,
height: ctx.height,
enableLayout: true,
})

if (this.yogaNode) {
this.yogaNode.free()
Expand Down
107 changes: 107 additions & 0 deletions packages/core/src/renderables/Box.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,110 @@ describe("BoxRenderable - border titles (top and bottom)", () => {
expect(lines[4].slice(0, 18)).toBe(expectedBorder)
})
})

describe("BoxRenderable - focus-within", () => {
test("hasFocusedDescendant is false initially", async () => {
const parent = new BoxRenderable(testRenderer, {
id: "parent",
focusable: true,
border: true,
width: 10,
height: 5,
})
const child = new BoxRenderable(testRenderer, {
id: "child",
focusable: true,
width: 5,
height: 3,
})

parent.add(child)
testRenderer.root.add(parent)
await renderOnce()

expect(parent.hasFocusedDescendant).toBe(false)
})

test("hasFocusedDescendant becomes true when child is focused", async () => {
const parent = new BoxRenderable(testRenderer, {
id: "parent",
focusable: true,
border: true,
width: 10,
height: 5,
})
const child = new BoxRenderable(testRenderer, {
id: "child",
focusable: true,
width: 5,
height: 3,
})

parent.add(child)
testRenderer.root.add(parent)
await renderOnce()

child.focus()

expect(child.focused).toBe(true)
expect(parent.hasFocusedDescendant).toBe(true)
})

test("hasFocusedDescendant becomes false when child is blurred", async () => {
const parent = new BoxRenderable(testRenderer, {
id: "parent",
focusable: true,
border: true,
width: 10,
height: 5,
})
const child = new BoxRenderable(testRenderer, {
id: "child",
focusable: true,
width: 5,
height: 3,
})

parent.add(child)
testRenderer.root.add(parent)
await renderOnce()

child.focus()
expect(parent.hasFocusedDescendant).toBe(true)

child.blur()
expect(parent.hasFocusedDescendant).toBe(false)
})

test("propagates up the ancestor chain", async () => {
const grandparent = new BoxRenderable(testRenderer, {
id: "grandparent",
focusable: true,
border: true,
width: 20,
height: 10,
})
const parent = new BoxRenderable(testRenderer, {
id: "parent",
focusable: true,
width: 15,
height: 8,
})
const child = new BoxRenderable(testRenderer, {
id: "child",
focusable: true,
width: 5,
height: 3,
})

grandparent.add(parent)
parent.add(child)
testRenderer.root.add(grandparent)
await renderOnce()

child.focus()

expect(parent.hasFocusedDescendant).toBe(true)
expect(grandparent.hasFocusedDescendant).toBe(true)
})
})
3 changes: 2 additions & 1 deletion packages/core/src/renderables/Box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,8 @@ export class BoxRenderable extends Renderable {
}

protected renderSelf(buffer: OptimizedBuffer): void {
const currentBorderColor = this._focused ? this._focusedBorderColor : this._borderColor
const hasFocusWithin = this._focusable && (this._focused || this._hasFocusedDescendant)
const currentBorderColor = hasFocusWithin ? this._focusedBorderColor : this._borderColor

buffer.drawBox({
x: this.x,
Expand Down
43 changes: 33 additions & 10 deletions packages/core/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,11 @@ export class CliRenderer extends EventEmitter implements RenderContext {
private automaticMemorySnapshot: boolean = false
private memorySnapshotInterval: number
private memorySnapshotTimer: TimerHandle | null = null
private lastMemorySnapshot: { heapUsed: number; heapTotal: number; arrayBuffers: number } = {
private lastMemorySnapshot: {
heapUsed: number
heapTotal: number
arrayBuffers: number
} = {
heapUsed: 0,
heapTotal: 0,
arrayBuffers: 0,
Expand Down Expand Up @@ -605,7 +609,11 @@ export class CliRenderer extends EventEmitter implements RenderContext {
private _capabilities: any | null = null
private _latestPointer: { x: number; y: number } = { x: 0, y: 0 }
private _hasPointer: boolean = false
private _lastPointerModifiers: RawMouseEvent["modifiers"] = { shift: false, alt: false, ctrl: false }
private _lastPointerModifiers: RawMouseEvent["modifiers"] = {
shift: false,
alt: false,
ctrl: false,
}
private _currentMousePointerStyle: MousePointerStyle | undefined = undefined

private _currentFocusedRenderable: Renderable | null = null
Expand Down Expand Up @@ -889,10 +897,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {

const prev = this.currentFocusedEditor

if (this._currentFocusedRenderable) {
this._currentFocusedRenderable.blur()
}

this._currentFocusedRenderable?.blur()
this._currentFocusedRenderable = renderable

const next = this.currentFocusedEditor
Expand All @@ -901,6 +906,12 @@ export class CliRenderer extends EventEmitter implements RenderContext {
}
}

public blurRenderable(renderable: Renderable): void {
if (this._currentFocusedRenderable === renderable) {
this._currentFocusedRenderable = null
}
}

private setCapturedRenderable(renderable: Renderable | undefined): void {
if (this.capturedRenderable === renderable) {
return
Expand Down Expand Up @@ -1638,7 +1649,10 @@ export class CliRenderer extends EventEmitter implements RenderContext {
this.updateSelection(maybeRenderable, mouseEvent.x, mouseEvent.y)

if (maybeRenderable) {
const event = new MouseEvent(maybeRenderable, { ...mouseEvent, isDragging: true })
const event = new MouseEvent(maybeRenderable, {
...mouseEvent,
isDragging: true,
})
maybeRenderable.processMouseEvent(event)
}

Expand All @@ -1647,7 +1661,10 @@ export class CliRenderer extends EventEmitter implements RenderContext {

if (mouseEvent.type === "up" && this.currentSelection?.isDragging) {
if (maybeRenderable) {
const event = new MouseEvent(maybeRenderable, { ...mouseEvent, isDragging: true })
const event = new MouseEvent(maybeRenderable, {
...mouseEvent,
isDragging: true,
})
maybeRenderable.processMouseEvent(event)
}

Expand All @@ -1669,7 +1686,10 @@ export class CliRenderer extends EventEmitter implements RenderContext {
this.lastOverRenderable !== this.capturedRenderable &&
!this.lastOverRenderable.isDestroyed
) {
const event = new MouseEvent(this.lastOverRenderable, { ...mouseEvent, type: "out" })
const event = new MouseEvent(this.lastOverRenderable, {
...mouseEvent,
type: "out",
})
this.lastOverRenderable.processMouseEvent(event)
}
this.lastOverRenderable = maybeRenderable
Expand All @@ -1690,7 +1710,10 @@ export class CliRenderer extends EventEmitter implements RenderContext {
}

if (this.capturedRenderable && mouseEvent.type === "up") {
const event = new MouseEvent(this.capturedRenderable, { ...mouseEvent, type: "drag-end" })
const event = new MouseEvent(this.capturedRenderable, {
...mouseEvent,
type: "drag-end",
})
this.capturedRenderable.processMouseEvent(event)
this.capturedRenderable.processMouseEvent(new MouseEvent(this.capturedRenderable, mouseEvent))
if (maybeRenderable) {
Expand Down
45 changes: 45 additions & 0 deletions packages/core/src/tests/renderable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,7 @@ describe("Renderable - Focus", () => {

renderable.focus()
expect(renderable.focused).toBe(true)
expect(testRenderer.currentFocusedRenderable).toEqual(renderable)

renderable.blur()
expect(renderable.focused).toBe(false)
Expand Down Expand Up @@ -817,6 +818,50 @@ describe("Renderable - Focus", () => {
expect(onPasteCalled).toBe(true)
expect(handlePasteCalled).toBe(false)
})

test("blur() calls _ctx.blurRenderable to reset focusedRenderable", () => {
const renderable = new TestFocusableRenderable(testRenderer, { id: "test-blur-context" })
const blurSpy = spyOn(testRenderer, "blurRenderable")

renderable.focus()
expect(renderable.focused).toBe(true)
expect(blurSpy).not.toHaveBeenCalled()
expect(testRenderer.currentFocusedRenderable).toEqual(renderable)

renderable.blur()
expect(blurSpy).toHaveBeenCalledWith(renderable)
expect(blurSpy).toHaveBeenCalledTimes(1)
expect(testRenderer.currentFocusedRenderable).toBeNull()
})

test("destroy() blurs renderable on context when focused", () => {
const renderable = new TestFocusableRenderable(testRenderer, { id: "test-destroy-focused" })
const blurSpy = spyOn(testRenderer, "blurRenderable")

renderable.focus()
expect(renderable.focused).toBe(true)
expect(blurSpy).not.toHaveBeenCalled()
expect(testRenderer.currentFocusedRenderable).toEqual(renderable)

renderable.destroy()
expect(blurSpy).toHaveBeenCalledWith(renderable)
expect(blurSpy).toHaveBeenCalledTimes(1)
expect(renderable.focused).toBe(false)
expect(testRenderer.currentFocusedRenderable).toBeNull()
})

test("destroy() does not call blurRenderable when renderable was not focused", () => {
const renderable = new TestFocusableRenderable(testRenderer, { id: "test-destroy-not-focused" })
const blurSpy = spyOn(testRenderer, "blurRenderable")

// Don't focus the renderable
expect(renderable.focused).toBe(false)

renderable.destroy()
// blur() is called but returns early since renderable wasn't focused
// so blurRenderable is never called
expect(blurSpy).not.toHaveBeenCalled()
})
})

describe("Renderable - Lifecycle", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tests/renderer.focus.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect, beforeEach, afterEach } from "bun:test"
import { test, expect, beforeEach, afterEach, describe } from "bun:test"
import { CliRenderEvents } from "../renderer.js"
import { createTestRenderer, MouseButtons, type MockMouse, type TestRenderer } from "../testing.js"
import { ScrollBoxRenderable } from "../renderables/ScrollBox.js"
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export interface RenderContext extends EventEmitter {
currentFocusedRenderable: Renderable | null
currentFocusedEditor: EditBufferRenderable | null
focusRenderable: (renderable: Renderable) => void
blurRenderable: (renderable: Renderable) => void
registerLifecyclePass: (renderable: Renderable) => void
unregisterLifecyclePass: (renderable: Renderable) => void
getLifecyclePasses: () => Set<Renderable>
Expand Down
Loading