mirror of
https://github.com/anomalyco/opentui.git
synced 2026-09-19 01:26:03 +08:00
terminal: match Ghostty wide grapheme widths (#1415)
Ghostty 1.3 renders certain multi-code-point graphemes two columns wide. Use its width profile for direct sessions so rendering, wrapping, and cursor placement stay aligned with the terminal. Fix #1401
This commit is contained in:
@@ -1439,8 +1439,7 @@ export class CliRenderer extends EventEmitter implements RenderContext {
|
||||
}
|
||||
|
||||
public get widthMethod(): WidthMethod {
|
||||
const caps = this.capabilities
|
||||
return caps?.unicode === "wcwidth" ? "wcwidth" : "unicode"
|
||||
return this.capabilities?.unicode ?? this.nextRenderBuffer.widthMethod
|
||||
}
|
||||
|
||||
public get frameId(): number {
|
||||
|
||||
@@ -1334,6 +1334,42 @@ test("custom stdout defaults to remote env behavior", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("Ghostty width profile reaches renderer-owned buffers", async () => {
|
||||
const previousTermProgram = process.env.TERM_PROGRAM
|
||||
const previousTermProgramVersion = process.env.TERM_PROGRAM_VERSION
|
||||
process.env.TERM_PROGRAM = "ghostty"
|
||||
process.env.TERM_PROGRAM_VERSION = "1.3.1"
|
||||
|
||||
try {
|
||||
const renderer = new CliRenderer(createTestStdin(), createCollectingStdout(80, 24), 80, 24, {
|
||||
remote: false,
|
||||
forwardEnvKeys: ["TERM_PROGRAM", "TERM_PROGRAM_VERSION"],
|
||||
})
|
||||
destroyFns.push(() => renderer.destroy())
|
||||
|
||||
expect(renderer.widthMethod).toBe("unicode-wide")
|
||||
await renderer.setupTerminal()
|
||||
expect(renderer.currentRenderBuffer.widthMethod).toBe("unicode-wide")
|
||||
expect(renderer.nextRenderBuffer.widthMethod).toBe("unicode-wide")
|
||||
const encoded = renderer.nextRenderBuffer.encodeUnicode("OpenCode search configuration പരിശോധിക്കൽ")
|
||||
expect(encoded).not.toBeNull()
|
||||
try {
|
||||
expect(encoded!.data.reduce((width, cell) => width + cell.width, 0)).toBe(40)
|
||||
} finally {
|
||||
if (encoded) renderer.nextRenderBuffer.freeUnicode(encoded)
|
||||
}
|
||||
|
||||
renderer.resize(81, 24)
|
||||
expect(renderer.currentRenderBuffer.widthMethod).toBe("unicode-wide")
|
||||
expect(renderer.nextRenderBuffer.widthMethod).toBe("unicode-wide")
|
||||
} finally {
|
||||
if (previousTermProgram === undefined) delete process.env.TERM_PROGRAM
|
||||
else process.env.TERM_PROGRAM = previousTermProgram
|
||||
if (previousTermProgramVersion === undefined) delete process.env.TERM_PROGRAM_VERSION
|
||||
else process.env.TERM_PROGRAM_VERSION = previousTermProgramVersion
|
||||
}
|
||||
})
|
||||
|
||||
// ---- Shutdown bytes reach the remote Writable (F1 regression test) ----
|
||||
|
||||
test("destroy emits shutdown ANSI sequence through the custom Writable", async () => {
|
||||
|
||||
@@ -66,7 +66,7 @@ export enum TargetChannel {
|
||||
Both = 3,
|
||||
}
|
||||
|
||||
export type WidthMethod = "wcwidth" | "unicode"
|
||||
export type WidthMethod = "wcwidth" | "unicode" | "unicode-wide"
|
||||
export type TerminalMultiplexer = "none" | "tmux" | "zellij" | "screen" | "unknown"
|
||||
export type TerminalCapabilityState = "unknown" | "supported" | "unsupported"
|
||||
export type ImageRenderProtocol = "auto" | "kitty" | "sixel" | "blocks"
|
||||
|
||||
@@ -88,7 +88,7 @@ export const VisualCursorStruct = defineStruct([
|
||||
["offset", "u32"],
|
||||
])
|
||||
|
||||
const UnicodeMethodEnum = defineEnum({ wcwidth: 0, unicode: 1 }, "u8")
|
||||
const UnicodeMethodEnum = defineEnum({ wcwidth: 0, unicode: 1, "unicode-wide": 3 }, "u8")
|
||||
const TerminalMultiplexerEnum = defineEnum({ none: 0, tmux: 1, zellij: 2, screen: 3, unknown: 4 }, "u8")
|
||||
const Osc52SupportEnum = defineEnum({ unknown: 0, supported: 1, unsupported: 2 }, "u8")
|
||||
const ImageProtocolEnum = defineEnum({ auto: 0, kitty: 1, sixel: 2, blocks: 3 }, "u8")
|
||||
|
||||
+24
-10
@@ -364,6 +364,15 @@ function editorLocalSelectionFlags(updateCursor: boolean, followCursor: boolean,
|
||||
return ffiBool(updateCursor) | (ffiBool(followCursor) << 1) | (selectionBehaviorByte(behavior) << 2)
|
||||
}
|
||||
|
||||
function widthMethodCode(widthMethod: WidthMethod): number {
|
||||
return widthMethod === "wcwidth" ? 0 : widthMethod === "unicode-wide" ? 3 : 1
|
||||
}
|
||||
|
||||
function widthMethodFromCode(code: number): WidthMethod {
|
||||
if (code === 0) return "wcwidth"
|
||||
return code === 3 ? "unicode-wide" : "unicode"
|
||||
}
|
||||
|
||||
function getOpenTUILib(libPath?: string) {
|
||||
const resolvedLibPath = libPath || targetLibPath
|
||||
if (!resolvedLibPath) {
|
||||
@@ -587,6 +596,10 @@ function getOpenTUILib(libPath?: string) {
|
||||
args: ["u32"],
|
||||
returns: "u32",
|
||||
},
|
||||
getBufferWidthMethod: {
|
||||
args: ["u32"],
|
||||
returns: "u8",
|
||||
},
|
||||
bufferClear: {
|
||||
args: ["u32", "buffer"],
|
||||
returns: "void",
|
||||
@@ -3772,8 +3785,9 @@ class FFIRenderLib implements RenderLib {
|
||||
|
||||
const width = this.opentui.symbols.getBufferWidth(bufferPtr)
|
||||
const height = this.opentui.symbols.getBufferHeight(bufferPtr)
|
||||
const widthMethod = widthMethodFromCode(this.opentui.symbols.getBufferWidthMethod(bufferPtr))
|
||||
|
||||
return new OptimizedBuffer(this, bufferPtr, width, height, { id: "next buffer", widthMethod: "unicode" })
|
||||
return new OptimizedBuffer(this, bufferPtr, width, height, { id: "next buffer", widthMethod })
|
||||
}
|
||||
|
||||
public getCurrentBuffer(renderer: Pointer): OptimizedBuffer {
|
||||
@@ -3784,8 +3798,9 @@ class FFIRenderLib implements RenderLib {
|
||||
|
||||
const width = this.opentui.symbols.getBufferWidth(bufferPtr)
|
||||
const height = this.opentui.symbols.getBufferHeight(bufferPtr)
|
||||
const widthMethod = widthMethodFromCode(this.opentui.symbols.getBufferWidthMethod(bufferPtr))
|
||||
|
||||
return new OptimizedBuffer(this, bufferPtr, width, height, { id: "current buffer", widthMethod: "unicode" })
|
||||
return new OptimizedBuffer(this, bufferPtr, width, height, { id: "current buffer", widthMethod })
|
||||
}
|
||||
|
||||
public rendererSetPaletteState(
|
||||
@@ -4273,14 +4288,13 @@ class FFIRenderLib implements RenderLib {
|
||||
console.error(new Error(`Invalid dimensions for OptimizedBuffer: ${width}x${height}`).stack)
|
||||
}
|
||||
|
||||
const widthMethodCode = widthMethod === "wcwidth" ? 0 : 1
|
||||
const idToUse = id || "unnamed buffer"
|
||||
const idBytes = this.encoder.encode(idToUse)
|
||||
const bufferPtr = this.opentui.symbols.createOptimizedBuffer(
|
||||
width,
|
||||
height,
|
||||
ffiBool(respectAlpha),
|
||||
widthMethodCode,
|
||||
widthMethodCode(widthMethod),
|
||||
idBytes,
|
||||
idBytes.byteLength,
|
||||
)
|
||||
@@ -4887,8 +4901,7 @@ class FFIRenderLib implements RenderLib {
|
||||
|
||||
// TextBuffer methods
|
||||
public createTextBuffer(widthMethod: WidthMethod): TextBuffer {
|
||||
const widthMethodCode = widthMethod === "wcwidth" ? 0 : 1
|
||||
const bufferPtr = this.opentui.symbols.createTextBuffer(widthMethodCode)
|
||||
const bufferPtr = this.opentui.symbols.createTextBuffer(widthMethodCode(widthMethod))
|
||||
if (!bufferPtr) {
|
||||
throw new Error(`Failed to create TextBuffer`)
|
||||
}
|
||||
@@ -5516,8 +5529,10 @@ class FFIRenderLib implements RenderLib {
|
||||
|
||||
// EditBuffer implementations
|
||||
public createEditBuffer(widthMethod: WidthMethod): EditBufferHandle {
|
||||
const widthMethodCode = widthMethod === "wcwidth" ? 0 : 1
|
||||
const bufferPtr = this.opentui.symbols.createEditBuffer(widthMethodCode, this.eventSinkPtr ?? 0) as EditBufferHandle
|
||||
const bufferPtr = this.opentui.symbols.createEditBuffer(
|
||||
widthMethodCode(widthMethod),
|
||||
this.eventSinkPtr ?? 0,
|
||||
) as EditBufferHandle
|
||||
if (!bufferPtr) {
|
||||
throw new Error("Failed to create EditBuffer")
|
||||
}
|
||||
@@ -6032,7 +6047,6 @@ class FFIRenderLib implements RenderLib {
|
||||
widthMethod: WidthMethod,
|
||||
): { ptr: Pointer; data: Array<{ width: number; char: number }> } | null {
|
||||
const textBytes = this.encoder.encode(text)
|
||||
const widthMethodCode = widthMethod === "wcwidth" ? 0 : 1
|
||||
|
||||
const outPtrBuffer = new ArrayBuffer(8) // Pointer-sized out slot
|
||||
const outLenBuffer = new ArrayBuffer(8) // Native length out slot
|
||||
@@ -6042,7 +6056,7 @@ class FFIRenderLib implements RenderLib {
|
||||
textBytes.byteLength,
|
||||
outPtrBuffer,
|
||||
outLenBuffer,
|
||||
widthMethodCode,
|
||||
widthMethodCode(widthMethod),
|
||||
)
|
||||
|
||||
if (!success) {
|
||||
|
||||
@@ -199,15 +199,29 @@ ${underline("Drag me too:")} 🇺🇸 🇩🇪 🇯🇵 🇮🇳 a̐éö̲
|
||||
left: 2,
|
||||
top: 1,
|
||||
zIndex: 3,
|
||||
content: "V: Toggle vignette",
|
||||
content: `Width: ${renderer.widthMethod} | V: Toggle vignette | The bars below should align`,
|
||||
fg: "#AAFFAA",
|
||||
})
|
||||
rootGroup.add(hintText)
|
||||
|
||||
const alignmentText = new TextRenderable(renderer, {
|
||||
id: "full-unicode-alignment",
|
||||
position: "absolute",
|
||||
left: 2,
|
||||
top: 2,
|
||||
zIndex: 3,
|
||||
selectable: false,
|
||||
content:
|
||||
"1234567890123456789012345678901234567890| 40-cell ruler\nOpenCode search configuration പരിശോധിക്കൽ| Malayalam",
|
||||
fg: "#FFFFFF",
|
||||
bg: "#001122",
|
||||
})
|
||||
rootGroup.add(alignmentText)
|
||||
|
||||
const keyHandler = (key: KeyEvent): void => {
|
||||
if (key.name?.toLowerCase() !== "v") return
|
||||
vignetteEnabled = !vignetteEnabled
|
||||
hintText.content = `V: Toggle vignette (${vignetteEnabled ? "ON" : "OFF"})`
|
||||
hintText.content = `Width: ${renderer.widthMethod} | V: Toggle vignette (${vignetteEnabled ? "ON" : "OFF"}) | The bars below should align`
|
||||
|
||||
renderer.clearPostProcessFns()
|
||||
if (vignetteEnabled) {
|
||||
|
||||
@@ -465,6 +465,7 @@ fn applyDependencies(
|
||||
"general_category",
|
||||
"is_emoji_presentation",
|
||||
}),
|
||||
.fields_1 = @as([]const []const u8, &.{"wcwidth_zero_in_grapheme"}),
|
||||
})) |uucode_dep| {
|
||||
module.addImport("uucode", uucode_dep.module("uucode"));
|
||||
}
|
||||
|
||||
@@ -1283,6 +1283,21 @@ export fn getCurrentBuffer(renderer_handle: NativeHandle) NativeHandle {
|
||||
return handles.getOrInsertBorrowed(.optimized_buffer, erasePtr(object_ptr.getCurrentBuffer()), renderer_handle) catch INVALID_HANDLE;
|
||||
}
|
||||
|
||||
fn widthMethodFromInt(value: u8) utf8.WidthMethod {
|
||||
return switch (value) {
|
||||
@intFromEnum(utf8.WidthMethod.wcwidth) => .wcwidth,
|
||||
@intFromEnum(utf8.WidthMethod.unicode_wide) => .unicode_wide,
|
||||
else => .unicode,
|
||||
};
|
||||
}
|
||||
|
||||
test "widthMethodFromInt preserves legacy numeric inputs" {
|
||||
try std.testing.expectEqual(utf8.WidthMethod.wcwidth, widthMethodFromInt(0));
|
||||
try std.testing.expectEqual(utf8.WidthMethod.unicode, widthMethodFromInt(1));
|
||||
try std.testing.expectEqual(utf8.WidthMethod.unicode, widthMethodFromInt(2));
|
||||
try std.testing.expectEqual(utf8.WidthMethod.unicode_wide, widthMethodFromInt(3));
|
||||
}
|
||||
|
||||
export fn setHyperlinksCapability(renderer_handle: NativeHandle, enabled: bool) void {
|
||||
const object_ptr = acquireRenderer(renderer_handle) orelse return;
|
||||
object_ptr.terminal.caps.hyperlinks = enabled;
|
||||
@@ -1302,6 +1317,11 @@ export fn getBufferHeight(buffer_handle: NativeHandle) u32 {
|
||||
return object_ptr.height;
|
||||
}
|
||||
|
||||
export fn getBufferWidthMethod(buffer_handle: NativeHandle) u8 {
|
||||
const object_ptr = acquireBuffer(buffer_handle) orelse return @intFromEnum(utf8.WidthMethod.unicode);
|
||||
return @intFromEnum(object_ptr.width_method);
|
||||
}
|
||||
|
||||
fn packRenderResult(result: renderer.RenderResult) u64 {
|
||||
return @as(u64, result.renderOffset) | (@as(u64, @intFromEnum(result.status)) << 32);
|
||||
}
|
||||
@@ -1377,7 +1397,7 @@ export fn createOptimizedBuffer(width: u32, height: u32, respectAlpha: u8, width
|
||||
|
||||
const pool = gp.initGlobalPool(globalArena);
|
||||
const link_pool = link.initGlobalLinkPool(globalArena);
|
||||
const wMethod: utf8.WidthMethod = if (widthMethod == 0) .wcwidth else .unicode;
|
||||
const wMethod = widthMethodFromInt(widthMethod);
|
||||
const id = sliceFromPtrLen(idPtr, idLen);
|
||||
|
||||
const bufferPtr = buffer.OptimizedBuffer.init(globalAllocator, width, height, .{
|
||||
@@ -1467,7 +1487,11 @@ export fn getTerminalCapabilities(renderer_handle: NativeHandle, capsPtr: *Exter
|
||||
.kitty_graphics = caps.kitty_graphics,
|
||||
.rgb = caps.rgb,
|
||||
.ansi256 = caps.ansi256,
|
||||
.unicode = if (caps.unicode == .wcwidth) 0 else 1,
|
||||
.unicode = switch (caps.unicode) {
|
||||
.wcwidth => 0,
|
||||
.unicode_wide => 3,
|
||||
.unicode, .no_zwj => 1,
|
||||
},
|
||||
.sgr_pixels = caps.sgr_pixels,
|
||||
.color_scheme_updates = caps.color_scheme_updates,
|
||||
.explicit_width = caps.explicit_width,
|
||||
@@ -2105,7 +2129,7 @@ fn destroyTextBufferViewChildren(owner: NativeHandle) void {
|
||||
export fn createTextBuffer(widthMethod: u8) NativeHandle {
|
||||
const pool = gp.initGlobalPool(globalArena);
|
||||
const link_pool = link.initGlobalLinkPool(globalArena);
|
||||
const wMethod: utf8.WidthMethod = if (widthMethod == 0) .wcwidth else .unicode;
|
||||
const wMethod = widthMethodFromInt(widthMethod);
|
||||
|
||||
const tb = text_buffer.UnifiedTextBuffer.init(globalAllocator, pool, link_pool, wMethod) catch {
|
||||
return INVALID_HANDLE;
|
||||
@@ -2454,7 +2478,7 @@ fn destroyEditorViewChildren(owner: NativeHandle) void {
|
||||
export fn createEditBuffer(widthMethod: u8, event_sink_handle: NativeHandle) NativeHandle {
|
||||
const pool = gp.initGlobalPool(globalArena);
|
||||
const link_pool = link.initGlobalLinkPool(globalArena);
|
||||
const wMethod: utf8.WidthMethod = if (widthMethod == 0) .wcwidth else .unicode;
|
||||
const wMethod = widthMethodFromInt(widthMethod);
|
||||
const event_sink_ptr = if (event_sink_handle == INVALID_HANDLE) null else acquireEventSink(event_sink_handle);
|
||||
const event_sink = if (event_sink_ptr) |object_ptr| object_ptr else null;
|
||||
|
||||
@@ -3625,7 +3649,7 @@ export fn encodeUnicode(
|
||||
}
|
||||
|
||||
const pool = gp.initGlobalPool(globalArena);
|
||||
const wMethod: utf8.WidthMethod = if (widthMethod == 0) .wcwidth else .unicode;
|
||||
const wMethod = widthMethodFromInt(widthMethod);
|
||||
|
||||
// Check if ASCII only for optimization
|
||||
const is_ascii_only = utf8.isAsciiOnly(text);
|
||||
|
||||
@@ -448,6 +448,7 @@ pub const CliRenderer = struct {
|
||||
.palette_epoch = 0,
|
||||
};
|
||||
|
||||
self.syncWidthMethod();
|
||||
self.resetFallbackPaletteState();
|
||||
nextBuffer.setBlendBackdropColor(ansi.rgbColor(ansi.red(self.backgroundColor), ansi.green(self.backgroundColor), ansi.blue(self.backgroundColor), 255));
|
||||
|
||||
@@ -3130,9 +3131,16 @@ pub const CliRenderer = struct {
|
||||
|
||||
pub fn setTerminalEnvVar(self: *CliRenderer, key: []const u8, value: []const u8) bool {
|
||||
self.terminal.setHostEnvVar(self.allocator, key, value) catch return false;
|
||||
self.syncWidthMethod();
|
||||
return true;
|
||||
}
|
||||
|
||||
fn syncWidthMethod(self: *CliRenderer) void {
|
||||
const width_method = if (self.terminal.caps.unicode == .no_zwj) .unicode else self.terminal.caps.unicode;
|
||||
self.currentRenderBuffer.width_method = width_method;
|
||||
self.nextRenderBuffer.width_method = width_method;
|
||||
}
|
||||
|
||||
pub fn processCapabilityResponse(self: *CliRenderer, response: []const u8) void {
|
||||
self.terminal.processCapabilityResponse(response);
|
||||
var writer: std.Io.Writer = .fixed(&self.writeOutBuf);
|
||||
|
||||
@@ -166,6 +166,7 @@ capability_queries_pending: bool = false,
|
||||
startup_cursor_query_pending: bool = false,
|
||||
startup_cursor_query_captured: bool = false,
|
||||
explicit_width_probe_reports_pending: u8 = 0,
|
||||
unicode_wide_locked: ?bool = null,
|
||||
|
||||
state: struct {
|
||||
alt_screen: bool = false,
|
||||
@@ -295,6 +296,7 @@ pub fn exitAltScreen(self: *Terminal, tty: anytype) !void {
|
||||
|
||||
pub fn queryTerminalSend(self: *Terminal, tty: anytype) !void {
|
||||
self.checkEnvironmentOverrides();
|
||||
self.unicode_wide_locked = self.caps.unicode == .unicode_wide;
|
||||
self.graphics_query_pending = !self.skip_graphics_query;
|
||||
self.sixel_query_pending = !self.skip_graphics_query;
|
||||
self.capability_queries_pending = false;
|
||||
@@ -416,7 +418,7 @@ pub fn enableDetectedFeatures(self: *Terminal, tty: anytype, use_kitty_keyboard:
|
||||
try self.setKittyKeyboard(tty, true, self.opts.kitty_keyboard_flags);
|
||||
}
|
||||
|
||||
if (self.caps.unicode == .unicode and !self.caps.explicit_width) {
|
||||
if ((self.caps.unicode == .unicode or self.caps.unicode == .unicode_wide) and !self.caps.explicit_width) {
|
||||
try tty.writeAll(ansi.ANSI.unicodeSet);
|
||||
}
|
||||
|
||||
@@ -692,6 +694,7 @@ fn checkEnvironmentOverrides(self: *Terminal) void {
|
||||
}
|
||||
|
||||
const env_is_forwarded = if (self.host_env_map) |*host_env_map| env_map == host_env_map else false;
|
||||
self.applyKnownUnicodeWidthIdentity();
|
||||
if (self.opts.remote_mode == .auto and self.remote and env_is_forwarded) {
|
||||
return;
|
||||
}
|
||||
@@ -854,6 +857,8 @@ fn checkEnvironmentOverrides(self: *Terminal) void {
|
||||
}
|
||||
}
|
||||
|
||||
self.applyKnownUnicodeWidthIdentity();
|
||||
|
||||
if (env_map.get("OPENTUI_FORCE_WCWIDTH")) |_| {
|
||||
self.caps.unicode = .wcwidth;
|
||||
}
|
||||
@@ -1156,6 +1161,16 @@ fn semanticVersionAtLeast(version: []const u8, required_major: u32, required_min
|
||||
return true;
|
||||
}
|
||||
|
||||
fn ghosttyWideGraphemeWidths(version: []const u8) bool {
|
||||
if (semanticVersionAtLeast(version, 1, 3)) return true;
|
||||
const prefix = "0.0.0-";
|
||||
if (!std.mem.startsWith(u8, version, prefix) or version.len <= prefix.len + 8 or version[prefix.len + 8] != '.') return false;
|
||||
const date_text = version[prefix.len .. prefix.len + 8];
|
||||
for (date_text) |char| if (!std.ascii.isDigit(char)) return false;
|
||||
const date = std.fmt.parseInt(u32, date_text, 10) catch return false;
|
||||
return date >= 20260224;
|
||||
}
|
||||
|
||||
fn wezTermBuildAtLeast(version: []const u8, required_date: u32) bool {
|
||||
var parts = std.mem.splitAny(u8, version, "-._");
|
||||
const date_text = parts.next() orelse return false;
|
||||
@@ -1203,6 +1218,27 @@ fn applyKnownGraphicsIdentity(self: *Terminal) void {
|
||||
}
|
||||
}
|
||||
|
||||
fn applyKnownUnicodeWidthIdentity(self: *Terminal) void {
|
||||
if (self.unicode_wide_locked) |enabled| {
|
||||
if (enabled) {
|
||||
self.caps.unicode = .unicode_wide;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.caps.unicode == .unicode_wide) {
|
||||
self.caps.unicode = .unicode;
|
||||
}
|
||||
if (self.remote or self.multiplexer != .none) return;
|
||||
const env_map = self.opts.env_map orelse return;
|
||||
const term_program = env_map.get("TERM_PROGRAM") orelse return;
|
||||
if (!std.ascii.eqlIgnoreCase(term_program, "ghostty")) return;
|
||||
const version = env_map.get("TERM_PROGRAM_VERSION") orelse return;
|
||||
if (!ghosttyWideGraphemeWidths(version)) return;
|
||||
if (self.term_info.from_xtversion and !std.ascii.eqlIgnoreCase(self.getTerminalName(), "ghostty")) return;
|
||||
self.caps.unicode = .unicode_wide;
|
||||
}
|
||||
|
||||
pub fn processCapabilityResponse(self: *Terminal, response: []const u8) void {
|
||||
self.parseOsc99NotificationQuery(response);
|
||||
self.parseItermCapabilities(response);
|
||||
|
||||
@@ -10,6 +10,7 @@ const link = @import("../link.zig");
|
||||
const ansi = @import("../ansi.zig");
|
||||
const image = @import("../image.zig");
|
||||
const handles = @import("../handles.zig");
|
||||
const ghostty_vt = @import("../ghostty-vt.zig");
|
||||
const test_renderer_mod = @import("test-renderer.zig");
|
||||
const terminal_image_test = @import("terminal-image_test.zig");
|
||||
|
||||
@@ -658,12 +659,14 @@ test "renderer preserves Malayalam report after Ghostty probe replies" {
|
||||
defer link.deinitGlobalLinkPool();
|
||||
var test_renderer = try TestRenderer.create(std.testing.allocator, 80, 1, pool);
|
||||
defer test_renderer.deinit();
|
||||
try std.testing.expect(test_renderer.renderer.setTerminalEnvVar("TERM_PROGRAM", "ghostty"));
|
||||
try std.testing.expect(test_renderer.renderer.setTerminalEnvVar("TERM_PROGRAM_VERSION", "1.3.1"));
|
||||
|
||||
var writer = DiscardTerminalWriter{};
|
||||
try test_renderer.renderer.terminal.queryTerminalSend(&writer);
|
||||
test_renderer.renderer.terminal.processCapabilityResponse("\x1b[1;5R");
|
||||
test_renderer.renderer.terminal.processCapabilityResponse("\x1b[1;1R");
|
||||
test_renderer.renderer.terminal.processCapabilityResponse("\x1b[1;1R\x1bP>|ghostty 1.3.1\x1b\\");
|
||||
test_renderer.renderer.processCapabilityResponse("\x1b[1;5R");
|
||||
test_renderer.renderer.processCapabilityResponse("\x1b[1;1R");
|
||||
test_renderer.renderer.processCapabilityResponse("\x1b[1;1R\x1bP>|ghostty 1.3.1\x1b\\");
|
||||
|
||||
const report = "OpenCode search configuration പരിശോധിക്കൽ";
|
||||
try test_renderer.renderer.getNextBuffer().drawText(
|
||||
@@ -674,11 +677,32 @@ test "renderer preserves Malayalam report after Ghostty probe replies" {
|
||||
ansi.rgbColor(0, 0, 0, 255),
|
||||
0,
|
||||
);
|
||||
try test_renderer.renderer.getNextBuffer().drawText(
|
||||
"|",
|
||||
40,
|
||||
0,
|
||||
ansi.rgbColor(255, 255, 255, 255),
|
||||
ansi.rgbColor(0, 0, 0, 255),
|
||||
0,
|
||||
);
|
||||
try std.testing.expectEqual(renderer.RenderStatus.rendered, test_renderer.renderer.render(true));
|
||||
|
||||
const output = test_renderer.memory.lastWrite();
|
||||
try std.testing.expect(std.mem.find(u8, output, report) != null);
|
||||
try std.testing.expect(std.mem.find(u8, output, "\x1b]66;") == null);
|
||||
|
||||
var terminal: ghostty_vt.vt.Terminal = try .init(std.testing.io, std.testing.allocator, .{
|
||||
.cols = 80,
|
||||
.rows = 1,
|
||||
});
|
||||
defer terminal.deinit(std.testing.allocator);
|
||||
var stream = terminal.vtStream();
|
||||
defer stream.deinit();
|
||||
stream.nextSlice("\x1b[?2027h");
|
||||
stream.nextSlice(output);
|
||||
|
||||
const screen = try terminal.plainString(std.testing.allocator);
|
||||
defer std.testing.allocator.free(screen);
|
||||
try std.testing.expectEqualStrings(report ++ "|", std.mem.trimEnd(u8, screen, " "));
|
||||
}
|
||||
|
||||
fn expectPlaneCoversImage(protocol: image.RenderProtocol) !void {
|
||||
|
||||
@@ -512,8 +512,10 @@ test "remote detection - auto mode ignores local capabilities after forwarded SS
|
||||
try term.setHostEnvVar(testing.allocator, "SSH_CONNECTION", "192.0.2.1 54231 192.0.2.2 22");
|
||||
try term.setHostEnvVar(testing.allocator, "TERM", "xterm-256color");
|
||||
try term.setHostEnvVar(testing.allocator, "TERM_PROGRAM", "ghostty");
|
||||
try term.setHostEnvVar(testing.allocator, "TERM_PROGRAM_VERSION", "1.3.1");
|
||||
|
||||
try testing.expect(term.caps.remote);
|
||||
try testing.expectEqual(utf8.WidthMethod.unicode, term.caps.unicode);
|
||||
try testing.expect(!term.caps.ansi256);
|
||||
try testing.expect(!term.caps.notifications);
|
||||
try testing.expectEqualStrings("", term.getTerminalName());
|
||||
@@ -1709,6 +1711,71 @@ test "enableDetectedFeatures - sends initial theme queries" {
|
||||
try testing.expect(term.state.theme_queries_sent);
|
||||
}
|
||||
|
||||
const WidthEnv = struct { key: []const u8, value: []const u8 };
|
||||
|
||||
fn expectWidthMethodForEnv(entries: []const WidthEnv, expected: utf8.WidthMethod) !void {
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
for (entries) |entry| try env.put(entry.key, entry.value);
|
||||
const term = Terminal.init(.{ .env_map = &env });
|
||||
try testing.expectEqual(expected, term.caps.unicode);
|
||||
}
|
||||
|
||||
test "Ghostty width profile requires a direct terminal" {
|
||||
try expectWidthMethodForEnv(&.{.{ .key = "TERM_PROGRAM", .value = "WezTerm" }}, .unicode);
|
||||
try expectWidthMethodForEnv(&.{
|
||||
.{ .key = "TMUX", .value = "/tmp/tmux-1000/default,12345,0" },
|
||||
.{ .key = "TERM_PROGRAM", .value = "ghostty" },
|
||||
.{ .key = "TERM_PROGRAM_VERSION", .value = "1.3.1" },
|
||||
}, .wcwidth);
|
||||
try expectWidthMethodForEnv(&.{
|
||||
.{ .key = "TERM_PROGRAM", .value = "ghostty" },
|
||||
.{ .key = "TERM_PROGRAM_VERSION", .value = "1.2.3" },
|
||||
}, .unicode);
|
||||
try expectWidthMethodForEnv(&.{
|
||||
.{ .key = "TERM_PROGRAM", .value = "ghostty" },
|
||||
.{ .key = "TERM_PROGRAM_VERSION", .value = "0.0.0-20260223.r14707.gc61f184" },
|
||||
}, .unicode);
|
||||
try expectWidthMethodForEnv(&.{
|
||||
.{ .key = "TERM_PROGRAM", .value = "ghostty" },
|
||||
.{ .key = "TERM_PROGRAM_VERSION", .value = "0.0.0-20260224.r14762.gc51f0d7" },
|
||||
}, .unicode_wide);
|
||||
}
|
||||
|
||||
test "Ghostty width profile preserves explicit wcwidth override" {
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
try env.put("TERM_PROGRAM", "ghostty");
|
||||
try env.put("TERM_PROGRAM_VERSION", "1.3.1");
|
||||
try env.put("OPENTUI_FORCE_WCWIDTH", "1");
|
||||
|
||||
var term = Terminal.init(.{ .env_map = &env });
|
||||
try testing.expectEqual(utf8.WidthMethod.wcwidth, term.caps.unicode);
|
||||
term.processCapabilityResponse("\x1b[?2027;2$y\x1bP>|ghostty 1.3.1\x1b\\");
|
||||
var writer = TestWriter.init(testing.allocator);
|
||||
defer writer.deinit();
|
||||
try term.enableDetectedFeatures(&writer, false);
|
||||
try testing.expectEqual(utf8.WidthMethod.wcwidth, term.caps.unicode);
|
||||
}
|
||||
|
||||
test "Ghostty width profile enables mode 2027 and remains stable after setup starts" {
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
try env.put("TERM_PROGRAM", "ghostty");
|
||||
try env.put("TERM_PROGRAM_VERSION", "1.3.1");
|
||||
|
||||
var term = Terminal.init(.{ .env_map = &env });
|
||||
try testing.expectEqual(utf8.WidthMethod.unicode_wide, term.caps.unicode);
|
||||
var writer = TestWriter.init(testing.allocator);
|
||||
defer writer.deinit();
|
||||
try term.queryTerminalSend(&writer);
|
||||
|
||||
term.processCapabilityResponse("\x1b[?2027;2$y\x1bP>|WezTerm 20240203-110809-5046fc22\x1b\\");
|
||||
try term.enableDetectedFeatures(&writer, false);
|
||||
try testing.expectEqual(utf8.WidthMethod.unicode_wide, term.caps.unicode);
|
||||
try testing.expect(std.mem.find(u8, writer.getWritten(), ansi.ANSI.unicodeSet) != null);
|
||||
}
|
||||
|
||||
test "setMouseMode - enable without movement keeps click/drag only" {
|
||||
var term = Terminal.init(.{});
|
||||
var writer = TestWriter.init(testing.allocator);
|
||||
|
||||
@@ -3692,6 +3692,19 @@ test "calculateTextWidth: Malayalam script" {
|
||||
try testing.expect(width >= 4 and width <= 5);
|
||||
}
|
||||
|
||||
test "calculateTextWidth: Malayalam report matches Ghostty grapheme widths" {
|
||||
const report = "OpenCode search configuration പരിശോധിക്കൽ";
|
||||
try testing.expectEqual(@as(u32, 36), utf8.calculateTextWidth(report, 4, false, .unicode));
|
||||
try testing.expectEqual(@as(u32, 2), utf8.calculateTextWidth("രി", 4, false, .unicode_wide));
|
||||
try testing.expectEqual(@as(u32, 2), utf8.calculateTextWidth("ശോ", 4, false, .unicode_wide));
|
||||
try testing.expectEqual(@as(u32, 2), utf8.calculateTextWidth("ധി", 4, false, .unicode_wide));
|
||||
try testing.expectEqual(@as(u32, 2), utf8.calculateTextWidth("ക്ക", 4, false, .unicode_wide));
|
||||
try testing.expectEqual(
|
||||
@as(u32, 40),
|
||||
utf8.calculateTextWidth(report, 4, false, .unicode_wide),
|
||||
);
|
||||
}
|
||||
|
||||
test "calculateTextWidth: Oriya script" {
|
||||
const oriya = "ଓଡ଼ିଆ";
|
||||
const width = utf8.calculateTextWidth(oriya, 4, false, .unicode);
|
||||
|
||||
@@ -2,10 +2,11 @@ const std = @import("std");
|
||||
const uucode = @import("uucode");
|
||||
|
||||
/// The method to use when calculating the width of a grapheme
|
||||
pub const WidthMethod = enum {
|
||||
wcwidth,
|
||||
unicode,
|
||||
no_zwj,
|
||||
pub const WidthMethod = enum(u8) {
|
||||
wcwidth = 0,
|
||||
unicode = 1,
|
||||
no_zwj = 2,
|
||||
unicode_wide = 3,
|
||||
};
|
||||
|
||||
/// Check if a byte slice contains only printable ASCII (32..126)
|
||||
@@ -847,7 +848,7 @@ const GraphemeWidthState = struct {
|
||||
return;
|
||||
}
|
||||
|
||||
// unicode and no_zwj modes: use grapheme-aware width
|
||||
// Grapheme-aware width modes.
|
||||
const is_ri = (cp >= 0x1F1E6 and cp <= 0x1F1FF);
|
||||
const is_vs16 = (cp == 0xFE0F); // Variation Selector-16 (emoji presentation)
|
||||
|
||||
@@ -878,6 +879,8 @@ const GraphemeWidthState = struct {
|
||||
} else if (!self.has_width and cp_width > 0) {
|
||||
self.width = cp_width;
|
||||
self.has_width = true;
|
||||
} else if (self.width_method == .unicode_wide and self.has_width and !uucode.get(.wcwidth_zero_in_grapheme, cp)) {
|
||||
self.width = @max(self.width, 2);
|
||||
} else if (self.has_width and is_spacing_mark and cp_width > 0) {
|
||||
self.width = @max(self.width, 2);
|
||||
} else if (self.has_width and self.has_indic_virama and is_devanagari_base and cp_width > 0) {
|
||||
@@ -990,7 +993,7 @@ pub fn findWrapPosByWidth(
|
||||
width_method: WidthMethod,
|
||||
) WrapByWidthResult {
|
||||
switch (width_method) {
|
||||
.unicode, .no_zwj => return findWrapPosByWidthUnicode(text, max_columns, tab_width, isASCIIOnly, width_method),
|
||||
.unicode, .unicode_wide, .no_zwj => return findWrapPosByWidthUnicode(text, max_columns, tab_width, isASCIIOnly, width_method),
|
||||
.wcwidth => return findWrapPosByWidthWCWidth(text, max_columns, tab_width, isASCIIOnly),
|
||||
}
|
||||
}
|
||||
@@ -1190,7 +1193,7 @@ pub fn findPosByWidth(
|
||||
width_method: WidthMethod,
|
||||
) PosByWidthResult {
|
||||
switch (width_method) {
|
||||
.unicode, .no_zwj => return findPosByWidthUnicode(text, max_columns, tab_width, isASCIIOnly, include_start_before, width_method),
|
||||
.unicode, .unicode_wide, .no_zwj => return findPosByWidthUnicode(text, max_columns, tab_width, isASCIIOnly, include_start_before, width_method),
|
||||
.wcwidth => return findPosByWidthWCWidth(text, max_columns, tab_width, isASCIIOnly, include_start_before),
|
||||
}
|
||||
}
|
||||
@@ -1404,7 +1407,7 @@ fn findPosByWidthWCWidth(
|
||||
/// Get width at byte offset - proxy function that dispatches based on width_method
|
||||
pub fn getWidthAt(text: []const u8, byte_offset: usize, tab_width: u8, width_method: WidthMethod) u32 {
|
||||
switch (width_method) {
|
||||
.unicode, .no_zwj => return getWidthAtUnicode(text, byte_offset, tab_width, width_method),
|
||||
.unicode, .unicode_wide, .no_zwj => return getWidthAtUnicode(text, byte_offset, tab_width, width_method),
|
||||
.wcwidth => return getWidthAtWCWidth(text, byte_offset, tab_width),
|
||||
}
|
||||
}
|
||||
@@ -1479,7 +1482,7 @@ pub const PrevGraphemeResult = struct {
|
||||
/// Get previous grapheme start - proxy function that dispatches based on width_method
|
||||
pub fn getPrevGraphemeStart(text: []const u8, byte_offset: usize, tab_width: u8, width_method: WidthMethod) ?PrevGraphemeResult {
|
||||
switch (width_method) {
|
||||
.unicode, .no_zwj => return getPrevGraphemeStartUnicode(text, byte_offset, tab_width, width_method),
|
||||
.unicode, .unicode_wide, .no_zwj => return getPrevGraphemeStartUnicode(text, byte_offset, tab_width, width_method),
|
||||
.wcwidth => return getPrevGraphemeStartWCWidth(text, byte_offset, tab_width),
|
||||
}
|
||||
}
|
||||
@@ -1569,7 +1572,7 @@ fn getPrevGraphemeStartUnicode(text: []const u8, byte_offset: usize, tab_width:
|
||||
/// Calculate the display width of text - proxy function that dispatches based on width_method
|
||||
pub fn calculateTextWidth(text: []const u8, tab_width: u8, isASCIIOnly: bool, width_method: WidthMethod) u32 {
|
||||
switch (width_method) {
|
||||
.unicode, .no_zwj => return calculateTextWidthUnicode(text, tab_width, isASCIIOnly, width_method),
|
||||
.unicode, .unicode_wide, .no_zwj => return calculateTextWidthUnicode(text, tab_width, isASCIIOnly, width_method),
|
||||
.wcwidth => return calculateTextWidthWCWidth(text, tab_width, isASCIIOnly),
|
||||
}
|
||||
}
|
||||
@@ -1694,7 +1697,7 @@ pub fn findGraphemeInfo(
|
||||
result: *std.ArrayListUnmanaged(GraphemeInfo),
|
||||
) !void {
|
||||
switch (width_method) {
|
||||
.unicode, .no_zwj => try findGraphemeInfoUnicode(allocator, text, tab_width, isASCIIOnly, width_method, result),
|
||||
.unicode, .unicode_wide, .no_zwj => try findGraphemeInfoUnicode(allocator, text, tab_width, isASCIIOnly, width_method, result),
|
||||
.wcwidth => try findGraphemeInfoWCWidth(allocator, text, tab_width, isASCIIOnly, result),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user