Skip to content
Open
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
216 changes: 215 additions & 1 deletion packages/core/src/renderables/Box.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"
import { BoxRenderable, type BoxOptions } from "./Box.js"
import { createTestRenderer, type TestRenderer } from "../testing/test-renderer.js"
import type { BorderStyle } from "../lib/border.js"
import type { BorderCharacters, BorderStyle } from "../lib/border.js"
import { RGBA } from "../lib/RGBA.js"

let testRenderer: TestRenderer
Expand Down Expand Up @@ -174,6 +174,220 @@ describe("BoxRenderable - borderStyle validation", () => {
})
})

describe("BoxRenderable - clearing border", () => {
test.each([null, undefined])("setting border to %p normalizes to false", async (value) => {
const box = new BoxRenderable(testRenderer, {
id: "clear-border-prop-box",
border: true,
width: 10,
height: 5,
})

testRenderer.root.add(box)
await renderOnce()

expect(getCellChar(0, 0)).toBe("┌")

box.border = value
await renderOnce()

expect(box.border).toBe(false)
expect(getCellChar(0, 0)).not.toBe("┌")
})
})

describe("BoxRenderable - clearing borderStyle", () => {
test.each([null, undefined])("setting borderStyle to %p removes an implicitly enabled border", async (value) => {
const box = new BoxRenderable(testRenderer, {
id: "clear-border-box",
borderStyle: "single",
width: 10,
height: 5,
})

testRenderer.root.add(box)
await renderOnce()

expect(getCellChar(0, 0)).toBe("┌")

box.borderStyle = value
await renderOnce()

expect(box.border).toBe(false)
expect(getCellChar(0, 0)).not.toBe("┌")
})

test.each([null, undefined])(
"setting borderStyle to %p keeps an explicit border with the default style",
async (value) => {
const box = new BoxRenderable(testRenderer, {
id: "explicit-border-box",
borderStyle: "double",
border: true,
width: 10,
height: 5,
})

testRenderer.root.add(box)
await renderOnce()

expect(getCellChar(0, 0)).toBe("╔")

box.borderStyle = value
await renderOnce()

expect(box.border).toBe(true)
expect(box.borderStyle).toBe("single")
expect(getCellChar(0, 0)).toBe("┌")
},
)

test.each([null, undefined])(
"setting borderStyle to %p keeps a border enabled through the border setter",
async (value) => {
const box = new BoxRenderable(testRenderer, {
id: "setter-border-box",
width: 10,
height: 5,
})

testRenderer.root.add(box)
await renderOnce()

box.borderStyle = "double"
box.border = true
box.borderStyle = value
await renderOnce()

expect(box.border).toBe(true)
expect(getCellChar(0, 0)).toBe("┌")
},
)

test("borderStyle toggles an implicit border on, off, and on again", async () => {
const box = new BoxRenderable(testRenderer, {
id: "toggle-border-box",
width: 10,
height: 5,
})

testRenderer.root.add(box)
await renderOnce()

box.borderStyle = "double"
await renderOnce()
expect(getCellChar(0, 0)).toBe("╔")

box.borderStyle = undefined
await renderOnce()
expect(box.border).toBe(false)
expect(getCellChar(0, 0)).not.toBe("╔")

box.borderStyle = "double"
await renderOnce()
expect(getCellChar(0, 0)).toBe("╔")
})

test.each([null, undefined])("setting borderStyle to %p on a borderless box does not add a border", async (value) => {
const box = new BoxRenderable(testRenderer, {
id: "still-borderless-box",
border: false,
width: 10,
height: 5,
})

testRenderer.root.add(box)
await renderOnce()

box.borderStyle = value
await renderOnce()

expect(box.border).toBe(false)
expect(getCellChar(0, 0)).not.toBe("┌")
})

test("clearing borderStyle preserves explicit custom border chars before re-enabling border", async () => {
const customBorderChars: BorderCharacters = {
topLeft: "A",
topRight: "B",
bottomLeft: "C",
bottomRight: "D",
horizontal: "-",
vertical: "|",
topT: "T",
bottomT: "U",
leftT: "L",
rightT: "R",
cross: "X",
}
const box = new BoxRenderable(testRenderer, {
id: "custom-border-clear-box",
customBorderChars,
width: 10,
height: 5,
})

testRenderer.root.add(box)
await renderOnce()

expect(getCellChar(0, 0)).toBe("A")

box.borderStyle = undefined
await renderOnce()

expect(box.border).toBe(false)

box.border = true
await renderOnce()

expect(box.border).toBe(true)
expect(box.borderStyle).toBe("single")
expect(box.customBorderChars).toBe(customBorderChars)
expect(getCellChar(0, 0)).toBe("A")
})
})

