diff --git a/packages/core/src/renderables/Box.test.ts b/packages/core/src/renderables/Box.test.ts index 1aa16a856..d7e5b5d05 100644 --- a/packages/core/src/renderables/Box.test.ts +++ b/packages/core/src/renderables/Box.test.ts @@ -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 { BorderChars, type BorderStyle } from "../lib/border.js" import { RGBA } from "../lib/RGBA.js" let testRenderer: TestRenderer @@ -472,3 +472,185 @@ describe("BoxRenderable - no-op rendering", () => { expect(getCellForeground(2, 0)).toEqual([0, 0, 255, 255]) }) }) + +describe("BoxRenderable - default fallbacks when props are removed", () => { + function topLine(width: number): string { + return captureFrame().split("\n")[0].slice(0, width) + } + + test("border-related setter implies a border on a borderless box", async () => { + const box = new BoxRenderable(testRenderer, { + id: "implied-border", + width: 10, + height: 4, + }) + testRenderer.root.add(box) + + box.borderStyle = "double" + await renderOnce() + + expect(box.border).toBe(true) + expect(topLine(10)).toBe("╔════════╗") + }) + + test("removing the border-implying prop turns the border back off", async () => { + const box = new BoxRenderable(testRenderer, { + id: "removable-border", + width: 10, + height: 4, + borderStyle: "double", + }) + testRenderer.root.add(box) + await renderOnce() + expect(topLine(10)).toBe("╔════════╗") + + box.borderStyle = undefined + await renderOnce() + + expect(box.border).toBe(false) + expect(topLine(10)).toBe(" ") + }) + + test("explicit border: false wins over border-implying props", async () => { + const box = new BoxRenderable(testRenderer, { + id: "explicit-border-off", + width: 10, + height: 4, + border: false, + borderStyle: "double", + }) + testRenderer.root.add(box) + await renderOnce() + + expect(box.border).toBe(false) + expect(topLine(10)).toBe(" ") + }) + + test("removing border keeps the border implied by borderStyle", async () => { + const box = new BoxRenderable(testRenderer, { + id: "border-removed-style-kept", + width: 10, + height: 4, + border: true, + borderStyle: "double", + }) + testRenderer.root.add(box) + + box.border = undefined + await renderOnce() + + expect(box.border).toBe(true) + expect(topLine(10)).toBe("╔════════╗") + }) + + test("removing borderColor falls back to the default color but keeps an explicit border", () => { + const box = new BoxRenderable(testRenderer, { + id: "border-color-reset", + width: 10, + height: 4, + border: true, + borderColor: "#ff0000", + }) + + expect(box.borderColor.toInts()).toEqual([255, 0, 0, 255]) + + box.borderColor = undefined + + expect(box.borderColor.toInts()).toEqual([255, 255, 255, 255]) + expect(box.border).toBe(true) + }) + + test("removing borderColor removes the border it implied", async () => { + const box = new BoxRenderable(testRenderer, { + id: "implied-border-color", + width: 10, + height: 4, + borderColor: "#ff0000", + }) + testRenderer.root.add(box) + await renderOnce() + expect(topLine(10)).toBe("┌────────┐") + + box.borderColor = undefined + await renderOnce() + + expect(box.border).toBe(false) + expect(topLine(10)).toBe(" ") + }) + + test("custom border chars imply a border and removal reverts", async () => { + const box = new BoxRenderable(testRenderer, { + id: "implied-custom-chars", + width: 10, + height: 4, + }) + testRenderer.root.add(box) + + box.customBorderChars = BorderChars.double + await renderOnce() + expect(box.border).toBe(true) + expect(topLine(10)).toBe("╔════════╗") + + box.customBorderChars = undefined + await renderOnce() + expect(box.border).toBe(false) + expect(topLine(10)).toBe(" ") + }) + + test("removing backgroundColor falls back to transparent", () => { + const box = new BoxRenderable(testRenderer, { + id: "bg-reset", + width: 4, + height: 2, + backgroundColor: "#123456", + }) + + expect(box.backgroundColor.toInts()).toEqual([18, 52, 86, 255]) + + box.backgroundColor = undefined + + expect(box.backgroundColor.a).toBe(0) + }) + + test("removing focusedBorderColor falls back to the default", () => { + const box = new BoxRenderable(testRenderer, { + id: "focused-color-reset", + focusable: true, + border: true, + focusedBorderColor: "#ff0000", + }) + + expect(box.focusedBorderColor.toInts()).toEqual([255, 0, 0, 255]) + + box.focusedBorderColor = undefined + + expect(box.focusedBorderColor.toInts()).toEqual([0, 170, 255, 255]) + }) + + test("removing title alignments falls back to left", async () => { + const box = new BoxRenderable(testRenderer, { + id: "alignment-reset", + width: 18, + height: 5, + border: true, + title: "Top", + titleAlignment: "right", + bottomTitle: "Bot", + bottomTitleAlignment: "right", + }) + testRenderer.root.add(box) + await renderOnce() + + let lines = captureFrame().split("\n") + expect(lines[0].slice(0, 18)).toBe("┌────────────Top─┐") + expect(lines[4].slice(0, 18)).toBe("└────────────Bot─┘") + + box.titleAlignment = undefined + box.bottomTitleAlignment = undefined + await renderOnce() + + lines = captureFrame().split("\n") + expect(lines[0].slice(0, 18)).toBe("┌─Top────────────┐") + expect(lines[4].slice(0, 18)).toBe("└─Bot────────────┘") + }) +}) diff --git a/packages/core/src/renderables/Box.ts b/packages/core/src/renderables/Box.ts index 12c34c7ff..7484e03f1 100644 --- a/packages/core/src/renderables/Box.ts +++ b/packages/core/src/renderables/Box.ts @@ -10,10 +10,21 @@ import { getBorderSides, parseBorderStyle, } from "../lib/index.js" -import { type ColorInput, RGBA, parseColor } from "../lib/RGBA.js" +import { type ColorInput, RGBA, hexToRgb, parseColor } from "../lib/RGBA.js" import { isValidPercentage } from "../lib/renderable.validations.js" import type { RenderContext } from "../types.js" +const BOX_DEFAULTS = { + backgroundColor: RGBA.fromValues(0, 0, 0, 0), // transparent + border: false, + borderStyle: "single", + borderColor: hexToRgb("#FFFFFF"), + focusedBorderColor: hexToRgb("#00AAFF"), + shouldFill: true, + titleAlignment: "left", + bottomTitleAlignment: "left", +} satisfies Partial + export interface BoxOptions extends RenderableOptions { backgroundColor?: string | RGBA borderStyle?: BorderStyle @@ -44,31 +55,20 @@ function isGapType(value: any): value is number | undefined { } export class BoxRenderable extends Renderable { - protected _backgroundColor: RGBA - protected _border: boolean | BorderSides[] - protected _borderStyle: BorderStyle - protected _borderColor: RGBA - protected _focusedBorderColor: RGBA + protected _backgroundColor: RGBA | undefined + protected _border: boolean | BorderSides[] | undefined + protected _borderStyle: BorderStyle | undefined + protected _borderColor: RGBA | undefined + protected _focusedBorderColor: RGBA | undefined private _customBorderCharsObj: BorderCharacters | undefined protected _customBorderChars?: Uint32Array protected borderSides: BorderSidesConfig public shouldFill: boolean - protected _title?: string - protected _titleColor?: RGBA - protected _titleAlignment: "left" | "center" | "right" - protected _bottomTitle?: string - protected _bottomTitleAlignment: "left" | "center" | "right" - - protected _defaultOptions = { - backgroundColor: "transparent", - borderStyle: "single", - border: false, - borderColor: "#FFFFFF", - shouldFill: true, - titleAlignment: "left", - bottomTitleAlignment: "left", - focusedBorderColor: "#00AAFF", - } satisfies Partial + protected _title: string | undefined + protected _titleColor: RGBA | undefined + protected _titleAlignment: "left" | "center" | "right" | undefined + protected _bottomTitle: string | undefined + protected _bottomTitleAlignment: "left" | "center" | "right" | undefined constructor(ctx: RenderContext, options: BoxOptions) { super(ctx, options) @@ -77,26 +77,20 @@ export class BoxRenderable extends Renderable { this._focusable = true } - this._backgroundColor = parseColor(options.backgroundColor || this._defaultOptions.backgroundColor) - this._border = options.border ?? this._defaultOptions.border - if ( - !options.border && - (options.borderStyle || options.borderColor || options.focusedBorderColor || options.customBorderChars) - ) { - this._border = true - } - this._borderStyle = parseBorderStyle(options.borderStyle, this._defaultOptions.borderStyle) - this._borderColor = parseColor(options.borderColor || this._defaultOptions.borderColor) - this._focusedBorderColor = parseColor(options.focusedBorderColor || this._defaultOptions.focusedBorderColor) + this._border = options.border + this._borderStyle = options.borderStyle != null ? parseBorderStyle(options.borderStyle) : undefined + this._borderColor = options.borderColor != null ? parseColor(options.borderColor) : undefined + this._focusedBorderColor = options.focusedBorderColor ? parseColor(options.focusedBorderColor) : undefined + this._backgroundColor = options.backgroundColor != null ? parseColor(options.backgroundColor) : undefined this._customBorderCharsObj = options.customBorderChars this._customBorderChars = this._customBorderCharsObj ? borderCharsToArray(this._customBorderCharsObj) : undefined - this.borderSides = getBorderSides(this._border) - this.shouldFill = options.shouldFill ?? this._defaultOptions.shouldFill + this.borderSides = getBorderSides(this.border) + this.shouldFill = options.shouldFill ?? BOX_DEFAULTS.shouldFill this._title = options.title + this._titleAlignment = options.titleAlignment this._titleColor = options.titleColor ? parseColor(options.titleColor) : undefined - this._titleAlignment = options.titleAlignment || this._defaultOptions.titleAlignment this._bottomTitle = options.bottomTitle - this._bottomTitleAlignment = options.bottomTitleAlignment || this._defaultOptions.bottomTitleAlignment + this._bottomTitleAlignment = options.bottomTitleAlignment this.applyYogaBorders() @@ -107,16 +101,11 @@ export class BoxRenderable extends Renderable { } } - private initializeBorder(): void { - // https://github.com/anomalyco/opentui/issues/186 - // Solid-js reconciler does not pass props to constructor on init, - // so we need to initialize the border when supporting properties are set. - // borderStyle, borderColor, focusedBorderColor - if (this._border === false) { - this._border = true - this.borderSides = getBorderSides(this._border) - this.applyYogaBorders() - } + // Recomputes the derived border state after any border-related property + // changes, since `border` can flip on/off without being set itself. + private syncBorderSides(): void { + this.borderSides = getBorderSides(this.border) + this.applyYogaBorders() } public get customBorderChars(): BorderCharacters | undefined { @@ -124,17 +113,19 @@ export class BoxRenderable extends Renderable { } public set customBorderChars(value: BorderCharacters | undefined) { - this._customBorderCharsObj = value - this._customBorderChars = value ? borderCharsToArray(value) : undefined - this.requestRender() + if (this._customBorderCharsObj !== value) { + this._customBorderCharsObj = value + this._customBorderChars = value ? borderCharsToArray(value) : undefined + this.syncBorderSides() + } } public get backgroundColor(): RGBA { - return this._backgroundColor + return this._backgroundColor ?? BOX_DEFAULTS.backgroundColor } public set backgroundColor(value: RGBA | string | undefined) { - const newColor = parseColor(value ?? this._defaultOptions.backgroundColor) + const newColor = value != null ? parseColor(value) : undefined if (this._backgroundColor !== newColor) { this._backgroundColor = newColor this.requestRender() @@ -142,57 +133,68 @@ export class BoxRenderable extends Renderable { } public get border(): boolean | BorderSides[] { - return this._border + if (this._border != null) { + return this._border + } + // https://github.com/anomalyco/opentui/issues/186 + // Reconcilers set properties one by one after construction, so any + // border-related property being set implies a border. + if ( + this._borderStyle != null || + this._borderColor != null || + this._focusedBorderColor != null || + this._customBorderChars != null + ) { + return true + } + return BOX_DEFAULTS.border } - public set border(value: boolean | BorderSides[]) { + public set border(value: boolean | BorderSides[] | undefined) { if (this._border !== value) { this._border = value - this.borderSides = getBorderSides(value) - this.applyYogaBorders() - this.requestRender() + this.syncBorderSides() } } public get borderStyle(): BorderStyle { - return this._borderStyle + return this._borderStyle ?? BOX_DEFAULTS.borderStyle } - public set borderStyle(value: BorderStyle) { - const _value = parseBorderStyle(value, this._defaultOptions.borderStyle) - if (this._borderStyle !== _value || !this._border) { - this._borderStyle = _value - this._customBorderChars = undefined - this.initializeBorder() - this.requestRender() + public set borderStyle(value: BorderStyle | undefined) { + const newValue = value != null ? parseBorderStyle(value) : undefined + if (this._borderStyle !== newValue) { + this._borderStyle = newValue + if (newValue != null) { + // a concrete style replaces previously set custom characters + this._customBorderCharsObj = undefined + this._customBorderChars = undefined + } + this.syncBorderSides() } } public get borderColor(): RGBA { - return this._borderColor + return this._borderColor ?? BOX_DEFAULTS.borderColor } - public set borderColor(value: RGBA | string) { - const newColor = parseColor(value ?? this._defaultOptions.borderColor) + public set borderColor(value: RGBA | string | undefined) { + const newColor = value != null ? parseColor(value) : undefined if (this._borderColor !== newColor) { this._borderColor = newColor - this.initializeBorder() - this.requestRender() + this.syncBorderSides() } } public get focusedBorderColor(): RGBA { - return this._focusedBorderColor + return this._focusedBorderColor ?? BOX_DEFAULTS.focusedBorderColor } - public set focusedBorderColor(value: RGBA | string) { - const newColor = parseColor(value ?? this._defaultOptions.focusedBorderColor) + public set focusedBorderColor(value: RGBA | string | undefined) { + const newColor = value != null ? parseColor(value) : undefined if (this._focusedBorderColor !== newColor) { this._focusedBorderColor = newColor - this.initializeBorder() - if (this._focused) { - this.requestRender() - } + this.syncBorderSides() } } @@ -220,10 +222,10 @@ export class BoxRenderable extends Renderable { } public get titleAlignment(): "left" | "center" | "right" { - return this._titleAlignment + return this._titleAlignment ?? BOX_DEFAULTS.titleAlignment } - public set titleAlignment(value: "left" | "center" | "right") { + public set titleAlignment(value: "left" | "center" | "right" | undefined) { if (this._titleAlignment !== value) { this._titleAlignment = value this.requestRender() @@ -242,10 +244,10 @@ export class BoxRenderable extends Renderable { } public get bottomTitleAlignment(): "left" | "center" | "right" { - return this._bottomTitleAlignment + return this._bottomTitleAlignment ?? BOX_DEFAULTS.bottomTitleAlignment } - public set bottomTitleAlignment(value: "left" | "center" | "right") { + public set bottomTitleAlignment(value: "left" | "center" | "right" | undefined) { if (this._bottomTitleAlignment !== value) { this._bottomTitleAlignment = value this.requestRender() @@ -254,7 +256,7 @@ export class BoxRenderable extends Renderable { protected renderSelf(buffer: OptimizedBuffer): void { const hasBorder = this.borderSides.top || this.borderSides.right || this.borderSides.bottom || this.borderSides.left - const hasVisibleFill = this.shouldFill && this._backgroundColor.a > 0 + const hasVisibleFill = this.shouldFill && this.backgroundColor.a > 0 // Many boxes are used only for layout. Skip drawBox entirely when a box // would not draw pixels so wrapper nodes do not pay the FFI/native cost. if (!hasBorder && !hasVisibleFill) { @@ -262,7 +264,7 @@ export class BoxRenderable extends Renderable { } const hasFocusWithin = this._focusable && (this._focused || this._hasFocusedDescendant) - const currentBorderColor = hasFocusWithin ? this._focusedBorderColor : this._borderColor + const currentBorderColor = hasFocusWithin ? this.focusedBorderColor : this.borderColor const screenX = this._screenX const screenY = this._screenY @@ -271,17 +273,17 @@ export class BoxRenderable extends Renderable { y: screenY, width: this.width, height: this.height, - borderStyle: this._borderStyle, + borderStyle: this.borderStyle, customBorderChars: this._customBorderChars, - border: this._border, + border: this.border, borderColor: currentBorderColor, - backgroundColor: this._backgroundColor, + backgroundColor: this.backgroundColor, shouldFill: this.shouldFill, title: this._title, titleColor: this._titleColor ?? currentBorderColor, - titleAlignment: this._titleAlignment, + titleAlignment: this.titleAlignment, bottomTitle: this._bottomTitle, - bottomTitleAlignment: this._bottomTitleAlignment, + bottomTitleAlignment: this.bottomTitleAlignment, }) }