Compare commits

...

6 commits

Author SHA1 Message Date
Jonathan Marler
5cc6724a07 win32 gui: center double-wide characters 2025-09-23 22:14:29 +02:00
Jonathan Marler
921f094509 workaround crash when rendering some utf8 on win32 gui
closes #194

Ignores cells that have graphemes with more than 1 codepoint rather than
crash.
2025-09-23 22:14:29 +02:00
Jonathan Marler
2790dcfd11 add some new text to the font test 2025-09-23 22:14:29 +02:00
Jonathan Marler
05b87b1406 finish win32 gui support for double-wide characters 2025-09-23 22:14:29 +02:00
8278a080af fix: actually use staging_size in WindowState.generateGlyph 2025-09-23 22:14:29 +02:00
a9d4fed205 feat: support wide characters in win32 gui
closes #132
2025-09-23 22:14:29 +02:00
6 changed files with 111 additions and 29 deletions

View file

@ -224,6 +224,17 @@ pub const font_test_text: []const u8 =
\\🙂‍↔ \\🙂‍↔
\\ \\
\\ \\
\\你好世界 "Hello World"
\\一二三四五六七八九十 "123456789"
\\龍鳳麟龜 (dragon, phoenix, qilin, turtle)
\\Fullwidth numbers:
\\Fullwidth letters:  
\\Fullwidth punctuation:
\\Half-width (normal): ABC 123
\\Full-width (double):  
\\Punctuation: 。,、;:「」『』
\\Symbols: ○●□■△▲☆★◇◆
\\
\\ recommended fonts for terminals with no nerdfont fallback support (e.g. flow-gui): \\ 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 \\ "IosevkaTerm Nerd Font" => https://github.com/ryanoasis/nerd-fonts/releases/download/v3.3.0/IosevkaTerm.zip

View file

