From ce85a73063864a4b8ec4d10f06a4d73b272d2622 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 1 Apr 2026 10:08:04 +0200 Subject: [PATCH 1/6] fix(gui): implement horizontal scrolling --- src/gui/wio/app.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/gui/wio/app.zig b/src/gui/wio/app.zig index a06fab56..ca32df66 100644 --- a/src/gui/wio/app.zig +++ b/src/gui/wio/app.zig @@ -399,6 +399,12 @@ fn wioLoop() void { const row_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(mouse_pos.y)), wio_font.cell_size.y)); tui_pid.send(.{ "RDR", "B", @as(u8, 1), btn_id, col_cell, row_cell, @as(i32, 0), @as(i32, 0) }) catch {}; }, + .scroll_horizontal => |dx| { + const btn_id: u8 = if (dx < 0) 66 else 67; // left / right scroll + const col_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(mouse_pos.x)), wio_font.cell_size.x)); + const row_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(mouse_pos.y)), wio_font.cell_size.y)); + tui_pid.send(.{ "RDR", "B", @as(u8, 1), btn_id, col_cell, row_cell, @as(i32, 0), @as(i32, 0) }) catch {}; + }, .focused => window.enableTextInput(.{}), .unfocused => window.disableTextInput(), else => {}, From 45db14f8949b7aff06ac66ed466b0ce3cfba2368 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 1 Apr 2026 10:08:35 +0200 Subject: [PATCH 2/6] refactor(gui): clean-up pixel to cell coordinate conversions --- src/gui/wio/app.zig | 69 ++++++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/src/gui/wio/app.zig b/src/gui/wio/app.zig index ca32df66..024fabca 100644 --- a/src/gui/wio/app.zig +++ b/src/gui/wio/app.zig @@ -205,6 +205,26 @@ pub fn requestAttention() void { // ── Internal helpers (wio thread only) ──────────────────────────────────── +const CellPos = struct { + col: i32, + row: i32, + xoff: i32, + yoff: i32, +}; + +fn pixelToCellPos(pos: wio.Position) CellPos { + const x: i32 = @intCast(pos.x); + const y: i32 = @intCast(pos.y); + const cw: i32 = wio_font.cell_size.x; + const ch: i32 = wio_font.cell_size.y; + return .{ + .col = @divTrunc(x, cw), + .row = @divTrunc(y, ch), + .xoff = @mod(x, cw), + .yoff = @mod(y, ch), + }; +} + // Reload wio_font from current settings. Called only from the wio thread. fn reloadFont() void { const name = if (font_name_len > 0) font_name_buf[0..font_name_len] else "monospace"; @@ -318,20 +338,15 @@ fn wioLoop() void { held_buttons.press(btn); const mods = input_translate.Mods.fromButtons(held_buttons); if (input_translate.mouseButtonId(btn)) |mb_id| { - const col: i32 = @intCast(mouse_pos.x); - const row: i32 = @intCast(mouse_pos.y); - const col_cell: i32 = @intCast(@divTrunc(col, wio_font.cell_size.x)); - const row_cell: i32 = @intCast(@divTrunc(row, wio_font.cell_size.y)); - const xoff: i32 = @intCast(@mod(col, wio_font.cell_size.x)); - const yoff: i32 = @intCast(@mod(row, wio_font.cell_size.y)); + const cp = pixelToCellPos(mouse_pos); tui_pid.send(.{ "RDR", "B", @as(u8, 1), // press mb_id, - col_cell, - row_cell, - xoff, - yoff, + cp.col, + cp.row, + cp.xoff, + cp.yoff, }) catch {}; } else { const base_cp = input_translate.codepointFromButton(btn, .{}); @@ -351,20 +366,15 @@ fn wioLoop() void { held_buttons.release(btn); const mods = input_translate.Mods.fromButtons(held_buttons); if (input_translate.mouseButtonId(btn)) |mb_id| { - const col: i32 = @intCast(mouse_pos.x); - const row: i32 = @intCast(mouse_pos.y); - const col_cell: i32 = @intCast(@divTrunc(col, wio_font.cell_size.x)); - const row_cell: i32 = @intCast(@divTrunc(row, wio_font.cell_size.y)); - const xoff: i32 = @intCast(@mod(col, wio_font.cell_size.x)); - const yoff: i32 = @intCast(@mod(row, wio_font.cell_size.y)); + const cp = pixelToCellPos(mouse_pos); tui_pid.send(.{ "RDR", "B", @as(u8, 3), // release mb_id, - col_cell, - row_cell, - xoff, - yoff, + cp.col, + cp.row, + cp.xoff, + cp.yoff, }) catch {}; } else { const base_cp = input_translate.codepointFromButton(btn, .{}); @@ -383,27 +393,22 @@ fn wioLoop() void { }, .mouse => |pos| { mouse_pos = pos; - const col_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(pos.x)), wio_font.cell_size.x)); - const row_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(pos.y)), wio_font.cell_size.y)); - const xoff: i32 = @intCast(@mod(@as(i32, @intCast(pos.x)), wio_font.cell_size.x)); - const yoff: i32 = @intCast(@mod(@as(i32, @intCast(pos.y)), wio_font.cell_size.y)); + const cp = pixelToCellPos(pos); if (input_translate.heldMouseButtonId(held_buttons)) |mb_id| { - tui_pid.send(.{ "RDR", "D", mb_id, col_cell, row_cell, xoff, yoff }) catch {}; + tui_pid.send(.{ "RDR", "D", mb_id, cp.col, cp.row, cp.xoff, cp.yoff }) catch {}; } else { - tui_pid.send(.{ "RDR", "M", col_cell, row_cell, xoff, yoff }) catch {}; + tui_pid.send(.{ "RDR", "M", cp.col, cp.row, cp.xoff, cp.yoff }) catch {}; } }, .scroll_vertical => |dy| { const btn_id: u8 = if (dy < 0) 64 else 65; // up / down scroll - const col_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(mouse_pos.x)), wio_font.cell_size.x)); - const row_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(mouse_pos.y)), wio_font.cell_size.y)); - tui_pid.send(.{ "RDR", "B", @as(u8, 1), btn_id, col_cell, row_cell, @as(i32, 0), @as(i32, 0) }) catch {}; + const cp = pixelToCellPos(mouse_pos); + tui_pid.send(.{ "RDR", "B", @as(u8, 1), btn_id, cp.col, cp.row, @as(i32, 0), @as(i32, 0) }) catch {}; }, .scroll_horizontal => |dx| { const btn_id: u8 = if (dx < 0) 66 else 67; // left / right scroll - const col_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(mouse_pos.x)), wio_font.cell_size.x)); - const row_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(mouse_pos.y)), wio_font.cell_size.y)); - tui_pid.send(.{ "RDR", "B", @as(u8, 1), btn_id, col_cell, row_cell, @as(i32, 0), @as(i32, 0) }) catch {}; + const cp = pixelToCellPos(mouse_pos); + tui_pid.send(.{ "RDR", "B", @as(u8, 1), btn_id, cp.col, cp.row, @as(i32, 0), @as(i32, 0) }) catch {}; }, .focused => window.enableTextInput(.{}), .unfocused => window.disableTextInput(), From f484ea0b57ece8e48a8596c575c6feadbdb793fc Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 1 Apr 2026 10:10:18 +0200 Subject: [PATCH 3/6] fix(gui): add pixel offsets to scroll events --- src/gui/wio/app.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gui/wio/app.zig b/src/gui/wio/app.zig index 024fabca..6cfffb05 100644 --- a/src/gui/wio/app.zig +++ b/src/gui/wio/app.zig @@ -403,12 +403,12 @@ fn wioLoop() void { .scroll_vertical => |dy| { const btn_id: u8 = if (dy < 0) 64 else 65; // up / down scroll const cp = pixelToCellPos(mouse_pos); - tui_pid.send(.{ "RDR", "B", @as(u8, 1), btn_id, cp.col, cp.row, @as(i32, 0), @as(i32, 0) }) catch {}; + tui_pid.send(.{ "RDR", "B", @as(u8, 1), btn_id, cp.col, cp.row, cp.xoff, cp.yoff }) catch {}; }, .scroll_horizontal => |dx| { const btn_id: u8 = if (dx < 0) 66 else 67; // left / right scroll const cp = pixelToCellPos(mouse_pos); - tui_pid.send(.{ "RDR", "B", @as(u8, 1), btn_id, cp.col, cp.row, @as(i32, 0), @as(i32, 0) }) catch {}; + tui_pid.send(.{ "RDR", "B", @as(u8, 1), btn_id, cp.col, cp.row, cp.xoff, cp.yoff }) catch {}; }, .focused => window.enableTextInput(.{}), .unfocused => window.disableTextInput(), From 6faea2ef02675890043f642989532edb27efb7d4 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 1 Apr 2026 10:39:20 +0200 Subject: [PATCH 4/6] refactor(gui): improve hidpi scaling support --- src/gui/wio/app.zig | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/gui/wio/app.zig b/src/gui/wio/app.zig index 6cfffb05..7891e8a1 100644 --- a/src/gui/wio/app.zig +++ b/src/gui/wio/app.zig @@ -42,6 +42,10 @@ var font_name_len: usize = 0; var font_dirty: std.atomic.Value(bool) = .init(true); var stop_requested: std.atomic.Value(bool) = .init(false); +// HiDPI scale factor (logical → physical pixels). Updated from wio .scale events. +// Only read/written on the wio thread after initialisation. +var dpi_scale: f32 = 1.0; + // Window title (written from TUI thread, applied by wio thread) var title_mutex: std.Thread.Mutex = .{}; var title_buf: [512]u8 = undefined; @@ -213,8 +217,11 @@ const CellPos = struct { }; fn pixelToCellPos(pos: wio.Position) CellPos { - const x: i32 = @intCast(pos.x); - const y: i32 = @intCast(pos.y); + // Mouse positions are in logical pixels; cell_size is in physical pixels. + // Scale up to physical before dividing so that col/row and sub-cell offsets + // are all expressed in physical pixels, matching the GPU coordinate space. + const x: i32 = @intFromFloat(@as(f32, @floatFromInt(pos.x)) * dpi_scale); + const y: i32 = @intFromFloat(@as(f32, @floatFromInt(pos.y)) * dpi_scale); const cw: i32 = wio_font.cell_size.x; const ch: i32 = wio_font.cell_size.y; return .{ @@ -228,7 +235,8 @@ fn pixelToCellPos(pos: wio.Position) CellPos { // Reload wio_font from current settings. Called only from the wio thread. fn reloadFont() void { const name = if (font_name_len > 0) font_name_buf[0..font_name_len] else "monospace"; - wio_font = gpu.loadFont(name, font_size_px) catch return; + const size_physical: u16 = @intFromFloat(@round(@as(f32, @floatFromInt(font_size_px)) * dpi_scale)); + wio_font = gpu.loadFont(name, @max(size_physical, 4)) catch return; } // Check dirty flag and reload if needed. @@ -298,19 +306,28 @@ fn wioLoop() void { }; defer gpu.deinit(); - // Load the initial font on the wio thread (gpu.init must be done first). - reloadFont(); - var state = gpu.WindowState.init(); defer state.deinit(); - // Current window size in pixels (updated by size_physical events) + // Current window sizes (updated by size_* events). var win_size: wio.Size = .{ .width = 1280, .height = 720 }; // Cell grid dimensions (updated on resize) var cell_width: u16 = 80; var cell_height: u16 = 24; + // Drain the initial wio events (scale + size_*) that are queued synchronously + // during createWindow. This ensures dpi_scale and win_size are correct before + // the first reloadFont / sendResize, avoiding a brief render at the wrong scale. + while (window.getEvent()) |event| { + switch (event) { + .scale => |s| dpi_scale = s, + .size_physical => |sz| win_size = sz, + else => {}, + } + } + // Notify the tui that the window is ready + reloadFont(); sendResize(win_size, &state, &cell_width, &cell_height); tui_pid.send(.{ "RDR", "WindowCreated", @as(usize, 0) }) catch {}; @@ -330,6 +347,10 @@ fn wioLoop() void { .close => { running = false; }, + .scale => |s| { + dpi_scale = s; + font_dirty.store(true, .release); + }, .size_physical => |sz| { win_size = sz; sendResize(sz, &state, &cell_width, &cell_height); From 4ca31b0f75c07365655f1e5c4ff79b42d0448bfe Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 1 Apr 2026 11:13:39 +0200 Subject: [PATCH 5/6] feat(gui): implement get_fontfaces --- build.zig | 1 + src/gui/rasterizer/font_finder.zig | 9 +++++ src/gui/rasterizer/font_finder/linux.zig | 50 ++++++++++++++++++++++++ src/gui/rasterizer/truetype.zig | 2 +- src/gui/wio/app.zig | 4 ++ src/renderer/gui/renderer.zig | 31 ++++++++++++++- 6 files changed, 95 insertions(+), 2 deletions(-) diff --git a/build.zig b/build.zig index 65c97662..035b480f 100644 --- a/build.zig +++ b/build.zig @@ -606,6 +606,7 @@ pub fn build_exe( .{ .name = "app", .module = app_mod }, .{ .name = "tuirenderer", .module = tui_renderer_mod }, .{ .name = "vaxis", .module = vaxis_mod }, + .{ .name = "rasterizer", .module = truetype_rasterizer_mod }, }, }); break :blk mod; diff --git a/src/gui/rasterizer/font_finder.zig b/src/gui/rasterizer/font_finder.zig index 4bb42041..4ad8f875 100644 --- a/src/gui/rasterizer/font_finder.zig +++ b/src/gui/rasterizer/font_finder.zig @@ -7,3 +7,12 @@ pub fn findFont(allocator: std.mem.Allocator, name: []const u8) ![]u8 { else => error.FontFinderNotSupported, }; } + +/// Returns a sorted, deduplicated list of monospace font family names. +/// Caller owns the returned slice and each string within it. +pub fn listFonts(allocator: std.mem.Allocator) ![][]u8 { + return switch (builtin.os.tag) { + .linux => @import("font_finder/linux.zig").list(allocator), + else => error.FontFinderNotSupported, + }; +} diff --git a/src/gui/rasterizer/font_finder/linux.zig b/src/gui/rasterizer/font_finder/linux.zig index ffa824ee..8800402a 100644 --- a/src/gui/rasterizer/font_finder/linux.zig +++ b/src/gui/rasterizer/font_finder/linux.zig @@ -3,6 +3,56 @@ const fc = @cImport({ @cInclude("fontconfig/fontconfig.h"); }); +/// Returns a sorted, deduplicated list of monospace font family names. +/// Caller owns the returned slice and each string within it. +pub fn list(allocator: std.mem.Allocator) ![][]u8 { + const config = fc.FcInitLoadConfigAndFonts() orelse return error.FontconfigInit; + defer fc.FcConfigDestroy(config); + + const pat = fc.FcPatternCreate() orelse return error.OutOfMemory; + defer fc.FcPatternDestroy(pat); + _ = fc.FcPatternAddInteger(pat, fc.FC_SPACING, fc.FC_MONO); + + const os = fc.FcObjectSetCreate() orelse return error.OutOfMemory; + defer fc.FcObjectSetDestroy(os); + _ = fc.FcObjectSetAdd(os, fc.FC_FAMILY); + + const font_set = fc.FcFontList(config, pat, os) orelse return error.OutOfMemory; + defer fc.FcFontSetDestroy(font_set); + + var names: std.ArrayList([]u8) = .empty; + errdefer { + for (names.items) |n| allocator.free(n); + names.deinit(allocator); + } + + for (0..@intCast(font_set.*.nfont)) |i| { + var family: [*c]fc.FcChar8 = undefined; + if (fc.FcPatternGetString(font_set.*.fonts[i], fc.FC_FAMILY, 0, &family) != fc.FcResultMatch) + continue; + try names.append(allocator, try allocator.dupe(u8, std.mem.sliceTo(family, 0))); + } + + const result = try names.toOwnedSlice(allocator); + std.mem.sort([]u8, result, {}, struct { + fn lessThan(_: void, a: []u8, b: []u8) bool { + return std.ascii.lessThanIgnoreCase(a, b); + } + }.lessThan); + + // Remove adjacent duplicates that survived the sort. + var w: usize = 0; + for (result) |name| { + if (w == 0 or !std.ascii.eqlIgnoreCase(result[w - 1], name)) { + result[w] = name; + w += 1; + } else { + allocator.free(name); + } + } + return result[0..w]; +} + pub fn find(allocator: std.mem.Allocator, name: []const u8) ![]u8 { const config = fc.FcInitLoadConfigAndFonts() orelse return error.FontconfigInit; defer fc.FcConfigDestroy(config); diff --git a/src/gui/rasterizer/truetype.zig b/src/gui/rasterizer/truetype.zig index 565fdb11..416e895c 100644 --- a/src/gui/rasterizer/truetype.zig +++ b/src/gui/rasterizer/truetype.zig @@ -1,7 +1,7 @@ const std = @import("std"); const TrueType = @import("TrueType"); const XY = @import("xy").XY; -const font_finder = @import("font_finder.zig"); +pub const font_finder = @import("font_finder.zig"); const Self = @This(); diff --git a/src/gui/wio/app.zig b/src/gui/wio/app.zig index 7891e8a1..3237ea87 100644 --- a/src/gui/wio/app.zig +++ b/src/gui/wio/app.zig @@ -202,6 +202,10 @@ pub fn setMouseCursor(shape: vaxis.Mouse.Shape) void { wio.cancelWait(); } +pub fn getFontName() []const u8 { + return if (font_name_len > 0) font_name_buf[0..font_name_len] else "monospace"; +} + pub fn requestAttention() void { attention_pending.store(true, .release); wio.cancelWait(); diff --git a/src/renderer/gui/renderer.zig b/src/renderer/gui/renderer.zig index 41ca35fe..3890fcea 100644 --- a/src/renderer/gui/renderer.zig +++ b/src/renderer/gui/renderer.zig @@ -381,7 +381,36 @@ pub fn reset_fontface(self: *Self) void { } pub fn get_fontfaces(self: *Self) void { - _ = self; + const font_finder = @import("rasterizer").font_finder; + const dispatch = self.dispatch_event orelse return; + + // Report the current font first. + if (self.fmtmsg(.{ "fontface", "current", app.getFontName() })) |msg| + dispatch(self.handler_ctx, msg) + else |_| {} + + // Enumerate all available monospace fonts and report each one. + const names = font_finder.listFonts(self.allocator) catch { + // If enumeration fails, still close the palette with "done". + if (self.fmtmsg(.{ "fontface", "done" })) |msg| + dispatch(self.handler_ctx, msg) + else |_| {} + return; + }; + defer { + for (names) |n| self.allocator.free(n); + self.allocator.free(names); + } + + for (names) |name| { + if (self.fmtmsg(.{ "fontface", name })) |msg| + dispatch(self.handler_ctx, msg) + else |_| {} + } + + if (self.fmtmsg(.{ "fontface", "done" })) |msg| + dispatch(self.handler_ctx, msg) + else |_| {} } pub fn set_terminal_cursor_color(self: *Self, color: Color) void { From 091345fd026d6bc10355b711141d11ee282b7059 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Wed, 1 Apr 2026 11:14:15 +0200 Subject: [PATCH 6/6] fix(gui): fix resize crash --- src/gui/gpu/gpu.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/gui/gpu/gpu.zig b/src/gui/gpu/gpu.zig index b1965e0a..f50c61d0 100644 --- a/src/gui/gpu/gpu.zig +++ b/src/gui/gpu/gpu.zig @@ -213,6 +213,9 @@ pub const WindowState = struct { c.deinit(global.glyph_cache_arena.allocator()); _ = global.glyph_cache_arena.reset(.retain_capacity); state.glyph_index_cache = null; + // cell_buf was allocated from the arena; clear it so the next + // resize doesn't memcpy from the now-freed memory. + state.cell_buf = .{}; } }