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:
Simon Klee
2026-08-24 13:54:37 +02:00
committed by GitHub
parent 0b06560ed4
commit 403b7637bb
14 changed files with 276 additions and 37 deletions
+1 -2
View File
@@ -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 () => {
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
View File
@@ -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) {
+16 -2
View File
@@ -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) {
+1
View File
@@ -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"));
}
+29 -5
View File
@@ -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);
+8
View File
@@ -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);
+37 -1
View File
@@ -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);
+28 -4
View File
@@ -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);
+13
View File
@@ -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);
+14 -11
View File
@@ -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),
}
}