@ -75,6 +75,7 @@ pub fn render(
self: *const DwriteRenderer, self: *const DwriteRenderer,
font: Font, font: Font,
utf8: []const u8, utf8: []const u8,
double_width: bool,
) void { ) void {
var utf16_buf: [10]u16 = undefined; var utf16_buf: [10]u16 = undefined;
const utf16_len = std.unicode.utf8ToUtf16Le(&utf16_buf, utf8) catch unreachable; const utf16_len = std.unicode.utf8ToUtf16Le(&utf16_buf, utf8) catch unreachable;
@ -85,7 +86,10 @@ pub fn render(
const rect: win32.D2D_RECT_F = .{ const rect: win32.D2D_RECT_F = .{
.left = 0, .left = 0,
.top = 0, .top = 0,
.right = @floatFromInt(font.cell_size.x), .right = if (double_width)
@as(f32, @floatFromInt(font.cell_size.x)) * 2
else
@as(f32, @floatFromInt(font.cell_size.x)),
.bottom = @floatFromInt(font.cell_size.y), .bottom = @floatFromInt(font.cell_size.y),
}; };
self.render_target.BeginDraw(); self.render_target.BeginDraw();
@ -96,7 +100,7 @@ pub fn render(
self.render_target.DrawText( self.render_target.DrawText(
@ptrCast(utf16.ptr), @ptrCast(utf16.ptr),
@intCast(utf16.len), @intCast(utf16.len),
font.text_format, if (double_width) font.text_format_double else font.text_format_single,
&rect, &rect,
&self.white_brush.ID2D1Brush, &self.white_brush.ID2D1Brush,
.{}, .{},

View file

@ -5,9 +5,15 @@ const Node = struct {
prev: ?u32, prev: ?u32,
next: ?u32, next: ?u32,
codepoint: ?u21, 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, nodes: []Node,
front: u32, front: u32,
back: 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 { pub fn clearRetainingCapacity(self: *GlyphIndexCache) void {
self.map.clearRetainingCapacity(); self.map.clearRetainingCapacity();
self.nodes[0] = .{ .prev = null, .next = 1, .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 }; 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| { for (self.nodes[1 .. self.nodes.len - 1], 1..) |*node, index| {
node.* = .{ node.* = .{
.prev = @intCast(index - 1), .prev = @intCast(index - 1),
.next = @intCast(index + 1), .next = @intCast(index + 1),
.codepoint = null, .codepoint = null,
.right_half = null,
}; };
} }
self.front = 0; self.front = 0;
@ -51,12 +58,12 @@ const Reserved = struct {
index: u32, index: u32,
replaced: ?u21, 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, newly_reserved: Reserved,
already_reserved: u32, 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) { if (entry.found_existing) {
self.moveToBack(entry.value_ptr.*); self.moveToBack(entry.value_ptr.*);
return .{ .already_reserved = 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; const replaced = self.nodes[self.front].codepoint;
self.nodes[self.front].codepoint = codepoint; self.nodes[self.front].codepoint = codepoint;
if (replaced) |r| { 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); std.debug.assert(removed);
} }
const save_front = self.front; const save_front = self.front;

View file

@ -149,7 +149,7 @@ pub const WindowState = struct {
} }
// TODO: this should take a utf8 graphme instead // 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, kind: enum { single, left, right }) u32 {
// for now we'll just use 1 texture and leverage the entire thing // 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: XY(u16) = getD3d11TextureMaxCellCount(font.cell_size);
const texture_cell_count_total: u32 = const texture_cell_count_total: u32 =
@ -184,16 +184,27 @@ pub const WindowState = struct {
break :blk &(state.glyph_index_cache.?); break :blk &(state.glyph_index_cache.?);
}; };
const right_half: bool = switch (kind) {
.single, .left => false,
.right => true,
};
switch (glyph_index_cache.reserve( switch (glyph_index_cache.reserve(
global.glyph_cache_arena.allocator(), global.glyph_cache_arena.allocator(),
codepoint, codepoint,
right_half,
) catch |e| oom(e)) { ) catch |e| oom(e)) {
.newly_reserved => |reserved| { .newly_reserved => |reserved| {
// var render_success = false; // var render_success = false;
// defer if (!render_success) state.glyph_index_cache.remove(reserved.index); // defer if (!render_success) state.glyph_index_cache.remove(reserved.index);
const pos: XY(u16) = cellPosFromIndex(reserved.index, texture_cell_count.x); const pos: XY(u16) = cellPosFromIndex(reserved.index, texture_cell_count.x);
const coord = coordFromCellPos(font.cell_size, pos); const coord = coordFromCellPos(font.cell_size, pos);
const staging = global.staging_texture.update(font.cell_size); 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; var utf8_buf: [7]u8 = undefined;
const utf8_len: u3 = std.unicode.utf8Encode(codepoint, &utf8_buf) catch |e| std.debug.panic( const utf8_len: u3 = std.unicode.utf8Encode(codepoint, &utf8_buf) catch |e| std.debug.panic(
"todo: handle invalid codepoint {} (0x{0x}) ({s})", "todo: handle invalid codepoint {} (0x{0x}) ({s})",
@ -202,12 +213,16 @@ pub const WindowState = struct {
staging.text_renderer.render( staging.text_renderer.render(
font, font,
utf8_buf[0..utf8_len], utf8_buf[0..utf8_len],
switch (kind) {
.single => false,
.left, .right => true,
},
); );
const box: win32.D3D11_BOX = .{ const box: win32.D3D11_BOX = .{
.left = 0, .left = if (right_half) font.cell_size.x else 0,
.top = 0, .top = 0,
.front = 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, .bottom = font.cell_size.y,
.back = 1, .back = 1,
}; };
@ -289,7 +304,7 @@ pub fn paint(
} }
const copy_col_count: u16 = @min(col_count, shader_col_count); 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, ' ', .single);
const cell_count: u32 = @as(u32, shader_col_count) * @as(u32, shader_row_count); const cell_count: u32 = @as(u32, shader_col_count) * @as(u32, shader_row_count);
state.shader_cells.updateCount(cell_count); state.shader_cells.updateCount(cell_count);

View file

@ -23,11 +23,13 @@ pub fn init() void {
} }
pub const Font = struct { pub const Font = struct {
text_format: *win32.IDWriteTextFormat, text_format_single: *win32.IDWriteTextFormat,
text_format_double: *win32.IDWriteTextFormat,
cell_size: XY(u16), cell_size: XY(u16),
pub fn init(dpi: u32, size: f32, face: *const FontFace) Font { 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( const hr = global.dwrite_factory.CreateTextFormat(
face.ptr(), face.ptr(),
@ -37,14 +39,43 @@ pub const Font = struct {
.NORMAL, // stretch .NORMAL, // stretch
win32.scaleDpi(f32, size, dpi), win32.scaleDpi(f32, size, dpi),
win32.L(""), // locale win32.L(""), // locale
&text_format, &text_format_single,
); );
if (hr < 0) std.debug.panic( if (hr < 0) std.debug.panic(
"CreateTextFormat '{}' height {d} failed, hresult=0x{x}", "CreateTextFormat '{}' height {d} failed, hresult=0x{x}",
.{ std.unicode.fmtUtf16Le(face.slice()), size, @as(u32, @bitCast(hr)) }, .{ 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: { const cell_size: XY(u16) = blk: {
var text_layout: *win32.IDWriteTextLayout = undefined; var text_layout: *win32.IDWriteTextLayout = undefined;
@ -52,7 +83,7 @@ pub const Font = struct {
const hr = global.dwrite_factory.CreateTextLayout( const hr = global.dwrite_factory.CreateTextLayout(
win32.L(""), win32.L(""),
1, 1,
text_format, text_format_single,
std.math.floatMax(f32), std.math.floatMax(f32),
std.math.floatMax(f32), std.math.floatMax(f32),
&text_layout, &text_layout,
@ -73,13 +104,15 @@ pub const Font = struct {
}; };
return .{ return .{
.text_format = text_format, .text_format_single = text_format_single,
.text_format_double = text_format_double,
.cell_size = cell_size, .cell_size = cell_size,
}; };
} }
pub fn deinit(self: *Font) void { 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; self.* = undefined;
} }

View file

@ -1081,19 +1081,31 @@ fn WndProc(
global.render_cells_arena.allocator(), global.render_cells_arena.allocator(),
global.screen.buf.len, global.screen.buf.len,
) catch |e| oom(e); ) 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| { for (global.screen.buf, global.render_cells.items) |*screen_cell, *render_cell| {
const codepoint = if (std.unicode.utf8ValidateSlice(screen_cell.char.grapheme)) const width = screen_cell.char.width;
// 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 std.unicode.wtf8Decode(screen_cell.char.grapheme) catch std.unicode.replacement_character
else else
std.unicode.replacement_character; std.unicode.replacement_character;
render_cell.* = .{ if (prev_width > 1) {
.glyph_index = state.render_state.generateGlyph( render_cell.* = prev_cell;
font, render_cell.glyph_index = state.render_state.generateGlyph(font, prev_codepoint, .right);
codepoint, } else {
), render_cell.* = .{
.background = renderColorFromVaxis(screen_cell.style.bg), .glyph_index = state.render_state.generateGlyph(font, codepoint, if (width == 1) .single else .left),
.foreground = renderColorFromVaxis(screen_cell.style.fg), .background = renderColorFromVaxis(screen_cell.style.bg),
}; .foreground = renderColorFromVaxis(screen_cell.style.fg),
};
}
prev_width = width;
prev_cell = render_cell.*;
prev_codepoint = codepoint;
} }
render.paint( render.paint(
&state.render_state, &state.render_state,