describe("BoxRenderable - clearing border colors", () => {
test.each([null, undefined])("setting borderColor to %p on a borderless box does not add a border", async (value) => {
const box = new BoxRenderable(testRenderer, {
id: "border-color-box",
border: false,
width: 10,
height: 5,
})

testRenderer.root.add(box)
await renderOnce()

box.borderColor = value
await renderOnce()

expect(box.border).toBe(false)
expect(getCellChar(0, 0)).not.toBe("┌")
})

test.each([null, undefined])(
"setting focusedBorderColor to %p on a borderless box does not add a border",
async (value) => {
const box = new BoxRenderable(testRenderer, {
id: "focused-border-color-box",
border: false,
width: 10,
height: 5,
})

testRenderer.root.add(box)
await renderOnce()

box.focusedBorderColor = value
await renderOnce()

expect(box.border).toBe(false)
expect(getCellChar(0, 0)).not.toBe("┌")
},
)
})

describe("BoxRenderable - border titles (top and bottom)", () => {
test("renders top and bottom titles on their respective borders", async () => {
const box = new BoxRenderable(testRenderer, {
Expand Down
54 changes: 44 additions & 10 deletions packages/core/src/renderables/Box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { RenderContext } from "../types.js"
export interface BoxOptions<TRenderable extends Renderable = BoxRenderable> extends RenderableOptions<TRenderable> {
backgroundColor?: string | RGBA
borderStyle?: BorderStyle
border?: boolean | BorderSides[]
border?: boolean | BorderSides[] | null
borderColor?: string | RGBA
customBorderChars?: BorderCharacters
shouldFill?: boolean
Expand Down Expand Up @@ -46,6 +46,7 @@ function isGapType(value: any): value is number | undefined {
export class BoxRenderable extends Renderable {
protected _backgroundColor: RGBA
protected _border: boolean | BorderSides[]
private _implicitBorder = false
protected _borderStyle: BorderStyle
protected _borderColor: RGBA
protected _focusedBorderColor: RGBA
Expand Down Expand Up @@ -84,6 +85,7 @@ export class BoxRenderable extends Renderable {
(options.borderStyle || options.borderColor || options.focusedBorderColor || options.customBorderChars)
) {
this._border = true
this._implicitBorder = true
}
this._borderStyle = parseBorderStyle(options.borderStyle, this._defaultOptions.borderStyle)
this._borderColor = parseColor(options.borderColor || this._defaultOptions.borderColor)
Expand Down Expand Up @@ -114,6 +116,7 @@ export class BoxRenderable extends Renderable {
// borderStyle, borderColor, focusedBorderColor
if (this._border === false) {
this._border = true
this._implicitBorder = true
this.borderSides = getBorderSides(this._border)
this.applyYogaBorders()
}
Expand Down Expand Up @@ -145,10 +148,12 @@ export class BoxRenderable extends Renderable {
return this._border
}

public set border(value: boolean | BorderSides[]) {
if (this._border !== value) {
this._border = value
this.borderSides = getBorderSides(value)
public set border(value: boolean | BorderSides[] | null | undefined) {
const next = value ?? false
this._implicitBorder = false
if (this._border !== next) {
this._border = next
this.borderSides = getBorderSides(next)
this.applyYogaBorders()
this.requestRender()
}
Expand All @@ -158,7 +163,28 @@ export class BoxRenderable extends Renderable {
return this._borderStyle
}

public set borderStyle(value: BorderStyle) {
public set borderStyle(value: BorderStyle | null | undefined) {
// Clearing the style (null/undefined) resets it to the default style and
// removes the border only when the border was implicitly enabled through
// initializeBorder(). An explicit border option keeps a default-styled
// border, so prop application order cannot clobber it.
if (value == null) {
if (this._implicitBorder && this._border !== false) {
this._implicitBorder = false
this._border = false
this.borderSides = getBorderSides(this._border)
this.applyYogaBorders()
}
const fallback = parseBorderStyle(value, this._defaultOptions.borderStyle)
if (this._borderStyle !== fallback) {
this._borderStyle = fallback
}
if (this._customBorderCharsObj === undefined) {
this._customBorderChars = undefined
}
this.requestRender()
return
}
const _value = parseBorderStyle(value, this._defaultOptions.borderStyle)
if (this._borderStyle !== _value || !this._border) {
this._borderStyle = _value
Expand All @@ -172,11 +198,15 @@ export class BoxRenderable extends Renderable {
return this._borderColor
}

public set borderColor(value: RGBA | string) {
public set borderColor(value: RGBA | string | null | undefined) {
const newColor = parseColor(value ?? this._defaultOptions.borderColor)
if (this._borderColor !== newColor) {
this._borderColor = newColor
this.initializeBorder()
// Clearing the color (null/undefined) resets it to the default without
// force-enabling a border on an otherwise borderless box.
if (value != null) {
this.initializeBorder()
}
this.requestRender()
}
}
Expand All @@ -185,11 +215,15 @@ export class BoxRenderable extends Renderable {
return this._focusedBorderColor
}

public set focusedBorderColor(value: RGBA | string) {
public set focusedBorderColor(value: RGBA | string | null | undefined) {
const newColor = parseColor(value ?? this._defaultOptions.focusedBorderColor)
if (this._focusedBorderColor !== newColor) {
this._focusedBorderColor = newColor
this.initializeBorder()
// Clearing the color (null/undefined) resets it to the default without
// force-enabling a border on an otherwise borderless box.
if (value != null) {
this.initializeBorder()
}
if (this._focused) {
this.requestRender()
}
Expand Down
Loading