Skip to content
Closed
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
5 changes: 5 additions & 0 deletions packages/core/src/editor-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
164 changes: 164 additions & 0 deletions packages/core/src/renderables/Input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({})
Expand Down Expand Up @@ -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()
})
})
})
64 changes: 64 additions & 0 deletions packages/core/src/renderables/Input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 = {
Expand All @@ -52,6 +58,8 @@ export class InputRenderable extends TextareaRenderable {
// Input-specific
maxLength: 1000,
value: "",
type: "text",
passwordChar: "●",
} satisfies Partial<InputRenderableOptions>

constructor(ctx: RenderContext, options: InputRenderableOptions) {
Expand All @@ -78,13 +86,42 @@ 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) {
this.cursorOffset = initialValue.length
}
}

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
*/
Expand Down Expand Up @@ -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
}
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/zig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,10 @@ function getOpenTUILib(libPath?: string) {
args: ["ptr", "ptr"],
returns: "void",
},
editorViewSetMaskCodepoint: {
args: ["ptr", "u32"],
returns: "void",
},

getArenaAllocatedBytes: {
args: [],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/zig/buffer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/zig/editor-view.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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;
}
};
4 changes: 4 additions & 0 deletions packages/core/src/zig/lib.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/zig/text-buffer-view.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading