[GH-ISSUE #510] Core renderables should be easier to extend/override/shouldn't have private properties #133

Open
opened 2026-03-02 23:44:47 +03:00 by kerem · 1 comment
Owner

Originally created by @Quackzoer on GitHub (Jan 11, 2026).
Original GitHub issue: https://github.com/anomalyco/opentui/issues/510

I wanted to create a small set of framework agnostic inputs with bindings to React. Right now I'm working on password renderable and I don't know if it's my code or if core renderables are really tough to work on. Keep in mind that this code had been heavily experimented on so it doesn't look great.

import {
    InputRenderable,
    InputRenderableEvents,
    type InputRenderableOptions,
    type KeyEvent,
    type RenderContext
} from "@opentui/core";

export enum PasswordEvents {
    INPUT = "input",
    CHANGE = "change",
    SUBMIT = "submit",
}

export interface PasswordOptions extends InputRenderableOptions {
    maskChar?: string; // Character to use for masking (default: '•')
    showPassword?: boolean; // Whether to show actual password (default: false)
    placeholder?: string; // Placeholder text when input is empty
}

export class PasswordRenderable extends InputRenderable {
    protected override _focusable: boolean = true;
    private readonly maskChar: string;
    private _showPassword: boolean;
    private realValue: string = "";

    constructor(ctx: RenderContext, options: PasswordOptions) {
        super(ctx, options);

        this.maskChar = options.maskChar || '•';
        this._showPassword = options.showPassword || false;

        if (options.value) {
            this.realValue = options.value;
            super.value = this._showPassword ? this.realValue : this.maskChar.repeat(this.realValue.length);
        }
    }

    override get value(): string {
        return this.realValue;
    }

    override set value(val: string) {
        this.realValue = val;
        super.value = this.showPassword ? this.realValue : this.maskChar.repeat(this.realValue.length);
    }

    override insertText(text: string): void {
        const cursorPos = this.cursorPosition;
        const beforeCursor = this.realValue.substring(0, cursorPos);
        const afterCursor = this.realValue.substring(cursorPos);
        this.realValue = beforeCursor + text + afterCursor;

        super.value = this.showPassword ? this.realValue : this.maskChar.repeat(this.realValue.length);
        this.cursorPosition = cursorPos + text.length;
    }

    override deleteCharacter(direction: "backward" | "forward"): void {
        const cursorPos = this.cursorPosition;

        if (direction === "backward" && cursorPos > 0) {
            const beforeCursor = this.realValue.substring(0, cursorPos - 1);
            const afterCursor = this.realValue.substring(cursorPos);
            this.realValue = beforeCursor + afterCursor;

            super.value = this.showPassword ? this.realValue : this.maskChar.repeat(this.realValue.length);
            this.cursorPosition = cursorPos - 1;
        } else if (direction === "forward" && cursorPos < this.realValue.length) {
            const beforeCursor = this.realValue.substring(0, cursorPos);
            const afterCursor = this.realValue.substring(cursorPos + 1);
            this.realValue = beforeCursor + afterCursor;
            super.value = this.showPassword ? this.realValue : this.maskChar.repeat(this.realValue.length);
        }
    }

     /*ooooo   ooooo                              
        `888'   `888'                              
        888     888   .ooooo.  oooo d8b  .ooooo.  
        888ooooo888  d88' `88b `888""8P d88' `88b 
        888     888  888ooo888  888     888ooo888 
        888     888  888    .o  888     888    .o 
        o888o   o888o `Y8bod8P' d888b    `Y8bod8P' 
                                                
                                                
                                                */
    // Override handleKeyPress to emit real value on submit
    override handleKeyPress(key: KeyEvent): boolean {
        const result = super.handleKeyPress(key);

        if (key.name === "return" || key.name === "linefeed") {
            this.emit(InputRenderableEvents.ENTER, this.realValue);
        }

        return result;
    }

    get showPassword(): boolean {
        return this._showPassword;
    }

    set showPassword(show: boolean) {
        if (this._showPassword !== show) {
            this._showPassword = show;
            super.value = this._showPassword ? this.realValue : this.maskChar.repeat(this.realValue.length);
        }
    }
}

This causes onSubmit to run twice (first time with e.g ***, second with abc). From what I tested value is fortunately set only once but I don't see why I can't directly access this._value. I know it's a good practice to encapsulate class properties but it would be great to have some way to create new renderables based on existing core ones. Recently I was working on checkbox and handling onChange also needed some workarounds because react-reconciler has hardcoded values. Ultimately I just added onChange in react wrapper:

interface ReactCheckboxOptions extends CheckboxOptions {
    focused?: boolean;
    onChange?: (selectedOptions: CheckboxOption[]) => void ;
}

class ReactCheckboxRenderable extends CheckboxRenderable {
    public onChange?: (selectedOptions: CheckboxOption[]) => void;
    constructor(ctx: RenderContext, {onChange, ...options}: ReactCheckboxOptions) {
        super(ctx, options);
        this.onChange = onChange;
    }

    protected override submit() {
        super.submit();
        this.onChange?.(this.getSelected());
    }
}

export type CheckboxProps = ComponentProps<ReactCheckboxOptions, ReactCheckboxRenderable> &
  ReactProps<ReactCheckboxRenderable> & {
    focused?: boolean;
    onChange?: (indices: number[], selectedOptions: CheckboxOption[]) => void;
  };

But it would be great I think if there were some rules like every renderable which has value when this value changes emits onChange event and react reconciler will treat all renderables equally.

Maybe it's me and I'm the one with wrong mindset or approach,, if so please let me know, in the meantime I will tryt my best to create these simple components and I will share them

Image
Originally created by @Quackzoer on GitHub (Jan 11, 2026). Original GitHub issue: https://github.com/anomalyco/opentui/issues/510 I wanted to create a small set of framework agnostic inputs with bindings to React. Right now I'm working on password renderable and I don't know if it's my code or if core renderables are really tough to work on. Keep in mind that this code had been heavily experimented on so it doesn't look great. ```ts import { InputRenderable, InputRenderableEvents, type InputRenderableOptions, type KeyEvent, type RenderContext } from "@opentui/core"; export enum PasswordEvents { INPUT = "input", CHANGE = "change", SUBMIT = "submit", } export interface PasswordOptions extends InputRenderableOptions { maskChar?: string; // Character to use for masking (default: '•') showPassword?: boolean; // Whether to show actual password (default: false) placeholder?: string; // Placeholder text when input is empty } export class PasswordRenderable extends InputRenderable { protected override _focusable: boolean = true; private readonly maskChar: string; private _showPassword: boolean; private realValue: string = ""; constructor(ctx: RenderContext, options: PasswordOptions) { super(ctx, options); this.maskChar = options.maskChar || '•'; this._showPassword = options.showPassword || false; if (options.value) { this.realValue = options.value; super.value = this._showPassword ? this.realValue : this.maskChar.repeat(this.realValue.length); } } override get value(): string { return this.realValue; } override set value(val: string) { this.realValue = val; super.value = this.showPassword ? this.realValue : this.maskChar.repeat(this.realValue.length); } override insertText(text: string): void { const cursorPos = this.cursorPosition; const beforeCursor = this.realValue.substring(0, cursorPos); const afterCursor = this.realValue.substring(cursorPos); this.realValue = beforeCursor + text + afterCursor; super.value = this.showPassword ? this.realValue : this.maskChar.repeat(this.realValue.length); this.cursorPosition = cursorPos + text.length; } override deleteCharacter(direction: "backward" | "forward"): void { const cursorPos = this.cursorPosition; if (direction === "backward" && cursorPos > 0) { const beforeCursor = this.realValue.substring(0, cursorPos - 1); const afterCursor = this.realValue.substring(cursorPos); this.realValue = beforeCursor + afterCursor; super.value = this.showPassword ? this.realValue : this.maskChar.repeat(this.realValue.length); this.cursorPosition = cursorPos - 1; } else if (direction === "forward" && cursorPos < this.realValue.length) { const beforeCursor = this.realValue.substring(0, cursorPos); const afterCursor = this.realValue.substring(cursorPos + 1); this.realValue = beforeCursor + afterCursor; super.value = this.showPassword ? this.realValue : this.maskChar.repeat(this.realValue.length); } } /*ooooo ooooo `888' `888' 888 888 .ooooo. oooo d8b .ooooo. 888ooooo888 d88' `88b `888""8P d88' `88b 888 888 888ooo888 888 888ooo888 888 888 888 .o 888 888 .o o888o o888o `Y8bod8P' d888b `Y8bod8P' */ // Override handleKeyPress to emit real value on submit override handleKeyPress(key: KeyEvent): boolean { const result = super.handleKeyPress(key); if (key.name === "return" || key.name === "linefeed") { this.emit(InputRenderableEvents.ENTER, this.realValue); } return result; } get showPassword(): boolean { return this._showPassword; } set showPassword(show: boolean) { if (this._showPassword !== show) { this._showPassword = show; super.value = this._showPassword ? this.realValue : this.maskChar.repeat(this.realValue.length); } } } ``` This causes onSubmit to run twice (first time with e.g `***`, second with `abc`). From what I tested value is fortunately set only once but I don't see why I can't directly access `this._value`. I know it's a good practice to encapsulate class properties but it would be great to have some way to create new renderables based on existing core ones. Recently I was working on checkbox and handling `onChange` also needed some workarounds because react-reconciler has hardcoded values. Ultimately I just added `onChange` in react wrapper: ```ts interface ReactCheckboxOptions extends CheckboxOptions { focused?: boolean; onChange?: (selectedOptions: CheckboxOption[]) => void ; } class ReactCheckboxRenderable extends CheckboxRenderable { public onChange?: (selectedOptions: CheckboxOption[]) => void; constructor(ctx: RenderContext, {onChange, ...options}: ReactCheckboxOptions) { super(ctx, options); this.onChange = onChange; } protected override submit() { super.submit(); this.onChange?.(this.getSelected()); } } export type CheckboxProps = ComponentProps<ReactCheckboxOptions, ReactCheckboxRenderable> & ReactProps<ReactCheckboxRenderable> & { focused?: boolean; onChange?: (indices: number[], selectedOptions: CheckboxOption[]) => void; }; ``` But it would be great I think if there were some rules like every renderable which has `value` when this value changes emits `onChange` event and react reconciler will treat all renderables equally. Maybe it's me and I'm the one with wrong mindset or approach,, if so please let me know, in the meantime I will tryt my best to create these simple components and I will share them <img width="747" height="269" alt="Image" src="https://github.com/user-attachments/assets/ee11f674-8dd0-4a43-95a5-bb289a7f3ffa" />
Author
Owner

@kommander commented on GitHub (Jan 12, 2026):

Hey, thanks for the feedback. @msmps is working on some framework agnostic UI components and hit some limits as well. I saw he had added a checkbox as well. Haven't seen the implementation yet, but I think he's about to publish these as well.

I agree, something like this should be straight forward to implement and if it isn't yet, I am open to improve the interfaces.

That said, for a component like this I would do the override only for rendering and let the rest behave the same, starting with something like this:

import { type RenderContext } from "../types"
import { TextareaRenderable, type TextareaOptions } from "./Textarea"
import type { OptimizedBuffer } from "../buffer"
import { TextBuffer } from "../text-buffer"
import { TextBufferView } from "../text-buffer-view"

export interface PasswordOptions extends TextareaOptions {
  conceal?: boolean
  concealChar?: string
}

export class PasswordRenderable extends TextareaRenderable {
  private _conceal: boolean
  private _concealChar: string

  // Parallel buffer/view for concealed display
  private _concealBuffer: TextBuffer
  private _concealView: TextBufferView

  private static readonly passwordDefaults = {
    conceal: true,
    concealChar: "*",
  } satisfies Partial<PasswordOptions>

  constructor(ctx: RenderContext, options: PasswordOptions) {
    super(ctx, options)
    this._conceal = options.conceal ?? PasswordRenderable.passwordDefaults.conceal
    this._concealChar = options.concealChar ?? PasswordRenderable.passwordDefaults.concealChar

    // Create concealed display buffer
    this._concealBuffer = TextBuffer.create(ctx.widthMethod)
    this._concealView = TextBufferView.create(this._concealBuffer)

    // Sync settings with main buffer
    this._concealBuffer.setDefaultFg(this.textColor)
    this._concealBuffer.setDefaultBg(this.backgroundColor)
    this._concealView.setWrapMode(this._wrapMode)

    if (this._wrapMode !== "none" && this.width > 0) {
      this._concealView.setWrapWidth(this.width)
    }

    if (this.width > 0 && this.height > 0) {
      this._concealView.setViewport(0, 0, this.width, this.height)
    }

    // Listen to content changes to update concealed buffer
    this.editBuffer.on("content-changed", () => {
      this.updateConcealedBuffer()
    })

    // Initial sync
    this.updateConcealedBuffer()
  }

  private updateConcealedBuffer(): void {
    const text = this.plainText
    // Replace each character with conceal char, preserving newlines
    const concealed = text
      .split("\n")
      .map((line) => this._concealChar.repeat(line.length))
      .join("\n")
    this._concealBuffer.setText(concealed)
  }

  get conceal(): boolean {
    return this._conceal
  }

  set conceal(value: boolean) {
    if (this._conceal !== value) {
      this._conceal = value
      this.requestRender()
    }
  }

  get concealChar(): string {
    return this._concealChar
  }

  set concealChar(value: string) {
    if (this._concealChar !== value) {
      this._concealChar = value
      if (this._conceal) {
        this.updateConcealedBuffer()
        this.requestRender()
      }
    }
  }

  protected override onResize(width: number, height: number): void {
    super.onResize(width, height)
    // Keep concealed view in sync
    this._concealView.setViewport(0, 0, width, height)
    if (this._wrapMode !== "none" && width > 0) {
      this._concealView.setWrapWidth(width)
    }
  }

  protected override renderSelf(buffer: OptimizedBuffer): void {
    if (!this._conceal) {
      super.renderSelf(buffer)
      return
    }

    // Sync colors before rendering
    this._concealBuffer.setDefaultFg(this.textColor)
    this._concealBuffer.setDefaultBg(this.backgroundColor)

    // Draw the concealed buffer instead
    buffer.drawTextBuffer(this._concealView, this.x, this.y)
  }

  override destroy(): void {
    this._concealView.destroy()
    this._concealBuffer.destroy()
    super.destroy()
  }
}
<!-- gh-comment-id:3736537676 --> @kommander commented on GitHub (Jan 12, 2026): Hey, thanks for the feedback. @msmps is working on some framework agnostic UI components and hit some limits as well. I saw he had added a checkbox as well. Haven't seen the implementation yet, but I think he's about to publish these as well. I agree, something like this should be straight forward to implement and if it isn't yet, I am open to improve the interfaces. That said, for a component like this I would do the override only for rendering and let the rest behave the same, starting with something like this: ```ts import { type RenderContext } from "../types" import { TextareaRenderable, type TextareaOptions } from "./Textarea" import type { OptimizedBuffer } from "../buffer" import { TextBuffer } from "../text-buffer" import { TextBufferView } from "../text-buffer-view" export interface PasswordOptions extends TextareaOptions { conceal?: boolean concealChar?: string } export class PasswordRenderable extends TextareaRenderable { private _conceal: boolean private _concealChar: string // Parallel buffer/view for concealed display private _concealBuffer: TextBuffer private _concealView: TextBufferView private static readonly passwordDefaults = { conceal: true, concealChar: "*", } satisfies Partial<PasswordOptions> constructor(ctx: RenderContext, options: PasswordOptions) { super(ctx, options) this._conceal = options.conceal ?? PasswordRenderable.passwordDefaults.conceal this._concealChar = options.concealChar ?? PasswordRenderable.passwordDefaults.concealChar // Create concealed display buffer this._concealBuffer = TextBuffer.create(ctx.widthMethod) this._concealView = TextBufferView.create(this._concealBuffer) // Sync settings with main buffer this._concealBuffer.setDefaultFg(this.textColor) this._concealBuffer.setDefaultBg(this.backgroundColor) this._concealView.setWrapMode(this._wrapMode) if (this._wrapMode !== "none" && this.width > 0) { this._concealView.setWrapWidth(this.width) } if (this.width > 0 && this.height > 0) { this._concealView.setViewport(0, 0, this.width, this.height) } // Listen to content changes to update concealed buffer this.editBuffer.on("content-changed", () => { this.updateConcealedBuffer() }) // Initial sync this.updateConcealedBuffer() } private updateConcealedBuffer(): void { const text = this.plainText // Replace each character with conceal char, preserving newlines const concealed = text .split("\n") .map((line) => this._concealChar.repeat(line.length)) .join("\n") this._concealBuffer.setText(concealed) } get conceal(): boolean { return this._conceal } set conceal(value: boolean) { if (this._conceal !== value) { this._conceal = value this.requestRender() } } get concealChar(): string { return this._concealChar } set concealChar(value: string) { if (this._concealChar !== value) { this._concealChar = value if (this._conceal) { this.updateConcealedBuffer() this.requestRender() } } } protected override onResize(width: number, height: number): void { super.onResize(width, height) // Keep concealed view in sync this._concealView.setViewport(0, 0, width, height) if (this._wrapMode !== "none" && width > 0) { this._concealView.setWrapWidth(width) } } protected override renderSelf(buffer: OptimizedBuffer): void { if (!this._conceal) { super.renderSelf(buffer) return } // Sync colors before rendering this._concealBuffer.setDefaultFg(this.textColor) this._concealBuffer.setDefaultBg(this.backgroundColor) // Draw the concealed buffer instead buffer.drawTextBuffer(this._concealView, this.x, this.y) } override destroy(): void { this._concealView.destroy() this._concealBuffer.destroy() super.destroy() } } ```
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/opentui#133
No description provided.