diff --git a/packages/core/src/editor-view.ts b/packages/core/src/editor-view.ts index 4ee917d0e..627e18f39 100644 --- a/packages/core/src/editor-view.ts +++ b/packages/core/src/editor-view.ts @@ -262,6 +262,11 @@ export class EditorView { this.lib.editorViewSetTabIndicatorColor(this.viewPtr, color) } + public setMaskCodepoint(codepoint: number): void { + this.guard() + this.lib.editorViewSetMaskCodepoint(this.viewPtr, codepoint) + } + public measureForDimensions(width: number, height: number): { lineCount: number; widthColsMax: number } | null { this.guard() if (!this._textBufferViewPtr) { diff --git a/packages/core/src/renderables/Input.test.ts b/packages/core/src/renderables/Input.test.ts index 9d92de927..d95893f0d 100644 --- a/packages/core/src/renderables/Input.test.ts +++ b/packages/core/src/renderables/Input.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, afterAll, beforeAll } from "bun:test" import { InputRenderable, type InputRenderableOptions, InputRenderableEvents } from "./Input.js" import { decodePasteBytes } from "../lib/paste.js" import { createTestRenderer } from "../testing/test-renderer.js" +import { OptimizedBuffer } from "../buffer.js" import type { KeyEvent } from "../lib/KeyHandler.js" const { renderer, mockInput } = await createTestRenderer({}) @@ -1225,4 +1226,167 @@ describe("InputRenderable", () => { expect(input.cursorOffset).toBe(5) }) }) + + describe("Password Input", () => { + function getCharAt(buffer: OptimizedBuffer, x: number, y: number): number { + return buffer.buffers.char[y * buffer.width + x] ?? 0 + } + + it("should return real value from plainText, not masked", () => { + const { input } = createInputRenderable({ + width: 20, + height: 1, + type: "password", + value: "secret", + }) + + expect(input.plainText).toBe("secret") + expect(input.value).toBe("secret") + }) + + it("should render masked characters in output", () => { + const { input } = createInputRenderable({ + width: 20, + height: 1, + type: "password", + value: "abc", + }) + + const buffer = OptimizedBuffer.create(20, 1, "wcwidth") + buffer.drawEditorView(input.editorView, 0, 0) + + const maskCp = "●".codePointAt(0)! + expect(getCharAt(buffer, 0, 0)).toBe(maskCp) + expect(getCharAt(buffer, 1, 0)).toBe(maskCp) + expect(getCharAt(buffer, 2, 0)).toBe(maskCp) + + buffer.destroy() + }) + + it("should handle backspace correctly", () => { + const { input } = createInputRenderable({ + width: 20, + height: 1, + type: "password", + value: "abc", + }) + + input.focus() + input.deleteCharBackward() + expect(input.plainText).toBe("ab") + expect(input.cursorOffset).toBe(2) + }) + + it("should maintain correct cursor position after typing", () => { + const { input } = createInputRenderable({ + width: 20, + height: 1, + type: "password", + }) + + input.focus() + input.insertText("hello") + expect(input.cursorOffset).toBe(5) + expect(input.plainText).toBe("hello") + }) + + it("should not mask placeholder text", () => { + const { input } = createInputRenderable({ + width: 20, + height: 1, + type: "password", + placeholder: "type password...", + }) + + const buffer = OptimizedBuffer.create(20, 1, "wcwidth") + buffer.drawEditorView(input.editorView, 0, 0) + + // Placeholder "t" should render as "t", not as the mask character + expect(getCharAt(buffer, 0, 0)).toBe("t".codePointAt(0)!) + + buffer.destroy() + }) + + it("should use custom passwordChar", () => { + const { input } = createInputRenderable({ + width: 20, + height: 1, + type: "password", + passwordChar: "*", + value: "abc", + }) + + const buffer = OptimizedBuffer.create(20, 1, "wcwidth") + buffer.drawEditorView(input.editorView, 0, 0) + + expect(getCharAt(buffer, 0, 0)).toBe("*".codePointAt(0)!) + expect(getCharAt(buffer, 1, 0)).toBe("*".codePointAt(0)!) + + buffer.destroy() + }) + + it("should not mask when type is text", () => { + const { input } = createInputRenderable({ + width: 20, + height: 1, + type: "text", + value: "abc", + }) + + const buffer = OptimizedBuffer.create(20, 1, "wcwidth") + buffer.drawEditorView(input.editorView, 0, 0) + + expect(getCharAt(buffer, 0, 0)).toBe("a".codePointAt(0)!) + expect(getCharAt(buffer, 1, 0)).toBe("b".codePointAt(0)!) + + buffer.destroy() + }) + + it("should warn and fall back to default for invalid passwordChar", () => { + const warnings: string[] = [] + const origWarn = console.warn + console.warn = (msg: string) => warnings.push(msg) + + try { + const { input } = createInputRenderable({ + width: 20, + height: 1, + type: "password", + passwordChar: "**", + value: "abc", + }) + + expect(warnings.length).toBe(1) + expect(input.passwordChar).toBe("●") + } finally { + console.warn = origWarn + } + }) + + it("should toggle masking when type changes", () => { + const { input } = createInputRenderable({ + width: 20, + height: 1, + value: "abc", + }) + + const buffer = OptimizedBuffer.create(20, 1, "wcwidth") + + // Initially text + buffer.drawEditorView(input.editorView, 0, 0) + expect(getCharAt(buffer, 0, 0)).toBe("a".codePointAt(0)!) + + // Switch to password + input.type = "password" + buffer.drawEditorView(input.editorView, 0, 0) + expect(getCharAt(buffer, 0, 0)).toBe("●".codePointAt(0)!) + + // Switch back to text + input.type = "text" + buffer.drawEditorView(input.editorView, 0, 0) + expect(getCharAt(buffer, 0, 0)).toBe("a".codePointAt(0)!) + + buffer.destroy() + }) + }) }) diff --git a/packages/core/src/renderables/Input.ts b/packages/core/src/renderables/Input.ts index 5f5ad8f45..2df19761b 100644 --- a/packages/core/src/renderables/Input.ts +++ b/packages/core/src/renderables/Input.ts @@ -21,6 +21,10 @@ export interface InputRenderableOptions extends Omit< maxLength?: number /** Placeholder text (Input only supports string, not StyledText) */ placeholder?: string + /** Input type ("text" or "password") */ + type?: "text" | "password" + /** Character used to mask input when type is "password" */ + passwordChar?: string } // TODO: make this just plain strings instead of an enum (same for other events) @@ -44,6 +48,8 @@ export enum InputRenderableEvents { export class InputRenderable extends TextareaRenderable { private _maxLength: number private _lastCommittedValue: string = "" + private _type: "text" | "password" + private _passwordChar: string // Only specify defaults that differ from TextareaRenderable/EditBufferRenderable private static readonly defaultOptions = { @@ -52,6 +58,8 @@ export class InputRenderable extends TextareaRenderable { // Input-specific maxLength: 1000, value: "", + type: "text", + passwordChar: "●", } satisfies Partial constructor(ctx: RenderContext, options: InputRenderableOptions) { @@ -78,6 +86,16 @@ export class InputRenderable extends TextareaRenderable { this._maxLength = maxLength this._lastCommittedValue = this.plainText + this._type = options.type ?? defaults.type + if (options.passwordChar) { + this._passwordChar = InputRenderable.validatePasswordChar(options.passwordChar) + } else { + this._passwordChar = defaults.passwordChar + } + + if (this._type === "password") { + this.applyMask() + } // Set cursor to end of initial value if (initialValue) { @@ -85,6 +103,25 @@ export class InputRenderable extends TextareaRenderable { } } + private static validatePasswordChar(value: string): string { + if (value.length !== 1) { + console.warn( + `Invalid passwordChar "${value}", falling back to "${InputRenderable.defaultOptions.passwordChar}". ` + + `passwordChar must be a single character.`, + ) + return InputRenderable.defaultOptions.passwordChar + } + return value + } + + // Note: the mask codepoint replaces each source character 1:1 in the renderer. + // This assumes the mask char and source characters share the same display width. + // For single-line password inputs (ASCII text, single-width mask) this is correct. + private applyMask(): void { + const codepoint = this._passwordChar.codePointAt(0)! + this.editorView.setMaskCodepoint(this._type === "password" ? codepoint : 0) + } + /** * Prevent newlines in single-line input */ @@ -241,6 +278,33 @@ export class InputRenderable extends TextareaRenderable { return typeof p === "string" ? p : "" } + public get type(): "text" | "password" { + return this._type + } + + public set type(value: "text" | "password") { + if (this._type !== value) { + this._type = value + this.applyMask() + this.requestRender() + } + } + + public get passwordChar(): string { + return this._passwordChar + } + + public set passwordChar(value: string) { + const char = InputRenderable.validatePasswordChar(value) + if (this._passwordChar !== char) { + this._passwordChar = char + if (this._type === "password") { + this.applyMask() + this.requestRender() + } + } + } + public override set initialValue(value: string) { void 0 } diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index 90f79ac1d..7f6987be4 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -1025,6 +1025,10 @@ function getOpenTUILib(libPath?: string) { args: ["ptr", "ptr"], returns: "void", }, + editorViewSetMaskCodepoint: { + args: ["ptr", "u32"], + returns: "void", + }, getArenaAllocatedBytes: { args: [], @@ -1786,6 +1790,7 @@ export interface RenderLib { ) => void editorViewSetTabIndicator: (view: Pointer, indicator: number) => void editorViewSetTabIndicatorColor: (view: Pointer, color: RGBA) => void + editorViewSetMaskCodepoint: (view: Pointer, codepoint: number) => void bufferPushScissorRect: (buffer: Pointer, x: number, y: number, width: number, height: number) => void bufferPopScissorRect: (buffer: Pointer) => void @@ -3838,6 +3843,10 @@ class FFIRenderLib implements RenderLib { this.opentui.symbols.editorViewSetTabIndicatorColor(view, color.buffer) } + public editorViewSetMaskCodepoint(view: Pointer, codepoint: number): void { + this.opentui.symbols.editorViewSetMaskCodepoint(view, codepoint) + } + public onNativeEvent(name: string, handler: (data: ArrayBuffer) => void): void { this._nativeEvents.on(name, handler) } diff --git a/packages/core/src/zig/buffer.zig b/packages/core/src/zig/buffer.zig index 7575bbdeb..c311f9fa2 100644 --- a/packages/core/src/zig/buffer.zig +++ b/packages/core/src/zig/buffer.zig @@ -1502,7 +1502,9 @@ pub const OptimizedBuffer = struct { } } else { var encoded_char: u32 = 0; - if (grapheme_bytes.len == 1 and g_width == 1 and grapheme_bytes[0] >= 32) { + if (view.getMaskCodepoint()) |mask_cp| { + encoded_char = mask_cp; + } else if (grapheme_bytes.len == 1 and g_width == 1 and grapheme_bytes[0] >= 32) { encoded_char = @as(u32, grapheme_bytes[0]); } else { const gid = self.pool.alloc(grapheme_bytes) catch |err| { diff --git a/packages/core/src/zig/editor-view.zig b/packages/core/src/zig/editor-view.zig index 3b18cebd1..3b58fb6e0 100644 --- a/packages/core/src/zig/editor-view.zig +++ b/packages/core/src/zig/editor-view.zig @@ -45,6 +45,7 @@ pub const EditorView = struct { placeholder_buffer: ?*UnifiedTextBuffer, placeholder_syntax_style: ?*ss.SyntaxStyle, placeholder_active: bool, + mask_codepoint: ?u32, // Memory management global_allocator: Allocator, @@ -83,6 +84,7 @@ pub const EditorView = struct { .placeholder_buffer = null, .placeholder_syntax_style = null, .placeholder_active = false, + .mask_codepoint = null, .global_allocator = global_allocator, }; @@ -799,4 +801,13 @@ pub const EditorView = struct { pub fn getTabIndicatorColor(self: *const EditorView) ?tb.RGBA { return self.text_buffer_view.getTabIndicatorColor(); } + + pub fn setMaskCodepoint(self: *EditorView, codepoint: ?u32) void { + self.mask_codepoint = codepoint; + } + + pub fn getMaskCodepoint(self: *const EditorView) ?u32 { + if (self.placeholder_active) return null; + return self.mask_codepoint; + } }; diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index b95b7e0b8..cf15ecaa1 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -1531,6 +1531,10 @@ export fn editorViewSetTabIndicatorColor(view: *editor_view.EditorView, color: [ view.setTabIndicatorColor(utils.f32PtrToRGBA(color)); } +export fn editorViewSetMaskCodepoint(view: *editor_view.EditorView, codepoint: u32) void { + view.setMaskCodepoint(if (codepoint == 0) null else codepoint); +} + export fn bufferDrawEditorView( bufferPtr: *buffer.OptimizedBuffer, viewPtr: *editor_view.EditorView, diff --git a/packages/core/src/zig/text-buffer-view.zig b/packages/core/src/zig/text-buffer-view.zig index ba74df7e2..13c6be036 100644 --- a/packages/core/src/zig/text-buffer-view.zig +++ b/packages/core/src/zig/text-buffer-view.zig @@ -764,6 +764,10 @@ pub const UnifiedTextBufferView = struct { return self.tab_indicator_color; } + pub fn getMaskCodepoint(_: *const Self) ?u32 { + return null; + } + pub fn setTruncate(self: *Self, truncate: bool) void { if (self.truncate != truncate) { self.truncate = truncate;