From a9d4fed205d2158a779cee7395505abf39fb5f99 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 23 Sep 2025 18:03:45 +0200 Subject: [PATCH 1/6] feat: support wide characters in win32 gui closes #132 --- src/win32/GlyphIndexCache.zig | 19 +++++++++++++------ src/win32/d3d11.zig | 11 +++++++---- src/win32/gui.zig | 23 +++++++++++++++-------- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/win32/GlyphIndexCache.zig b/src/win32/GlyphIndexCache.zig index 02bc281..85fb686 100644 --- a/src/win32/GlyphIndexCache.zig +++ b/src/win32/GlyphIndexCache.zig @@ -5,9 +5,15 @@ const Node = struct { prev: ?u32, next: ?u32, codepoint: ?u21, + right_half: ?bool, }; -map: std.AutoHashMapUnmanaged(u21, u32) = .{}, +const MapKey = struct { + codepoint: u21, + right_half: bool, +}; + +map: std.AutoHashMapUnmanaged(MapKey, u32) = .{}, nodes: []Node, front: u32, back: u32, @@ -25,13 +31,14 @@ pub fn init(allocator: std.mem.Allocator, capacity: u32) error{OutOfMemory}!Glyp pub fn clearRetainingCapacity(self: *GlyphIndexCache) void { self.map.clearRetainingCapacity(); - self.nodes[0] = .{ .prev = null, .next = 1, .codepoint = null }; - self.nodes[self.nodes.len - 1] = .{ .prev = @intCast(self.nodes.len - 2), .next = null, .codepoint = null }; + self.nodes[0] = .{ .prev = null, .next = 1, .codepoint = null, .right_half = null }; + self.nodes[self.nodes.len - 1] = .{ .prev = @intCast(self.nodes.len - 2), .next = null, .codepoint = null, .right_half = null }; for (self.nodes[1 .. self.nodes.len - 1], 1..) |*node, index| { node.* = .{ .prev = @intCast(index - 1), .next = @intCast(index + 1), .codepoint = null, + .right_half = null, }; } self.front = 0; @@ -51,12 +58,12 @@ const Reserved = struct { index: u32, replaced: ?u21, }; -pub fn reserve(self: *GlyphIndexCache, allocator: std.mem.Allocator, codepoint: u21) error{OutOfMemory}!union(enum) { +pub fn reserve(self: *GlyphIndexCache, allocator: std.mem.Allocator, codepoint: u21, right_half: bool) error{OutOfMemory}!union(enum) { newly_reserved: Reserved, already_reserved: u32, } { { - const entry = try self.map.getOrPut(allocator, codepoint); + const entry = try self.map.getOrPut(allocator, .{ .codepoint = codepoint, .right_half = right_half }); if (entry.found_existing) { self.moveToBack(entry.value_ptr.*); return .{ .already_reserved = entry.value_ptr.* }; @@ -69,7 +76,7 @@ pub fn reserve(self: *GlyphIndexCache, allocator: std.mem.Allocator, codepoint: const replaced = self.nodes[self.front].codepoint; self.nodes[self.front].codepoint = codepoint; if (replaced) |r| { - const removed = self.map.remove(r); + const removed = self.map.remove(.{ .codepoint = r, .right_half = self.nodes[self.front].right_half orelse false }); std.debug.assert(removed); } const save_front = self.front; diff --git a/src/win32/d3d11.zig b/src/win32/d3d11.zig index 7dbe105..529ba1c 100644 --- a/src/win32/d3d11.zig +++ b/src/win32/d3d11.zig @@ -149,7 +149,7 @@ pub const WindowState = struct { } // TODO: this should take a utf8 graphme instead - pub fn generateGlyph(state: *WindowState, font: Font, codepoint: u21) u32 { + pub fn generateGlyph(state: *WindowState, font: Font, codepoint: u21, right_half: bool) u32 { // for now we'll just use 1 texture and leverage the entire thing const texture_cell_count: XY(u16) = getD3d11TextureMaxCellCount(font.cell_size); const texture_cell_count_total: u32 = @@ -187,12 +187,15 @@ pub const WindowState = struct { switch (glyph_index_cache.reserve( global.glyph_cache_arena.allocator(), codepoint, + right_half, ) catch |e| oom(e)) { .newly_reserved => |reserved| { // var render_success = false; // defer if (!render_success) state.glyph_index_cache.remove(reserved.index); const pos: XY(u16) = cellPosFromIndex(reserved.index, texture_cell_count.x); const coord = coordFromCellPos(font.cell_size, pos); + var staging_size = font.cell_size; + staging_size.x = if (right_half) staging_size.x * 2 else staging_size.x; const staging = global.staging_texture.update(font.cell_size); var utf8_buf: [7]u8 = undefined; const utf8_len: u3 = std.unicode.utf8Encode(codepoint, &utf8_buf) catch |e| std.debug.panic( @@ -204,10 +207,10 @@ pub const WindowState = struct { utf8_buf[0..utf8_len], ); const box: win32.D3D11_BOX = .{ - .left = 0, + .left = if (right_half) font.cell_size.x else 0, .top = 0, .front = 0, - .right = font.cell_size.x, + .right = if (right_half) font.cell_size.x * 2 else font.cell_size.x, .bottom = font.cell_size.y, .back = 1, }; @@ -289,7 +292,7 @@ pub fn paint( } const copy_col_count: u16 = @min(col_count, shader_col_count); - const blank_space_glyph_index = state.generateGlyph(font, ' '); + const blank_space_glyph_index = state.generateGlyph(font, ' ', false); const cell_count: u32 = @as(u32, shader_col_count) * @as(u32, shader_row_count); state.shader_cells.updateCount(cell_count); diff --git a/src/win32/gui.zig b/src/win32/gui.zig index 6d49cec..485d1b4 100644 --- a/src/win32/gui.zig +++ b/src/win32/gui.zig @@ -1081,19 +1081,26 @@ fn WndProc( global.render_cells_arena.allocator(), global.screen.buf.len, ) catch |e| oom(e); + var prev_width: usize = 1; + var prev_cell: render.Cell = undefined; for (global.screen.buf, global.render_cells.items) |*screen_cell, *render_cell| { + const width = screen_cell.char.width; const codepoint = if (std.unicode.utf8ValidateSlice(screen_cell.char.grapheme)) std.unicode.wtf8Decode(screen_cell.char.grapheme) catch std.unicode.replacement_character else std.unicode.replacement_character; - render_cell.* = .{ - .glyph_index = state.render_state.generateGlyph( - font, - codepoint, - ), - .background = renderColorFromVaxis(screen_cell.style.bg), - .foreground = renderColorFromVaxis(screen_cell.style.fg), - }; + if (prev_width > 1) { + render_cell.* = prev_cell; + render_cell.glyph_index = state.render_state.generateGlyph(font, codepoint, true); + } else { + render_cell.* = .{ + .glyph_index = state.render_state.generateGlyph(font, codepoint, false), + .background = renderColorFromVaxis(screen_cell.style.bg), + .foreground = renderColorFromVaxis(screen_cell.style.fg), + }; + } + prev_width = width; + prev_cell = render_cell.*; } render.paint( &state.render_state, From 8278a080aff87ff0c8b41e65d33519f2c06abe6f Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 23 Sep 2025 18:09:09 +0200 Subject: [PATCH 2/6] fix: actually use staging_size in WindowState.generateGlyph --- src/win32/d3d11.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/win32/d3d11.zig b/src/win32/d3d11.zig index 529ba1c..b1b88cf 100644 --- a/src/win32/d3d11.zig +++ b/src/win32/d3d11.zig @@ -196,7 +196,7 @@ pub const WindowState = struct { const coord = coordFromCellPos(font.cell_size, pos); var staging_size = font.cell_size; staging_size.x = if (right_half) staging_size.x * 2 else staging_size.x; - const staging = global.staging_texture.update(font.cell_size); + const staging = global.staging_texture.update(staging_size); var utf8_buf: [7]u8 = undefined; const utf8_len: u3 = std.unicode.utf8Encode(codepoint, &utf8_buf) catch |e| std.debug.panic( "todo: handle invalid codepoint {} (0x{0x}) ({s})", From 05b87b1406d10a839a8864e13930f486f647fde4 Mon Sep 17 00:00:00 2001 From: Jonathan Marler Date: Tue, 23 Sep 2025 13:32:13 -0600 Subject: [PATCH 3/6] finish win32 gui support for double-wide characters --- src/win32/DwriteRenderer.zig | 6 +++++- src/win32/d3d11.zig | 20 ++++++++++++++++---- src/win32/gui.zig | 6 ++++-- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/win32/DwriteRenderer.zig b/src/win32/DwriteRenderer.zig index 8ad34a0..39abee5 100644 --- a/src/win32/DwriteRenderer.zig +++ b/src/win32/DwriteRenderer.zig @@ -75,6 +75,7 @@ pub fn render( self: *const DwriteRenderer, font: Font, utf8: []const u8, + double_width: bool, ) void { var utf16_buf: [10]u16 = undefined; const utf16_len = std.unicode.utf8ToUtf16Le(&utf16_buf, utf8) catch unreachable; @@ -85,7 +86,10 @@ pub fn render( const rect: win32.D2D_RECT_F = .{ .left = 0, .top = 0, - .right = @floatFromInt(font.cell_size.x), + .right = if (double_width) + @as(f32, @floatFromInt(font.cell_size.x)) * @as(f32, @floatFromInt(font.cell_size.x)) + else + @as(f32, @floatFromInt(font.cell_size.x)), .bottom = @floatFromInt(font.cell_size.y), }; self.render_target.BeginDraw(); diff --git a/src/win32/d3d11.zig b/src/win32/d3d11.zig index b1b88cf..dd1c5a2 100644 --- a/src/win32/d3d11.zig +++ b/src/win32/d3d11.zig @@ -149,7 +149,7 @@ pub const WindowState = struct { } // TODO: this should take a utf8 graphme instead - pub fn generateGlyph(state: *WindowState, font: Font, codepoint: u21, right_half: bool) u32 { + pub fn generateGlyph(state: *WindowState, font: Font, codepoint: u21, kind: enum { single, left, right }) u32 { // for now we'll just use 1 texture and leverage the entire thing const texture_cell_count: XY(u16) = getD3d11TextureMaxCellCount(font.cell_size); const texture_cell_count_total: u32 = @@ -184,6 +184,11 @@ pub const WindowState = struct { break :blk &(state.glyph_index_cache.?); }; + const right_half: bool = switch (kind) { + .single, .left => false, + .right => true, + }; + switch (glyph_index_cache.reserve( global.glyph_cache_arena.allocator(), codepoint, @@ -194,8 +199,11 @@ pub const WindowState = struct { // defer if (!render_success) state.glyph_index_cache.remove(reserved.index); const pos: XY(u16) = cellPosFromIndex(reserved.index, texture_cell_count.x); const coord = coordFromCellPos(font.cell_size, pos); - var staging_size = font.cell_size; - staging_size.x = if (right_half) staging_size.x * 2 else staging_size.x; + const staging_size: XY(u16) = .{ + // twice the width to handle double-wide glyphs + .x = font.cell_size.x * 2, + .y = font.cell_size.y, + }; const staging = global.staging_texture.update(staging_size); var utf8_buf: [7]u8 = undefined; const utf8_len: u3 = std.unicode.utf8Encode(codepoint, &utf8_buf) catch |e| std.debug.panic( @@ -205,6 +213,10 @@ pub const WindowState = struct { staging.text_renderer.render( font, utf8_buf[0..utf8_len], + switch (kind) { + .single => false, + .left, .right => true, + }, ); const box: win32.D3D11_BOX = .{ .left = if (right_half) font.cell_size.x else 0, @@ -292,7 +304,7 @@ pub fn paint( } const copy_col_count: u16 = @min(col_count, shader_col_count); - const blank_space_glyph_index = state.generateGlyph(font, ' ', false); + const blank_space_glyph_index = state.generateGlyph(font, ' ', .single); const cell_count: u32 = @as(u32, shader_col_count) * @as(u32, shader_row_count); state.shader_cells.updateCount(cell_count); diff --git a/src/win32/gui.zig b/src/win32/gui.zig index 485d1b4..bab2196 100644 --- a/src/win32/gui.zig +++ b/src/win32/gui.zig @@ -1083,6 +1083,7 @@ fn WndProc( ) catch |e| oom(e); var prev_width: usize = 1; var prev_cell: render.Cell = undefined; + var prev_codepoint: u21 = undefined; for (global.screen.buf, global.render_cells.items) |*screen_cell, *render_cell| { const width = screen_cell.char.width; const codepoint = if (std.unicode.utf8ValidateSlice(screen_cell.char.grapheme)) @@ -1091,16 +1092,17 @@ fn WndProc( std.unicode.replacement_character; if (prev_width > 1) { render_cell.* = prev_cell; - render_cell.glyph_index = state.render_state.generateGlyph(font, codepoint, true); + render_cell.glyph_index = state.render_state.generateGlyph(font, prev_codepoint, .right); } else { render_cell.* = .{ - .glyph_index = state.render_state.generateGlyph(font, codepoint, false), + .glyph_index = state.render_state.generateGlyph(font, codepoint, if (width == 1) .single else .left), .background = renderColorFromVaxis(screen_cell.style.bg), .foreground = renderColorFromVaxis(screen_cell.style.fg), }; } prev_width = width; prev_cell = render_cell.*; + prev_codepoint = codepoint; } render.paint( &state.render_state, From 2790dcfd11bbb7c4b8bb0247a5fd051bba34ba91 Mon Sep 17 00:00:00 2001 From: Jonathan Marler Date: Tue, 23 Sep 2025 14:03:34 -0600 Subject: [PATCH 4/6] add some new text to the font test --- src/tui/fonts.zig | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/tui/fonts.zig b/src/tui/fonts.zig index 35ef2d6..1c78176 100644 --- a/src/tui/fonts.zig +++ b/src/tui/fonts.zig @@ -224,6 +224,17 @@ pub const font_test_text: []const u8 = \\🙂‍↔ \\ \\ + \\你好世界 "Hello World" + \\一二三四五六七八九十 "123456789" + \\龍鳳麟龜 (dragon, phoenix, qilin, turtle) + \\Fullwidth numbers: 1234567890 + \\Fullwidth letters: ABCDEFG abcdefg + \\Fullwidth punctuation: !@#$%^&*() + \\Half-width (normal): ABC 123 + \\Full-width (double): ABC 123 + \\Punctuation: 。,、;:「」『』 + \\Symbols: ○●□■△▲☆★◇◆ + \\ \\ recommended fonts for terminals with no nerdfont fallback support (e.g. flow-gui): \\ \\ "IosevkaTerm Nerd Font" => https://github.com/ryanoasis/nerd-fonts/releases/download/v3.3.0/IosevkaTerm.zip From 921f09450924936625df6c1c855e3cfeac47cf7a Mon Sep 17 00:00:00 2001 From: Jonathan Marler Date: Tue, 23 Sep 2025 14:04:58 -0600 Subject: [PATCH 5/6] workaround crash when rendering some utf8 on win32 gui closes #194 Ignores cells that have graphemes with more than 1 codepoint rather than crash. --- src/win32/gui.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/win32/gui.zig b/src/win32/gui.zig index bab2196..d1bcaed 100644 --- a/src/win32/gui.zig +++ b/src/win32/gui.zig @@ -1086,7 +1086,10 @@ fn WndProc( var prev_codepoint: u21 = undefined; for (global.screen.buf, global.render_cells.items) |*screen_cell, *render_cell| { const width = screen_cell.char.width; - const codepoint = if (std.unicode.utf8ValidateSlice(screen_cell.char.grapheme)) + // temporary workaround, ignore multi-codepoint graphemes + const codepoint = if (screen_cell.char.grapheme.len > 4) + std.unicode.replacement_character + else if (std.unicode.utf8ValidateSlice(screen_cell.char.grapheme)) std.unicode.wtf8Decode(screen_cell.char.grapheme) catch std.unicode.replacement_character else std.unicode.replacement_character; From 5cc6724a079ca87f769636d19fb04b0dd79d1137 Mon Sep 17 00:00:00 2001 From: Jonathan Marler Date: Tue, 23 Sep 2025 14:05:24 -0600 Subject: [PATCH 6/6] win32 gui: center double-wide characters --- src/win32/DwriteRenderer.zig | 4 +-- src/win32/dwrite.zig | 47 ++++++++++++++++++++++++++++++------ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/win32/DwriteRenderer.zig b/src/win32/DwriteRenderer.zig index 39abee5..0a9b20d 100644 --- a/src/win32/DwriteRenderer.zig +++ b/src/win32/DwriteRenderer.zig @@ -87,7 +87,7 @@ pub fn render( .left = 0, .top = 0, .right = if (double_width) - @as(f32, @floatFromInt(font.cell_size.x)) * @as(f32, @floatFromInt(font.cell_size.x)) + @as(f32, @floatFromInt(font.cell_size.x)) * 2 else @as(f32, @floatFromInt(font.cell_size.x)), .bottom = @floatFromInt(font.cell_size.y), @@ -100,7 +100,7 @@ pub fn render( self.render_target.DrawText( @ptrCast(utf16.ptr), @intCast(utf16.len), - font.text_format, + if (double_width) font.text_format_double else font.text_format_single, &rect, &self.white_brush.ID2D1Brush, .{}, diff --git a/src/win32/dwrite.zig b/src/win32/dwrite.zig index 0bd85f2..23129e7 100644 --- a/src/win32/dwrite.zig +++ b/src/win32/dwrite.zig @@ -23,11 +23,13 @@ pub fn init() void { } pub const Font = struct { - text_format: *win32.IDWriteTextFormat, + text_format_single: *win32.IDWriteTextFormat, + text_format_double: *win32.IDWriteTextFormat, cell_size: XY(u16), pub fn init(dpi: u32, size: f32, face: *const FontFace) Font { - var text_format: *win32.IDWriteTextFormat = undefined; + var text_format_single: *win32.IDWriteTextFormat = undefined; + { const hr = global.dwrite_factory.CreateTextFormat( face.ptr(), @@ -37,14 +39,43 @@ pub const Font = struct { .NORMAL, // stretch win32.scaleDpi(f32, size, dpi), win32.L(""), // locale - &text_format, + &text_format_single, ); if (hr < 0) std.debug.panic( "CreateTextFormat '{}' height {d} failed, hresult=0x{x}", .{ std.unicode.fmtUtf16Le(face.slice()), size, @as(u32, @bitCast(hr)) }, ); } - errdefer _ = text_format.IUnknown.Release(); + errdefer _ = text_format_single.IUnknown.Release(); + + var text_format_double: *win32.IDWriteTextFormat = undefined; + { + const hr = global.dwrite_factory.CreateTextFormat( + face.ptr(), + null, + .NORMAL, //weight + .NORMAL, // style + .NORMAL, // stretch + win32.scaleDpi(f32, size, dpi), + win32.L(""), // locale + &text_format_double, + ); + if (hr < 0) std.debug.panic( + "CreateTextFormat '{}' height {d} failed, hresult=0x{x}", + .{ std.unicode.fmtUtf16Le(face.slice()), size, @as(u32, @bitCast(hr)) }, + ); + } + errdefer _ = text_format_double.IUnknown.Release(); + + { + const hr = text_format_double.SetTextAlignment(win32.DWRITE_TEXT_ALIGNMENT_CENTER); + if (hr < 0) fatalHr("SetTextAlignment", hr); + } + + { + const hr = text_format_double.SetParagraphAlignment(win32.DWRITE_PARAGRAPH_ALIGNMENT_CENTER); + if (hr < 0) fatalHr("SetParagraphAlignment", hr); + } const cell_size: XY(u16) = blk: { var text_layout: *win32.IDWriteTextLayout = undefined; @@ -52,7 +83,7 @@ pub const Font = struct { const hr = global.dwrite_factory.CreateTextLayout( win32.L("█"), 1, - text_format, + text_format_single, std.math.floatMax(f32), std.math.floatMax(f32), &text_layout, @@ -73,13 +104,15 @@ pub const Font = struct { }; return .{ - .text_format = text_format, + .text_format_single = text_format_single, + .text_format_double = text_format_double, .cell_size = cell_size, }; } pub fn deinit(self: *Font) void { - _ = self.text_format.IUnknown.Release(); + _ = self.text_format_single.IUnknown.Release(); + _ = self.text_format_double.IUnknown.Release(); self.* = undefined; }