From fdfaff7adf2801854df57010fde3e75448806534 Mon Sep 17 00:00:00 2001 From: HissingRat <84263094+HissingRat@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:15:10 +0800 Subject: [PATCH 01/11] integrate SDL3_ttf shaped text --- build.zig | 1 + build.zig.zon | 4 +- docs/architecture.md | 6 + docs/integration-inventory.md | 42 +- docs/plans/active/sdl3-ttf-font-system.md | 211 ++++++++ docs/quality/terminal-regression-checklist.md | 3 + src/backends/text/sdl_ttf.zig | 450 ++++++++++++++++++ src/main.zig | 1 + src/ui/fonts.zig | 16 + src/ui/foundation/typography.zig | 4 +- .../file_panel_elements/remote_editor.zig | 3 + src/ui/workspace/terminal_panel.zig | 2 +- 12 files changed, 733 insertions(+), 10 deletions(-) create mode 100644 docs/plans/active/sdl3-ttf-font-system.md create mode 100644 src/backends/text/sdl_ttf.zig diff --git a/build.zig b/build.zig index 28177cd..44fb562 100644 --- a/build.zig +++ b/build.zig @@ -37,6 +37,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, .backend = .sdl3, + .sdl3_ttf = true, .system_include_path = system_include_path, .system_framework_path = system_framework_path, .library_path = library_path, diff --git a/build.zig.zon b/build.zig.zon index d6cf904..88b5411 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .minimum_zig_version = "0.16.0", .dependencies = .{ .dvui = .{ - .url = "git+https://github.com/david-vanderson/dvui?ref=main#9707de2c7e9408b9326bb3c2c83ca0aa603fddca", - .hash = "dvui-0.5.0-dev-AQFJmbbs9gAKq7l6sNTZ5958PkPBH_dJ7rlbc4D7oYMs", + .url = "https://codeload.github.com/HissingRat/dvui/tar.gz/a0ef4c33f6c0f7f8b80f6a808844c09f5bb6f1a8", + .hash = "dvui-0.5.0-dev-AQFJmfjQTgHV-kJLqADnBE-L-VKBhfdjqMfrfzC0XYl4", }, // Keep SDL's Linux platform metadata eager at the application root. // Without this, SDL's second-level lazy dependency can be absent while diff --git a/docs/architecture.md b/docs/architecture.md index 4bbedd3..71ae44b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,6 +31,9 @@ Shellow 第一版要解决的是“原生桌面远程工作台”的核心闭环 - 远程文件编辑器:加载、查找/替换、UTF-8/BOM/ASCII 与换行识别、远端冲突检查、临时文件原子替换和未保存关闭确认 - 系统信息面板雏形与进程/网络快照 - 设置与主题系统基础:`owoConfig.json`、Light/Dark、窗口/布局尺寸和下载路径持久化 +- SDL3_ttf + FreeType + HarfBuzz shaped-text backend;DVUI 测量、绘制、 + TextLayout hit testing、caret/selection 与 TextEntry cluster 边界共享同一 + layout source,Zed Mono + Noto CJK fallback chain 已接入 - 三平台 CI 构建、nightly/tag Release、macOS `.app` 基础打包 尚未具备: @@ -141,6 +144,8 @@ Shellow/ backends/ ssh/ libssh2.zig + text/ + sdl_ttf.zig terminal/ libvterm.zig libvterm_shim.c @@ -161,6 +166,7 @@ Shellow/ transfers/ progress_store.zig ui/ + fonts.zig foundation/ widgets/ layouts/ diff --git a/docs/integration-inventory.md b/docs/integration-inventory.md index 5ec2835..833aa2b 100644 --- a/docs/integration-inventory.md +++ b/docs/integration-inventory.md @@ -9,6 +9,11 @@ | `dvui` | 原生 UI、窗口 app lifecycle、widgets | `build.zig`, `src/main.zig` | Shellow 主界面走 DVUI,不用 Electron/WebView 替代主 UI。 | | `dvui_sdl3` | SDL3 backend | `build.zig` | 通过 `b.dependency("dvui", .{ .backend = .sdl3 })` 导入。 | +当前 DVUI 使用 `HissingRat/dvui` fork 的固定 commit。fork 基于原 Shellowo +pin 的 DVUI commit,增加了可选 `TextEngine` 契约,使字体测量、绘制、 +TextLayout 命中和 TextEntry cluster 边界可以由同一 shaped-text backend +提供。未安装 text engine 时仍保留 DVUI 原始字体路径。 + 维护原则: - 新 UI 优先使用 DVUI widget 和布局能力。 @@ -36,7 +41,34 @@ 通过 `src/platform/macos_window_chrome.m` 调整 SDL 创建的原生 `NSWindow`,保留系统交通灯和 fullscreen 行为;平台 handle 不进入产品 UI。 -## 3. SSH / SFTP +## 3. Text Shaping: SDL3_ttf / FreeType / HarfBuzz + +当前实现: + +- SDL3_ttf 3.2.2 源码随 Shellowo 的 DVUI fork 固定,构建时启用 HarfBuzz。 +- SDL3_ttf 使用 DVUI 同一份 SDL3 3.4.4 renderer/backend。 +- FreeType 使用 DVUI 已有的固定依赖,HarfBuzz 源码随 fork 固定。 +- `src/backends/text/sdl_ttf.zig` 是 Shellowo-owned backend,负责 + `TTF_TextEngine`、字体、fallback 和 shaped layout cache 生命周期。 +- Zed Mono Extended 的 regular/bold/italic 是主字体,Noto Sans CJK SC + 通过 SDL3_ttf fallback chain 提供中文 glyph。 +- DVUI 的测量、绘制、鼠标命中、caret、selection 和 TextEntry + cluster movement 使用同一个 text engine。 +- terminal 继续由 libvterm cell grid 决定列宽、选择和 cursor;启用 shaped + backend 时终端 glyph 按 cell 绘制,避免 ligature 改变占用列数。 + +维护原则: + +- Raw `TTF_Font`、`TTF_Text` 和 `TTF_TextEngine` 只允许出现在 + `src/backends/text/`。 +- 不允许重新引入“绘制走 SDL3_ttf、测量走 DVUI”的双几何路径。 +- 新增字体 fallback 时必须保持主字体和 fallback 的 size/style 一致。 +- 更新 SDL3_ttf、FreeType、HarfBuzz 或 DVUI fork 后必须执行 + `zig build test` 和 `zig build`,并复测编辑器、IME、terminal cell 对齐。 +- SDL3_ttf renderer text engine 是 window/renderer scoped;必须在 SDL + renderer 销毁之前释放。 + +## 4. SSH / SFTP 当前实现: @@ -75,7 +107,7 @@ | `third_party/libssh2-1.11.1` | vendored libssh2 1.11.1 source。 | | `third_party/mbedtls-3.6.6` | vendored mbedTLS 3.6.6 source for libssh2 crypto backend。 | -## 4. Terminal Emulator +## 5. Terminal Emulator 当前实现: @@ -109,7 +141,7 @@ | `src/backends/terminal/libvterm_shim.c` | C shim,负责把 libvterm bitfield cell/color 数据转成 Zig 可直接消费的 plain struct。 | | `third_party/libvterm-0.3.3` | vendored libvterm 0.3.3 source。 | -## 5. 本地存储 +## 6. 本地存储 当前实现: @@ -125,7 +157,7 @@ - 平台安全存储 - 无 Master Password 模式的发布级凭据策略 -## 6. 新依赖准入规则 +## 7. 新依赖准入规则 新增或替换第三方项目时,至少补齐: @@ -135,7 +167,7 @@ 4. 如果改变分层边界,同步更新 `docs/architecture.md` 或 `docs/decisions/`。 5. 跑 `zig build`。 -## 7. 打包与发布 +## 8. 打包与发布 当前实现: diff --git a/docs/plans/active/sdl3-ttf-font-system.md b/docs/plans/active/sdl3-ttf-font-system.md new file mode 100644 index 0000000..e378742 --- /dev/null +++ b/docs/plans/active/sdl3-ttf-font-system.md @@ -0,0 +1,211 @@ +# SDL3_ttf Font System + +Implementation status: complete; awaiting user visual and functional +acceptance. + +## Background + +Shellowo currently uses DVUI's built-in font cache and codepoint renderer. Text +measurement, line wrapping, rendering, selection geometry, mouse hit testing, +caret placement, and editor cursor movement are all derived from that path. + +Replacing only glyph drawing would create two geometry sources: SDL3_ttf would +shape the visible text while DVUI would continue measuring individual Unicode +codepoints. Ligatures, combining marks, fallback fonts, emoji sequences, and +bidirectional text would then produce incorrect selections and cursor +positions. + +The new system therefore treats SDL3_ttf's shaped text as the single geometry +source for flow text and editable text. Terminal rendering remains a fixed cell +grid: libvterm owns terminal columns, while SDL3_ttf supplies font metrics and +glyph rendering constrained to those columns. + +## Goals + +- Use SDL3_ttf with FreeType and HarfBuzz for application text. +- Keep one shaped layout result for measurement, wrapping, rendering, hit + testing, caret geometry, and selection geometry. +- Support explicit font fallback instead of switching an entire string to the + CJK font when any non-ASCII byte is present. +- Preserve DVUI widgets and immediate-mode layout. +- Keep raw SDL3_ttf handles inside a Shellowo-owned backend. +- Preserve the terminal's libvterm cell geometry and PTY resize contract. +- Keep the DVUI changes isolated in `HissingRat/dvui`. + +## Non-goals + +- Replacing DVUI as the UI framework. +- Changing SSH, SFTP, transfer, storage, or session runtime behavior. +- Implementing a new terminal emulator. +- Packaging or signing the macOS application in this change. +- Completing visual acceptance without user review. + +## Architecture + +```text +DVUI Font / TextLayoutWidget / TextEntryWidget + | + v + dvui.TextEngine contract + | + v + Shellowo SDL3_ttf text backend + | | + v v + TTF_Text layout SDL_Renderer draw + | + +-- measure and wrap + +-- point to UTF-8 byte boundary + +-- UTF-8 range to selection rectangles + +-- UTF-8 offset to caret rectangle + +-- previous and next cluster boundary +``` + +The text backend is window-scoped because a renderer text engine is associated +with an SDL renderer. Font data and layout caches remain backend-owned. + +## DVUI Fork Work + +Baseline: the commit currently pinned by Shellowo, +`9707de2c7e9408b9326bb3c2c83ca0aa603fddca`. + +Required changes: + +1. Add a public `TextEngine` contract. +2. Allow a window to install an optional text engine. +3. Route `Font.textSizeEx()` through the installed engine. +4. Route deferred `renderText()` commands through the installed engine. +5. Route TextLayout point hit testing and caret/selection geometry through the + same layout source. +6. Route TextEntry left/right movement and backspace/delete through shaped + cluster boundaries. +7. Preserve the current renderer as the fallback when no engine is installed. +8. Add contract and fallback regression tests. + +## Shellowo Backend Work + +New boundary: + +```text +src/backends/text/sdl_ttf.zig +``` + +Responsibilities: + +- Initialize and quit SDL3_ttf. +- Create one renderer text engine per SDL/DVUI window. +- Open embedded fonts from memory. +- Build normal, bold, and italic face variants. +- Attach the CJK face as fallback. +- Cache `TTF_Text` layouts by text, face, size, scale, wrap width, direction, + and relevant style. +- Expose measurement, drawing, hit testing, caret, selection, and cluster + boundaries through `dvui.TextEngine`. +- Apply and restore the SDL renderer clip rectangle around direct text draws. +- Destroy text layouts, fonts, and engines before the SDL renderer is destroyed. + +## Build and Dependency Work + +- Add SDL3_ttf 3.2.2 source as a pinned third-party dependency in the DVUI fork. +- Build it statically against the same SDL3 used by DVUI. +- Build and link pinned FreeType and HarfBuzz dependencies. +- Disable optional SVG/color-font dependencies for the first integration. +- Update `docs/integration-inventory.md`. +- Pin Shellowo's DVUI dependency to the pushed `HissingRat/dvui` commit. + +## UI Font Policy + +The primary application family remains Zed Mono Extended. The first fallback is +Noto Sans CJK SC. + +The old `needsCjkFont()` whole-string switch is removed from layout decisions. +Mixed strings are shaped as one logical string with fallback handled by the +font backend. Existing baseline helpers may remain temporarily only where the +terminal grid needs an explicit visual correction. + +## Editable Text Policy + +- External selection state continues to use UTF-8 byte offsets. +- Mouse hit testing returns a valid shaped cluster boundary. +- Left/right movement advances between shaped cluster boundaries. +- Backspace/Delete remove one shaped cluster. +- Selection rectangles come from the shaped layout. +- Wrapped-line vertical navigation uses shaped caret positions. +- Search remains byte-based, but scrolling to a match queries the actual text + layout instead of independently estimating line breaks. + +## Remote Editor Work + +- Keep DVUI TextEntry storage and editing behavior. +- Replace `visualYOfOffset()`'s independent `Font.textSizeEx()` wrapping loop + with a caret/layout query from the active text engine. +- Invalidate layout cache on text, width, font, scale, or wrap changes. +- Preserve the 64 MiB editor limit and current search/replace behavior. + +## Terminal Grid Policy + +The terminal is not flow text. + +- libvterm cell width remains the source of columns and cursor positions. +- PTY resize, selection, mouse hit testing, and cursor rectangles continue to + use `TerminalMetrics`. +- `TerminalMetrics` obtains glyph height and monospace advance from SDL3_ttf. +- Terminal text is shaped per style run, but each run is constrained to the + cell span provided by libvterm. +- Ligatures must not alter the occupied terminal column count. +- Wide characters continue to occupy the width reported by libvterm. +- IME composition is drawn with the same font backend at the terminal cursor + cell. + +## Tests + +Automated checks should cover: + +- Text engine fallback when no custom engine is installed. +- Measurement and render dispatch through the same engine. +- UTF-8 byte boundaries for combining marks and multi-codepoint clusters. +- Point hit testing and caret geometry. +- Selection rectangles over shaped clusters. +- Mixed Latin/CJK fallback. +- Wrapped editor caret lookup. +- Terminal monospace metrics and unchanged grid hit testing. + +The implementation includes DVUI dispatch/fallback coverage plus Shellowo +UTF-8 fallback-boundary tests. Geometry cases that require an active renderer +remain in the manual acceptance matrix below. + +Manual user acceptance should cover: + +- Latin ligatures such as `fi`. +- Combining marks. +- Chinese and Latin mixed text. +- Emoji and ZWJ sequences where supported by the configured fonts. +- Arabic/RTL geometry. +- Mouse selection, keyboard selection, cursor movement, and deletion. +- Remote editor wrapping and search navigation. +- Terminal ASCII, CJK, Powerline, IME, cursor, and selection alignment. + +## Delivery Sequence + +1. Fork DVUI and branch from the Shellowo-pinned commit. +2. Implement and test the text engine contract. +3. Push the DVUI branch. +4. Add SDL3_ttf, FreeType, and HarfBuzz to Shellowo. +5. Implement the Shellowo text backend and window lifecycle. +6. Replace whole-string font switching with fallback chains. +7. Align TextLayout/TextEntry and the remote editor. +8. Align terminal metrics and rendering without changing cell geometry. +9. Run `zig build test`. +10. Run `zig build`. +11. Push both repositories for user visual and functional acceptance. + +## Completion Criteria + +- Shellowo uses the `HissingRat/dvui` fork. +- SDL3_ttf is initialized and used for application text. +- Measurement and drawing share SDL3_ttf shaping. +- Editable text uses shaped hit testing and cluster boundaries. +- Remote editor search scrolling uses actual layout geometry. +- Terminal grid dimensions and cursor positioning remain stable. +- `zig build test` passes. +- `zig build` passes. diff --git a/docs/quality/terminal-regression-checklist.md b/docs/quality/terminal-regression-checklist.md index c254d59..dff5628 100644 --- a/docs/quality/terminal-regression-checklist.md +++ b/docs/quality/terminal-regression-checklist.md @@ -17,6 +17,9 @@ - 大文本粘贴会分批发送,期间 UI 仍可响应,末尾内容不会丢失。 - 单个 chunk 内的小段粘贴在远端回显后立即可见,不需要再输入字符或移动 cursor 才刷新。 - 中文、Powerline 字符和宽字符不明显错位。 +- SDL3_ttf shaping 开启后,ASCII ligature 不改变 libvterm cell 占用列数。 +- terminal glyph、selection、underline cursor 与 PTY grid 使用同一 + `TerminalMetrics`,切换 DPI/窗口缩放后仍对齐。 - IME composition 期间候选窗靠近 cursor,提交后只发送最终文本。 ## Resize diff --git a/src/backends/text/sdl_ttf.zig b/src/backends/text/sdl_ttf.zig new file mode 100644 index 0000000..b60a565 --- /dev/null +++ b/src/backends/text/sdl_ttf.zig @@ -0,0 +1,450 @@ +const std = @import("std"); +const dvui = @import("dvui"); + +const c = dvui.backend.c; + +pub const System = struct { + allocator: std.mem.Allocator, + renderer: *c.SDL_Renderer, + engine: *c.TTF_TextEngine, + font_sources: FontSources, + fonts: std.AutoHashMapUnmanaged(u64, Face) = .empty, + layouts: std.AutoHashMapUnmanaged(u64, *c.TTF_Text) = .empty, + + pub const FontSources = struct { + regular: []const u8, + bold: []const u8, + italic: []const u8, + cjk: []const u8, + }; + + const Face = struct { + primary: *c.TTF_Font, + fallback: ?*c.TTF_Font, + }; + + const Layout = struct { + text: *c.TTF_Text, + cached: bool, + + fn release(self: Layout) void { + if (!self.cached) c.TTF_DestroyText(self.text); + } + }; + + pub fn init( + allocator: std.mem.Allocator, + renderer: *c.SDL_Renderer, + font_sources: FontSources, + ) !System { + if (!c.TTF_Init()) return error.SdlTtfInitFailed; + errdefer c.TTF_Quit(); + + const engine = c.TTF_CreateRendererTextEngine(renderer) orelse + return error.SdlTtfTextEngineFailed; + return .{ + .allocator = allocator, + .renderer = renderer, + .engine = engine, + .font_sources = font_sources, + }; + } + + pub fn deinit(self: *System) void { + self.clearLayouts(); + self.layouts.deinit(self.allocator); + var it = self.fonts.valueIterator(); + while (it.next()) |face| { + c.TTF_CloseFont(face.primary); + if (face.fallback) |fallback| c.TTF_CloseFont(fallback); + } + self.fonts.deinit(self.allocator); + c.TTF_DestroyRendererTextEngine(self.engine); + c.TTF_Quit(); + self.* = undefined; + } + + pub fn dvuiEngine(self: *System) dvui.TextEngine { + return .{ .context = self, .vtable = &vtable }; + } + + fn faceFor(self: *System, font: dvui.Font, scale: f32) ?Face { + const physical_size = @max(1, font.size * safeScale(scale)); + var hasher = std.hash.Wyhash.init(font.hash()); + hasher.update(std.mem.asBytes(&physical_size)); + const key = hasher.final(); + if (self.fonts.get(key)) |face| return face; + + const family = font.familyName(); + const cjk_primary = std.mem.eql(u8, family, "Noto Sans CJK SC"); + const primary_bytes = if (cjk_primary) + self.font_sources.cjk + else if (font.weight == .bold) + self.font_sources.bold + else if (font.style == .italic) + self.font_sources.italic + else + self.font_sources.regular; + + const primary = openFont(primary_bytes, physical_size) orelse return null; + errdefer c.TTF_CloseFont(primary); + applyFontStyle(primary, font); + + var fallback: ?*c.TTF_Font = null; + if (!cjk_primary) { + fallback = openFont(self.font_sources.cjk, physical_size); + if (fallback) |fallback_font| { + applyFontStyle(fallback_font, font); + if (!c.TTF_AddFallbackFont(primary, fallback_font)) { + c.TTF_CloseFont(fallback_font); + fallback = null; + } + } + } + + const face: Face = .{ .primary = primary, .fallback = fallback }; + self.fonts.put(self.allocator, key, face) catch { + if (fallback) |fallback_font| c.TTF_CloseFont(fallback_font); + c.TTF_CloseFont(primary); + return null; + }; + return face; + } + + fn layout( + self: *System, + font: dvui.Font, + text: []const u8, + scale: f32, + wrap_width: ?f32, + ) ?Layout { + var hasher = std.hash.Wyhash.init(font.hash()); + hasher.update(std.mem.asBytes(&scale)); + const wrap_key = wrap_width orelse -1; + hasher.update(std.mem.asBytes(&wrap_key)); + hasher.update(text); + const key = hasher.final(); + const cacheable = text.len <= 256; + if (cacheable) { + if (self.layouts.get(key)) |cached| { + return .{ .text = cached, .cached = true }; + } + } + + const face = self.faceFor(font, scale) orelse return null; + const result = c.TTF_CreateText( + self.engine, + face.primary, + if (text.len == 0) null else text.ptr, + text.len, + ) orelse return null; + if (wrap_width) |width| { + const physical_width: c_int = @intFromFloat(@max(1, @ceil(width * safeScale(scale)))); + if (!c.TTF_SetTextWrapWidth(result, physical_width)) { + c.TTF_DestroyText(result); + return null; + } + _ = c.TTF_SetTextWrapWhitespaceVisible(result, true); + } + if (cacheable) { + if (self.layouts.count() >= 2048) self.clearLayouts(); + self.layouts.put(self.allocator, key, result) catch + return .{ .text = result, .cached = false }; + return .{ .text = result, .cached = true }; + } + return .{ .text = result, .cached = false }; + } + + fn clearLayouts(self: *System) void { + var it = self.layouts.valueIterator(); + while (it.next()) |text| c.TTF_DestroyText(text.*); + self.layouts.clearRetainingCapacity(); + } + + fn measure( + context: *anyopaque, + font: dvui.Font, + text: []const u8, + options: dvui.Font.TextSizeOptions, + ) dvui.Size { + const self: *System = @ptrCast(@alignCast(context)); + const scale = currentScale(); + const line = firstLine(text); + const shaped = self.layout(font, line.bytes, scale, null) orelse + return .{ .w = font.size, .h = font.size }; + defer shaped.release(); + const layout_text = shaped.text; + + var width_px: c_int = 0; + var height_px: c_int = 0; + if (!c.TTF_GetTextSize(layout_text, &width_px, &height_px)) { + return .{ .w = font.size, .h = font.size }; + } + + var end = line.bytes.len; + if (options.max_width) |max_width| { + const point_x: c_int = @intFromFloat(@max(0, @round(max_width * scale))); + var substring: c.TTF_SubString = undefined; + if (c.TTF_GetTextSubStringForPoint(layout_text, point_x, @divTrunc(height_px, 2), &substring)) { + end = boundaryForPoint(substring, point_x, options.end_metric); + } + } + if (line.has_newline and end == line.bytes.len) end += 1; + if (options.end_idx) |out| out.* = @min(end, text.len); + if (options.ascent_out) |out| { + const face = self.faceFor(font, scale) orelse { + out.* = font.size; + return .{ .w = @as(f32, @floatFromInt(width_px)) / scale, .h = @as(f32, @floatFromInt(height_px)) / scale }; + }; + out.* = @as(f32, @floatFromInt(c.TTF_GetFontAscent(face.primary))) / scale; + } + + const measured_width_px = if (end < line.bytes.len) + caretXPhysical(layout_text, end) + else + @as(f32, @floatFromInt(width_px)); + return .{ + .w = measured_width_px / scale, + .h = @as(f32, @floatFromInt(height_px)) / scale, + }; + } + + fn render(context: *anyopaque, options: dvui.TextEngine.RenderOptions) anyerror!void { + const self: *System = @ptrCast(@alignCast(context)); + if (options.rotation != 0) return error.UnsupportedTextRotation; + + const scale = safeScale(options.rs.s); + const shaped = self.layout(options.font, options.text, scale, null) orelse + return error.SdlTtfLayoutFailed; + defer shaped.release(); + const text = shaped.text; + + _ = c.TTF_SetTextColor(text, options.color.r, options.color.g, options.color.b, options.color.a); + const start = options.p orelse options.rs.r.topLeft(); + + if (options.background_color) |background| { + var w: c_int = 0; + var h: c_int = 0; + if (c.TTF_GetTextSize(text, &w, &h)) { + (dvui.Rect.Physical{ + .x = start.x, + .y = start.y, + .w = @floatFromInt(w), + .h = @floatFromInt(h), + }).fill(.{}, .{ .color = background, .fade = 0 }); + } + } + + const sel_start = @min(options.sel_start orelse 0, options.text.len); + const sel_end = @min(options.sel_end orelse 0, options.text.len); + if (sel_start < sel_end) { + var count: c_int = 0; + const substrings = c.TTF_GetTextSubStringsForRange( + text, + @intCast(sel_start), + @intCast(sel_end - sel_start), + &count, + ); + if (substrings != null) { + defer c.SDL_free(@ptrCast(substrings)); + const selection_color = options.sel_color orelse dvui.themeGet().focus; + var i: usize = 0; + while (i < @as(usize, @intCast(count))) : (i += 1) { + const substring = substrings[i].*; + (dvui.Rect.Physical{ + .x = start.x + @as(f32, @floatFromInt(substring.rect.x)), + .y = start.y + @as(f32, @floatFromInt(substring.rect.y)), + .w = @as(f32, @floatFromInt(substring.rect.w)), + .h = @as(f32, @floatFromInt(substring.rect.h)), + }).fill(.{}, .{ .color = selection_color, .fade = 0 }); + } + } + } + + var previous_clip: c.SDL_Rect = undefined; + const had_clip = c.SDL_GetRenderClipRect(self.renderer, &previous_clip); + const clip = dvui.clipGet(); + const next_clip: c.SDL_Rect = .{ + .x = @intFromFloat(@floor(clip.x)), + .y = @intFromFloat(@floor(clip.y)), + .w = @intFromFloat(@ceil(clip.w)), + .h = @intFromFloat(@ceil(clip.h)), + }; + _ = c.SDL_SetRenderClipRect(self.renderer, &next_clip); + defer _ = c.SDL_SetRenderClipRect(self.renderer, if (had_clip) &previous_clip else null); + + if (!c.TTF_DrawRendererText(text, start.x, start.y)) return error.SdlTtfDrawFailed; + } + + fn caretX( + context: *anyopaque, + font: dvui.Font, + text: []const u8, + byte_offset: usize, + ) f32 { + const self: *System = @ptrCast(@alignCast(context)); + const scale = currentScale(); + const shaped = self.layout(font, text, scale, null) orelse return 0; + defer shaped.release(); + const layout_text = shaped.text; + return caretXPhysical(layout_text, byte_offset) / scale; + } + + fn caretPoint( + context: *anyopaque, + font: dvui.Font, + text: []const u8, + byte_offset: usize, + wrap_width: ?f32, + ) dvui.Point { + const self: *System = @ptrCast(@alignCast(context)); + const scale = currentScale(); + const shaped = self.layout(font, text, scale, wrap_width) orelse return .{}; + defer shaped.release(); + const layout_text = shaped.text; + + var substring: c.TTF_SubString = undefined; + if (!c.TTF_GetTextSubString(layout_text, @intCast(byte_offset), &substring)) return .{}; + const rtl = (substring.flags & c.TTF_SUBSTRING_DIRECTION_MASK) == c.TTF_DIRECTION_RTL; + const at_start = byte_offset <= @as(usize, @intCast(@max(0, substring.offset))); + const x_px = if (at_start) + (if (rtl) substring.rect.x + substring.rect.w else substring.rect.x) + else + (if (rtl) substring.rect.x else substring.rect.x + substring.rect.w); + return .{ + .x = @as(f32, @floatFromInt(x_px)) / scale, + .y = @as(f32, @floatFromInt(substring.rect.y)) / scale, + }; + } + + fn previousBoundary( + context: *anyopaque, + font: dvui.Font, + text: []const u8, + byte_offset: usize, + ) usize { + if (byte_offset == 0 or text.len == 0) return 0; + const self: *System = @ptrCast(@alignCast(context)); + const shaped = self.layout(font, text, currentScale(), null) orelse + return previousCodepoint(text, byte_offset); + defer shaped.release(); + const layout_text = shaped.text; + + var substring: c.TTF_SubString = undefined; + if (!c.TTF_GetTextSubString(layout_text, @intCast(byte_offset - 1), &substring)) { + return previousCodepoint(text, byte_offset); + } + return @intCast(@max(0, substring.offset)); + } + + fn nextBoundary( + context: *anyopaque, + font: dvui.Font, + text: []const u8, + byte_offset: usize, + ) usize { + if (byte_offset >= text.len) return text.len; + const self: *System = @ptrCast(@alignCast(context)); + const shaped = self.layout(font, text, currentScale(), null) orelse + return nextCodepoint(text, byte_offset); + defer shaped.release(); + const layout_text = shaped.text; + + var substring: c.TTF_SubString = undefined; + if (!c.TTF_GetTextSubString(layout_text, @intCast(byte_offset), &substring)) { + return nextCodepoint(text, byte_offset); + } + return @min(text.len, @as(usize, @intCast(@max(0, substring.offset + substring.length)))); + } +}; + +const vtable: dvui.TextEngine.VTable = .{ + .measure = System.measure, + .render = System.render, + .caret_x = System.caretX, + .caret_point = System.caretPoint, + .previous_boundary = System.previousBoundary, + .next_boundary = System.nextBoundary, +}; + +fn openFont(bytes: []const u8, size: f32) ?*c.TTF_Font { + const io = c.SDL_IOFromConstMem(bytes.ptr, bytes.len) orelse return null; + return c.TTF_OpenFontIO(io, true, size); +} + +fn applyFontStyle(font: *c.TTF_Font, requested: dvui.Font) void { + var style: c.TTF_FontStyleFlags = c.TTF_STYLE_NORMAL; + if (requested.weight == .bold) style |= c.TTF_STYLE_BOLD; + if (requested.style == .italic) style |= c.TTF_STYLE_ITALIC; + if (requested.underline != null) style |= c.TTF_STYLE_UNDERLINE; + if (requested.strike != null) style |= c.TTF_STYLE_STRIKETHROUGH; + c.TTF_SetFontStyle(font, style); +} + +fn currentScale() f32 { + return safeScale(dvui.parentGet().screenRectScale(.{}).s); +} + +fn safeScale(scale: f32) f32 { + return if (scale > 0) scale else 1; +} + +fn firstLine(text: []const u8) struct { bytes: []const u8, has_newline: bool } { + if (std.mem.indexOfScalar(u8, text, '\n')) |idx| { + return .{ .bytes = text[0..idx], .has_newline = true }; + } + return .{ .bytes = text, .has_newline = false }; +} + +fn boundaryForPoint( + substring: c.TTF_SubString, + x: c_int, + metric: dvui.Font.EndMetric, +) usize { + const start: usize = @intCast(@max(0, substring.offset)); + if (metric == .before or substring.length <= 0) return start; + const rtl = (substring.flags & c.TTF_SUBSTRING_DIRECTION_MASK) == c.TTF_DIRECTION_RTL; + const midpoint = substring.rect.x + @divTrunc(substring.rect.w, 2); + const choose_end = if (rtl) x < midpoint else x >= midpoint; + return if (choose_end) + @intCast(@max(0, substring.offset + substring.length)) + else + start; +} + +fn caretXPhysical(text: *c.TTF_Text, byte_offset: usize) f32 { + var substring: c.TTF_SubString = undefined; + if (!c.TTF_GetTextSubString(text, @intCast(byte_offset), &substring)) return 0; + const rtl = (substring.flags & c.TTF_SUBSTRING_DIRECTION_MASK) == c.TTF_DIRECTION_RTL; + if (byte_offset <= @as(usize, @intCast(@max(0, substring.offset)))) { + return @floatFromInt(if (rtl) substring.rect.x + substring.rect.w else substring.rect.x); + } + return @floatFromInt(if (rtl) substring.rect.x else substring.rect.x + substring.rect.w); +} + +fn previousCodepoint(text: []const u8, offset: usize) usize { + var result = @min(offset, text.len); + if (result == 0) return 0; + result -= 1; + while (result > 0 and text[result] & 0xc0 == 0x80) result -= 1; + return result; +} + +fn nextCodepoint(text: []const u8, offset: usize) usize { + if (offset >= text.len) return text.len; + return @min(text.len, offset + (std.unicode.utf8ByteSequenceLength(text[offset]) catch 1)); +} + +test "fallback boundaries preserve UTF-8 codepoints" { + const text = "a你🙂z"; + try std.testing.expectEqual(@as(usize, 1), previousCodepoint(text, 4)); + try std.testing.expectEqual(@as(usize, 4), previousCodepoint(text, 8)); + try std.testing.expectEqual(@as(usize, 4), nextCodepoint(text, 1)); + try std.testing.expectEqual(@as(usize, 8), nextCodepoint(text, 4)); +} + +test "firstLine reports the consumed newline" { + const line = firstLine("abc\n下一行"); + try std.testing.expectEqualStrings("abc", line.bytes); + try std.testing.expect(line.has_newline); +} diff --git a/src/main.zig b/src/main.zig index b83859d..5ae73a3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -47,6 +47,7 @@ fn appInit(window: *dvui.Window) !void { } fn appDeinit() void { + ui_fonts.deinit(); if (app_state) |*app| { app.deinit(); } diff --git a/src/ui/fonts.zig b/src/ui/fonts.zig index c0af5b5..5c125ad 100644 --- a/src/ui/fonts.zig +++ b/src/ui/fonts.zig @@ -1,6 +1,7 @@ const std = @import("std"); const dvui = @import("dvui"); +const sdl_ttf = @import("../backends/text/sdl_ttf.zig"); const theme = @import("theme.zig"); const zed_font_bytes = @embedFile("shellowo-zed-font"); @@ -8,6 +9,8 @@ const zed_font_italic_bytes = @embedFile("shellowo-zed-italic-font"); const zed_font_bold_bytes = @embedFile("shellowo-zed-bold-font"); const cjk_font_bytes = @embedFile("shellowo-cjk-font"); +var system: ?sdl_ttf.System = null; + pub fn loadEmbedded(window: *dvui.Window) void { var zed_loaded = true; window.addFont(theme.cjk_font_family, cjk_font_bytes, null) catch return; @@ -24,6 +27,19 @@ pub fn loadEmbedded(window: *dvui.Window) void { current_theme.font_title = current_theme.font_title.withFamily(primary_family).withWeight(.normal).withSize(theme.font_sizes.title); current_theme.font_mono = current_theme.font_mono.withFamily(primary_family).withSize(theme.font_sizes.body); window.themeSet(current_theme); + + system = sdl_ttf.System.init(window.gpa, window.backend.impl.renderer, .{ + .regular = zed_font_bytes, + .bold = zed_font_bold_bytes, + .italic = zed_font_italic_bytes, + .cjk = cjk_font_bytes, + }) catch return; + _ = dvui.textEngineSet(system.?.dvuiEngine()); +} + +pub fn deinit() void { + if (system) |*value| value.deinit(); + system = null; } fn addFontSource( diff --git a/src/ui/foundation/typography.zig b/src/ui/foundation/typography.zig index 4d44629..fd4d48e 100644 --- a/src/ui/foundation/typography.zig +++ b/src/ui/foundation/typography.zig @@ -15,8 +15,8 @@ pub const FontSizes = struct { pub const font_sizes: FontSizes = .{}; pub fn textFont(text: []const u8, size: f32) dvui.Font { - const family = if (needsCjkFont(text)) cjk_font_family else zed_font_family; - return dvui.Font.theme(.body).withFamily(family).withSize(size); + _ = text; + return dvui.Font.theme(.body).withFamily(zed_font_family).withSize(size); } pub fn cjkFont(size: f32) dvui.Font { diff --git a/src/ui/workspace/file_panel_elements/remote_editor.zig b/src/ui/workspace/file_panel_elements/remote_editor.zig index 60e3c56..40680f4 100644 --- a/src/ui/workspace/file_panel_elements/remote_editor.zig +++ b/src/ui/workspace/file_panel_elements/remote_editor.zig @@ -865,6 +865,9 @@ fn visualYOfOffset(te: *dvui.TextEntryWidget, offset: usize) f32 { const font = te.textLayout.data().options.fontGet(); const line_h = editorLineHeight(te); const content_w = @max(te.textLayout.data().contentRect().w, font.sizeM(1, 1).w); + if (dvui.currentWindow().text_engine) |engine| { + return engine.caretPoint(font, te.textGet(), @min(offset, te.len), content_w).y; + } const break_width = content_w + 0.001; const m_width = font.sizeM(1, 1).w; diff --git a/src/ui/workspace/terminal_panel.zig b/src/ui/workspace/terminal_panel.zig index 0bbaa91..74b1e88 100644 --- a/src/ui/workspace/terminal_panel.zig +++ b/src/ui/workspace/terminal_panel.zig @@ -484,7 +484,7 @@ fn renderTerminalSnapshotRow(snapshot: terminal.Snapshot, crs: dvui.RectScale, v continue; } - if (isSingleWidthAscii(cell)) { + if (isSingleWidthAscii(cell) and dvui.currentWindow().text_engine == null) { const run = asciiRun(snapshot, absolute_row, visible_row, col, cell.style, &run_buf); renderTerminalRun(run, crs, palette); col = run.end_col; From b4ae342104b0b01eafd9d6554f4faac13f63013d Mon Sep 17 00:00:00 2001 From: HissingRat <84263094+HissingRat@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:36:51 +0800 Subject: [PATCH 02/11] fix SDL_ttf editor window lifecycle --- src/backends/text/sdl_ttf.zig | 11 +++- src/ui/fonts.zig | 57 ++++++++++++++++--- .../file_panel_elements/remote_editor.zig | 22 ++++++- 3 files changed, 77 insertions(+), 13 deletions(-) diff --git a/src/backends/text/sdl_ttf.zig b/src/backends/text/sdl_ttf.zig index b60a565..b7ab807 100644 --- a/src/backends/text/sdl_ttf.zig +++ b/src/backends/text/sdl_ttf.zig @@ -261,8 +261,11 @@ pub const System = struct { } } + const previous_clip_enabled = c.SDL_RenderClipEnabled(self.renderer); var previous_clip: c.SDL_Rect = undefined; - const had_clip = c.SDL_GetRenderClipRect(self.renderer, &previous_clip); + if (!c.SDL_GetRenderClipRect(self.renderer, &previous_clip)) { + return error.SdlRenderStateFailed; + } const clip = dvui.clipGet(); const next_clip: c.SDL_Rect = .{ .x = @intFromFloat(@floor(clip.x)), @@ -270,8 +273,10 @@ pub const System = struct { .w = @intFromFloat(@ceil(clip.w)), .h = @intFromFloat(@ceil(clip.h)), }; - _ = c.SDL_SetRenderClipRect(self.renderer, &next_clip); - defer _ = c.SDL_SetRenderClipRect(self.renderer, if (had_clip) &previous_clip else null); + if (!c.SDL_SetRenderClipRect(self.renderer, &next_clip)) { + return error.SdlRenderStateFailed; + } + defer _ = c.SDL_SetRenderClipRect(self.renderer, if (previous_clip_enabled) &previous_clip else null); if (!c.TTF_DrawRendererText(text, start.x, start.y)) return error.SdlTtfDrawFailed; } diff --git a/src/ui/fonts.zig b/src/ui/fonts.zig index 5c125ad..fa6ad00 100644 --- a/src/ui/fonts.zig +++ b/src/ui/fonts.zig @@ -9,7 +9,8 @@ const zed_font_italic_bytes = @embedFile("shellowo-zed-italic-font"); const zed_font_bold_bytes = @embedFile("shellowo-zed-bold-font"); const cjk_font_bytes = @embedFile("shellowo-cjk-font"); -var system: ?sdl_ttf.System = null; +var systems: std.AutoHashMapUnmanaged(*dvui.Window, *sdl_ttf.System) = .empty; +var systems_allocator: ?std.mem.Allocator = null; pub fn loadEmbedded(window: *dvui.Window) void { var zed_loaded = true; @@ -28,18 +29,58 @@ pub fn loadEmbedded(window: *dvui.Window) void { current_theme.font_mono = current_theme.font_mono.withFamily(primary_family).withSize(theme.font_sizes.body); window.themeSet(current_theme); - system = sdl_ttf.System.init(window.gpa, window.backend.impl.renderer, .{ + const system = systemFor(window) catch return; + window.text_engine = system.dvuiEngine(); +} + +pub fn unloadEmbedded(window: *dvui.Window) void { + const removed = systems.fetchRemove(window) orelse return; + const system = removed.value; + if (window.text_engine) |engine| { + if (engine.context == @as(*anyopaque, @ptrCast(system))) { + window.text_engine = null; + } + } + const allocator = system.allocator; + system.deinit(); + allocator.destroy(system); +} + +pub fn deinit() void { + var it = systems.valueIterator(); + while (it.next()) |entry| { + const system = entry.*; + const allocator = system.allocator; + system.deinit(); + allocator.destroy(system); + } + if (systems_allocator) |allocator| { + systems.deinit(allocator); + } + systems = .empty; + systems_allocator = null; +} + +fn systemFor(window: *dvui.Window) !*sdl_ttf.System { + if (systems.get(window)) |system| return system; + + const allocator = window.gpa; + if (systems_allocator == null) { + systems_allocator = allocator; + } + + const system = try allocator.create(sdl_ttf.System); + errdefer allocator.destroy(system); + system.* = try sdl_ttf.System.init(allocator, window.backend.impl.renderer, .{ .regular = zed_font_bytes, .bold = zed_font_bold_bytes, .italic = zed_font_italic_bytes, .cjk = cjk_font_bytes, - }) catch return; - _ = dvui.textEngineSet(system.?.dvuiEngine()); -} + }); + errdefer system.deinit(); -pub fn deinit() void { - if (system) |*value| value.deinit(); - system = null; + try systems.put(allocator, window, system); + return system; } fn addFontSource( diff --git a/src/ui/workspace/file_panel_elements/remote_editor.zig b/src/ui/workspace/file_panel_elements/remote_editor.zig index 40680f4..1be827e 100644 --- a/src/ui/workspace/file_panel_elements/remote_editor.zig +++ b/src/ui/workspace/file_panel_elements/remote_editor.zig @@ -27,6 +27,7 @@ pub const State = struct { initialized: bool = false, positioned: bool = false, fonts_loaded: bool = false, + font_window: ?*dvui.Window = null, loaded_version: u64 = 0, dirty: bool = false, confirm_close: bool = false, @@ -49,6 +50,7 @@ pub const State = struct { pub fn show(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: theme.Palette, id_extra: usize) ?remote_file.FilePanelIntent { if (!snapshot.isOpen()) { + unloadEditorFonts(state); state.* = .{}; return null; } @@ -69,6 +71,9 @@ pub fn show(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: th state.close_requested = false; const parent_window = dvui.currentWindow().backend.impl.window; + var unload_fonts_after_render = false; + defer if (unload_fonts_after_render) unloadEditorFonts(state); + var os_win = dvui.osWindow(@src(), .{ .title = editor_title, .size = editor_initial_size, @@ -190,7 +195,10 @@ pub fn show(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: th if (action != null) { switch (action.?) { - .close_edit => state.open = false, + .close_edit => { + state.open = false; + unload_fonts_after_render = true; + }, else => {}, } } @@ -211,11 +219,21 @@ fn observeSaveConflict(state: *State, save_conflict: bool) void { fn loadEditorFontsOnce(state: *State, os_win: anytype) void { if (state.fonts_loaded) return; if (dvui.Backend.support_child_os_wins) { - ui_fonts.loadEmbedded(os_win.inner.dvui_win); + const font_window = os_win.inner.dvui_win; + ui_fonts.loadEmbedded(font_window); + state.font_window = font_window; } state.fonts_loaded = true; } +fn unloadEditorFonts(state: *State) void { + if (state.font_window) |font_window| { + ui_fonts.unloadEmbedded(font_window); + } + state.font_window = null; + state.fonts_loaded = false; +} + fn centerEditorWindowOnce(state: *State, os_win: anytype, parent_window: anytype) void { if (state.positioned) return; state.positioned = true; From fdf1a431a72401c9c271c51fe75bb12532d3bfc7 Mon Sep 17 00:00:00 2001 From: HissingRat <84263094+HissingRat@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:52:50 +0800 Subject: [PATCH 03/11] fix shaped text engine ownership --- build.zig.zon | 4 +- src/backends/text/sdl_ttf.zig | 12 ++++ src/main.zig | 1 - src/ui/fonts.zig | 60 ++++--------------- .../file_panel_elements/remote_editor.zig | 22 +------ 5 files changed, 27 insertions(+), 72 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 88b5411..b6cfdf2 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .minimum_zig_version = "0.16.0", .dependencies = .{ .dvui = .{ - .url = "https://codeload.github.com/HissingRat/dvui/tar.gz/a0ef4c33f6c0f7f8b80f6a808844c09f5bb6f1a8", - .hash = "dvui-0.5.0-dev-AQFJmfjQTgHV-kJLqADnBE-L-VKBhfdjqMfrfzC0XYl4", + .url = "https://codeload.github.com/HissingRat/dvui/tar.gz/aa20ca2b1d27c91e82640cedebafe57157524d3d", + .hash = "dvui-0.5.0-dev-AQFJmZ4nTwEV5QgY8zlsVF-_s923qRRlUbbC_2sKCWnx", }, // Keep SDL's Linux platform metadata eager at the application root. // Without this, SDL's second-level lazy dependency can be absent while diff --git a/src/backends/text/sdl_ttf.zig b/src/backends/text/sdl_ttf.zig index b7ab807..30f41d9 100644 --- a/src/backends/text/sdl_ttf.zig +++ b/src/backends/text/sdl_ttf.zig @@ -68,6 +68,13 @@ pub const System = struct { return .{ .context = self, .vtable = &vtable }; } + fn destroyFromTextEngine(context: *anyopaque) void { + const self: *System = @ptrCast(@alignCast(context)); + const allocator = self.allocator; + self.deinit(); + allocator.destroy(self); + } + fn faceFor(self: *System, font: dvui.Font, scale: f32) ?Face { const physical_size = @max(1, font.size * safeScale(scale)); var hasher = std.hash.Wyhash.init(font.hash()); @@ -364,6 +371,7 @@ pub const System = struct { }; const vtable: dvui.TextEngine.VTable = .{ + .deinit = System.destroyFromTextEngine, .measure = System.measure, .render = System.render, .caret_x = System.caretX, @@ -372,6 +380,10 @@ const vtable: dvui.TextEngine.VTable = .{ .next_boundary = System.nextBoundary, }; +pub fn ownsEngine(engine: dvui.TextEngine) bool { + return engine.vtable == &vtable; +} + fn openFont(bytes: []const u8, size: f32) ?*c.TTF_Font { const io = c.SDL_IOFromConstMem(bytes.ptr, bytes.len) orelse return null; return c.TTF_OpenFontIO(io, true, size); diff --git a/src/main.zig b/src/main.zig index 5ae73a3..b83859d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -47,7 +47,6 @@ fn appInit(window: *dvui.Window) !void { } fn appDeinit() void { - ui_fonts.deinit(); if (app_state) |*app| { app.deinit(); } diff --git a/src/ui/fonts.zig b/src/ui/fonts.zig index fa6ad00..d35acdb 100644 --- a/src/ui/fonts.zig +++ b/src/ui/fonts.zig @@ -9,10 +9,11 @@ const zed_font_italic_bytes = @embedFile("shellowo-zed-italic-font"); const zed_font_bold_bytes = @embedFile("shellowo-zed-bold-font"); const cjk_font_bytes = @embedFile("shellowo-cjk-font"); -var systems: std.AutoHashMapUnmanaged(*dvui.Window, *sdl_ttf.System) = .empty; -var systems_allocator: ?std.mem.Allocator = null; - pub fn loadEmbedded(window: *dvui.Window) void { + if (window.text_engine) |engine| { + if (sdl_ttf.ownsEngine(engine)) return; + } + var zed_loaded = true; window.addFont(theme.cjk_font_family, cjk_font_bytes, null) catch return; window.addFont(theme.zed_font_family, zed_font_bytes, null) catch { @@ -29,58 +30,19 @@ pub fn loadEmbedded(window: *dvui.Window) void { current_theme.font_mono = current_theme.font_mono.withFamily(primary_family).withSize(theme.font_sizes.body); window.themeSet(current_theme); - const system = systemFor(window) catch return; - window.text_engine = system.dvuiEngine(); -} - -pub fn unloadEmbedded(window: *dvui.Window) void { - const removed = systems.fetchRemove(window) orelse return; - const system = removed.value; - if (window.text_engine) |engine| { - if (engine.context == @as(*anyopaque, @ptrCast(system))) { - window.text_engine = null; - } - } - const allocator = system.allocator; - system.deinit(); - allocator.destroy(system); -} - -pub fn deinit() void { - var it = systems.valueIterator(); - while (it.next()) |entry| { - const system = entry.*; - const allocator = system.allocator; - system.deinit(); - allocator.destroy(system); - } - if (systems_allocator) |allocator| { - systems.deinit(allocator); - } - systems = .empty; - systems_allocator = null; -} - -fn systemFor(window: *dvui.Window) !*sdl_ttf.System { - if (systems.get(window)) |system| return system; - const allocator = window.gpa; - if (systems_allocator == null) { - systems_allocator = allocator; - } - - const system = try allocator.create(sdl_ttf.System); - errdefer allocator.destroy(system); - system.* = try sdl_ttf.System.init(allocator, window.backend.impl.renderer, .{ + const system = allocator.create(sdl_ttf.System) catch return; + system.* = sdl_ttf.System.init(allocator, window.backend.impl.renderer, .{ .regular = zed_font_bytes, .bold = zed_font_bold_bytes, .italic = zed_font_italic_bytes, .cjk = cjk_font_bytes, - }); - errdefer system.deinit(); + }) catch { + allocator.destroy(system); + return; + }; - try systems.put(allocator, window, system); - return system; + window.text_engine = system.dvuiEngine(); } fn addFontSource( diff --git a/src/ui/workspace/file_panel_elements/remote_editor.zig b/src/ui/workspace/file_panel_elements/remote_editor.zig index 1be827e..40680f4 100644 --- a/src/ui/workspace/file_panel_elements/remote_editor.zig +++ b/src/ui/workspace/file_panel_elements/remote_editor.zig @@ -27,7 +27,6 @@ pub const State = struct { initialized: bool = false, positioned: bool = false, fonts_loaded: bool = false, - font_window: ?*dvui.Window = null, loaded_version: u64 = 0, dirty: bool = false, confirm_close: bool = false, @@ -50,7 +49,6 @@ pub const State = struct { pub fn show(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: theme.Palette, id_extra: usize) ?remote_file.FilePanelIntent { if (!snapshot.isOpen()) { - unloadEditorFonts(state); state.* = .{}; return null; } @@ -71,9 +69,6 @@ pub fn show(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: th state.close_requested = false; const parent_window = dvui.currentWindow().backend.impl.window; - var unload_fonts_after_render = false; - defer if (unload_fonts_after_render) unloadEditorFonts(state); - var os_win = dvui.osWindow(@src(), .{ .title = editor_title, .size = editor_initial_size, @@ -195,10 +190,7 @@ pub fn show(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: th if (action != null) { switch (action.?) { - .close_edit => { - state.open = false; - unload_fonts_after_render = true; - }, + .close_edit => state.open = false, else => {}, } } @@ -219,21 +211,11 @@ fn observeSaveConflict(state: *State, save_conflict: bool) void { fn loadEditorFontsOnce(state: *State, os_win: anytype) void { if (state.fonts_loaded) return; if (dvui.Backend.support_child_os_wins) { - const font_window = os_win.inner.dvui_win; - ui_fonts.loadEmbedded(font_window); - state.font_window = font_window; + ui_fonts.loadEmbedded(os_win.inner.dvui_win); } state.fonts_loaded = true; } -fn unloadEditorFonts(state: *State) void { - if (state.font_window) |font_window| { - ui_fonts.unloadEmbedded(font_window); - } - state.font_window = null; - state.fonts_loaded = false; -} - fn centerEditorWindowOnce(state: *State, os_win: anytype, parent_window: anytype) void { if (state.positioned) return; state.positioned = true; From 5825fb97959568848807e56533f5ae0b2c006100 Mon Sep 17 00:00:00 2001 From: HissingRat <84263094+HissingRat@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:57:27 +0800 Subject: [PATCH 04/11] feat: integrate SDL3_ttf Unicode text rendering - add SDL3_ttf fallback font discovery and macOS emoji rendering support - align editor measurement, caret geometry, selection, and search with shaped text - restore large-file editor loading/search performance - normalize UI font sizing after SDL3_ttf migration --- build.zig | 20 + docs/integration-inventory.md | 14 + .../plans/active/unicode-emoji-font-system.md | 137 +++ src/backends/text/platform_emoji_macos.m | 115 ++ src/backends/text/platform_fonts.zig | 86 ++ src/backends/text/platform_fonts_macos.c | 207 ++++ src/backends/text/sdl_ttf.zig | 1050 ++++++++++++++++- src/ui/features/app_shell/screen.zig | 99 +- src/ui/features/profiles/config_panel.zig | 30 +- .../security/master_password_popup.zig | 6 +- src/ui/features/security/unlock_screen.zig | 6 +- src/ui/features/terminal/metrics.zig | 2 +- src/ui/foundation/typography.zig | 12 +- src/ui/widgets/text_field.zig | 2 +- src/ui/workspace/file_panel.zig | 28 +- .../active_tasks_panel.zig | 20 +- .../file_panel_elements/context_menu.zig | 10 +- .../file_panel_elements/details_panel.zig | 10 +- .../file_panel_elements/permissions_panel.zig | 16 +- .../file_panel_elements/remote_editor.zig | 422 +++++-- .../file_panel_elements/transfer_confirm.zig | 8 +- src/ui/workspace/status_panel.zig | 14 +- src/ui/workspace/terminal_panel.zig | 2 +- src/ui/workspace/terminal_slot_bar.zig | 2 +- 24 files changed, 2018 insertions(+), 300 deletions(-) create mode 100644 docs/plans/active/unicode-emoji-font-system.md create mode 100644 src/backends/text/platform_emoji_macos.m create mode 100644 src/backends/text/platform_fonts.zig create mode 100644 src/backends/text/platform_fonts_macos.c diff --git a/build.zig b/build.zig index 44fb562..ca6ac4c 100644 --- a/build.zig +++ b/build.zig @@ -27,6 +27,7 @@ pub fn build(b: *std.Build) void { exe.subsystem = .windows; } attachWindowChrome(b, exe); + attachTextPlatform(b, exe); exe.root_module.addWin32ResourceFile(.{ .file = b.path("assets/shellowo.rc"), .include_paths = &.{b.path("assets")}, @@ -160,6 +161,7 @@ pub fn build(b: *std.Build) void { }); attachNativeDeps(b, tests, native_deps); attachWindowChrome(b, tests); + attachTextPlatform(b, tests); tests.root_module.addImport("dvui", dvui_dep.module("dvui_sdl3")); tests.root_module.addAnonymousImport("shellowo-ssh-status-script", .{ .root_source_file = b.path("assets/script/ssh_status_linux.sh"), @@ -240,6 +242,24 @@ fn attachWindowChrome(b: *std.Build, compile: *std.Build.Step.Compile) void { } } +fn attachTextPlatform(b: *std.Build, compile: *std.Build.Step.Compile) void { + switch (compile.root_module.resolved_target.?.result.os.tag) { + .macos => { + compile.root_module.addCSourceFile(.{ + .file = b.path("src/backends/text/platform_fonts_macos.c"), + .flags = &.{}, + }); + compile.root_module.addCSourceFile(.{ + .file = b.path("src/backends/text/platform_emoji_macos.m"), + .flags = &.{"-fobjc-arc"}, + }); + compile.root_module.linkFramework("CoreFoundation", .{}); + compile.root_module.linkFramework("CoreText", .{}); + }, + else => {}, + } +} + const NativeDeps = struct { mbedcrypto: *std.Build.Step.Compile, libssh2: *std.Build.Step.Compile, diff --git a/docs/integration-inventory.md b/docs/integration-inventory.md index 833aa2b..84209df 100644 --- a/docs/integration-inventory.md +++ b/docs/integration-inventory.md @@ -52,6 +52,15 @@ TextLayout 命中和 TextEntry cluster 边界可以由同一 shaped-text backend `TTF_TextEngine`、字体、fallback 和 shaped layout cache 生命周期。 - Zed Mono Extended 的 regular/bold/italic 是主字体,Noto Sans CJK SC 通过 SDL3_ttf fallback chain 提供中文 glyph。 +- macOS 通过 `src/backends/text/platform_fonts_macos.c` 使用 CoreText + 发现 Apple Color Emoji、Apple Symbols 和系统 cascade 字体;这些系统 + 字体作为 SDL3_ttf fallback chain 的后续候选,用于 emoji、符号和更广 + Unicode 覆盖。Windows/Linux 保留同一 Shellowo-owned 边界,后续分别接 + DirectWrite/known fonts 与 Fontconfig。 +- macOS 还通过 `src/backends/text/platform_emoji_macos.m` 提供 + AppKit-backed emoji bitmap overlay。普通文字的 geometry 仍来自 + SDL3_ttf;overlay 只在 render 阶段补 Apple Color Emoji 这类当前 + FreeType/SDL_ttf 构建无法直接 rasterize 的彩色 glyph。 - DVUI 的测量、绘制、鼠标命中、caret、selection 和 TextEntry cluster movement 使用同一个 text engine。 - terminal 继续由 libvterm cell grid 决定列宽、选择和 cursor;启用 shaped @@ -61,6 +70,11 @@ TextLayout 命中和 TextEntry cluster 边界可以由同一 shaped-text backend - Raw `TTF_Font`、`TTF_Text` 和 `TTF_TextEngine` 只允许出现在 `src/backends/text/`。 +- 平台字体发现只能返回候选路径/元数据;UI 不直接调用 CoreText、 + DirectWrite 或 Fontconfig。 +- emoji overlay 不得成为第二套测量/命中系统;如果未来引入通用 emoji + atlas,必须显式接入 TextEngine 的 layout、caret、selection 和 hit + testing。 - 不允许重新引入“绘制走 SDL3_ttf、测量走 DVUI”的双几何路径。 - 新增字体 fallback 时必须保持主字体和 fallback 的 size/style 一致。 - 更新 SDL3_ttf、FreeType、HarfBuzz 或 DVUI fork 后必须执行 diff --git a/docs/plans/active/unicode-emoji-font-system.md b/docs/plans/active/unicode-emoji-font-system.md new file mode 100644 index 0000000..d18f43e --- /dev/null +++ b/docs/plans/active/unicode-emoji-font-system.md @@ -0,0 +1,137 @@ +# Unicode and Emoji Font System + +Implementation status: first macOS pass implemented; the temporary Home visual +probe has been retired after acceptance. + +## Goal + +Shellowo should render emoji, symbols, CJK, combining marks, RTL text, and +common complex-script samples through the same SDL3_ttf shaped-text path used +for measurement, selection, hit testing, and caret geometry. + +The first implementation path is system font fallback, not a custom emoji image +atlas. SDL3_ttf remains the single geometry source. + +## Current Baseline + +- Application text uses `src/backends/text/sdl_ttf.zig`. +- DVUI flow text and editable text query the installed `dvui.TextEngine`. +- The primary family is embedded Zed Mono Extended. +- Embedded Noto Sans CJK SC is the first fallback. +- Terminal rendering remains a libvterm cell-grid problem; emoji support must + not alter terminal column ownership. + +## Acceptance Surface + +The first pass was accepted with a temporary Home `UNICODE / EMOJI PROBE` +section, then the probe was removed so Home stays product-facing. + +Keep the sample set below for future ad hoc visual checks: + +- basic emoji +- ZWJ emoji +- skin-tone modifiers +- flags +- text-vs-emoji variation selectors +- mixed Latin/CJK/Korean/Japanese text +- combining marks +- RTL samples +- Devanagari and Thai samples +- symbols, arrows, and box drawing + +Run: + +```sh +zig build run +``` + +When temporarily reintroducing a probe or adding a focused dev-only surface, +verify that text is not blank, emoji do not become tofu boxes where the platform +provides a font, and baseline/spacing remain reasonable. + +## Platform Font Discovery + +`src/backends/text/platform_fonts.zig` is the Shellowo-owned boundary for +platform font discovery. + +macOS implementation: + +- `src/backends/text/platform_fonts_macos.c` +- CoreText font descriptor matching for Apple Color Emoji and Apple Symbols +- CoreText default cascade list for broad Unicode coverage +- available-font URL fallback as a last resort +- `src/backends/text/platform_emoji_macos.m` renders emoji clusters into + AppKit bitmap images when SDL3_ttf/FreeType cannot draw the color glyph. + +Planned platform implementations: + +- Windows: Segoe UI Emoji, Segoe UI Symbol, and eventually DirectWrite-backed + fallback discovery. +- Linux: Fontconfig-backed discovery, prioritizing Noto Color Emoji, Noto Sans + Symbols 2, and Noto CJK families. + +## SDL3_ttf Fallback Chain + +Each cached face in `src/backends/text/sdl_ttf.zig` should attach fallbacks in +this order: + +1. embedded Noto Sans CJK SC when the primary face is not already CJK +2. platform emoji fonts +3. platform symbol fonts +4. platform cascade candidates + +All fallback fonts must be opened at the same physical size and styled +consistently with the primary face. + +## Current macOS Limitation + +The first pass makes basic emoji, skin-tone samples, flags, variation selector +samples, CJK, RTL, Devanagari, Thai, and common symbols visible in the Home +probe. + +Full ZWJ emoji sequences are not yet accepted as complete. Current SDL3_ttf +geometry remains intact, but the macOS offscreen system-rendering path used for +the bitmap overlay still fails to produce visible bitmaps for some full ZWJ +clusters in the Home probe. The temporary overlay scanner therefore avoids +using its result as an editing/layout authority. + +Next choices: + +1. keep investigating native macOS offscreen emoji rendering for ZWJ clusters; +2. enable FreeType PNG/SVG/color dependencies in the DVUI fork if practical; +3. introduce a real emoji asset/atlas layer that participates in TextEngine + layout instead of merely drawing over text. + +## Non-goal for the First Pass + +Do not introduce an inline image emoji atlas unless SDL3_ttf plus platform +fallback fonts cannot provide acceptable emoji rendering. + +An atlas fallback would need to participate in layout, caret, hit testing, and +selection geometry. Drawing images over already-laid-out text would reintroduce +a split geometry source and is not acceptable. + +## Manual Acceptance Split + +Codex checks: + +- `zig build test` +- `zig build` +- `zig build run` +- focused Unicode/Emoji visual sanity when a temporary probe or editor sample is + available + +User checks: + +- remote editor editing and selection +- TextEntry editing and cluster deletion +- broader visual acceptance across real workflows + +## Future Work + +- Add Windows and Linux platform font discovery. +- Add optional diagnostic logging for the concrete fallback paths discovered at + runtime. +- If platform emoji fonts still render as tofu, revisit the DVUI fork's + SDL3_ttf build options for color/SVG glyph support. +- Only after that, evaluate an inline emoji atlas with layout integration. diff --git a/src/backends/text/platform_emoji_macos.m b/src/backends/text/platform_emoji_macos.m new file mode 100644 index 0000000..021ca1b --- /dev/null +++ b/src/backends/text/platform_emoji_macos.m @@ -0,0 +1,115 @@ +#import +#include +#include + +typedef struct ShellowoEmojiBitmap { + unsigned char *pixels; + int width; + int height; + int stride; +} ShellowoEmojiBitmap; + +int shellowo_render_emoji_bitmap( + const char *utf8, + size_t utf8_len, + double point_size, + ShellowoEmojiBitmap *out_bitmap +) { + if (out_bitmap == NULL) return 0; + memset(out_bitmap, 0, sizeof(*out_bitmap)); + if (utf8 == NULL || utf8_len == 0 || point_size <= 0.0) return 0; + + @autoreleasepool { + NSString *text = [[NSString alloc] initWithBytes:utf8 + length:utf8_len + encoding:NSUTF8StringEncoding]; + if (text == nil || text.length == 0) return 0; + + NSFont *font = [NSFont systemFontOfSize:(CGFloat)point_size]; + NSFont *emoji_font = [NSFont fontWithName:@"Apple Color Emoji" size:(CGFloat)point_size]; + if (emoji_font != nil) font = emoji_font; + + NSDictionary *attributes = @{ + NSFontAttributeName : font, + NSForegroundColorAttributeName : NSColor.whiteColor, + }; + NSSize measured = [text sizeWithAttributes:attributes]; + CGFloat padding_f = 0.0; + + int width = (int)ceil(measured.width + padding_f * 2.0); + int height = (int)ceil(measured.height + padding_f * 2.0); + int minimum = (int)ceil(point_size); + if (width < minimum) width = minimum; + if (height < minimum) height = minimum; + if (width <= 0 || height <= 0 || width > 1024 || height > 1024) return 0; + + NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] + initWithBitmapDataPlanes:NULL + pixelsWide:width + pixelsHigh:height + bitsPerSample:8 + samplesPerPixel:4 + hasAlpha:YES + isPlanar:NO + colorSpaceName:NSDeviceRGBColorSpace + bytesPerRow:width * 4 + bitsPerPixel:32]; + if (rep == nil || rep.bitmapData == NULL) return 0; + + NSGraphicsContext *context = [NSGraphicsContext graphicsContextWithBitmapImageRep:rep]; + if (context == nil) return 0; + + [NSGraphicsContext saveGraphicsState]; + [NSGraphicsContext setCurrentContext:context]; + [NSColor.clearColor setFill]; + NSRectFill(NSMakeRect(0, 0, width, height)); + [text drawAtPoint:NSMakePoint(padding_f, padding_f) withAttributes:attributes]; + [NSGraphicsContext restoreGraphicsState]; + + const int source_stride = (int)rep.bytesPerRow; + unsigned char *source = rep.bitmapData; + int min_x = width; + int min_y = height; + int max_x = -1; + int max_y = -1; + for (int y = 0; y < height; y++) { + unsigned char *row = source + ((size_t)y * (size_t)source_stride); + for (int x = 0; x < width; x++) { + unsigned char alpha = row[(size_t)x * 4 + 3]; + if (alpha == 0) continue; + if (x < min_x) min_x = x; + if (y < min_y) min_y = y; + if (x > max_x) max_x = x; + if (y > max_y) max_y = y; + } + } + + if (max_x >= min_x && max_y >= min_y) { + width = max_x - min_x + 1; + height = max_y - min_y + 1; + } else { + min_x = 0; + min_y = 0; + } + + const int stride = width * 4; + const size_t byte_len = (size_t)stride * (size_t)height; + unsigned char *copy = (unsigned char *)malloc(byte_len); + if (copy == NULL) return 0; + for (int y = 0; y < height; y++) { + unsigned char *dst_row = copy + ((size_t)y * (size_t)stride); + unsigned char *src_row = source + ((size_t)(min_y + y) * (size_t)source_stride) + ((size_t)min_x * 4); + memcpy(dst_row, src_row, (size_t)stride); + } + + out_bitmap->pixels = copy; + out_bitmap->width = width; + out_bitmap->height = height; + out_bitmap->stride = stride; + return 1; + } +} + +void shellowo_free_emoji_bitmap(unsigned char *pixels) { + free(pixels); +} diff --git a/src/backends/text/platform_fonts.zig b/src/backends/text/platform_fonts.zig new file mode 100644 index 0000000..261016e --- /dev/null +++ b/src/backends/text/platform_fonts.zig @@ -0,0 +1,86 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +pub const List = struct { + paths: []const []const u8 = &.{}, + + pub fn deinit(self: *List, allocator: std.mem.Allocator) void { + for (self.paths) |path| allocator.free(path); + allocator.free(self.paths); + self.* = .{}; + } +}; + +const CandidateKind = enum(c_int) { + emoji = 1, + symbol = 2, + cascade = 3, +}; + +const max_candidates_per_kind = 32; +const max_total_candidates = 48; + +pub fn discoverFallbacks(allocator: std.mem.Allocator) !List { + if (builtin.os.tag != .macos) return .{}; + + var paths = std.ArrayList([]const u8).empty; + errdefer { + for (paths.items) |path| allocator.free(path); + paths.deinit(allocator); + } + + try appendCandidates(allocator, &paths, .emoji, 0x1f600); + try appendCandidates(allocator, &paths, .symbol, 0x2699); + try appendCandidates(allocator, &paths, .cascade, 0x4e2d); + try appendCandidates(allocator, &paths, .cascade, 0x1f600); + try appendCandidates(allocator, &paths, .cascade, 0x0915); + try appendCandidates(allocator, &paths, .cascade, 0x0633); + + return .{ .paths = try paths.toOwnedSlice(allocator) }; +} + +fn appendCandidates( + allocator: std.mem.Allocator, + paths: *std.ArrayList([]const u8), + kind: CandidateKind, + codepoint: u32, +) !void { + if (paths.items.len >= max_total_candidates) return; + + var candidate_index: c_int = 0; + while (candidate_index < max_candidates_per_kind and paths.items.len < max_total_candidates) : (candidate_index += 1) { + var buffer: [1024]u8 = undefined; + const len = shellowo_text_font_candidate(@intFromEnum(kind), codepoint, candidate_index, &buffer, buffer.len); + if (len <= 0) break; + + const candidate = buffer[0..@as(usize, @intCast(len))]; + if (contains(paths.items, candidate)) continue; + if (!pathExists(candidate)) continue; + try paths.append(allocator, try allocator.dupe(u8, candidate)); + } +} + +fn contains(paths: []const []const u8, candidate: []const u8) bool { + for (paths) |path| { + if (std.mem.eql(u8, path, candidate)) return true; + } + return false; +} + +fn pathExists(path: []const u8) bool { + _ = std.Io.Dir.cwd().statFile(std.Options.debug_io, path, .{}) catch return false; + return true; +} + +extern fn shellowo_text_font_candidate( + kind: c_int, + codepoint: u32, + candidate_index: c_int, + out_path: [*]u8, + out_len: usize, +) callconv(.c) c_int; + +test "platform fallback discovery is safe to call" { + var list = try discoverFallbacks(std.testing.allocator); + defer list.deinit(std.testing.allocator); +} diff --git a/src/backends/text/platform_fonts_macos.c b/src/backends/text/platform_fonts_macos.c new file mode 100644 index 0000000..c19a203 --- /dev/null +++ b/src/backends/text/platform_fonts_macos.c @@ -0,0 +1,207 @@ +#include +#include +#include +#include +#include + +enum { + SHELLOWO_FONT_KIND_EMOJI = 1, + SHELLOWO_FONT_KIND_SYMBOL = 2, + SHELLOWO_FONT_KIND_CASCADE = 3, +}; + +static int shellowo_copy_path(const char *candidate, char *out_path, int out_len) { + if (candidate == NULL || out_path == NULL || out_len <= 1) return 0; + size_t len = strlen(candidate); + if (len >= (size_t)out_len) len = (size_t)out_len - 1; + memcpy(out_path, candidate, len); + out_path[len] = '\0'; + return (int)len; +} + +static int shellowo_copy_font_url_path(CFTypeRef value, char *out_path, int out_len) { + if (value == NULL || out_path == NULL || out_len <= 1) return 0; + CFURLRef url = NULL; + if (CFGetTypeID(value) == CTFontDescriptorGetTypeID()) { + url = (CFURLRef)CTFontDescriptorCopyAttribute((CTFontDescriptorRef)value, kCTFontURLAttribute); + } else if (CFGetTypeID(value) == CTFontGetTypeID()) { + url = (CFURLRef)CTFontCopyAttribute((CTFontRef)value, kCTFontURLAttribute); + } else if (CFGetTypeID(value) == CFURLGetTypeID()) { + url = (CFURLRef)CFRetain(value); + } + + if (url == NULL) return 0; + bool ok = CFURLGetFileSystemRepresentation(url, true, (UInt8 *)out_path, out_len); + out_path[out_len - 1] = '\0'; + int len = ok ? (int)strlen(out_path) : 0; + CFRelease(url); + return len; +} + +static int shellowo_try_font_attribute( + CFStringRef attribute, + CFStringRef value, + int candidate_index, + int *path_index, + char *out_path, + int out_len +) { + const void *keys[] = { attribute }; + const void *values[] = { value }; + CFDictionaryRef attributes = CFDictionaryCreate( + kCFAllocatorDefault, + keys, + values, + 1, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks + ); + if (attributes == NULL) return 0; + + CTFontDescriptorRef descriptor = CTFontDescriptorCreateWithAttributes(attributes); + CFRelease(attributes); + if (descriptor == NULL) return 0; + + CFArrayRef matches = CTFontDescriptorCreateMatchingFontDescriptors(descriptor, NULL); + CFRelease(descriptor); + if (matches == NULL) return 0; + + int result = 0; + CFIndex count = CFArrayGetCount(matches); + for (CFIndex i = 0; i < count; i++) { + char candidate_path[1024]; + int len = shellowo_copy_font_url_path(CFArrayGetValueAtIndex(matches, i), candidate_path, sizeof(candidate_path)); + if (len <= 0) continue; + if (*path_index == candidate_index) { + result = shellowo_copy_path(candidate_path, out_path, out_len); + break; + } + (*path_index)++; + } + + CFRelease(matches); + return result; +} + +static int shellowo_try_named_fonts( + const CFStringRef *names, + int name_count, + int candidate_index, + char *out_path, + int out_len +) { + int path_index = 0; + for (int i = 0; i < name_count; i++) { + int len = shellowo_try_font_attribute(kCTFontNameAttribute, names[i], candidate_index, &path_index, out_path, out_len); + if (len > 0) return len; + len = shellowo_try_font_attribute(kCTFontFamilyNameAttribute, names[i], candidate_index, &path_index, out_path, out_len); + if (len > 0) return len; + } + return 0; +} + +static int shellowo_try_cascade( + uint32_t codepoint, + int candidate_index, + char *out_path, + int out_len +) { + (void)codepoint; + CTFontRef base_font = CTFontCreateUIFontForLanguage(kCTFontUIFontSystem, 16.0, NULL); + if (base_font == NULL) return 0; + + const void *language_values[] = { + CFSTR("en"), + CFSTR("zh-Hans"), + CFSTR("zh-Hant"), + CFSTR("ja"), + CFSTR("ko"), + CFSTR("ar"), + CFSTR("he"), + CFSTR("hi"), + CFSTR("th"), + }; + CFArrayRef languages = CFArrayCreate(kCFAllocatorDefault, language_values, 9, &kCFTypeArrayCallBacks); + if (languages == NULL) { + CFRelease(base_font); + return 0; + } + + CFArrayRef cascade = CTFontCopyDefaultCascadeListForLanguages(base_font, languages); + int result = 0; + int path_index = 0; + if (cascade != NULL) { + CFIndex count = CFArrayGetCount(cascade); + for (CFIndex i = 0; i < count; i++) { + char candidate_path[1024]; + int len = shellowo_copy_font_url_path(CFArrayGetValueAtIndex(cascade, i), candidate_path, sizeof(candidate_path)); + if (len <= 0) continue; + if (path_index == candidate_index) { + result = shellowo_copy_path(candidate_path, out_path, out_len); + break; + } + path_index++; + } + CFRelease(cascade); + } + + if (result == 0) { + CFArrayRef urls = CTFontManagerCopyAvailableFontURLs(); + CFIndex url_count = urls == NULL ? 0 : CFArrayGetCount(urls); + for (CFIndex i = 0; i < url_count; i++) { + char candidate_path[1024]; + int len = shellowo_copy_font_url_path(CFArrayGetValueAtIndex(urls, i), candidate_path, sizeof(candidate_path)); + if (len <= 0) continue; + if (path_index == candidate_index) { + result = shellowo_copy_path(candidate_path, out_path, out_len); + break; + } + path_index++; + } + if (urls != NULL) CFRelease(urls); + } + + CFRelease(languages); + CFRelease(base_font); + return result; +} + +int shellowo_text_font_candidate( + int kind, + uint32_t codepoint, + int candidate_index, + char *out_path, + size_t out_len +) { + if (out_path == NULL || out_len <= 1 || candidate_index < 0) return 0; + out_path[0] = '\0'; + if (out_len > 2147483647u) out_len = 2147483647u; + + if (kind == SHELLOWO_FONT_KIND_EMOJI) { + const CFStringRef names[] = { + CFSTR("AppleColorEmoji"), + CFSTR("Apple Color Emoji"), + CFSTR("Noto Color Emoji"), + CFSTR("Segoe UI Emoji"), + }; + return shellowo_try_named_fonts(names, 4, candidate_index, out_path, (int)out_len); + } + + if (kind == SHELLOWO_FONT_KIND_SYMBOL) { + const CFStringRef names[] = { + CFSTR("AppleSymbols"), + CFSTR("Apple Symbols"), + CFSTR("SF Symbols"), + CFSTR("Noto Sans Symbols"), + CFSTR("Noto Sans Symbols 2"), + CFSTR("Segoe UI Symbol"), + }; + return shellowo_try_named_fonts(names, 6, candidate_index, out_path, (int)out_len); + } + + if (kind == SHELLOWO_FONT_KIND_CASCADE) { + return shellowo_try_cascade(codepoint, candidate_index, out_path, (int)out_len); + } + + return 0; +} diff --git a/src/backends/text/sdl_ttf.zig b/src/backends/text/sdl_ttf.zig index 30f41d9..fc4f7d3 100644 --- a/src/backends/text/sdl_ttf.zig +++ b/src/backends/text/sdl_ttf.zig @@ -1,15 +1,21 @@ const std = @import("std"); +const builtin = @import("builtin"); const dvui = @import("dvui"); +const platform_fonts = @import("platform_fonts.zig"); const c = dvui.backend.c; +const cjk_font_family = "Noto Sans CJK SC"; pub const System = struct { allocator: std.mem.Allocator, renderer: *c.SDL_Renderer, engine: *c.TTF_TextEngine, font_sources: FontSources, + system_fallbacks: platform_fonts.List = .{}, fonts: std.AutoHashMapUnmanaged(u64, Face) = .empty, + simple_metrics: std.AutoHashMapUnmanaged(u64, SimpleMetrics) = .empty, layouts: std.AutoHashMapUnmanaged(u64, *c.TTF_Text) = .empty, + emoji_textures: std.AutoHashMapUnmanaged(u64, EmojiTexture) = .empty, pub const FontSources = struct { regular: []const u8, @@ -20,7 +26,14 @@ pub const System = struct { const Face = struct { primary: *c.TTF_Font, - fallback: ?*c.TTF_Font, + fallbacks: std.ArrayListUnmanaged(*c.TTF_Font) = .empty, + + fn deinit(self: *Face, allocator: std.mem.Allocator) void { + for (self.fallbacks.items) |fallback| c.TTF_CloseFont(fallback); + self.fallbacks.deinit(allocator); + c.TTF_CloseFont(self.primary); + self.* = undefined; + } }; const Layout = struct { @@ -32,6 +45,30 @@ pub const System = struct { } }; + const EmojiTexture = struct { + texture: *c.SDL_Texture, + width: f32, + height: f32, + + fn deinit(self: EmojiTexture) void { + c.SDL_DestroyTexture(self.texture); + } + }; + + const SimpleMetrics = struct { + advances: [128]c_int, + height: c_int, + ascent: c_int, + + fn advance(self: *const SimpleMetrics, byte: u8) c_int { + if (byte == '\t') { + const space = @max(1, self.advances[' ']); + return space * 4; + } + return @max(0, self.advances[byte]); + } + }; + pub fn init( allocator: std.mem.Allocator, renderer: *c.SDL_Renderer, @@ -47,18 +84,23 @@ pub const System = struct { .renderer = renderer, .engine = engine, .font_sources = font_sources, + .system_fallbacks = platform_fonts.discoverFallbacks(allocator) catch .{}, }; } pub fn deinit(self: *System) void { self.clearLayouts(); self.layouts.deinit(self.allocator); + var emoji_it = self.emoji_textures.valueIterator(); + while (emoji_it.next()) |texture| texture.deinit(); + self.emoji_textures.deinit(self.allocator); + self.simple_metrics.deinit(self.allocator); var it = self.fonts.valueIterator(); while (it.next()) |face| { - c.TTF_CloseFont(face.primary); - if (face.fallback) |fallback| c.TTF_CloseFont(fallback); + face.deinit(self.allocator); } self.fonts.deinit(self.allocator); + self.system_fallbacks.deinit(self.allocator); c.TTF_DestroyRendererTextEngine(self.engine); c.TTF_Quit(); self.* = undefined; @@ -76,14 +118,12 @@ pub const System = struct { } fn faceFor(self: *System, font: dvui.Font, scale: f32) ?Face { - const physical_size = @max(1, font.size * safeScale(scale)); - var hasher = std.hash.Wyhash.init(font.hash()); - hasher.update(std.mem.asBytes(&physical_size)); - const key = hasher.final(); + const physical_size = physicalFontSize(font, scale); + const key = fontScaleKey(font, physical_size); if (self.fonts.get(key)) |face| return face; const family = font.familyName(); - const cjk_primary = std.mem.eql(u8, family, "Noto Sans CJK SC"); + const cjk_primary = std.mem.eql(u8, family, cjk_font_family); const primary_bytes = if (cjk_primary) self.font_sources.cjk else if (font.weight == .bold) @@ -97,27 +137,62 @@ pub const System = struct { errdefer c.TTF_CloseFont(primary); applyFontStyle(primary, font); - var fallback: ?*c.TTF_Font = null; + var face: Face = .{ .primary = primary }; + errdefer face.deinit(self.allocator); + if (!cjk_primary) { - fallback = openFont(self.font_sources.cjk, physical_size); - if (fallback) |fallback_font| { - applyFontStyle(fallback_font, font); - if (!c.TTF_AddFallbackFont(primary, fallback_font)) { - c.TTF_CloseFont(fallback_font); - fallback = null; - } - } + tryAddFallbackBytes(self.allocator, &face, self.font_sources.cjk, physical_size, font); + } + for (self.system_fallbacks.paths) |path| { + tryAddFallbackPath(self.allocator, &face, path, physical_size, font); } - const face: Face = .{ .primary = primary, .fallback = fallback }; self.fonts.put(self.allocator, key, face) catch { - if (fallback) |fallback_font| c.TTF_CloseFont(fallback_font); - c.TTF_CloseFont(primary); + face.deinit(self.allocator); return null; }; return face; } + fn simpleMetricsFor(self: *System, font: dvui.Font, scale: f32) ?*SimpleMetrics { + if (!isMonospaceFont(font)) return null; + const physical_size = physicalFontSize(font, scale); + const key = fontScaleKey(font, physical_size); + if (self.simple_metrics.getPtr(key)) |metrics| return metrics; + + const face = self.faceFor(font, scale) orelse return null; + var metrics: SimpleMetrics = .{ + .advances = [_]c_int{0} ** 128, + .height = @max(1, c.TTF_GetFontHeight(face.primary)), + .ascent = c.TTF_GetFontAscent(face.primary), + }; + var byte: usize = 0; + while (byte < metrics.advances.len) : (byte += 1) { + const ch: u8 = @intCast(byte); + if (ch == '\n' or ch == '\r') { + metrics.advances[byte] = 0; + continue; + } + var advance: c_int = 0; + if (ch >= 0x20 and c.TTF_GetGlyphMetrics(face.primary, ch, null, null, null, null, &advance)) { + metrics.advances[byte] = @max(0, advance); + continue; + } + if (ch >= 0x20) { + const one = [_]u8{ch}; + var width_px: c_int = 0; + if (c.TTF_GetStringSize(face.primary, &one, one.len, &width_px, null)) { + metrics.advances[byte] = @max(0, width_px); + } + } + } + if (metrics.advances[' '] <= 0) metrics.advances[' '] = @max(1, @divTrunc(metrics.height, 2)); + if (metrics.advances['M'] <= 0) metrics.advances['M'] = metrics.advances[' ']; + + self.simple_metrics.put(self.allocator, key, metrics) catch return null; + return self.simple_metrics.getPtr(key); + } + fn layout( self: *System, font: dvui.Font, @@ -168,6 +243,25 @@ pub const System = struct { self.layouts.clearRetainingCapacity(); } + fn fontTextHeightPhysical(self: *System, font: dvui.Font, scale: f32) f32 { + const shaped = self.layout(font, "M", scale, null) orelse + return @max(1, font.size * safeScale(scale)); + defer shaped.release(); + + var width_px: c_int = 0; + var height_px: c_int = 0; + if (c.TTF_GetTextSize(shaped.text, &width_px, &height_px)) { + return @floatFromInt(@max(1, height_px)); + } + return @max(1, font.size * safeScale(scale)); + } + + fn emojiBoxPhysical(self: *System, font: dvui.Font, scale: f32) f32 { + const text_height = self.fontTextHeightPhysical(font, scale); + const em = @max(1, font.size * safeScale(scale)); + return @max(1, @min(text_height * 0.82, em * 1.04)); + } + fn measure( context: *anyopaque, font: dvui.Font, @@ -176,6 +270,8 @@ pub const System = struct { ) dvui.Size { const self: *System = @ptrCast(@alignCast(context)); const scale = currentScale(); + if (self.measureSimple(font, text, scale, options)) |fast| return fast; + const line = firstLine(text); const shaped = self.layout(font, line.bytes, scale, null) orelse return .{ .w = font.size, .h = font.size }; @@ -189,11 +285,31 @@ pub const System = struct { } var end = line.bytes.len; + const has_emoji = containsEmojiCluster(line.bytes); + const emoji_box = self.emojiBoxPhysical(font, scale); if (options.max_width) |max_width| { const point_x: c_int = @intFromFloat(@max(0, @round(max_width * scale))); - var substring: c.TTF_SubString = undefined; - if (c.TTF_GetTextSubStringForPoint(layout_text, point_x, @divTrunc(height_px, 2), &substring)) { - end = boundaryForPoint(substring, point_x, options.end_metric); + if (has_emoji) { + end = compensatedEndForMaxWidth( + layout_text, + line.bytes, + @floatFromInt(point_x), + options.end_metric, + emoji_box, + ); + } else { + var substring: c.TTF_SubString = undefined; + if (c.TTF_GetTextSubStringForPoint(layout_text, point_x, @divTrunc(height_px, 2), &substring)) { + end = boundaryForPoint(substring, point_x, options.end_metric); + } + } + } + if (options.max_width) |max_width| { + const max_width_px = @max(0, max_width * scale); + while (end > 0 and compensatedCaretXPhysical(layout_text, line.bytes, end, emoji_box) > max_width_px) { + const previous = previousEmojiOrCodepoint(line.bytes, end); + if (previous >= end) break; + end = previous; } } if (line.has_newline and end == line.bytes.len) end += 1; @@ -207,15 +323,95 @@ pub const System = struct { } const measured_width_px = if (end < line.bytes.len) - caretXPhysical(layout_text, end) + compensatedCaretXPhysical(layout_text, line.bytes, end, emoji_box) else - @as(f32, @floatFromInt(width_px)); + @as(f32, @floatFromInt(width_px)) + emojiCompensationBefore(layout_text, line.bytes, line.bytes.len, emoji_box); + const measured_height_px = if (has_emoji) + @max(self.fontTextHeightPhysical(font, scale), emoji_box) + else + @as(f32, @floatFromInt(height_px)); + return .{ + .w = @max(0, measured_width_px) / scale, + .h = measured_height_px / scale, + }; + } + + fn measureSimple( + self: *System, + font: dvui.Font, + text: []const u8, + scale: f32, + options: dvui.Font.TextSizeOptions, + ) ?dvui.Size { + const line = simpleMeasureLine(text, options.max_width != null) orelse return null; + if (self.simpleMetricsFor(font, scale)) |metrics| { + return measureSimpleWithMetrics(line, metrics, scale, options); + } + + const face = self.faceFor(font, scale) orelse return null; + + var width_px: c_int = 0; + var end = line.bytes.len; + if (options.max_width) |max_width| { + const max_width_px: c_int = @intFromFloat(@max(0, @round(max_width * scale))); + var measured_width: c_int = 0; + var measured_length: usize = 0; + if (line.bytes.len > 0) { + if (!c.TTF_MeasureString( + face.primary, + line.bytes.ptr, + line.bytes.len, + max_width_px, + &measured_width, + &measured_length, + )) return null; + } + end = @min(measured_length, line.bytes.len); + width_px = measured_width; + if (options.end_metric == .nearest and end < line.bytes.len) { + const nearest = self.simpleNearestEnd(face.primary, line.bytes, end, max_width_px, measured_width); + end = nearest.end; + width_px = nearest.width_px; + } + } else { + if (line.bytes.len > 0) { + if (!c.TTF_GetStringSize(face.primary, line.bytes.ptr, line.bytes.len, &width_px, null)) return null; + } + } + + if (line.has_newline and end == line.bytes.len) end += 1; + if (options.end_idx) |out| out.* = @min(end, text.len); + if (options.ascent_out) |out| out.* = @as(f32, @floatFromInt(c.TTF_GetFontAscent(face.primary))) / scale; + return .{ - .w = measured_width_px / scale, - .h = @as(f32, @floatFromInt(height_px)) / scale, + .w = @max(0, @as(f32, @floatFromInt(width_px))) / scale, + .h = @as(f32, @floatFromInt(@max(1, c.TTF_GetFontHeight(face.primary)))) / scale, }; } + fn simpleNearestEnd( + self: *System, + font: *c.TTF_Font, + bytes: []const u8, + before_end: usize, + max_width_px: c_int, + before_width_px: c_int, + ) struct { end: usize, width_px: c_int } { + _ = self; + if (before_end >= bytes.len) return .{ .end = bytes.len, .width_px = before_width_px }; + const after_end = before_end + 1; + var after_width_px: c_int = before_width_px; + if (!c.TTF_GetStringSize(font, bytes.ptr, after_end, &after_width_px, null)) { + return .{ .end = before_end, .width_px = before_width_px }; + } + const before_dist = @abs(@as(f32, @floatFromInt(max_width_px - before_width_px))); + const after_dist = @abs(@as(f32, @floatFromInt(after_width_px - max_width_px))); + return if (after_dist < before_dist) + .{ .end = after_end, .width_px = after_width_px } + else + .{ .end = before_end, .width_px = before_width_px }; + } + fn render(context: *anyopaque, options: dvui.TextEngine.RenderOptions) anyerror!void { const self: *System = @ptrCast(@alignCast(context)); if (options.rotation != 0) return error.UnsupportedTextRotation; @@ -229,22 +425,42 @@ pub const System = struct { _ = c.TTF_SetTextColor(text, options.color.r, options.color.g, options.color.b, options.color.a); const start = options.p orelse options.rs.r.topLeft(); + var text_w: c_int = 0; + var text_h: c_int = 0; + const have_size = c.TTF_GetTextSize(text, &text_w, &text_h); + const default_h = if (have_size) + @as(f32, @floatFromInt(@max(1, text_h))) + else + 16 * scale; + const emoji_box = self.emojiBoxPhysical(options.font, scale); + const has_emoji = containsEmojiCluster(options.text); + const emoji_extra = emojiCompensationBefore(text, options.text, options.text.len, emoji_box); + const render_height = if (has_emoji) @max(self.fontTextHeightPhysical(options.font, scale), emoji_box) else default_h; + if (options.background_color) |background| { - var w: c_int = 0; - var h: c_int = 0; - if (c.TTF_GetTextSize(text, &w, &h)) { + if (have_size) { (dvui.Rect.Physical{ .x = start.x, .y = start.y, - .w = @floatFromInt(w), - .h = @floatFromInt(h), + .w = @max(1, @as(f32, @floatFromInt(text_w)) + emoji_extra), + .h = render_height, }).fill(.{}, .{ .color = background, .fade = 0 }); } } const sel_start = @min(options.sel_start orelse 0, options.text.len); const sel_end = @min(options.sel_end orelse 0, options.text.len); - if (sel_start < sel_end) { + if (sel_start < sel_end and has_emoji) { + const selection_color = options.sel_color orelse dvui.themeGet().focus; + const x0 = compensatedCaretXPhysical(text, options.text, sel_start, emoji_box); + const x1 = compensatedCaretXPhysical(text, options.text, sel_end, emoji_box); + (dvui.Rect.Physical{ + .x = start.x + @min(x0, x1), + .y = start.y, + .w = @max(1, @abs(x1 - x0)), + .h = render_height, + }).fill(.{}, .{ .color = selection_color, .fade = 0 }); + } else if (sel_start < sel_end) { var count: c_int = 0; const substrings = c.TTF_GetTextSubStringsForRange( text, @@ -285,7 +501,11 @@ pub const System = struct { } defer _ = c.SDL_SetRenderClipRect(self.renderer, if (previous_clip_enabled) &previous_clip else null); - if (!c.TTF_DrawRendererText(text, start.x, start.y)) return error.SdlTtfDrawFailed; + const align_cjk = containsCjkBaselineCodepoint(options.text) and !isCjkFont(options.font); + if (has_emoji or align_cjk) { + try self.renderVisualTextSegments(options.font, text, options.text, start, scale, options.color, emoji_box, align_cjk); + } else if (!c.TTF_DrawRendererText(text, start.x, start.y)) return error.SdlTtfDrawFailed; + self.renderEmojiOverlays(text, options.text, start, emoji_box, render_height); } fn caretX( @@ -296,10 +516,12 @@ pub const System = struct { ) f32 { const self: *System = @ptrCast(@alignCast(context)); const scale = currentScale(); + if (self.simpleCaretX(font, text, byte_offset, scale)) |fast| return fast; + const shaped = self.layout(font, text, scale, null) orelse return 0; defer shaped.release(); const layout_text = shaped.text; - return caretXPhysical(layout_text, byte_offset) / scale; + return compensatedCaretXPhysical(layout_text, text, byte_offset, self.emojiBoxPhysical(font, scale)) / scale; } fn caretPoint( @@ -311,24 +533,117 @@ pub const System = struct { ) dvui.Point { const self: *System = @ptrCast(@alignCast(context)); const scale = currentScale(); + if (self.simpleCaretPoint(font, text, byte_offset, scale, wrap_width)) |fast| return fast; + const shaped = self.layout(font, text, scale, wrap_width) orelse return .{}; defer shaped.release(); const layout_text = shaped.text; var substring: c.TTF_SubString = undefined; - if (!c.TTF_GetTextSubString(layout_text, @intCast(byte_offset), &substring)) return .{}; + if (!c.TTF_GetTextSubString(layout_text, @intCast(byte_offset), &substring)) { + return .{ + .x = compensatedCaretXPhysical(layout_text, text, byte_offset, self.emojiBoxPhysical(font, scale)) / scale, + .y = 0, + }; + } + const emoji_box = self.emojiBoxPhysical(font, scale); const rtl = (substring.flags & c.TTF_SUBSTRING_DIRECTION_MASK) == c.TTF_DIRECTION_RTL; const at_start = byte_offset <= @as(usize, @intCast(@max(0, substring.offset))); - const x_px = if (at_start) + const x_px: f32 = @floatFromInt(if (at_start) (if (rtl) substring.rect.x + substring.rect.w else substring.rect.x) else - (if (rtl) substring.rect.x else substring.rect.x + substring.rect.w); + (if (rtl) substring.rect.x else substring.rect.x + substring.rect.w)); return .{ - .x = @as(f32, @floatFromInt(x_px)) / scale, + .x = (x_px + emojiCompensationBefore(layout_text, text, byte_offset, emoji_box)) / scale, .y = @as(f32, @floatFromInt(substring.rect.y)) / scale, }; } + fn simpleCaretX( + self: *System, + font: dvui.Font, + text: []const u8, + byte_offset: usize, + scale: f32, + ) ?f32 { + const offset = @min(byte_offset, text.len); + if (!isSimpleAscii(text[0..offset])) return null; + if (self.simpleMetricsFor(font, scale)) |metrics| { + return @as(f32, @floatFromInt(simpleMetricsWidth(metrics, text[0..offset]))) / scale; + } + const face = self.faceFor(font, scale) orelse return null; + return @as(f32, @floatFromInt(simpleStringWidth(face.primary, text[0..offset]) orelse return null)) / scale; + } + + fn simpleCaretPoint( + self: *System, + font: dvui.Font, + text: []const u8, + byte_offset: usize, + scale: f32, + wrap_width: ?f32, + ) ?dvui.Point { + const offset = @min(byte_offset, text.len); + const prefix = text[0..offset]; + if (!isSimpleAscii(prefix)) return null; + if (self.simpleMetricsFor(font, scale)) |metrics| { + return simpleCaretPointWithMetrics(prefix, metrics, scale, wrap_width); + } + const face = self.faceFor(font, scale) orelse return null; + const line_h = @as(f32, @floatFromInt(@max(1, c.TTF_GetFontHeight(face.primary)))); + + const wrap_px: ?c_int = if (wrap_width) |width| + @intFromFloat(@max(1, @round(width * scale))) + else + null; + + var y_px: f32 = 0; + var line_start: usize = 0; + var pos: usize = 0; + while (pos < prefix.len) { + if (prefix[pos] == '\n') { + line_start = pos + 1; + y_px += line_h; + pos += 1; + continue; + } + + const next_newline = std.mem.indexOfScalarPos(u8, prefix, pos, '\n') orelse prefix.len; + if (wrap_px) |limit| { + while (pos < next_newline) { + const remaining = prefix[pos..next_newline]; + var measured_width: c_int = 0; + var measured_length: usize = 0; + if (!c.TTF_MeasureString( + face.primary, + remaining.ptr, + remaining.len, + limit, + &measured_width, + &measured_length, + )) return null; + const step = @max(@as(usize, 1), @min(measured_length, remaining.len)); + if (pos + step >= prefix.len) { + return .{ + .x = @as(f32, @floatFromInt(simpleStringWidth(face.primary, prefix[pos..prefix.len]) orelse measured_width)) / scale, + .y = y_px / scale, + }; + } + pos += step; + line_start = pos; + if (pos < next_newline) y_px += line_h; + } + } else { + pos = next_newline; + } + } + + return .{ + .x = @as(f32, @floatFromInt(simpleStringWidth(face.primary, prefix[line_start..prefix.len]) orelse 0)) / scale, + .y = y_px / scale, + }; + } + fn previousBoundary( context: *anyopaque, font: dvui.Font, @@ -368,6 +683,161 @@ pub const System = struct { } return @min(text.len, @as(usize, @intCast(@max(0, substring.offset + substring.length)))); } + + fn renderVisualTextSegments( + self: *System, + font: dvui.Font, + original_text: *c.TTF_Text, + bytes: []const u8, + start: dvui.Point.Physical, + scale: f32, + color: dvui.Color, + emoji_advance: f32, + align_cjk: bool, + ) !void { + const cjk_offset_y = if (align_cjk) self.cjkBaselineOffsetPhysical(font, scale) else 0; + + var emoji_index: usize = 0; + var next_emoji = nextEmojiCluster(bytes, &emoji_index); + var pos: usize = 0; + while (pos < bytes.len) { + while (next_emoji) |cluster| { + if (cluster.end > pos) break; + next_emoji = nextEmojiCluster(bytes, &emoji_index); + } + + if (next_emoji) |cluster| { + if (pos >= cluster.start and pos < cluster.end) { + pos = cluster.end; + continue; + } + } + + var codepoint_index = pos; + const first = nextCodepointValue(bytes, &codepoint_index) orelse { + pos += 1; + continue; + }; + const cjk_run = align_cjk and isCjkBaselineCodepoint(first); + const segment_start = pos; + pos = codepoint_index; + + while (pos < bytes.len) { + if (next_emoji) |cluster| { + if (pos >= cluster.start) break; + } + + var next_index = pos; + const codepoint = nextCodepointValue(bytes, &next_index) orelse break; + const next_cjk = align_cjk and (isCjkBaselineCodepoint(codepoint) or (cjk_run and (isCombiningMark(codepoint) or isVariationSelector(codepoint)))); + if (next_cjk != cjk_run) break; + pos = next_index; + } + + const segment_font = if (cjk_run) font.withFamily(cjk_font_family) else font; + try self.renderTextSegment( + segment_font, + bytes[segment_start..pos], + start.x + compensatedCaretXPhysical(original_text, bytes, segment_start, emoji_advance), + start.y + (if (cjk_run) cjk_offset_y else 0), + scale, + color, + ); + } + } + + fn renderTextSegment( + self: *System, + font: dvui.Font, + bytes: []const u8, + x: f32, + y: f32, + scale: f32, + color: dvui.Color, + ) !void { + if (bytes.len == 0) return; + const shaped = self.layout(font, bytes, scale, null) orelse return error.SdlTtfLayoutFailed; + defer shaped.release(); + const text = shaped.text; + _ = c.TTF_SetTextColor(text, color.r, color.g, color.b, color.a); + if (!c.TTF_DrawRendererText(text, x, y)) return error.SdlTtfDrawFailed; + } + + fn cjkBaselineOffsetPhysical(self: *System, font: dvui.Font, scale: f32) f32 { + const primary = self.faceFor(font, scale) orelse return 0; + const cjk_font = font.withFamily(cjk_font_family); + const cjk = self.faceFor(cjk_font, scale) orelse return 0; + + const raw_offset: f32 = @floatFromInt(c.TTF_GetFontAscent(primary.primary) - c.TTF_GetFontAscent(cjk.primary)); + const em = @max(1, font.size * safeScale(scale)); + return std.math.clamp(raw_offset, -em * 0.22, em * 0.22); + } + + fn renderEmojiOverlays( + self: *System, + layout_text: *c.TTF_Text, + bytes: []const u8, + start: dvui.Point.Physical, + draw_h: f32, + line_h: f32, + ) void { + var index: usize = 0; + while (nextEmojiCluster(bytes, &index)) |cluster| { + const texture = self.emojiTexture(bytes[cluster.start..cluster.end], draw_h) orelse continue; + const cluster_x = compensatedCaretXPhysical(layout_text, bytes, cluster.start, draw_h); + const aspect = if (texture.height > 0) texture.width / texture.height else 1; + const draw_w = @max(1, draw_h * aspect); + const draw_x = start.x + cluster_x + (draw_h - draw_w) * 0.5; + const draw_y = start.y + @max(0, line_h - draw_h) * 0.5; + const dst: c.SDL_FRect = .{ + .x = draw_x, + .y = draw_y, + .w = draw_w, + .h = draw_h, + }; + _ = c.SDL_RenderTexture(self.renderer, texture.texture, null, &dst); + } + } + + fn emojiTexture(self: *System, cluster: []const u8, physical_size: f32) ?EmojiTexture { + if (builtin.os.tag != .macos) return null; + const size_key: u16 = @intFromFloat(@max(1, @round(physical_size))); + var hasher = std.hash.Wyhash.init(0x656d6f6a69); + hasher.update(cluster); + hasher.update(std.mem.asBytes(&size_key)); + const key = hasher.final(); + if (self.emoji_textures.get(key)) |texture| return texture; + if (self.emoji_textures.count() >= 256) self.clearEmojiTextures(); + + const bitmap = loadNativeEmojiBitmap(cluster, physical_size) orelse return null; + defer bitmap.deinit(); + const texture = c.SDL_CreateTexture( + self.renderer, + c.SDL_PIXELFORMAT_RGBA32, + c.SDL_TEXTUREACCESS_STATIC, + @intCast(bitmap.width), + @intCast(bitmap.height), + ) orelse return null; + var keep = false; + defer if (!keep) c.SDL_DestroyTexture(texture); + if (!c.SDL_UpdateTexture(texture, null, bitmap.pixels, bitmap.stride)) return null; + _ = c.SDL_SetTextureBlendMode(texture, c.SDL_BLENDMODE_BLEND); + + const result: EmojiTexture = .{ + .texture = texture, + .width = @floatFromInt(bitmap.width), + .height = @floatFromInt(bitmap.height), + }; + self.emoji_textures.put(self.allocator, key, result) catch return null; + keep = true; + return result; + } + + fn clearEmojiTextures(self: *System) void { + var it = self.emoji_textures.valueIterator(); + while (it.next()) |texture| texture.deinit(); + self.emoji_textures.clearRetainingCapacity(); + } }; const vtable: dvui.TextEngine.VTable = .{ @@ -389,6 +859,50 @@ fn openFont(bytes: []const u8, size: f32) ?*c.TTF_Font { return c.TTF_OpenFontIO(io, true, size); } +fn openFontPath(allocator: std.mem.Allocator, path: []const u8, size: f32) ?*c.TTF_Font { + const path_z = allocator.dupeZ(u8, path) catch return null; + defer allocator.free(path_z); + return c.TTF_OpenFont(path_z.ptr, size); +} + +fn tryAddFallbackBytes( + allocator: std.mem.Allocator, + face: *System.Face, + bytes: []const u8, + size: f32, + requested: dvui.Font, +) void { + const fallback = openFont(bytes, size) orelse return; + var keep = false; + defer if (!keep) c.TTF_CloseFont(fallback); + applyFontStyle(fallback, requested); + face.fallbacks.append(allocator, fallback) catch return; + if (!c.TTF_AddFallbackFont(face.primary, fallback)) { + face.fallbacks.items.len -= 1; + return; + } + keep = true; +} + +fn tryAddFallbackPath( + allocator: std.mem.Allocator, + face: *System.Face, + path: []const u8, + size: f32, + requested: dvui.Font, +) void { + const fallback = openFontPath(allocator, path, size) orelse return; + var keep = false; + defer if (!keep) c.TTF_CloseFont(fallback); + applyFontStyle(fallback, requested); + face.fallbacks.append(allocator, fallback) catch return; + if (!c.TTF_AddFallbackFont(face.primary, fallback)) { + face.fallbacks.items.len -= 1; + return; + } + keep = true; +} + fn applyFontStyle(font: *c.TTF_Font, requested: dvui.Font) void { var style: c.TTF_FontStyleFlags = c.TTF_STYLE_NORMAL; if (requested.weight == .bold) style |= c.TTF_STYLE_BOLD; @@ -398,6 +912,27 @@ fn applyFontStyle(font: *c.TTF_Font, requested: dvui.Font) void { c.TTF_SetFontStyle(font, style); } +fn physicalFontSize(font: dvui.Font, scale: f32) f32 { + return @max(1, font.size * safeScale(scale)); +} + +fn fontScaleKey(font: dvui.Font, physical_size: f32) u64 { + var hasher = std.hash.Wyhash.init(font.hash()); + hasher.update(std.mem.asBytes(&physical_size)); + return hasher.final(); +} + +fn isMonospaceFont(font: dvui.Font) bool { + const family = font.familyName(); + return std.mem.indexOf(u8, family, "Mono") != null or + std.mem.indexOf(u8, family, "mono") != null or + std.mem.indexOf(u8, family, "Code") != null; +} + +fn isCjkFont(font: dvui.Font) bool { + return std.mem.eql(u8, font.familyName(), cjk_font_family); +} + fn currentScale() f32 { return safeScale(dvui.parentGet().screenRectScale(.{}).s); } @@ -413,6 +948,145 @@ fn firstLine(text: []const u8) struct { bytes: []const u8, has_newline: bool } { return .{ .bytes = text, .has_newline = false }; } +const simple_measure_probe_bytes: usize = 16 * 1024; + +const SimpleMeasureLine = struct { + bytes: []const u8, + has_newline: bool, +}; + +fn simpleMeasureLine(text: []const u8, max_width: bool) ?SimpleMeasureLine { + const limit = if (max_width) + @min(text.len, simple_measure_probe_bytes) + else + text.len; + + var i: usize = 0; + while (i < limit) : (i += 1) { + const byte = text[i]; + if (byte == '\n') return .{ .bytes = text[0..i], .has_newline = true }; + if (!isSimpleAsciiByte(byte)) return null; + } + + return .{ .bytes = text[0..limit], .has_newline = false }; +} + +fn measureSimpleWithMetrics( + line: SimpleMeasureLine, + metrics: *const System.SimpleMetrics, + scale: f32, + options: dvui.Font.TextSizeOptions, +) dvui.Size { + var end = line.bytes.len; + var width_px = simpleMetricsWidth(metrics, line.bytes); + + if (options.max_width) |max_width| { + const result = simpleMetricsMeasureWidth(metrics, line.bytes, @max(0, @round(max_width * scale)), options.end_metric); + end = result.end; + width_px = result.width_px; + } + + if (line.has_newline and end == line.bytes.len) end += 1; + if (options.end_idx) |out| out.* = @min(end, line.bytes.len + @intFromBool(line.has_newline)); + if (options.ascent_out) |out| out.* = @as(f32, @floatFromInt(metrics.ascent)) / scale; + + return .{ + .w = @as(f32, @floatFromInt(width_px)) / scale, + .h = @as(f32, @floatFromInt(@max(1, metrics.height))) / scale, + }; +} + +fn simpleMetricsWidth(metrics: *const System.SimpleMetrics, bytes: []const u8) c_int { + var width: c_int = 0; + for (bytes) |byte| { + if (byte == '\n' or byte == '\r') break; + width += metrics.advance(byte); + } + return width; +} + +fn simpleMetricsMeasureWidth( + metrics: *const System.SimpleMetrics, + bytes: []const u8, + max_width_px: f32, + end_metric: dvui.Font.EndMetric, +) struct { end: usize, width_px: c_int } { + if (bytes.len == 0) return .{ .end = 0, .width_px = 0 }; + + var width: c_int = 0; + var pos: usize = 0; + while (pos < bytes.len) : (pos += 1) { + const next_width = width + metrics.advance(bytes[pos]); + if (@as(f32, @floatFromInt(next_width)) > max_width_px) { + if (end_metric == .nearest) { + const before_dist = @abs(max_width_px - @as(f32, @floatFromInt(width))); + const after_dist = @abs(@as(f32, @floatFromInt(next_width)) - max_width_px); + if (after_dist < before_dist) { + return .{ .end = pos + 1, .width_px = next_width }; + } + } + return .{ .end = pos, .width_px = width }; + } + width = next_width; + } + return .{ .end = bytes.len, .width_px = width }; +} + +fn simpleCaretPointWithMetrics( + prefix: []const u8, + metrics: *const System.SimpleMetrics, + scale: f32, + wrap_width: ?f32, +) ?dvui.Point { + const line_h = @as(f32, @floatFromInt(@max(1, metrics.height))); + const wrap_px = if (wrap_width) |width| @max(1, @round(width * scale)) else null; + + var y_px: f32 = 0; + var x_px: c_int = 0; + var pos: usize = 0; + while (pos < prefix.len) : (pos += 1) { + const byte = prefix[pos]; + if (byte == '\n') { + x_px = 0; + y_px += line_h; + continue; + } + const advance = metrics.advance(byte); + if (wrap_px) |limit| { + if (x_px > 0 and @as(f32, @floatFromInt(x_px + advance)) > limit) { + x_px = 0; + y_px += line_h; + } + } + x_px += advance; + } + + return .{ + .x = @as(f32, @floatFromInt(x_px)) / scale, + .y = y_px / scale, + }; +} + +fn isSimpleAscii(bytes: []const u8) bool { + for (bytes) |byte| { + if (!isSimpleAsciiByte(byte)) return false; + } + return true; +} + +fn isSimpleAsciiByte(byte: u8) bool { + if (byte >= 0x80) return false; + if (byte >= 0x20) return true; + return byte == '\n' or byte == '\t' or byte == '\r'; +} + +fn simpleStringWidth(font: *c.TTF_Font, bytes: []const u8) ?c_int { + if (bytes.len == 0) return 0; + var width_px: c_int = 0; + if (!c.TTF_GetStringSize(font, bytes.ptr, bytes.len, &width_px, null)) return null; + return width_px; +} + fn boundaryForPoint( substring: c.TTF_SubString, x: c_int, @@ -429,6 +1103,126 @@ fn boundaryForPoint( start; } +fn emojiCompensationBefore( + layout_text: *c.TTF_Text, + bytes: []const u8, + byte_offset: usize, + emoji_advance: f32, +) f32 { + if (!emojiOverlayEnabled()) return 0; + var extra: f32 = 0; + var index: usize = 0; + const offset = @min(byte_offset, bytes.len); + while (nextEmojiCluster(bytes, &index)) |cluster| { + if (cluster.start >= offset) break; + const delta = emojiClusterDeltaPhysical(layout_text, bytes, cluster, emoji_advance); + if (cluster.end <= offset) { + extra += delta; + } else { + extra += delta; + break; + } + } + return extra; +} + +fn compensatedCaretXPhysical( + layout_text: *c.TTF_Text, + bytes: []const u8, + byte_offset: usize, + emoji_advance: f32, +) f32 { + const offset = @min(byte_offset, bytes.len); + return caretXPhysicalAt(layout_text, bytes.len, offset) + + emojiCompensationBefore(layout_text, bytes, offset, emoji_advance); +} + +fn compensatedEndForMaxWidth( + layout_text: *c.TTF_Text, + bytes: []const u8, + max_width_px: f32, + metric: dvui.Font.EndMetric, + emoji_advance: f32, +) usize { + if (bytes.len == 0) return 0; + var previous: usize = 0; + var previous_x: f32 = 0; + var offset: usize = 0; + const limit = @max(0, max_width_px); + + while (offset < bytes.len) { + const next = nextEmojiOrCodepointBoundary(bytes, offset); + if (next <= offset) break; + const next_x = compensatedCaretXPhysical(layout_text, bytes, next, emoji_advance); + if (next_x > limit) { + if (metric == .nearest and next != previous) { + const previous_dist = @abs(limit - previous_x); + const next_dist = @abs(next_x - limit); + return if (next_dist < previous_dist) next else previous; + } + return previous; + } + previous = next; + previous_x = next_x; + offset = next; + } + + return bytes.len; +} + +fn emojiClusterDeltaPhysical( + layout_text: *c.TTF_Text, + bytes: []const u8, + cluster: ByteRange, + emoji_advance: f32, +) f32 { + const raw_start = caretXPhysicalAt(layout_text, bytes.len, cluster.start); + const raw_end = caretXPhysicalAt(layout_text, bytes.len, cluster.end); + const raw_width = @abs(raw_end - raw_start); + return @max(1, emoji_advance) - raw_width; +} + +fn containsEmojiCluster(bytes: []const u8) bool { + var index: usize = 0; + return nextEmojiCluster(bytes, &index) != null; +} + +fn containsCjkBaselineCodepoint(bytes: []const u8) bool { + var index: usize = 0; + while (nextCodepointValue(bytes, &index)) |codepoint| { + if (isCjkBaselineCodepoint(codepoint)) return true; + } + return false; +} + +fn isCjkBaselineCodepoint(codepoint: u21) bool { + return (codepoint >= 0x3400 and codepoint <= 0x4dbf) or // CJK Unified Ideographs Extension A + (codepoint >= 0x4e00 and codepoint <= 0x9fff) or // CJK Unified Ideographs + (codepoint >= 0xf900 and codepoint <= 0xfaff) or // CJK Compatibility Ideographs + (codepoint >= 0x20000 and codepoint <= 0x2ebef) or // CJK extensions + (codepoint >= 0x3040 and codepoint <= 0x309f) or // Hiragana + (codepoint >= 0x30a0 and codepoint <= 0x30ff) or // Katakana + (codepoint >= 0x31f0 and codepoint <= 0x31ff) or // Katakana Phonetic Extensions + (codepoint >= 0x1100 and codepoint <= 0x11ff) or // Hangul Jamo + (codepoint >= 0x3130 and codepoint <= 0x318f) or // Hangul Compatibility Jamo + (codepoint >= 0xac00 and codepoint <= 0xd7af); // Hangul Syllables +} + +fn emojiOverlayEnabled() bool { + return builtin.os.tag == .macos; +} + +fn caretXPhysicalAt(text: *c.TTF_Text, text_len: usize, byte_offset: usize) f32 { + if (byte_offset >= text_len) { + var width_px: c_int = 0; + var height_px: c_int = 0; + if (c.TTF_GetTextSize(text, &width_px, &height_px)) { + return @floatFromInt(@max(0, width_px)); + } + } + return caretXPhysical(text, byte_offset); +} + fn caretXPhysical(text: *c.TTF_Text, byte_offset: usize) f32 { var substring: c.TTF_SubString = undefined; if (!c.TTF_GetTextSubString(text, @intCast(byte_offset), &substring)) return 0; @@ -447,11 +1241,165 @@ fn previousCodepoint(text: []const u8, offset: usize) usize { return result; } +fn previousEmojiOrCodepoint(text: []const u8, offset: usize) usize { + const bounded = @min(offset, text.len); + var index: usize = 0; + while (nextEmojiCluster(text, &index)) |cluster| { + if (cluster.start < bounded and bounded <= cluster.end) return cluster.start; + if (cluster.start >= bounded) break; + } + return previousCodepoint(text, bounded); +} + fn nextCodepoint(text: []const u8, offset: usize) usize { if (offset >= text.len) return text.len; return @min(text.len, offset + (std.unicode.utf8ByteSequenceLength(text[offset]) catch 1)); } +fn nextEmojiOrCodepointBoundary(text: []const u8, offset: usize) usize { + const bounded = @min(offset, text.len); + var index: usize = bounded; + if (nextEmojiCluster(text, &index)) |cluster| { + if (cluster.start == bounded) return cluster.end; + } + return nextCodepoint(text, bounded); +} + +const ByteRange = struct { + start: usize, + end: usize, +}; + +fn nextEmojiCluster(text: []const u8, index: *usize) ?ByteRange { + while (index.* < text.len) { + const start = index.*; + const first = nextCodepointValue(text, index) orelse return null; + if (!isEmojiBase(first)) continue; + + var end = index.*; + if (isRegionalIndicator(first) and index.* < text.len) { + const save = index.*; + if (nextCodepointValue(text, index)) |second| { + if (isRegionalIndicator(second)) { + end = index.*; + return .{ .start = start, .end = end }; + } + } + index.* = save; + } + + var after_zwj = false; + while (index.* < text.len) { + const next_start = index.*; + const codepoint = nextCodepointValue(text, index) orelse { + index.* = next_start; + break; + }; + if (isEmojiClusterPart(codepoint)) { + end = index.*; + after_zwj = codepoint == 0x200d; + continue; + } + if (after_zwj and isEmojiBase(codepoint)) { + end = index.*; + after_zwj = false; + continue; + } + index.* = next_start; + break; + } + return .{ .start = start, .end = end }; + } + return null; +} + +fn nextCodepointValue(text: []const u8, index: *usize) ?u21 { + if (index.* >= text.len) return null; + const len = std.unicode.utf8ByteSequenceLength(text[index.*]) catch return null; + const end = index.* + len; + if (end > text.len) return null; + const codepoint = std.unicode.utf8Decode(text[index.*..end]) catch return null; + index.* = end; + return codepoint; +} + +fn isEmojiBase(codepoint: u21) bool { + return isRegionalIndicator(codepoint) or + (codepoint >= 0x1f000 and codepoint <= 0x1faff) or + (codepoint >= 0x2600 and codepoint <= 0x27bf) or + codepoint == 0x00a9 or + codepoint == 0x00ae or + codepoint == 0x2122 or + codepoint == 0x2139 or + codepoint == 0x3030 or + codepoint == 0x303d or + codepoint == 0x3297 or + codepoint == 0x3299; +} + +fn isEmojiClusterPart(codepoint: u21) bool { + // Keep ZWJ out of the overlay cluster. The macOS offscreen renderer used + // here reliably draws individual emoji but may return blank bitmaps for + // some full ZWJ sequences, so the visual fallback renders their emoji + // components instead of hiding the whole sequence. + return codepoint == 0x20e3 or + isVariationSelector(codepoint) or + isEmojiModifier(codepoint) or + isCombiningMark(codepoint); +} + +fn isRegionalIndicator(codepoint: u21) bool { + return codepoint >= 0x1f1e6 and codepoint <= 0x1f1ff; +} + +fn isVariationSelector(codepoint: u21) bool { + return (codepoint >= 0xfe00 and codepoint <= 0xfe0f) or + (codepoint >= 0xe0100 and codepoint <= 0xe01ef); +} + +fn isEmojiModifier(codepoint: u21) bool { + return codepoint >= 0x1f3fb and codepoint <= 0x1f3ff; +} + +fn isCombiningMark(codepoint: u21) bool { + return (codepoint >= 0x0300 and codepoint <= 0x036f) or + (codepoint >= 0x1ab0 and codepoint <= 0x1aff) or + (codepoint >= 0x1dc0 and codepoint <= 0x1dff) or + (codepoint >= 0x20d0 and codepoint <= 0x20ff) or + (codepoint >= 0xfe20 and codepoint <= 0xfe2f); +} + +const NativeEmojiBitmap = extern struct { + pixels: *anyopaque, + width: c_int, + height: c_int, + stride: c_int, + + fn deinit(self: NativeEmojiBitmap) void { + shellowo_free_emoji_bitmap(self.pixels); + } +}; + +fn loadNativeEmojiBitmap(cluster: []const u8, physical_size: f32) ?NativeEmojiBitmap { + if (builtin.os.tag != .macos) return null; + var bitmap: NativeEmojiBitmap = undefined; + if (shellowo_render_emoji_bitmap(cluster.ptr, cluster.len, @floatCast(physical_size), &bitmap) == 0) return null; + if (bitmap.width <= 0 or bitmap.height <= 0 or bitmap.stride <= 0) { + bitmap.deinit(); + return null; + } + return bitmap; +} + +extern fn shellowo_render_emoji_bitmap( + utf8: [*]const u8, + utf8_len: usize, + point_size: f64, + out_bitmap: *NativeEmojiBitmap, +) callconv(.c) c_int; + +extern fn shellowo_free_emoji_bitmap(pixels: *anyopaque) callconv(.c) void; + test "fallback boundaries preserve UTF-8 codepoints" { const text = "a你🙂z"; try std.testing.expectEqual(@as(usize, 1), previousCodepoint(text, 4)); @@ -460,6 +1408,30 @@ test "fallback boundaries preserve UTF-8 codepoints" { try std.testing.expectEqual(@as(usize, 8), nextCodepoint(text, 4)); } +test "emoji scanner groups modifiers and ZWJ sequences" { + const text = "a👍🏽 b👨‍👩‍👧‍👦 c🇨🇳"; + var index: usize = 0; + const first = nextEmojiCluster(text, &index).?; + try std.testing.expectEqualStrings("👍🏽", text[first.start..first.end]); + const second = nextEmojiCluster(text, &index).?; + try std.testing.expectEqualStrings("👨", text[second.start..second.end]); + const third = nextEmojiCluster(text, &index).?; + try std.testing.expectEqualStrings("👩", text[third.start..third.end]); + const fourth = nextEmojiCluster(text, &index).?; + try std.testing.expectEqualStrings("👧", text[fourth.start..fourth.end]); + const fifth = nextEmojiCluster(text, &index).?; + try std.testing.expectEqualStrings("👦", text[fifth.start..fifth.end]); + const sixth = nextEmojiCluster(text, &index).?; + try std.testing.expectEqualStrings("🇨🇳", text[sixth.start..sixth.end]); + try std.testing.expect(nextEmojiCluster(text, &index) == null); +} + +test "emoji cluster previous boundary keeps modifiers with base emoji" { + const text = "a😊b👍🏽c"; + try std.testing.expectEqual(@as(usize, 1), previousEmojiOrCodepoint(text, 5)); + try std.testing.expectEqual(@as(usize, 6), previousEmojiOrCodepoint(text, 14)); +} + test "firstLine reports the consumed newline" { const line = firstLine("abc\n下一行"); try std.testing.expectEqualStrings("abc", line.bytes); diff --git a/src/ui/features/app_shell/screen.zig b/src/ui/features/app_shell/screen.zig index 51a7d26..309b271 100644 --- a/src/ui/features/app_shell/screen.zig +++ b/src/ui/features/app_shell/screen.zig @@ -53,7 +53,7 @@ const TopBarState = struct { }; const theme_switch_anim_ns: i128 = 180 * std.time.ns_per_ms; -const connection_tab_font_size: f32 = 9.5; +const connection_tab_font_size: f32 = 12.5; const connection_tab_horizontal_padding: f32 = 6; const connection_tab_status_size: f32 = 6; const connection_tab_status_gap: f32 = 7; @@ -149,7 +149,7 @@ fn topBar(app: *App, palette: theme.Palette) void { spacer(@src(), 8, 91); } - const title_button_width = @ceil(theme.textFont("Shellowo", 10).textSize("Shellowo").w) + 14; + const title_button_width = @ceil(theme.textFont("Shellowo", 13).textSize("Shellowo").w) + 14; var home_slot = dvui.box(@src(), .{}, .{ .gravity_y = 0.5, .min_size_content = .{ .w = title_button_width, .h = button_height }, @@ -164,7 +164,7 @@ fn topBar(app: *App, palette: theme.Palette) void { .margin = .all(0), .id_extra = 1, }, palette, .{ - .font_size = 10, + .font_size = 13, }); home_slot.deinit(); if (home_clicked) { @@ -309,7 +309,7 @@ fn tabOverflowIndicator(state: *TopBarState, height: f32, palette: theme.Palette .color_text = if (can_scroll_right) palette.muted_text else palette.text_subtle.opacity(0.45), .id_extra = 106, }, palette, .{ - .font_size = 8.5, + .font_size = 11.5, }); slot.deinit(); if (clicked and can_scroll_right) { @@ -597,7 +597,7 @@ fn predictionModeButton(app: *App, label: []const u8, mode: predictive.Predictio .variant = if (active) .solid else .ghost, .intent = if (active) .primary else .neutral, .state = if (active) .selected else .normal, - .font_size = 10, + .font_size = 13, })) { app.setTerminalPredictionMode(mode); } @@ -667,7 +667,7 @@ fn masterPasswordToggleSwitch(app: *App, state: *TopBarState, palette: theme.Pal .margin = .all(0), .corner_radius = .all(switch_h / 2), .id_extra = id_extra, - }, palette, .{ .variant = .ghost, .font_size = 9 }, .{ + }, palette, .{ .variant = .ghost, .font_size = 12 }, .{ .interactive = false, .override = .{ .color_fill = dvui.Color.transparent, @@ -749,7 +749,7 @@ fn folderPathButton(palette: theme.Palette, id_extra: usize) bool { .corner_radius = .all(4), .id_extra = id_extra, .margin = .all(0), - }, palette, .{ .variant = .ghost, .font_size = 9 }, .{ + }, palette, .{ .variant = .ghost, .font_size = 12 }, .{ .override = .{ .color_fill = palette.surface_hover }, }); bw.processEvents(); @@ -772,7 +772,7 @@ fn folderPathButton(palette: theme.Palette, id_extra: usize) bool { fn settingLabel(text: []const u8, width: f32, palette: theme.Palette, id_extra: usize) void { dvui.label(@src(), "{s}:", .{text}, .{ - .font = theme.textFont(text, 11), + .font = theme.textFont(text, 14), .color_text = palette.text, .gravity_y = 0.5, .min_size_content = .width(width), @@ -797,7 +797,7 @@ fn themeToggleSwitch(app: *App, state: *TopBarState, palette: theme.Palette, id_ .margin = .all(0), .corner_radius = .all(switch_h / 2), .id_extra = id_extra, - }, palette, .{ .variant = .ghost, .font_size = 9 }, .{ + }, palette, .{ .variant = .ghost, .font_size = 12 }, .{ .interactive = false, .override = .{ .color_fill = dvui.Color.transparent, @@ -927,7 +927,7 @@ fn pathValue(path: []const u8, width: f32, palette: theme.Palette, id_extra: usi const crs = slot.data().contentRectScale(); var path_buf: [160]u8 = undefined; - const font = theme.textFont(path, 9); + const font = theme.textFont(path, 12); const display_path = foldedPathForWidth(path, &path_buf, width - 8, font); const text_size = font.textSize(display_path); const text_height = text_size.h * crs.s; @@ -1111,7 +1111,7 @@ fn tabTitleWidth(title: []const u8, font_size: f32) f32 { fn workspaceTabSeparator(palette: theme.Palette) void { dvui.label(@src(), "›", .{}, .{ - .font = theme.textFont("›", 10), + .font = theme.textFont("›", 13), .color_text = palette.text_subtle, .gravity_y = 0.5, .padding = .all(0), @@ -1228,7 +1228,7 @@ fn windowClosePrompt(app: *App, palette: theme.Palette) void { .expand = .both, .gravity_x = 0.5, .gravity_y = 0.5, - .font = theme.textFont("!", 10), + .font = theme.textFont("!", 13), .color_text = palette.danger, .padding = .{ .x = 6, .y = 2.5 }, .margin = .all(0), @@ -1238,7 +1238,7 @@ fn windowClosePrompt(app: *App, palette: theme.Palette) void { dvui.label(@src(), "Quit Shellowo?", .{}, .{ .gravity_y = 0.5, - .font = theme.textFont("Quit Shellowo?", 12), + .font = theme.textFont("Quit Shellowo?", 15), .color_text = palette.text, .padding = .all(0), .margin = .all(0), @@ -1247,7 +1247,7 @@ fn windowClosePrompt(app: *App, palette: theme.Palette) void { heading.deinit(); dvui.label(@src(), "Active work will be interrupted.", .{}, .{ - .font = theme.textFont("Active work will be interrupted.", 9), + .font = theme.textFont("Active work will be interrupted.", 12), .color_text = palette.muted_text, .margin = .{ .y = 7, .h = 8 }, .padding = .all(0), @@ -1267,7 +1267,7 @@ fn windowClosePrompt(app: *App, palette: theme.Palette) void { .max_size_content = .height(22), .margin = .{ .x = 3 }, .id_extra = 930_051, - }, palette, .{ .variant = .ghost, .font_size = 9.5 })) { + }, palette, .{ .variant = .ghost, .font_size = 12.5 })) { app.cancelWindowClose(); } if (theme.button(@src(), "Quit", .{ @@ -1275,52 +1275,11 @@ fn windowClosePrompt(app: *App, palette: theme.Palette) void { .max_size_content = .height(22), .margin = .{ .x = 3 }, .id_extra = 930_052, - }, palette, .{ .variant = .solid, .intent = .danger, .font_size = 9.5 })) { + }, palette, .{ .variant = .solid, .intent = .danger, .font_size = 12.5 })) { app.confirmWindowClose(); } } -fn closeBlockerRow(label: []const u8, count: usize, palette: theme.Palette, id_extra: usize) void { - if (count == 0) return; - var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .horizontal, - .min_size_content = .height(21), - .max_size_content = .height(21), - .padding = .all(0), - .margin = .all(0), - .id_extra = id_extra, - }); - defer row.deinit(); - - dvui.label(@src(), "•", .{}, .{ - .min_size_content = .width(14), - .max_size_content = .width(14), - .gravity_y = 0.5, - .font = theme.textFont("•", 9), - .color_text = palette.warning, - .padding = .all(0), - .margin = .all(0), - .id_extra = id_extra + 1, - }); - dvui.label(@src(), "{s}", .{label}, .{ - .gravity_y = 0.5, - .font = theme.textFont(label, 9.5), - .color_text = palette.muted_text, - .expand = .horizontal, - .padding = .all(0), - .margin = .all(0), - .id_extra = id_extra + 2, - }); - dvui.label(@src(), "{d}", .{count}, .{ - .gravity_y = 0.5, - .font = theme.textFont("0", 9.5), - .color_text = palette.text, - .padding = .all(0), - .margin = .all(0), - .id_extra = id_extra + 3, - }); -} - fn handleWindowClosePromptKeys(app: *App, data: *dvui.WidgetData) void { for (dvui.events()) |*event| { if (event.handled or event.evt != .key) continue; @@ -1364,7 +1323,7 @@ fn hostKeyPrompt(app: *App, palette: theme.Palette) void { defer panel.deinit(); dvui.label(@src(), "Trust SSH Host Key", .{}, .{ - .font = theme.textFont("Trust SSH Host Key", 14), + .font = theme.textFont("Trust SSH Host Key", 17), .color_text = palette.text, .margin = .{ .h = 3 }, .id_extra = 920_002, @@ -1392,7 +1351,7 @@ fn hostKeyPrompt(app: *App, palette: theme.Palette) void { .id_extra = 920_061, }, palette, .{ .variant = .ghost, - .font_size = 11, + .font_size = 14, })) { app.rejectPendingHostKey(); } @@ -1402,7 +1361,7 @@ fn hostKeyPrompt(app: *App, palette: theme.Palette) void { .id_extra = 920_062, }, palette, .{ .variant = .ghost, - .font_size = 11, + .font_size = 14, })) { app.trustPendingHostKey(); } @@ -1420,7 +1379,7 @@ fn hostKeyRow(label: []const u8, value: []const u8, palette: theme.Palette, id_e dvui.label(@src(), "{s}", .{label}, .{ .min_size_content = .width(78), - .font = theme.textFont(label, 10), + .font = theme.textFont(label, 13), .color_text = palette.muted_text, .id_extra = id_extra + 1, .margin = .all(0), @@ -1428,7 +1387,7 @@ fn hostKeyRow(label: []const u8, value: []const u8, palette: theme.Palette, id_e }); dvui.label(@src(), "{s}", .{value}, .{ .expand = .horizontal, - .font = theme.textFont(value, 10), + .font = theme.textFont(value, 13), .color_text = palette.text, .id_extra = id_extra + 2, .margin = .all(0), @@ -1457,14 +1416,14 @@ fn homeStage(app: *App, palette: theme.Palette) void { defer column.deinit(); dvui.label(@src(), "Shellowo", .{}, .{ - .font = theme.textFont("Shellowo", 24), + .font = theme.textFont("Shellowo", 27), .color_text = palette.text, .gravity_x = 0.5, .margin = .{ .h = 4 }, }); dvui.label(@src(), "Remote workspace for who loves owo", .{}, .{ - .font = theme.textFont("Remote workspace for who loves owo", 12), + .font = theme.textFont("Remote workspace for who loves owo", 15), .color_text = palette.muted_text, .gravity_x = 0.5, .margin = .{ .h = 28 }, @@ -1479,7 +1438,7 @@ fn homeStage(app: *App, palette: theme.Palette) void { .corner_radius = .all(5), .margin = .{ .y = 4 }, .id_extra = 401, - }, palette, .{ .variant = .row, .font_size = 12 })) { + }, palette, .{ .variant = .row, .font_size = 15 })) { app.newProfile(); } @@ -1527,7 +1486,7 @@ fn connectionsHeader(app: *App, palette: theme.Palette) void { defer row.deinit(); dvui.label(@src(), "CONNECTIONS", .{}, .{ - .font = theme.textFont("CONNECTIONS", 10), + .font = theme.textFont("CONNECTIONS", 13), .color_text = palette.text_subtle, .gravity_y = 0.5, .id_extra = 412, @@ -1542,7 +1501,7 @@ fn searchBox(app: *App, palette: theme.Palette) void { .gravity_y = 0.5, .min_size_content = .{ .w = 120, .h = 20 }, .max_size_content = .{ .w = 120, .h = 20 }, - .font = theme.cjkFont(10), + .font = theme.cjkFont(13), .corner_radius = .all(5), .padding = .{ .x = 8, .y = 2, .w = 8, .h = 2 }, .id_extra = 405, @@ -1582,7 +1541,7 @@ fn groupedConnectionList(app: *App, palette: theme.Palette) void { if (!rendered_any) { dvui.label(@src(), "No connections found", .{}, .{ - .font = theme.textFont("No connections found", 12), + .font = theme.textFont("No connections found", 15), .color_text = palette.text_subtle, .gravity_x = 0.5, .margin = .{ .y = 16 }, @@ -1593,7 +1552,7 @@ fn groupedConnectionList(app: *App, palette: theme.Palette) void { fn sectionLabel(label: []const u8, palette: theme.Palette, id_extra: usize) void { dvui.label(@src(), "{s}", .{label}, .{ - .font = theme.textFont(label, 10), + .font = theme.textFont(label, 13), .color_text = palette.text_subtle, .margin = .{ .y = 20, .h = 5 }, .id_extra = id_extra, @@ -1613,7 +1572,7 @@ fn groupHeader(app: *App, group: []const u8, group_idx: usize, count: usize, exp .id_extra = 50_000 + group_idx, }, palette, .{ .variant = .ghost, - .font_size = 11, + .font_size = 14, .text_align_x = 0.0, })) { app.toggleGroup(group); diff --git a/src/ui/features/profiles/config_panel.zig b/src/ui/features/profiles/config_panel.zig index 9932356..6cf8458 100644 --- a/src/ui/features/profiles/config_panel.zig +++ b/src/ui/features/profiles/config_panel.zig @@ -9,8 +9,6 @@ const header_height: f32 = 36; const footer_height: f32 = 44; const separator_height: f32 = 1; const min_content_height: f32 = 120; -const form_font_size: f32 = 11; -const control_font_size: f32 = 11; const field_height: f32 = 20; pub fn show(app: *App, palette: theme.Palette) void { @@ -58,7 +56,7 @@ fn header(palette: theme.Palette) void { defer box.deinit(); dvui.label(@src(), "Configuration", .{}, .{ - .font = theme.cjkFont(13), + .font = theme.cjkFont(16), .color_text = palette.text, .gravity_y = 0.5, .id_extra = 50_011, @@ -127,7 +125,7 @@ fn form(app: *App, palette: theme.Palette, content_height: f32) void { _ = theme.checkbox(@src(), &app.draft.sftp_enabled, "Enable SFTP", palette, .{ .id_extra = 50_080, - .font_size = form_font_size, + .font_size = 14, }); } @@ -141,7 +139,7 @@ fn authSelector(app: *App, palette: theme.Palette) void { dvui.label(@src(), "Authentication", .{}, .{ .color_text = palette.muted_text, - .font = theme.textFont("Authentication", form_font_size), + .font = theme.textFont("Authentication", 14), .id_extra = 50_066, }); @@ -164,7 +162,7 @@ fn authButton(app: *App, auth_type: profile.AuthType, id_extra: usize, palette: .intent = if (selected) .primary else .neutral, .state = if (selected) .selected else .normal, .variant = if (selected) .solid else .ghost, - .font_size = control_font_size, + .font_size = 14, })) { app.draft.auth_type = auth_type; } @@ -180,7 +178,7 @@ fn privateKeyPathField(app: *App, palette: theme.Palette) void { dvui.label(@src(), "Private Key Path", .{}, .{ .color_text = palette.muted_text, - .font = theme.textFont("Private Key Path", form_font_size), + .font = theme.textFont("Private Key Path", 14), .id_extra = 50_073, }); @@ -197,7 +195,7 @@ fn privateKeyPathField(app: *App, palette: theme.Palette) void { .gravity_y = 0.5, .min_size_content = .height(field_height), .max_size_content = .height(field_height), - .font = theme.cjkFont(form_font_size), + .font = theme.cjkFont(14), .corner_radius = .all(5), .id_extra = 50_076, }, palette).override(.{ @@ -211,7 +209,7 @@ fn privateKeyPathField(app: *App, palette: theme.Palette) void { .min_size_content = .{ .w = 70, .h = field_height }, .margin = .{ .x = 6 }, .id_extra = 50_078, - }, palette, .{ .variant = .ghost, .font_size = control_font_size })) { + }, palette, .{ .variant = .ghost, .font_size = 14 })) { const selected = dvui.dialogNativeFileOpen(dvui.currentWindow().arena(), .{ .title = "Select Private Key" }) catch null; if (selected) |path| profile.setBuffer(&app.draft.private_key_path, path); } @@ -220,7 +218,7 @@ fn privateKeyPathField(app: *App, palette: theme.Palette) void { fn authNotice(message: []const u8, id_extra: usize, palette: theme.Palette) void { dvui.label(@src(), "{s}", .{message}, .{ .color_text = palette.muted_text, - .font = theme.textFont(message, form_font_size), + .font = theme.textFont(message, 14), .margin = .{ .y = 4, .h = 6 }, .id_extra = id_extra, }); @@ -252,7 +250,7 @@ fn footer(app: *App, palette: theme.Palette) void { .id_extra = 50_092, .gravity_y = 0.5, .padding = .{ .y = 1, .h = 0 }, - }, palette, .{ .intent = .danger, .variant = .ghost, .font_size = control_font_size })) { + }, palette, .{ .intent = .danger, .variant = .ghost, .font_size = 14 })) { app.deleteSelectedProfile(); } if (theme.button(@src(), "Save", .{ @@ -261,7 +259,7 @@ fn footer(app: *App, palette: theme.Palette) void { .id_extra = 50_093, .gravity_y = 0.5, .padding = .{ .y = 1, .h = 0 }, - }, palette, .{ .intent = .primary, .variant = .ghost, .font_size = control_font_size })) { + }, palette, .{ .intent = .primary, .variant = .ghost, .font_size = 14 })) { app.saveDraft(); } if (theme.button(@src(), "Cancel", .{ @@ -270,7 +268,7 @@ fn footer(app: *App, palette: theme.Palette) void { .id_extra = 50_094, .gravity_y = 0.5, .padding = .{ .y = 1, .h = 0 }, - }, palette, .{ .variant = .ghost, .font_size = control_font_size })) { + }, palette, .{ .variant = .ghost, .font_size = 14 })) { app.cancelConfig(); } } @@ -284,7 +282,7 @@ fn textFieldSized(label: []const u8, buffer: []u8, id_extra: usize, palette: the .id_extra = id_extra, .expand = expand, .field_height = field_height, - .font_size = form_font_size, + .font_size = 14, }); } @@ -298,7 +296,7 @@ fn portField(app: *App, palette: theme.Palette) void { dvui.label(@src(), "Port", .{}, .{ .color_text = palette.muted_text, - .font = theme.textFont("Port", form_font_size), + .font = theme.textFont("Port", 14), .id_extra = 50_046, }); var port_i32: i32 = app.draft.port; @@ -309,7 +307,7 @@ fn portField(app: *App, palette: theme.Palette) void { }, theme.panel(.{ .expand = .horizontal, .min_size_content = .height(field_height), - .font = theme.textFont("22", form_font_size), + .font = theme.textFont("22", 14), .corner_radius = .all(5), .id_extra = 50_047, }, palette).override(.{ diff --git a/src/ui/features/security/master_password_popup.zig b/src/ui/features/security/master_password_popup.zig index 6dc55e2..ef0e673 100644 --- a/src/ui/features/security/master_password_popup.zig +++ b/src/ui/features/security/master_password_popup.zig @@ -50,7 +50,7 @@ pub fn show(app: *App, mode: Mode, palette: theme.Palette, id_extra: usize) Acti const title = if (disable_mode) "Disable Master Password" else "Enable Master Password"; dvui.label(@src(), "{s}", .{title}, .{ - .font = theme.textFont(title, 14), + .font = theme.textFont(title, 17), .color_text = palette.text, .margin = .{ .h = 8 }, .id_extra = id_extra + 1, @@ -122,7 +122,7 @@ fn fieldLabel(label: []const u8, palette: theme.Palette, id_extra: usize) void { defer slot.deinit(); dvui.label(@src(), "{s}", .{label}, .{ - .font = theme.textFont(label, 10), + .font = theme.textFont(label, 13), .color_text = palette.muted_text, .gravity_y = 0.5, .id_extra = id_extra + 1, @@ -138,7 +138,7 @@ fn passwordEntry(buffer: []u8, width: f32, palette: theme.Palette, id_extra: usi .gravity_y = 0.5, .min_size_content = .{ .w = @max(80, width), .h = field_height }, .max_size_content = .{ .w = @max(80, width), .h = field_height }, - .font = theme.textFont("password", 10), + .font = theme.textFont("password", 13), .corner_radius = .all(5), .padding = .{ .x = 8, .y = 4, .w = 8, .h = 0 }, .margin = .all(0), diff --git a/src/ui/features/security/unlock_screen.zig b/src/ui/features/security/unlock_screen.zig index b4427cc..83a4c3f 100644 --- a/src/ui/features/security/unlock_screen.zig +++ b/src/ui/features/security/unlock_screen.zig @@ -41,14 +41,14 @@ pub fn show(app: *App, palette: theme.Palette) void { defer content.deinit(); dvui.label(@src(), "Shellowo", .{}, .{ - .font = theme.textFont("Shellowo", 17), + .font = theme.textFont("Shellowo", 20), .color_text = palette.text, .margin = .{ .h = 8 }, .id_extra = 930_002, }); dvui.label(@src(), "Password:", .{}, .{ - .font = theme.textFont("Password:", 10), + .font = theme.textFont("Password:", 13), .color_text = palette.muted_text, .margin = .{ .h = 4 }, .id_extra = 930_003, @@ -87,7 +87,7 @@ fn passwordEntry(buffer: []u8, width: f32, palette: theme.Palette, id_extra: usi }, theme.panel(.{ .min_size_content = .{ .w = width, .h = field_height }, .max_size_content = .{ .w = width, .h = field_height }, - .font = theme.textFont("password", 10), + .font = theme.textFont("password", 13), .corner_radius = .all(5), .padding = .{ .x = 8, .y = 5, .w = 8, .h = 0 }, .id_extra = id_extra, diff --git a/src/ui/features/terminal/metrics.zig b/src/ui/features/terminal/metrics.zig index 7f0025d..a9f3bfb 100644 --- a/src/ui/features/terminal/metrics.zig +++ b/src/ui/features/terminal/metrics.zig @@ -3,7 +3,7 @@ const std = @import("std"); const terminal = @import("../../../contracts/terminal_emulator.zig"); const theme = @import("../../theme.zig"); -pub const font_size: f32 = 10; +pub const font_size: f32 = 13; pub const default_line_height: f32 = 18; pub const default_cjk_baseline_lift: f32 = 2; pub const cursor_underline_height: f32 = 2; diff --git a/src/ui/foundation/typography.zig b/src/ui/foundation/typography.zig index fd4d48e..c0a644c 100644 --- a/src/ui/foundation/typography.zig +++ b/src/ui/foundation/typography.zig @@ -4,12 +4,12 @@ pub const zed_font_family = "Zed Mono Extended"; pub const cjk_font_family = "Noto Sans CJK SC"; pub const FontSizes = struct { - body: f32 = 14, - heading: f32 = 15, - title: f32 = 22, - control: f32 = 11, - tab: f32 = 11, - close: f32 = 10, + body: f32 = 17, + heading: f32 = 18, + title: f32 = 25, + control: f32 = 14, + tab: f32 = 14, + close: f32 = 13, }; pub const font_sizes: FontSizes = .{}; diff --git a/src/ui/widgets/text_field.zig b/src/ui/widgets/text_field.zig index 3a6df80..84a9cdc 100644 --- a/src/ui/widgets/text_field.zig +++ b/src/ui/widgets/text_field.zig @@ -9,7 +9,7 @@ pub const Options = struct { id_extra: usize, expand: bool = true, field_height: f32 = metrics.defaults.control_height, - font_size: f32 = 12, + font_size: f32 = 15, }; pub const Entry = struct { diff --git a/src/ui/workspace/file_panel.zig b/src/ui/workspace/file_panel.zig index df49a15..0c916c1 100644 --- a/src/ui/workspace/file_panel.zig +++ b/src/ui/workspace/file_panel.zig @@ -169,7 +169,7 @@ fn pathBar( .expand = .horizontal, .min_size_content = .height(toolbar_height - 8), .max_size_content = .height(toolbar_height - 8), - .font = theme.textFont(remote.path, 10), + .font = theme.textFont(remote.path, 13), .padding = .{ .x = 6, .y = 2.5, .w = 6, .h = 0 }, .corner_radius = .all(3), .id_extra = id_extra + 1, @@ -229,7 +229,7 @@ fn pathBar( } path_slot.drawBackground(); dvui.label(@src(), "{s}", .{remote.path}, .{ - .font = theme.textFont(remote.path, 10), + .font = theme.textFont(remote.path, 13), .color_text = palette.muted_text, .expand = .horizontal, .gravity_y = 0.5, @@ -527,7 +527,7 @@ fn treeRow(snapshot: remote_file.FileTreeSnapshot, entry: remote_file.RemoteFile } fn treeRowWidth(entry: remote_file.RemoteFileEntry) f32 { - const font = theme.textFont(entry.name, 10); + const font = theme.textFont(entry.name, 13); const text_width = font.textSize(entry.name).w; const indent = @as(f32, @floatFromInt(entry.depth)) * tree_indent + 8; const right_padding: f32 = 8; @@ -571,7 +571,7 @@ fn fileRow(app: *App, snapshot: remote_file.FilePaneSnapshot, entry: remote_file .margin = .all(0), .corner_radius = .all(0), .id_extra = id_extra, - }, palette, .{ .variant = .row, .font_size = 10, .text_align_x = 0 }, .{ + }, palette, .{ .variant = .row, .font_size = 13, .text_align_x = 0 }, .{ .override = .{ .color_fill = if (selected) palette.surface_active else palette.panel_bg, .color_border = palette.border_subtle, @@ -686,7 +686,7 @@ fn editFileRow(snapshot: remote_file.FilePaneSnapshot, layout: *PaneLayoutState, te.init(@src(), .{ .text = .{ .buffer = &layout.edit_buffer } }, theme.panel(.{ .expand = .horizontal, .min_size_content = .height(row_height - 4), - .font = theme.textFont("filename", 10), + .font = theme.textFont("filename", 13), .padding = .{ .x = 4, .y = 0, .w = 4, .h = 0 }, .corner_radius = .all(0), .id_extra = id_extra + 3, @@ -915,7 +915,7 @@ fn renderDropTooltip(app: *const App, snapshot: remote_file.FilePaneSnapshot, pa defer tooltip.deinit(); dvui.label(@src(), "Upload", .{}, .{ - .font = theme.textFont("Upload", 9), + .font = theme.textFont("Upload", 12), .color_text = palette.text, .gravity_y = 0.5, .id_extra = id_extra + 1, @@ -1057,7 +1057,7 @@ fn failureTooltip(layout: *PaneLayoutState, palette: theme.Palette, id_extra: us dvui.refresh(null, @src(), dvui.currentWindow().data().id.update("file_failure_tooltip")); const message = layout.toastMessage(); - const font = theme.textFont(message, 9); + const font = theme.textFont(message, 12); const text_size = font.textSize(message); const natural_mouse = dvui.windowRectScale().pointFromPhysical(dvui.currentWindow().mouse_pt); var rect = dvui.Rect.Natural{ @@ -1131,7 +1131,7 @@ fn headerCell(text: []const u8, width: f32, palette: theme.Palette, id_extra: us const horizontal_padding: f32 = 16; const content_width = @max(0, width - horizontal_padding); dvui.label(@src(), "{s}", .{text}, .{ - .font = theme.textFont(text, 9), + .font = theme.textFont(text, 12), .color_text = palette.text_subtle, .min_size_content = .width(content_width), .max_size_content = .width(content_width), @@ -1164,7 +1164,7 @@ fn drawColumnSeparator(crs: dvui.RectScale, x: f32, palette: theme.Palette) void fn emptyRow(text: []const u8, palette: theme.Palette, id_extra: usize) void { dvui.label(@src(), "{s}", .{text}, .{ - .font = theme.textFont(text, 10), + .font = theme.textFont(text, 13), .color_text = palette.muted_text, .expand = .horizontal, .min_size_content = .height(row_height), @@ -1265,7 +1265,7 @@ fn renderNameCell(crs: dvui.RectScale, x_offset: f32, width: f32, entry: remote_ const old_clip = dvui.clip(text_rect); defer dvui.clipSet(old_clip); - const font = theme.textFont(entry.name, 10); + const font = theme.textFont(entry.name, 13); const text_size = font.textSize(entry.name); dvui.renderText(.{ .font = font, @@ -1299,7 +1299,7 @@ fn renderTreeEntry(crs: dvui.RectScale, entry: remote_file.RemoteFileEntry, pale .h = crs.r.h, }; const glyph = if (entry.isRootDirectory() or entry.expanded) "v" else ">"; - const disclosure_font = theme.textFont(glyph, 9); + const disclosure_font = theme.textFont(glyph, 12); const disclosure_size = disclosure_font.textSize(glyph); dvui.renderText(.{ .font = disclosure_font, @@ -1323,7 +1323,7 @@ fn renderTreeEntry(crs: dvui.RectScale, entry: remote_file.RemoteFileEntry, pale const old_clip = dvui.clip(text_rect); defer dvui.clipSet(old_clip); - const font = theme.textFont(entry.name, 10); + const font = theme.textFont(entry.name, 13); const text_size = font.textSize(entry.name); dvui.renderText(.{ .font = font, @@ -1358,7 +1358,7 @@ fn renderCellText(crs: dvui.RectScale, x_offset: f32, width: f32, text: []const const old_clip = dvui.clip(text_rect); defer dvui.clipSet(old_clip); - const font = theme.textFont(text, 9); + const font = theme.textFont(text, 12); const text_size = font.textSize(text); dvui.renderText(.{ .font = font, @@ -1373,7 +1373,7 @@ fn renderCellText(crs: dvui.RectScale, x_offset: f32, width: f32, text: []const } fn maybeNameTooltip(name: []const u8, crs: dvui.RectScale, x_offset: f32, width: f32) void { - const font = theme.textFont(name, 10); + const font = theme.textFont(name, 13); const available = @max(0, width - 16); if (font.textSize(name).w <= available) return; const active_rect = dvui.Rect.Physical{ diff --git a/src/ui/workspace/file_panel_elements/active_tasks_panel.zig b/src/ui/workspace/file_panel_elements/active_tasks_panel.zig index 9e41ec4..a990057 100644 --- a/src/ui/workspace/file_panel_elements/active_tasks_panel.zig +++ b/src/ui/workspace/file_panel_elements/active_tasks_panel.zig @@ -54,7 +54,7 @@ pub fn showButton(app: *App, panel_bounds: dvui.Rect.Natural, palette: theme.Pal .margin = .all(0), .corner_radius = .all(0), .id_extra = id_extra + 1, - }, palette, .{ .variant = .ghost, .font_size = 10, .text_align_x = 0.5 }); + }, palette, .{ .variant = .ghost, .font_size = 13, .text_align_x = 0.5 }); button_box.deinit(); state.popup_anchor = button_rect.toNatural(); @@ -95,7 +95,7 @@ fn popup(app: *App, button_rect: dvui.Rect.Physical, palette: theme.Palette, sta defer win.deinit(); dvui.label(@src(), "Transfers", .{}, .{ - .font = theme.textFont("Transfers", 9), + .font = theme.textFont("Transfers", 12), .color_text = palette.text_subtle, .min_size_content = .height(22), .padding = .{ .x = 2, .y = 1, .w = 2, .h = 1 }, @@ -179,7 +179,7 @@ fn popupRect(anchor: dvui.Rect.Natural, panel_bounds: dvui.Rect.Natural, tasks: fn popupWidth(tasks: []const transfer.TransferTask) f32 { var width = popup_min_width; for (tasks) |task| { - const measured = theme.textFont(task.title, 9).textSize(task.title).w; + const measured = theme.textFont(task.title, 12).textSize(task.title).w; const title_width = measured + 48; width = @max(width, title_width); } @@ -196,7 +196,7 @@ fn popupContentHeight(tasks: []const transfer.TransferTask, width: f32) f32 { fn taskRowHeight(task: transfer.TransferTask, width: f32) f32 { const title_height = wrappedTextHeight( task.title, - theme.textFont(task.title, 9), + theme.textFont(task.title, 12), taskTitleWidth(task, width), task_title_height, ); @@ -204,7 +204,7 @@ fn taskRowHeight(task: transfer.TransferTask, width: f32) f32 { const error_height = if (task.errorSummary()) |error_summary| wrappedTextHeight( error_summary, - theme.textFont(error_summary, 9), + theme.textFont(error_summary, 12), taskContentWidth(width), task_error_height, ) @@ -262,7 +262,7 @@ fn wrappedTextHeight(text: []const u8, font: dvui.Font, max_width: f32, min_heig fn emptyRow(palette: theme.Palette, id_extra: usize) void { dvui.label(@src(), "No active tasks", .{}, .{ - .font = theme.textFont("No active tasks", 9), + .font = theme.textFont("No active tasks", 12), .color_text = palette.muted_text, .min_size_content = .{ .w = 240, .h = 26 }, .gravity_y = 0.5, @@ -275,7 +275,7 @@ fn taskRow(task: transfer.TransferTask, width: f32, palette: theme.Palette, id_e const row_height = taskRowHeight(task, width); const title_height = wrappedTextHeight( task.title, - theme.textFont(task.title, 9), + theme.textFont(task.title, 12), taskTitleWidth(task, width), task_title_height, ); @@ -307,7 +307,7 @@ fn taskRow(task: transfer.TransferTask, width: f32, palette: theme.Palette, id_e defer title_line.deinit(); const title = task.title; - const title_font = theme.textFont(title, 9); + const title_font = theme.textFont(title, 12); var title_layout = dvui.textLayout(@src(), .{ .break_lines = true }, .{ .font = title_font, .color_text = palette.text, @@ -369,7 +369,7 @@ fn taskRow(task: transfer.TransferTask, width: f32, palette: theme.Palette, id_e defer status_line.deinit(); dvui.label(@src(), "{s}", .{status_text}, .{ - .font = theme.textFont(status_text, 9), + .font = theme.textFont(status_text, 12), .color_text = palette.muted_text, .expand = .horizontal, .min_size_content = .height(task_status_height), @@ -380,7 +380,7 @@ fn taskRow(task: transfer.TransferTask, width: f32, palette: theme.Palette, id_e } if (task.errorSummary()) |error_summary| { - const error_font = theme.textFont(error_summary, 9); + const error_font = theme.textFont(error_summary, 12); const error_height = wrappedTextHeight( error_summary, error_font, diff --git a/src/ui/workspace/file_panel_elements/context_menu.zig b/src/ui/workspace/file_panel_elements/context_menu.zig index 351d91e..6c7415e 100644 --- a/src/ui/workspace/file_panel_elements/context_menu.zig +++ b/src/ui/workspace/file_panel_elements/context_menu.zig @@ -3,8 +3,6 @@ const dvui = @import("dvui"); const remote_file = @import("../../../core/remote_file.zig"); const theme = @import("../../theme.zig"); -const context_menu_font_size: f32 = 9; - pub const EntryOptions = struct { rect: dvui.Rect.Physical, can_mutate: bool, @@ -108,7 +106,7 @@ pub fn deleteConfirm(palette: theme.Palette, name: []const u8, anchor: dvui.Poin defer menu.deinit(); dvui.label(@src(), "Delete {s}?", .{name}, .{ - .font = theme.textFont("Delete file?", 9), + .font = theme.textFont("Delete file?", 12), .color_text = palette.text, .expand = .horizontal, .min_size_content = .{ .w = 142, .h = 18 }, @@ -127,14 +125,14 @@ pub fn deleteConfirm(palette: theme.Palette, name: []const u8, anchor: dvui.Poin if (theme.button(@src(), "Cancel", .{ .min_size_content = .{ .w = 62, .h = 19 }, .id_extra = id_extra + 3, - }, palette, .{ .variant = .ghost, .font_size = 9 })) { + }, palette, .{ .variant = .ghost, .font_size = 12 })) { menu.close(); return .cancel; } if (theme.button(@src(), "Delete", .{ .min_size_content = .{ .w = 62, .h = 19 }, .id_extra = id_extra + 4, - }, palette, .{ .variant = .solid, .intent = .danger, .font_size = 9 })) { + }, palette, .{ .variant = .solid, .intent = .danger, .font_size = 12 })) { menu.close(); return .confirm; } @@ -172,7 +170,7 @@ fn menuOptions(palette: theme.Palette, id_extra: usize) dvui.Options { fn menuItem(label: []const u8, enabled: bool, palette: theme.Palette, id_extra: usize) ?dvui.Rect.Natural { return theme.menuItem(@src(), label, palette, .{ .id_extra = id_extra, - .font_size = context_menu_font_size, + .font_size = 12, .enabled = enabled, .layout = .{ .expand = .horizontal, diff --git a/src/ui/workspace/file_panel_elements/details_panel.zig b/src/ui/workspace/file_panel_elements/details_panel.zig index 242eb18..2c47f58 100644 --- a/src/ui/workspace/file_panel_elements/details_panel.zig +++ b/src/ui/workspace/file_panel_elements/details_panel.zig @@ -138,7 +138,7 @@ fn header(state: *State, palette: theme.Palette, id_extra: usize) void { var title_buf: [320]u8 = undefined; const title = std.fmt.bufPrint(&title_buf, "{s}", .{state.nameText()}) catch "Details"; dvui.label(@src(), "{s}", .{title}, .{ - .font = theme.textFont(title, 11), + .font = theme.textFont(title, 14), .color_text = palette.text, .expand = .horizontal, .gravity_y = 0.5, @@ -206,7 +206,7 @@ fn footer(state: *State, palette: theme.Palette, id_extra: usize) ?Action { .margin = .{ .x = 4, .y = 2, .w = 4, .h = 2 }, .id_extra = id_extra + 2, .gravity_y = 0, - }, palette, .{ .variant = .ghost, .font_size = 10 })) { + }, palette, .{ .variant = .ghost, .font_size = 13 })) { return .{ .edit_permissions = state.entry() }; } @@ -217,7 +217,7 @@ fn footer(state: *State, palette: theme.Palette, id_extra: usize) ?Action { .margin = .{ .y = 2, .w = 4, .h = 2 }, .id_extra = id_extra + 3, .gravity_y = 0.5, - }, palette, .{ .variant = .solid, .font_size = 10 })) { + }, palette, .{ .variant = .solid, .font_size = 13 })) { state.open = false; } return null; @@ -249,7 +249,7 @@ fn row(label: []const u8, value: []const u8, palette: theme.Palette, id_extra: u defer box.deinit(); dvui.label(@src(), "{s}", .{label}, .{ - .font = theme.textFont(label, 10), + .font = theme.textFont(label, 13), .color_text = palette.text_subtle, .min_size_content = .{ .w = label_width, .h = 24 }, .max_size_content = .{ .w = label_width, .h = 24 }, @@ -259,7 +259,7 @@ fn row(label: []const u8, value: []const u8, palette: theme.Palette, id_extra: u .margin = .all(0), }); dvui.label(@src(), "{s}", .{value}, .{ - .font = theme.textFont(value, 10), + .font = theme.textFont(value, 13), .color_text = palette.text, .expand = .horizontal, .min_size_content = .height(24), diff --git a/src/ui/workspace/file_panel_elements/permissions_panel.zig b/src/ui/workspace/file_panel_elements/permissions_panel.zig index 49843a3..34b3187 100644 --- a/src/ui/workspace/file_panel_elements/permissions_panel.zig +++ b/src/ui/workspace/file_panel_elements/permissions_panel.zig @@ -141,7 +141,7 @@ fn header(state: *const State, palette: theme.Palette, id_extra: usize) void { var title_buf: [320]u8 = undefined; const title = std.fmt.bufPrint(&title_buf, "Permissions: {s}", .{state.nameText()}) catch "Permissions"; dvui.label(@src(), "{s}", .{title}, .{ - .font = theme.textFont(title, 11), + .font = theme.textFont(title, 14), .color_text = palette.text, .gravity_y = 0.5, .id_extra = id_extra + 1, @@ -167,7 +167,7 @@ fn content(state: *State, palette: theme.Palette, height: f32, id_extra: usize) if (state.error_len > 0) { dvui.label(@src(), "{s}", .{state.errorText()}, .{ - .font = theme.textFont(state.errorText(), 9), + .font = theme.textFont(state.errorText(), 12), .color_text = palette.danger, .min_size_content = .height(18), .id_extra = id_extra + 70, @@ -187,7 +187,7 @@ fn textRow(label: []const u8, value: []const u8, palette: theme.Palette, id_extr fieldLabel(label, palette, id_extra + 1); dvui.label(@src(), "{s}", .{value}, .{ - .font = theme.textFont(value, 10), + .font = theme.textFont(value, 13), .color_text = palette.text, .expand = .horizontal, .gravity_y = 0.5, @@ -211,7 +211,7 @@ fn modeRow(state: *State, palette: theme.Palette, id_extra: usize) void { te.init(@src(), .{ .text = .{ .buffer = &state.mode_text } }, theme.panel(.{ .min_size_content = .{ .w = 72, .h = 22 }, .max_size_content = .{ .w = 72, .h = 22 }, - .font = theme.textFont("000", 10), + .font = theme.textFont("000", 13), .padding = .{ .x = 4, .y = 0, .w = 4, .h = 0 }, .corner_radius = .all(4), .id_extra = id_extra + 2, @@ -254,7 +254,7 @@ fn permissionGrid(state: *State, palette: theme.Palette, id_extra: usize) void { const bit_idx = row_idx * 3 + col_idx; if (theme.checkbox(@src(), &state.bits[bit_idx], bit_labels[col_idx], palette, .{ .id_extra = id_extra + 3 + row_idx * 10 + col_idx, - .font_size = 9, + .font_size = 12, .layout = .{ .min_size_content = .{ .w = 78, .h = 22 }, .max_size_content = .{ .w = 78, .h = 22 }, @@ -289,7 +289,7 @@ fn footer(state: *State, palette: theme.Palette, id_extra: usize) ?remote_file.F .margin = .{ .y = 2, .w = 4, .h = 2 }, .padding = .all(0), .id_extra = id_extra + 2, - }, palette, .{ .variant = .ghost, .font_size = 10 })) { + }, palette, .{ .variant = .ghost, .font_size = 13 })) { state.open = false; return null; } @@ -300,7 +300,7 @@ fn footer(state: *State, palette: theme.Palette, id_extra: usize) ?remote_file.F .margin = .{ .y = 2, .w = 10, .h = 2 }, .padding = .all(0), .id_extra = id_extra + 3, - }, palette, .{ .variant = .solid, .font_size = 10 })) { + }, palette, .{ .variant = .solid, .font_size = 13 })) { const mode = state.parseMode() orelse { state.setError("Mode must be octal, e.g. 755"); return null; @@ -317,7 +317,7 @@ fn footer(state: *State, palette: theme.Palette, id_extra: usize) ?remote_file.F fn fieldLabel(label: []const u8, palette: theme.Palette, id_extra: usize) void { dvui.label(@src(), "{s}", .{label}, .{ - .font = theme.textFont(label, 10), + .font = theme.textFont(label, 13), .color_text = palette.text_subtle, .min_size_content = .{ .w = label_width, .h = row_height }, .max_size_content = .{ .w = label_width, .h = row_height }, diff --git a/src/ui/workspace/file_panel_elements/remote_editor.zig b/src/ui/workspace/file_panel_elements/remote_editor.zig index 40680f4..a472f5f 100644 --- a/src/ui/workspace/file_panel_elements/remote_editor.zig +++ b/src/ui/workspace/file_panel_elements/remote_editor.zig @@ -17,6 +17,13 @@ const editor_initial_size: dvui.Size = .{ .w = 860, .h = 620 }; const editor_min_size: dvui.Size = .{ .w = 460, .h = 320 }; const search_capacity = 128; const replace_capacity = 256; +const search_match_count_unknown = std.math.maxInt(usize); +const search_stats_chunk_bytes: usize = 256 * 1024; +const search_stats_frame_us: i32 = 16_000; +const save_flash_total_s: f32 = 1.0; +const save_flash_fade_in_s: f32 = 0.3; +const save_flash_fade_out_s: f32 = save_flash_total_s - save_flash_fade_in_s; +const save_flash_frame_us: i32 = 16_000; const ConfirmAction = enum { cancel, discard, save }; const ConflictAction = enum { keep_editing, reload, overwrite }; @@ -35,6 +42,9 @@ pub const State = struct { close_requested: bool = false, conflict_dismissed: bool = false, observed_save_conflict: bool = false, + save_flash_version: u64 = 0, + save_flash_elapsed_s: f32 = save_flash_total_s, + save_flash_just_started: bool = false, search_focus_requested: bool = false, search_action: ?SearchAction = null, search_query: [search_capacity]u8 = std.mem.zeroes([search_capacity]u8), @@ -45,6 +55,15 @@ pub const State = struct { search_match_count: usize = 0, search_active_index: usize = 0, search_target_y: ?f32 = null, + search_stats_dirty: bool = true, + search_stats_text_len: usize = 0, + search_stats_query_len: usize = 0, + search_stats_query: [search_capacity]u8 = std.mem.zeroes([search_capacity]u8), + search_stats_has_match: bool = false, + search_stats_active_start: usize = 0, + search_stats_scan_pos: usize = 0, + search_stats_scan_count: usize = 0, + search_stats_scan_active_index: usize = 0, }; pub fn show(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: theme.Palette, id_extra: usize) ?remote_file.FilePanelIntent { @@ -91,6 +110,7 @@ pub fn show(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: th })); defer root.deinit(); + observeSaveFlash(state, snapshot); header(state, snapshot, state.dirty, palette, id_extra + 10); separator(palette, id_extra + 20); progressBar(snapshot, palette, id_extra + 24); @@ -208,6 +228,49 @@ fn observeSaveConflict(state: *State, save_conflict: bool) void { state.observed_save_conflict = save_conflict; } +fn observeSaveFlash(state: *State, snapshot: remote_file.FileEditorSnapshot) void { + if (snapshot.state != .ready or snapshot.error_summary == null or snapshot.save_conflict) return; + if (!std.mem.eql(u8, snapshot.error_summary.?, "Saved")) return; + if (state.save_flash_version == snapshot.version) return; + + state.save_flash_version = snapshot.version; + state.save_flash_elapsed_s = 0; + state.save_flash_just_started = true; +} + +fn saveFlashLabel(state: *State, palette: theme.Palette, title_group_id: dvui.Id, id_extra: usize) void { + const alpha = saveFlashAlpha(state) orelse return; + dvui.timer(title_group_id.update("save_flash"), save_flash_frame_us); + dvui.label(@src(), "save", .{}, .{ + .font = theme.textFont("save", 10), + .color_text = palette.network_rx.opacity(alpha), + .gravity_y = 0.5, + .margin = .{ .x = 8 }, + .id_extra = id_extra, + }); +} + +fn saveFlashAlpha(state: *State) ?f32 { + if (state.save_flash_elapsed_s >= save_flash_total_s) return null; + const elapsed = state.save_flash_elapsed_s; + + if (state.save_flash_just_started) { + state.save_flash_just_started = false; + } else { + state.save_flash_elapsed_s = @min(save_flash_total_s, state.save_flash_elapsed_s + dvui.secondsSinceLastFrame()); + } + + if (elapsed >= save_flash_total_s) { + return null; + } + if (elapsed <= 0) return 0; + if (elapsed < save_flash_fade_in_s) { + return elapsed / save_flash_fade_in_s; + } + const fade_elapsed = elapsed - save_flash_fade_in_s; + return 1 - (fade_elapsed / save_flash_fade_out_s); +} + fn loadEditorFontsOnce(state: *State, os_win: anytype) void { if (state.fonts_loaded) return; if (dvui.Backend.support_child_os_wins) { @@ -258,13 +321,15 @@ fn header(state: *State, snapshot: remote_file.FileEditorSnapshot, dirty: bool, std.fmt.bufPrint(&title_buf, "{s}*", .{name}) catch name else name; + dvui.label(@src(), "{s}", .{title}, .{ - .font = theme.textFont(title, 12), + .font = theme.textFont(title, 15), .color_text = palette.text, .expand = .horizontal, .gravity_y = 0.5, - .id_extra = id_extra + 1, + .id_extra = id_extra + 2, }); + saveFlashLabel(state, palette, row.data().id, id_extra + 3); var format_buf: [96]u8 = undefined; const format_label = std.fmt.bufPrint( @@ -277,19 +342,19 @@ fn header(state: *State, snapshot: remote_file.FileEditorSnapshot, dirty: bool, }, ) catch snapshot.encoding.label(); dvui.label(@src(), "{s}", .{format_label}, .{ - .font = theme.textFont(format_label, 8.5), + .font = theme.textFont(format_label, 11.5), .color_text = if (snapshot.large_file) palette.warning else palette.muted_text, .gravity_y = 0.5, .margin = .{ .x = 8 }, - .id_extra = id_extra + 2, + .id_extra = id_extra + 6, }); if (theme.button(@src(), "Save", .{ .min_size_content = .{ .w = 62, .h = 24 }, .max_size_content = .{ .w = 62, .h = 24 }, .gravity_y = 0.5, - .id_extra = id_extra + 3, - }, palette, .{ .intent = .primary, .variant = .solid, .font_size = 9.5 })) { + .id_extra = id_extra + 7, + }, palette, .{ .intent = .primary, .variant = .solid, .font_size = 12.5 })) { state.save_requested = true; } @@ -329,12 +394,9 @@ fn searchBox(state: *State, palette: theme.Palette, id_extra: usize) void { } var label_buf: [32]u8 = undefined; - const label = if (state.search_match_count == 0) - "0/0" - else - std.fmt.bufPrint(&label_buf, "{d}/{d}", .{ state.search_active_index, state.search_match_count }) catch ""; + const label = searchCountLabel(state, &label_buf); dvui.label(@src(), "{s}", .{label}, .{ - .font = theme.textFont(label, 9), + .font = theme.textFont(label, 12), .color_text = palette.muted_text, .gravity_y = 0.5, .padding = .{ .x = 3, .w = 5 }, @@ -372,10 +434,10 @@ fn tinyIconButton(bytes: []const u8, name: []const u8, palette: theme.Palette, i .min_size_content = .{ .w = 22, .h = 11 }, .max_size_content = .{ .w = 22, .h = 11 }, .padding = .all(0), - .margin = .{ .y = 3 }, + .margin = .{ .y = 1 }, .corner_radius = .all(2), .id_extra = id_extra, - }, palette, .{ .variant = .ghost, .font_size = 7 }, .{}); + }, palette, .{ .variant = .ghost, .font_size = 10 }, .{}); bw.processEvents(); bw.drawBackground(); renderPng(bytes, name, bw.data().contentRectScale(), bw.style().color(.text)); @@ -398,7 +460,7 @@ fn searchEntryOptions(palette: theme.Palette, id_extra: usize) dvui.Options { }, palette).override(.{ .color_fill = dvui.Color.transparent, .color_border = palette.border_subtle, - .font = theme.textFont("Search", 9), + .font = theme.textFont("Search", 12), }); } @@ -483,7 +545,7 @@ fn replaceEntryOptions(palette: theme.Palette, id_extra: usize) dvui.Options { }, palette).override(.{ .color_fill = palette.surface_bg.opacity(0.92), .color_border = palette.border_subtle, - .font = theme.textFont("Replace", 9), + .font = theme.textFont("Replace", 12), }); } @@ -497,7 +559,7 @@ fn iconButton(bytes: []const u8, name: []const u8, palette: theme.Palette, id_ex .corner_radius = .all(3), .id_extra = id_extra, .gravity_y = 0.5, - }, palette, .{ .variant = .ghost, .font_size = 9 }, .{}); + }, palette, .{ .variant = .ghost, .font_size = 12 }, .{}); bw.processEvents(); bw.drawBackground(); renderPng(bytes, name, bw.data().contentRectScale(), bw.style().color(.text)); @@ -560,6 +622,7 @@ fn progressBar(snapshot: remote_file.FileEditorSnapshot, palette: theme.Palette, fn editorNotice(snapshot: remote_file.FileEditorSnapshot, palette: theme.Palette, id_extra: usize) void { if (snapshot.state != .ready or snapshot.error_summary == null or snapshot.save_conflict) return; const message = snapshot.error_summary.?; + if (std.mem.eql(u8, message, "Saved")) return; var row = dvui.box(@src(), .{ .dir = .horizontal }, theme.panel(.{ .expand = .horizontal, .min_size_content = .height(24), @@ -572,8 +635,8 @@ fn editorNotice(snapshot: remote_file.FileEditorSnapshot, palette: theme.Palette })); defer row.deinit(); dvui.label(@src(), "{s}", .{message}, .{ - .font = theme.textFont(message, 9), - .color_text = if (std.mem.eql(u8, message, "Saved")) palette.network_rx else palette.warning, + .font = theme.textFont(message, 12), + .color_text = palette.warning, .expand = .horizontal, .id_extra = id_extra + 1, }); @@ -627,13 +690,15 @@ fn editorBody(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: te.textSet(snapshot.content, false); state.loaded_version = snapshot.version; clearSearchMatch(state); + markSearchStatsDirty(state); } applySearchAction(state, te); applyPendingSearchScroll(state, te); te.processEvents(); te.draw(); - updateSearchStats(state, te.textGet()); + if (te.text_changed) markSearchStatsDirty(state); + updateSearchStatsLight(state, te.textGet()); return te.textGet(); } @@ -768,30 +833,17 @@ fn findPrevMatch(text: []const u8, query: []const u8, start: usize) ?SearchMatch if (query.len == 0 or text.len < query.len) return null; const safe_start = @min(start, text.len); - if (lastIndexBefore(text, query, safe_start)) |idx| { + if (std.mem.lastIndexOf(u8, text[0..safe_start], query)) |idx| { return .{ .start = idx, .end = idx + query.len }; } if (safe_start < text.len) { - if (lastIndexBefore(text, query, text.len)) |idx| { + if (std.mem.lastIndexOf(u8, text, query)) |idx| { return .{ .start = idx, .end = idx + query.len }; } } return null; } -fn lastIndexBefore(text: []const u8, query: []const u8, end: usize) ?usize { - const safe_end = @min(end, text.len); - if (query.len == 0 or safe_end < query.len) return null; - - var pos: usize = 0; - var last: ?usize = null; - while (std.mem.indexOfPos(u8, text[0..safe_end], pos, query)) |idx| { - last = idx; - pos = idx + query.len; - } - return last; -} - fn nearestMatch(prev: ?SearchMatch, next: ?SearchMatch, cursor: usize) ?SearchMatch { if (prev == null) return next; if (next == null) return prev; @@ -806,13 +858,13 @@ fn selectMatch(state: *State, te: *dvui.TextEntryWidget, start: usize, end: usiz sel.start = start; sel.cursor = start; sel.end = end; - te.textLayout.scroll_to_cursor = true; + te.textLayout.scroll_to_cursor = false; state.search_active_start = start; state.search_active_end = end; state.search_has_match = true; state.search_target_y = visualYOfOffset(te, start); - updateSearchStats(state, te.textGet()); + setSearchStatsUnknown(state); dvui.refresh(null, @src(), te.data().id); } @@ -828,7 +880,22 @@ fn clearSearchMatch(state: *State) void { state.search_active_end = 0; state.search_has_match = false; state.search_active_index = 0; + state.search_match_count = 0; state.search_target_y = null; + markSearchStatsDirty(state); +} + +fn markSearchStatsDirty(state: *State) void { + state.search_stats_dirty = true; +} + +fn setSearchStatsUnknown(state: *State) void { + state.search_active_index = 0; + state.search_match_count = search_match_count_unknown; + state.search_stats_dirty = true; + state.search_stats_scan_pos = 0; + state.search_stats_scan_count = 0; + state.search_stats_scan_active_index = 0; } fn applyPendingSearchScroll(state: *State, te: *dvui.TextEntryWidget) void { @@ -842,14 +909,12 @@ fn applyPendingSearchScroll(state: *State, te: *dvui.TextEntryWidget) void { const margin = @min(viewport_h * 0.25, line_h * 3); const next_offset = target_y - margin; te.scroll.si.virtual_size.h = @max(te.scroll.si.virtual_size.h, target_y + viewport_h); - te.scroll.si.viewport.y = @max(0, next_offset); + te.scroll.si.scrollToOffset(.vertical, @max(0, next_offset)); te.scroll.si.velocity.y = 0; if (te.scroll.scroll) |*scroll| { scroll.frame_viewport.y = te.scroll.si.viewport.y; scroll.frame_viewport.x = te.scroll.si.viewport.x; } - te.init_opts.cache_layout = false; - te.textLayout.cache_layout = false; te.textLayout.scroll_to_cursor = false; dvui.refresh(null, @src(), te.data().id); } @@ -858,83 +923,231 @@ fn editorLineHeight(te: *dvui.TextEntryWidget) f32 { return @max(te.textLayout.data().options.fontGet().lineHeight(), 1); } +const SearchScrollAnchor = struct { + byte: usize, + y: f32, +}; + fn visualYOfOffset(te: *dvui.TextEntryWidget, offset: usize) f32 { - const text = te.textGet()[0..@min(offset, te.len)]; - if (text.len == 0) return 0; + const text = te.textGet(); + const bounded = @min(offset, text.len); + if (bounded == 0) return 0; - const font = te.textLayout.data().options.fontGet(); const line_h = editorLineHeight(te); - const content_w = @max(te.textLayout.data().contentRect().w, font.sizeM(1, 1).w); - if (dvui.currentWindow().text_engine) |engine| { - return engine.caretPoint(font, te.textGet(), @min(offset, te.len), content_w).y; + const wrap_cols = editorWrapColumns(te); + + if (byteHeightAnchorBefore(te, bounded)) |anchor| { + const start = @min(anchor.byte, bounded); + const local_rows = estimatedVisualRows(text[start..bounded], wrap_cols); + return anchor.y + @as(f32, @floatFromInt(local_rows)) * line_h; } - const break_width = content_w + 0.001; - const m_width = font.sizeM(1, 1).w; - var y: f32 = 0; - var pos: usize = 0; - while (pos < text.len) { - var end: usize = 0; - _ = font.textSizeEx(text[pos..], .{ - .kerning = te.textLayout.kerning, - .max_width = break_width, - .end_idx = &end, - }); - if (end == 0) { - end = std.unicode.utf8ByteSequenceLength(text[pos]) catch 1; + return @as(f32, @floatFromInt(estimatedVisualRows(text[0..bounded], wrap_cols))) * line_h; +} + +fn editorWrapColumns(te: *dvui.TextEntryWidget) usize { + const font = te.textLayout.data().options.fontGet(); + const msize = font.sizeM(1, 1); + const content_w = @max(@max(te.textLayout.data().contentRect().w, te.scroll.si.viewport.w), msize.w); + const cell_w = @max(msize.w, 1); + return @max(1, @as(usize, @intFromFloat(@max(1, @floor(content_w / cell_w))))); +} + +fn byteHeightAnchorBefore(te: *dvui.TextEntryWidget, offset: usize) ?SearchScrollAnchor { + const byte_heights = te.textLayout.byte_heights; + if (byte_heights.len == 0) return null; + + var lo: usize = 0; + var hi: usize = byte_heights.len; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + if (byte_heights[mid].byte <= offset) { + lo = mid + 1; + } else { + hi = mid; } + } + if (lo == 0) return null; + + var idx = lo - 1; + while (true) { + const bh = byte_heights[idx]; + const is_final = idx == byte_heights.len - 1; + // Non-final byte-height entries are recorded immediately after a + // newline, which makes them safe y anchors. The final entry can be in + // the middle of a line when the layout cache only covered a visible + // prefix, so only trust it if it is the real EOF marker. + if (!is_final or bh.byte == te.len) { + return .{ .byte = @min(bh.byte, te.len), .y = bh.height }; + } + if (idx == 0) return null; + idx -= 1; + } +} - var line_end = end; - const newline = text[pos + line_end - 1] == '\n'; - if (line_end < text.len - pos and !newline and break_width > (10 * m_width)) { - if (std.mem.findLastLinear(u8, text[pos .. pos + line_end + 1], " ")) |space_idx| { - line_end = space_idx + 1; - } +fn estimatedVisualRows(text: []const u8, wrap_cols: usize) usize { + var rows: usize = 0; + var col: usize = 0; + var i: usize = 0; + while (i < text.len) { + const byte = text[i]; + if (byte == '\n') { + rows += 1; + col = 0; + i += 1; + continue; } - if (pos + line_end >= text.len) { - if (newline) y += line_h; - break; + const width = estimatedColumnWidth(text, &i); + if (wrap_cols > 0 and col > 0 and col + width > wrap_cols) { + rows += 1; + col = 0; } + col += width; + } + return rows; +} - y += line_h; - pos += line_end; +fn estimatedColumnWidth(text: []const u8, index: *usize) usize { + const byte = text[index.*]; + if (byte == '\t') { + index.* += 1; + return 4; } - return y; + if (byte < 0x80) { + index.* += 1; + return 1; + } + + const len = std.unicode.utf8ByteSequenceLength(byte) catch 1; + index.* = @min(text.len, index.* + len); + return 2; } -fn updateSearchStats(state: *State, text: []const u8) void { +fn updateSearchStatsLight(state: *State, text: []const u8) void { const query = searchQuery(state); - state.search_match_count = countMatches(text, query); - if (state.search_match_count == 0 or !activeMatchValid(state, text, query)) { + + if (query.len == 0) { + state.search_match_count = 0; state.search_active_index = 0; - if (state.search_match_count == 0) state.search_has_match = false; + state.search_has_match = false; + resetSearchStatsScan(state); + cacheSearchStatsKey(state, text, query); return; } - state.search_active_index = matchIndex(text, query, state.search_active_start) orelse 0; + + if (!activeMatchValid(state, text, query)) { + state.search_active_index = 0; + state.search_has_match = false; + state.search_match_count = 0; + resetSearchStatsScan(state); + cacheSearchStatsKey(state, text, query); + return; + } + + if (searchStatsNeedsRefresh(state, text, query)) { + beginSearchStatsScan(state, text, query); + } + + if (state.search_match_count == search_match_count_unknown) { + continueSearchStatsScan(state, text, query); + } } -fn countMatches(text: []const u8, query: []const u8) usize { - if (query.len == 0) return 0; - var count: usize = 0; - var pos: usize = 0; - while (std.mem.indexOfPos(u8, text, pos, query)) |idx| { - count += 1; +fn searchStatsNeedsRefresh(state: *const State, text: []const u8, query: []const u8) bool { + if (state.search_stats_dirty) return true; + if (state.search_stats_text_len != text.len) return true; + if (state.search_stats_query_len != query.len) return true; + if (!std.mem.eql(u8, state.search_stats_query[0..state.search_stats_query_len], query)) return true; + if (state.search_stats_has_match != state.search_has_match) return true; + if (state.search_has_match and state.search_stats_active_start != state.search_active_start) return true; + return false; +} + +fn cacheSearchStatsKey(state: *State, text: []const u8, query: []const u8) void { + state.search_stats_dirty = false; + state.search_stats_text_len = text.len; + state.search_stats_query_len = @min(query.len, state.search_stats_query.len); + @memset(&state.search_stats_query, 0); + std.mem.copyForwards(u8, state.search_stats_query[0..state.search_stats_query_len], query[0..state.search_stats_query_len]); + state.search_stats_has_match = state.search_has_match; + state.search_stats_active_start = state.search_active_start; +} + +fn beginSearchStatsScan(state: *State, text: []const u8, query: []const u8) void { + state.search_match_count = search_match_count_unknown; + state.search_active_index = 0; + resetSearchStatsScan(state); + cacheSearchStatsKey(state, text, query); +} + +fn resetSearchStatsScan(state: *State) void { + state.search_stats_scan_pos = 0; + state.search_stats_scan_count = 0; + state.search_stats_scan_active_index = 0; +} + +fn continueSearchStatsScan(state: *State, text: []const u8, query: []const u8) void { + if (query.len == 0 or text.len < query.len) { + finishSearchStatsScan(state); + return; + } + + const scan_start = state.search_stats_scan_pos; + if (scan_start >= text.len) { + finishSearchStatsScan(state); + return; + } + + const chunk_end = @min(text.len, scan_start + search_stats_chunk_bytes); + const search_end = @min(text.len, chunk_end + query.len - 1); + var pos = scan_start; + var next_pos = chunk_end; + + while (std.mem.indexOfPos(u8, text[0..search_end], pos, query)) |idx| { + if (idx >= chunk_end) break; + + state.search_stats_scan_count += 1; + if (state.search_has_match and idx <= state.search_active_start) { + state.search_stats_scan_active_index = state.search_stats_scan_count; + state.search_active_index = state.search_stats_scan_active_index; + } + pos = idx + query.len; + next_pos = @max(next_pos, pos); + if (pos >= search_end) break; + } + + state.search_stats_scan_pos = next_pos; + if (state.search_stats_scan_pos >= text.len) { + finishSearchStatsScan(state); + } else { + dvui.timer(dvui.currentWindow().data().id.update("remote_editor_search_stats"), search_stats_frame_us); + dvui.refresh(null, @src(), null); } - return count; } -fn matchIndex(text: []const u8, query: []const u8, match_start: usize) ?usize { - if (query.len == 0) return null; - var count: usize = 0; - var pos: usize = 0; - while (std.mem.indexOfPos(u8, text, pos, query)) |idx| { - count += 1; - if (idx == match_start) return count; - pos = idx + query.len; +fn finishSearchStatsScan(state: *State) void { + state.search_match_count = state.search_stats_scan_count; + if (state.search_match_count == 0) { + state.search_active_index = 0; + } else if (state.search_stats_scan_active_index > 0) { + state.search_active_index = state.search_stats_scan_active_index; + } else { + state.search_active_index = @min(@max(1, state.search_active_index), state.search_match_count); } - return null; + dvui.refresh(null, @src(), null); +} + +fn searchCountLabel(state: *const State, buf: []u8) []const u8 { + if (state.search_match_count == search_match_count_unknown) { + if (state.search_active_index > 0) { + return std.fmt.bufPrint(buf, "{d}/?", .{state.search_active_index}) catch "?/?"; + } + return "?/?"; + } + if (state.search_match_count == 0) return "0/0"; + return std.fmt.bufPrint(buf, "{d}/{d}", .{ state.search_active_index, state.search_match_count }) catch ""; } fn searchQuery(state: *const State) []const u8 { @@ -960,10 +1173,9 @@ fn editorTextOptions(palette: theme.Palette, naked_theme: *const dvui.Theme, id_ .color_fill_hover = dvui.Color.transparent, .color_fill_press = dvui.Color.transparent, .color_border = dvui.Color.transparent, - // TextEntry uses one font for the entire editable buffer and does not - // provide per-codepoint fallback. Use the bundled Unicode-capable font - // so content typed after opening an ASCII file is visible as well. - .font = theme.cjkFont(10), + // SDL3_ttf owns fallback shaping, so keep ASCII-heavy editor content on + // the bundled mono face while CJK/emoji can still fall back per glyph. + .font = theme.textFont("editor", 13), }); } @@ -983,7 +1195,7 @@ fn centerLabel(text: []const u8, palette: theme.Palette, id_extra: usize) void { }); defer box.deinit(); dvui.label(@src(), "{s}", .{text}, .{ - .font = theme.textFont(text, 10), + .font = theme.textFont(text, 13), .color_text = palette.muted_text, .gravity_x = 0.5, .gravity_y = 0.5, @@ -1075,13 +1287,13 @@ fn unsavedPrompt(palette: theme.Palette, id_extra: usize) ?ConfirmAction { defer content.deinit(); dvui.label(@src(), "Unsaved Changes", .{}, .{ - .font = theme.textFont("Unsaved Changes", 12), + .font = theme.textFont("Unsaved Changes", 15), .color_text = palette.text, .expand = .horizontal, .id_extra = id_extra + 2, }); dvui.label(@src(), "Save changes before closing?", .{}, .{ - .font = theme.textFont("Save changes before closing?", 9), + .font = theme.textFont("Save changes before closing?", 12), .color_text = palette.muted_text, .expand = .horizontal, .min_size_content = .height(20), @@ -1102,17 +1314,17 @@ fn unsavedPrompt(palette: theme.Palette, id_extra: usize) ?ConfirmAction { .min_size_content = .{ .w = 76, .h = 24 }, .id_extra = id_extra + 12, .padding = .{ .h = 0 }, - }, palette, .{ .variant = .ghost, .font_size = 10 })) return .cancel; + }, palette, .{ .variant = .ghost, .font_size = 13 })) return .cancel; if (theme.button(@src(), "Discard", .{ .min_size_content = .{ .w = 82, .h = 24 }, .id_extra = id_extra + 13, .padding = .{ .h = 0 }, - }, palette, .{ .intent = .danger, .variant = .ghost, .font_size = 10 })) return .discard; + }, palette, .{ .intent = .danger, .variant = .ghost, .font_size = 13 })) return .discard; if (theme.button(@src(), "Save", .{ .min_size_content = .{ .w = 76, .h = 24 }, .id_extra = id_extra + 14, .padding = .{ .h = 0 }, - }, palette, .{ .intent = .primary, .variant = .solid, .font_size = 10 })) return .save; + }, palette, .{ .intent = .primary, .variant = .solid, .font_size = 13 })) return .save; return null; } @@ -1141,7 +1353,7 @@ fn conflictPrompt(palette: theme.Palette, id_extra: usize) ?ConflictAction { dvui.focusSubwindow(panel.data().id, null); dvui.label(@src(), "Remote File Changed", .{}, .{ - .font = theme.textFont("Remote File Changed", 12), + .font = theme.textFont("Remote File Changed", 15), .color_text = palette.warning, .expand = .horizontal, .id_extra = id_extra + 1, @@ -1158,17 +1370,17 @@ fn conflictPrompt(palette: theme.Palette, id_extra: usize) ?ConflictAction { .min_size_content = .{ .w = 106, .h = 24 }, .id_extra = id_extra + 11, .padding = .{ .h = 0 }, - }, palette, .{ .variant = .ghost, .font_size = 9.5 })) return .keep_editing; + }, palette, .{ .variant = .ghost, .font_size = 12.5 })) return .keep_editing; if (theme.button(@src(), "Reload", .{ .min_size_content = .{ .w = 72, .h = 24 }, .id_extra = id_extra + 12, .padding = .{ .h = 0 }, - }, palette, .{ .variant = .ghost, .font_size = 9.5 })) return .reload; + }, palette, .{ .variant = .ghost, .font_size = 12.5 })) return .reload; if (theme.button(@src(), "Overwrite", .{ .min_size_content = .{ .w = 82, .h = 24 }, .id_extra = id_extra + 13, .padding = .{ .h = 0 }, - }, palette, .{ .intent = .danger, .variant = .solid, .font_size = 9.5 })) return .overwrite; + }, palette, .{ .intent = .danger, .variant = .solid, .font_size = 12.5 })) return .overwrite; return null; } diff --git a/src/ui/workspace/file_panel_elements/transfer_confirm.zig b/src/ui/workspace/file_panel_elements/transfer_confirm.zig index 50cdd78..152af08 100644 --- a/src/ui/workspace/file_panel_elements/transfer_confirm.zig +++ b/src/ui/workspace/file_panel_elements/transfer_confirm.zig @@ -169,13 +169,13 @@ pub fn show(state: *State, palette: theme.Palette, id_extra: usize) Action { dvui.focusSubwindow(panel.data().id, null); dvui.label(@src(), "Overwrite Existing Item?", .{}, .{ - .font = theme.textFont("Overwrite Existing Item?", 12), + .font = theme.textFont("Overwrite Existing Item?", 15), .color_text = palette.text, .margin = .all(0), .id_extra = id_extra + 1, }); dvui.label(@src(), "{s}", .{state.messageText()}, .{ - .font = theme.textFont(state.messageText(), 9), + .font = theme.textFont(state.messageText(), 12), .color_text = palette.text_subtle, .expand = .horizontal, .min_size_content = .height(20), @@ -196,13 +196,13 @@ pub fn show(state: *State, palette: theme.Palette, id_extra: usize) Action { .min_size_content = .{ .w = 72, .h = 19 }, .margin = .{ .x = 4, .y = 4 }, .id_extra = id_extra + 5, - }, palette, .{ .variant = .ghost, .font_size = 10 })) return .cancel; + }, palette, .{ .variant = .ghost, .font_size = 13 })) return .cancel; if (theme.button(@src(), "Overwrite", .{ .min_size_content = .{ .w = 88, .h = 19 }, .margin = .{ .x = 4, .y = 4 }, .id_extra = id_extra + 6, - }, palette, .{ .variant = .solid, .font_size = 10 })) return .overwrite; + }, palette, .{ .variant = .solid, .font_size = 13 })) return .overwrite; return .none; } diff --git a/src/ui/workspace/status_panel.zig b/src/ui/workspace/status_panel.zig index c4a957a..c511e64 100644 --- a/src/ui/workspace/status_panel.zig +++ b/src/ui/workspace/status_panel.zig @@ -4,15 +4,15 @@ const std = @import("std"); const status_panel = @import("../../core/status_panel.zig"); const theme = @import("../theme.zig"); -const section_font_size: f32 = 9; -const row_font_size: f32 = 9; -const metric_font_size: f32 = 9; -const table_header_font_size: f32 = 9; -const table_cell_font_size: f32 = 9; -const network_tooltip_font_size: f32 = 9; +const section_font_size: f32 = 12; +const row_font_size: f32 = 12; +const metric_font_size: f32 = 12; +const table_header_font_size: f32 = 12; +const table_cell_font_size: f32 = 12; +const network_tooltip_font_size: f32 = 12; const network_chart_height: f32 = 86; const network_axis_width: f32 = 34; -const network_axis_font_size: f32 = 9; +const network_axis_font_size: f32 = 12; const network_target_bar_slot_width: f32 = 4; const network_min_scale_max: u64 = 5 * 1024; const network_line_thickness: f32 = 1.25; diff --git a/src/ui/workspace/terminal_panel.zig b/src/ui/workspace/terminal_panel.zig index 74b1e88..52dfe8d 100644 --- a/src/ui/workspace/terminal_panel.zig +++ b/src/ui/workspace/terminal_panel.zig @@ -63,7 +63,7 @@ const search_query_capacity: usize = 128; const search_max_matches: usize = 512; const search_bar_width: f32 = 260; const search_bar_height: f32 = 34; -const search_bar_font_size: f32 = 10; +const search_bar_font_size: f32 = 13; const row_render_cache_capacity: usize = 256; pub const Options = struct { diff --git a/src/ui/workspace/terminal_slot_bar.zig b/src/ui/workspace/terminal_slot_bar.zig index 979e2d3..b6920ae 100644 --- a/src/ui/workspace/terminal_slot_bar.zig +++ b/src/ui/workspace/terminal_slot_bar.zig @@ -12,7 +12,7 @@ const chip_max_width: f32 = 132; const chip_margin_width: f32 = 4; const side_button_width: f32 = 20; const side_button_margin_width: f32 = 8; -const font_size: f32 = 9; +const font_size: f32 = 12; const menu_item_height: f32 = 18; const prediction_diagnostics_width: f32 = 76; From 7528028072db552bfc9bddfd748ad2a810a7d0a3 Mon Sep 17 00:00:00 2001 From: HissingRat <84263094+HissingRat@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:33:53 +0800 Subject: [PATCH 05/11] fix: address SDL_ttf review issues Guard cached SDL_ttf layouts against eviction while borrowed, keep emoji segmentation scoped to the macOS overlay path, and align editor previous search with the non-overlapping match sequence used by count and replace. --- src/backends/text/sdl_ttf.zig | 56 ++++++++++++---- .../file_panel_elements/remote_editor.zig | 65 ++++++++++++++----- 2 files changed, 93 insertions(+), 28 deletions(-) diff --git a/src/backends/text/sdl_ttf.zig b/src/backends/text/sdl_ttf.zig index fc4f7d3..f6f41e6 100644 --- a/src/backends/text/sdl_ttf.zig +++ b/src/backends/text/sdl_ttf.zig @@ -5,6 +5,7 @@ const platform_fonts = @import("platform_fonts.zig"); const c = dvui.backend.c; const cjk_font_family = "Noto Sans CJK SC"; +const max_cached_layouts = 2048; pub const System = struct { allocator: std.mem.Allocator, @@ -15,6 +16,8 @@ pub const System = struct { fonts: std.AutoHashMapUnmanaged(u64, Face) = .empty, simple_metrics: std.AutoHashMapUnmanaged(u64, SimpleMetrics) = .empty, layouts: std.AutoHashMapUnmanaged(u64, *c.TTF_Text) = .empty, + active_cached_layouts: usize = 0, + clear_layouts_after_release: bool = false, emoji_textures: std.AutoHashMapUnmanaged(u64, EmojiTexture) = .empty, pub const FontSources = struct { @@ -37,11 +40,16 @@ pub const System = struct { }; const Layout = struct { + system: *System, text: *c.TTF_Text, cached: bool, fn release(self: Layout) void { - if (!self.cached) c.TTF_DestroyText(self.text); + if (self.cached) { + self.system.releaseCachedLayout(); + } else { + c.TTF_DestroyText(self.text); + } } }; @@ -209,7 +217,7 @@ pub const System = struct { const cacheable = text.len <= 256; if (cacheable) { if (self.layouts.get(key)) |cached| { - return .{ .text = cached, .cached = true }; + return self.borrowCachedLayout(cached); } } @@ -229,18 +237,38 @@ pub const System = struct { _ = c.TTF_SetTextWrapWhitespaceVisible(result, true); } if (cacheable) { - if (self.layouts.count() >= 2048) self.clearLayouts(); + if (self.layouts.count() >= max_cached_layouts) { + if (self.active_cached_layouts > 0) { + self.clear_layouts_after_release = true; + return .{ .system = self, .text = result, .cached = false }; + } + self.clearLayouts(); + } self.layouts.put(self.allocator, key, result) catch - return .{ .text = result, .cached = false }; - return .{ .text = result, .cached = true }; + return .{ .system = self, .text = result, .cached = false }; + return self.borrowCachedLayout(result); } - return .{ .text = result, .cached = false }; + return .{ .system = self, .text = result, .cached = false }; } fn clearLayouts(self: *System) void { var it = self.layouts.valueIterator(); while (it.next()) |text| c.TTF_DestroyText(text.*); self.layouts.clearRetainingCapacity(); + self.clear_layouts_after_release = false; + } + + fn borrowCachedLayout(self: *System, text: *c.TTF_Text) Layout { + self.active_cached_layouts += 1; + return .{ .system = self, .text = text, .cached = true }; + } + + fn releaseCachedLayout(self: *System) void { + std.debug.assert(self.active_cached_layouts > 0); + self.active_cached_layouts -= 1; + if (self.active_cached_layouts == 0 and self.clear_layouts_after_release) { + self.clearLayouts(); + } } fn fontTextHeightPhysical(self: *System, font: dvui.Font, scale: f32) f32 { @@ -434,8 +462,9 @@ pub const System = struct { 16 * scale; const emoji_box = self.emojiBoxPhysical(options.font, scale); const has_emoji = containsEmojiCluster(options.text); - const emoji_extra = emojiCompensationBefore(text, options.text, options.text.len, emoji_box); - const render_height = if (has_emoji) @max(self.fontTextHeightPhysical(options.font, scale), emoji_box) else default_h; + const use_emoji_overlay = has_emoji and emojiOverlayEnabled(); + const emoji_extra = if (use_emoji_overlay) emojiCompensationBefore(text, options.text, options.text.len, emoji_box) else 0; + const render_height = if (use_emoji_overlay) @max(self.fontTextHeightPhysical(options.font, scale), emoji_box) else default_h; if (options.background_color) |background| { if (have_size) { @@ -450,7 +479,7 @@ pub const System = struct { const sel_start = @min(options.sel_start orelse 0, options.text.len); const sel_end = @min(options.sel_end orelse 0, options.text.len); - if (sel_start < sel_end and has_emoji) { + if (sel_start < sel_end and use_emoji_overlay) { const selection_color = options.sel_color orelse dvui.themeGet().focus; const x0 = compensatedCaretXPhysical(text, options.text, sel_start, emoji_box); const x1 = compensatedCaretXPhysical(text, options.text, sel_end, emoji_box); @@ -502,10 +531,10 @@ pub const System = struct { defer _ = c.SDL_SetRenderClipRect(self.renderer, if (previous_clip_enabled) &previous_clip else null); const align_cjk = containsCjkBaselineCodepoint(options.text) and !isCjkFont(options.font); - if (has_emoji or align_cjk) { - try self.renderVisualTextSegments(options.font, text, options.text, start, scale, options.color, emoji_box, align_cjk); + if (use_emoji_overlay or align_cjk) { + try self.renderVisualTextSegments(options.font, text, options.text, start, scale, options.color, emoji_box, use_emoji_overlay, align_cjk); } else if (!c.TTF_DrawRendererText(text, start.x, start.y)) return error.SdlTtfDrawFailed; - self.renderEmojiOverlays(text, options.text, start, emoji_box, render_height); + if (use_emoji_overlay) self.renderEmojiOverlays(text, options.text, start, emoji_box, render_height); } fn caretX( @@ -693,12 +722,13 @@ pub const System = struct { scale: f32, color: dvui.Color, emoji_advance: f32, + skip_emoji_clusters: bool, align_cjk: bool, ) !void { const cjk_offset_y = if (align_cjk) self.cjkBaselineOffsetPhysical(font, scale) else 0; var emoji_index: usize = 0; - var next_emoji = nextEmojiCluster(bytes, &emoji_index); + var next_emoji: ?ByteRange = if (skip_emoji_clusters) nextEmojiCluster(bytes, &emoji_index) else null; var pos: usize = 0; while (pos < bytes.len) { while (next_emoji) |cluster| { diff --git a/src/ui/workspace/file_panel_elements/remote_editor.zig b/src/ui/workspace/file_panel_elements/remote_editor.zig index a472f5f..791522c 100644 --- a/src/ui/workspace/file_panel_elements/remote_editor.zig +++ b/src/ui/workspace/file_panel_elements/remote_editor.zig @@ -818,30 +818,65 @@ fn findNextMatch(text: []const u8, query: []const u8, start: usize) ?SearchMatch if (query.len == 0 or text.len < query.len) return null; const safe_start = @min(start, text.len); - if (std.mem.indexOfPos(u8, text, safe_start, query)) |idx| { - return .{ .start = idx, .end = idx + query.len }; - } - if (safe_start > 0) { - if (std.mem.indexOf(u8, text[0..safe_start], query)) |idx| { - return .{ .start = idx, .end = idx + query.len }; - } + var first: ?SearchMatch = null; + var pos: usize = 0; + while (std.mem.indexOfPos(u8, text, pos, query)) |idx| { + const match: SearchMatch = .{ .start = idx, .end = idx + query.len }; + if (first == null) first = match; + if (match.end > safe_start) return match; + pos = match.end; } - return null; + return if (safe_start > 0) first else null; } fn findPrevMatch(text: []const u8, query: []const u8, start: usize) ?SearchMatch { if (query.len == 0 or text.len < query.len) return null; const safe_start = @min(start, text.len); - if (std.mem.lastIndexOf(u8, text[0..safe_start], query)) |idx| { - return .{ .start = idx, .end = idx + query.len }; - } - if (safe_start < text.len) { - if (std.mem.lastIndexOf(u8, text, query)) |idx| { - return .{ .start = idx, .end = idx + query.len }; + var last: ?SearchMatch = null; + var pos: usize = 0; + while (std.mem.indexOfPos(u8, text, pos, query)) |idx| { + const match: SearchMatch = .{ .start = idx, .end = idx + query.len }; + if (match.start < safe_start) { + last = match; + } else if (safe_start < text.len and last == null) { + last = lastNonOverlappingMatch(text, query); + break; } + pos = match.end; } - return null; + if (last == null and safe_start < text.len) return lastNonOverlappingMatch(text, query); + return last; +} + +fn lastNonOverlappingMatch(text: []const u8, query: []const u8) ?SearchMatch { + var last: ?SearchMatch = null; + var pos: usize = 0; + while (std.mem.indexOfPos(u8, text, pos, query)) |idx| { + last = .{ .start = idx, .end = idx + query.len }; + pos = idx + query.len; + } + return last; +} + +test "remote editor search uses non-overlapping match sequence" { + const text = "aaa"; + const query = "aa"; + + try std.testing.expectEqual(SearchMatch{ .start = 0, .end = 2 }, findNextMatch(text, query, 0).?); + try std.testing.expectEqual(SearchMatch{ .start = 0, .end = 2 }, findNextMatch(text, query, 1).?); + try std.testing.expectEqual(SearchMatch{ .start = 0, .end = 2 }, findPrevMatch(text, query, 2).?); + try std.testing.expectEqual(SearchMatch{ .start = 0, .end = 2 }, findPrevMatch(text, query, 1).?); +} + +test "remote editor search previous skips overlapping matches" { + const text = "aaaa"; + const query = "aa"; + + try std.testing.expectEqual(SearchMatch{ .start = 0, .end = 2 }, findPrevMatch(text, query, 2).?); + try std.testing.expectEqual(SearchMatch{ .start = 2, .end = 4 }, findPrevMatch(text, query, 4).?); + try std.testing.expectEqual(SearchMatch{ .start = 2, .end = 4 }, findNextMatch(text, query, 2).?); + try std.testing.expectEqual(SearchMatch{ .start = 0, .end = 2 }, findNextMatch(text, query, 4).?); } fn nearestMatch(prev: ?SearchMatch, next: ?SearchMatch, cursor: usize) ?SearchMatch { From b8d9604d4b0c39d9cf0c6a87d8bacb0a04e8710c Mon Sep 17 00:00:00 2001 From: HissingRat <84263094+HissingRat@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:19:42 +0800 Subject: [PATCH 06/11] fix: keep terminal ASCII run batching Restore run batching for single-width ASCII terminal cells when SDL_ttf is installed, while keeping non-ASCII and wide cells on the per-cell path. --- src/ui/workspace/terminal_panel.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/workspace/terminal_panel.zig b/src/ui/workspace/terminal_panel.zig index 52dfe8d..a3c243d 100644 --- a/src/ui/workspace/terminal_panel.zig +++ b/src/ui/workspace/terminal_panel.zig @@ -484,7 +484,7 @@ fn renderTerminalSnapshotRow(snapshot: terminal.Snapshot, crs: dvui.RectScale, v continue; } - if (isSingleWidthAscii(cell) and dvui.currentWindow().text_engine == null) { + if (isSingleWidthAscii(cell)) { const run = asciiRun(snapshot, absolute_row, visible_row, col, cell.style, &run_buf); renderTerminalRun(run, crs, palette); col = run.end_col; From ab1350fb1881d9910dd52cb1a48c7bcb3994f757 Mon Sep 17 00:00:00 2001 From: HissingRat <84263094+HissingRat@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:10:41 +0800 Subject: [PATCH 07/11] Stabilize SDL_ttf emoji text editing --- src/backends/text/sdl_ttf.zig | 379 ++++++++++++++---- src/ui/features/files/panel_state.zig | 9 + src/ui/workspace/file_panel.zig | 1 + .../file_panel_elements/remote_editor.zig | 211 +++------- 4 files changed, 384 insertions(+), 216 deletions(-) diff --git a/src/backends/text/sdl_ttf.zig b/src/backends/text/sdl_ttf.zig index f6f41e6..740d87c 100644 --- a/src/backends/text/sdl_ttf.zig +++ b/src/backends/text/sdl_ttf.zig @@ -5,7 +5,6 @@ const platform_fonts = @import("platform_fonts.zig"); const c = dvui.backend.c; const cjk_font_family = "Noto Sans CJK SC"; -const max_cached_layouts = 2048; pub const System = struct { allocator: std.mem.Allocator, @@ -14,10 +13,8 @@ pub const System = struct { font_sources: FontSources, system_fallbacks: platform_fonts.List = .{}, fonts: std.AutoHashMapUnmanaged(u64, Face) = .empty, + font_heights: std.AutoHashMapUnmanaged(u64, f32) = .empty, simple_metrics: std.AutoHashMapUnmanaged(u64, SimpleMetrics) = .empty, - layouts: std.AutoHashMapUnmanaged(u64, *c.TTF_Text) = .empty, - active_cached_layouts: usize = 0, - clear_layouts_after_release: bool = false, emoji_textures: std.AutoHashMapUnmanaged(u64, EmojiTexture) = .empty, pub const FontSources = struct { @@ -40,16 +37,10 @@ pub const System = struct { }; const Layout = struct { - system: *System, text: *c.TTF_Text, - cached: bool, fn release(self: Layout) void { - if (self.cached) { - self.system.releaseCachedLayout(); - } else { - c.TTF_DestroyText(self.text); - } + c.TTF_DestroyText(self.text); } }; @@ -97,12 +88,11 @@ pub const System = struct { } pub fn deinit(self: *System) void { - self.clearLayouts(); - self.layouts.deinit(self.allocator); var emoji_it = self.emoji_textures.valueIterator(); while (emoji_it.next()) |texture| texture.deinit(); self.emoji_textures.deinit(self.allocator); self.simple_metrics.deinit(self.allocator); + self.font_heights.deinit(self.allocator); var it = self.fonts.valueIterator(); while (it.next()) |face| { face.deinit(self.allocator); @@ -208,25 +198,15 @@ pub const System = struct { scale: f32, wrap_width: ?f32, ) ?Layout { - var hasher = std.hash.Wyhash.init(font.hash()); - hasher.update(std.mem.asBytes(&scale)); - const wrap_key = wrap_width orelse -1; - hasher.update(std.mem.asBytes(&wrap_key)); - hasher.update(text); - const key = hasher.final(); - const cacheable = text.len <= 256; - if (cacheable) { - if (self.layouts.get(key)) |cached| { - return self.borrowCachedLayout(cached); - } - } - const face = self.faceFor(font, scale) orelse return null; + const safe_text = emojiSafeLayoutText(self.allocator, text) catch return null; + defer if (safe_text) |buffer| self.allocator.free(buffer); + const layout_text = safe_text orelse text; const result = c.TTF_CreateText( self.engine, face.primary, - if (text.len == 0) null else text.ptr, - text.len, + if (layout_text.len == 0) null else layout_text.ptr, + layout_text.len, ) orelse return null; if (wrap_width) |width| { const physical_width: c_int = @intFromFloat(@max(1, @ceil(width * safeScale(scale)))); @@ -236,52 +216,26 @@ pub const System = struct { } _ = c.TTF_SetTextWrapWhitespaceVisible(result, true); } - if (cacheable) { - if (self.layouts.count() >= max_cached_layouts) { - if (self.active_cached_layouts > 0) { - self.clear_layouts_after_release = true; - return .{ .system = self, .text = result, .cached = false }; - } - self.clearLayouts(); - } - self.layouts.put(self.allocator, key, result) catch - return .{ .system = self, .text = result, .cached = false }; - return self.borrowCachedLayout(result); - } - return .{ .system = self, .text = result, .cached = false }; - } - - fn clearLayouts(self: *System) void { - var it = self.layouts.valueIterator(); - while (it.next()) |text| c.TTF_DestroyText(text.*); - self.layouts.clearRetainingCapacity(); - self.clear_layouts_after_release = false; - } - - fn borrowCachedLayout(self: *System, text: *c.TTF_Text) Layout { - self.active_cached_layouts += 1; - return .{ .system = self, .text = text, .cached = true }; - } - - fn releaseCachedLayout(self: *System) void { - std.debug.assert(self.active_cached_layouts > 0); - self.active_cached_layouts -= 1; - if (self.active_cached_layouts == 0 and self.clear_layouts_after_release) { - self.clearLayouts(); - } + return .{ .text = result }; } fn fontTextHeightPhysical(self: *System, font: dvui.Font, scale: f32) f32 { + const physical_size = physicalFontSize(font, scale); + const key = fontScaleKey(font, physical_size); + if (self.font_heights.get(key)) |height| return height; + const shaped = self.layout(font, "M", scale, null) orelse return @max(1, font.size * safeScale(scale)); defer shaped.release(); + var result = @max(1, font.size * safeScale(scale)); var width_px: c_int = 0; var height_px: c_int = 0; if (c.TTF_GetTextSize(shaped.text, &width_px, &height_px)) { - return @floatFromInt(@max(1, height_px)); + result = @floatFromInt(@max(1, height_px)); } - return @max(1, font.size * safeScale(scale)); + self.font_heights.put(self.allocator, key, result) catch {}; + return result; } fn emojiBoxPhysical(self: *System, font: dvui.Font, scale: f32) f32 { @@ -301,6 +255,8 @@ pub const System = struct { if (self.measureSimple(font, text, scale, options)) |fast| return fast; const line = firstLine(text); + if (self.measureEmojiLine(font, line, scale, options)) |emoji| return emoji; + const shaped = self.layout(font, line.bytes, scale, null) orelse return .{ .w = font.size, .h = font.size }; defer shaped.release(); @@ -364,6 +320,114 @@ pub const System = struct { }; } + fn measureEmojiLine( + self: *System, + font: dvui.Font, + line: TextLine, + scale: f32, + options: dvui.Font.TextSizeOptions, + ) ?dvui.Size { + if (!emojiOverlayEnabled()) return null; + if (!containsEmojiCluster(line.bytes)) return null; + + const face = self.faceFor(font, scale) orelse return null; + const emoji_box = self.emojiBoxPhysical(font, scale); + const metrics = self.simpleMetricsFor(font, scale); + + const measured = if (options.max_width) |max_width| + measureEmojiWidth(face.primary, metrics, line.bytes, emoji_box, @max(0, max_width * scale), options.end_metric) + else + measureEmojiWidth(face.primary, metrics, line.bytes, emoji_box, null, options.end_metric); + + var end = measured.end; + if (line.has_newline and end == line.bytes.len) end += 1; + if (options.end_idx) |out| out.* = @min(end, line.bytes.len + @intFromBool(line.has_newline)); + if (options.ascent_out) |out| out.* = @as(f32, @floatFromInt(c.TTF_GetFontAscent(face.primary))) / scale; + + return .{ + .w = @max(0, measured.width_px) / scale, + .h = @max(self.fontTextHeightPhysical(font, scale), emoji_box) / scale, + }; + } + + fn measureEmojiWidth( + primary: *c.TTF_Font, + metrics: ?*SimpleMetrics, + bytes: []const u8, + emoji_advance: f32, + max_width_px: ?f32, + end_metric: dvui.Font.EndMetric, + ) struct { end: usize, width_px: f32 } { + var emoji_index: usize = 0; + var next_emoji = nextEmojiCluster(bytes, &emoji_index); + var pos: usize = 0; + var end: usize = 0; + var width_px: f32 = 0; + + while (pos < bytes.len) { + while (next_emoji) |cluster| { + if (cluster.end > pos) break; + next_emoji = nextEmojiCluster(bytes, &emoji_index); + } + + const token: EmojiMeasureToken = if (next_emoji) |cluster| blk: { + if (cluster.start == pos) { + break :blk .{ .end = cluster.end, .advance = emoji_advance }; + } + const token_end = @min(nextCodepoint(bytes, pos), cluster.start); + break :blk .{ + .end = token_end, + .advance = nonEmojiTokenAdvancePhysical(primary, metrics, bytes[pos..token_end]), + }; + } else blk: { + const token_end = nextCodepoint(bytes, pos); + break :blk .{ + .end = token_end, + .advance = nonEmojiTokenAdvancePhysical(primary, metrics, bytes[pos..token_end]), + }; + }; + + if (token.end <= pos) break; + const next_width = width_px + token.advance; + if (max_width_px) |limit| { + if (next_width > limit) { + if (end_metric == .nearest and token.end != end) { + const before_dist = @abs(limit - width_px); + const after_dist = @abs(next_width - limit); + if (after_dist < before_dist) { + return .{ .end = token.end, .width_px = next_width }; + } + } + return .{ .end = end, .width_px = width_px }; + } + } + pos = token.end; + end = pos; + width_px = next_width; + } + + return .{ .end = end, .width_px = width_px }; + } + + fn nonEmojiTokenAdvancePhysical( + primary: *c.TTF_Font, + metrics: ?*SimpleMetrics, + bytes: []const u8, + ) f32 { + if (bytes.len == 0) return 0; + if (metrics) |m| { + if (isSimpleAscii(bytes)) { + return @floatFromInt(simpleMetricsWidth(m, bytes)); + } + } + + var width_px: c_int = 0; + if (c.TTF_GetStringSize(primary, bytes.ptr, bytes.len, &width_px, null)) { + return @floatFromInt(@max(0, width_px)); + } + return 0; + } + fn measureSimple( self: *System, font: dvui.Font, @@ -680,17 +744,24 @@ pub const System = struct { byte_offset: usize, ) usize { if (byte_offset == 0 or text.len == 0) return 0; + if (previousEmojiBoundaryNear(text, byte_offset)) |boundary| return boundary; + if (isFastSingleBytePreviousBoundary(text, byte_offset)) return byte_offset - 1; + const self: *System = @ptrCast(@alignCast(context)); - const shaped = self.layout(font, text, currentScale(), null) orelse + const window = boundaryWindow(text, byte_offset); + const local_offset = byte_offset - window.start; + const shaped = self.layout(font, text[window.start..window.end], currentScale(), null) orelse return previousCodepoint(text, byte_offset); defer shaped.release(); const layout_text = shaped.text; var substring: c.TTF_SubString = undefined; - if (!c.TTF_GetTextSubString(layout_text, @intCast(byte_offset - 1), &substring)) { + if (local_offset == 0 or !c.TTF_GetTextSubString(layout_text, @intCast(local_offset - 1), &substring)) { return previousCodepoint(text, byte_offset); } - return @intCast(@max(0, substring.offset)); + const result = window.start + @as(usize, @intCast(@max(0, substring.offset))); + if (result >= byte_offset) return previousCodepoint(text, byte_offset); + return result; } fn nextBoundary( @@ -700,17 +771,25 @@ pub const System = struct { byte_offset: usize, ) usize { if (byte_offset >= text.len) return text.len; + if (nextEmojiBoundaryNear(text, byte_offset)) |boundary| return boundary; + if (isFastSingleByteNextBoundary(text, byte_offset)) return byte_offset + 1; + const self: *System = @ptrCast(@alignCast(context)); - const shaped = self.layout(font, text, currentScale(), null) orelse + const window = boundaryWindow(text, byte_offset); + const local_offset = byte_offset - window.start; + const shaped = self.layout(font, text[window.start..window.end], currentScale(), null) orelse return nextCodepoint(text, byte_offset); defer shaped.release(); const layout_text = shaped.text; var substring: c.TTF_SubString = undefined; - if (!c.TTF_GetTextSubString(layout_text, @intCast(byte_offset), &substring)) { + if (!c.TTF_GetTextSubString(layout_text, @intCast(local_offset), &substring)) { return nextCodepoint(text, byte_offset); } - return @min(text.len, @as(usize, @intCast(@max(0, substring.offset + substring.length)))); + const local_result = @as(usize, @intCast(@max(0, substring.offset + substring.length))); + const result = @min(text.len, window.start + local_result); + if (result <= byte_offset) return nextCodepoint(text, byte_offset); + return result; } fn renderVisualTextSegments( @@ -971,13 +1050,30 @@ fn safeScale(scale: f32) f32 { return if (scale > 0) scale else 1; } -fn firstLine(text: []const u8) struct { bytes: []const u8, has_newline: bool } { +const TextLine = struct { + bytes: []const u8, + has_newline: bool, +}; + +fn firstLine(text: []const u8) TextLine { if (std.mem.indexOfScalar(u8, text, '\n')) |idx| { return .{ .bytes = text[0..idx], .has_newline = true }; } return .{ .bytes = text, .has_newline = false }; } +const EmojiMeasureToken = struct { + end: usize, + advance: f32, +}; + +const boundary_context_bytes: usize = 512; + +const BoundaryWindow = struct { + start: usize, + end: usize, +}; + const simple_measure_probe_bytes: usize = 16 * 1024; const SimpleMeasureLine = struct { @@ -1217,6 +1313,53 @@ fn containsEmojiCluster(bytes: []const u8) bool { return nextEmojiCluster(bytes, &index) != null; } +fn emojiSafeLayoutText(allocator: std.mem.Allocator, bytes: []const u8) !?[]u8 { + if (!emojiOverlayEnabled()) return null; + if (!containsEmojiCluster(bytes)) return null; + + const safe = try allocator.dupe(u8, bytes); + var index: usize = 0; + while (nextEmojiCluster(bytes, &index)) |cluster| { + @memset(safe[cluster.start..cluster.end], ' '); + } + replaceEmojiJoiners(safe); + return safe; +} + +fn replaceEmojiJoiners(bytes: []u8) void { + var index: usize = 0; + while (index < bytes.len) { + const start = index; + const codepoint = nextCodepointValue(bytes, &index) orelse { + index = start + 1; + continue; + }; + if (codepoint == 0x200d) { + @memset(bytes[start..index], ' '); + } + } +} + +fn previousEmojiBoundary(bytes: []const u8, byte_offset: usize) ?usize { + const offset = @min(byte_offset, bytes.len); + var index: usize = 0; + while (nextEmojiCluster(bytes, &index)) |cluster| { + if (cluster.start < offset and offset <= cluster.end) return cluster.start; + if (cluster.start >= offset) break; + } + return null; +} + +fn nextEmojiBoundary(bytes: []const u8, byte_offset: usize) ?usize { + const offset = @min(byte_offset, bytes.len); + var index: usize = 0; + while (nextEmojiCluster(bytes, &index)) |cluster| { + if (cluster.start <= offset and offset < cluster.end) return cluster.end; + if (cluster.start > offset) break; + } + return null; +} + fn containsCjkBaselineCodepoint(bytes: []const u8) bool { var index: usize = 0; while (nextCodepointValue(bytes, &index)) |codepoint| { @@ -1281,6 +1424,64 @@ fn previousEmojiOrCodepoint(text: []const u8, offset: usize) usize { return previousCodepoint(text, bounded); } +fn isFastSingleBytePreviousBoundary(text: []const u8, offset: usize) bool { + const bounded = @min(offset, text.len); + if (bounded == 0) return false; + const byte = text[bounded - 1]; + return byte < 0x80 and byte != '\r' and byte != '\n'; +} + +fn isFastSingleByteNextBoundary(text: []const u8, offset: usize) bool { + if (offset >= text.len) return false; + const byte = text[offset]; + return byte < 0x80 and byte != '\r' and byte != '\n'; +} + +fn boundaryWindow(text: []const u8, byte_offset: usize) BoundaryWindow { + const offset = @min(byte_offset, text.len); + var start = offset -| boundary_context_bytes; + if (std.mem.lastIndexOfScalar(u8, text[start..offset], '\n')) |newline| { + start += newline + 1; + } + start = utf8StartAtOrBefore(text, start); + + var end = @min(text.len, offset + boundary_context_bytes); + if (std.mem.indexOfScalar(u8, text[offset..end], '\n')) |newline| { + end = offset + newline; + } + end = utf8EndAtOrAfter(text, end); + if (end <= start) end = @min(text.len, nextCodepoint(text, start)); + return .{ .start = start, .end = end }; +} + +fn utf8StartAtOrBefore(text: []const u8, offset: usize) usize { + var result = @min(offset, text.len); + while (result > 0 and result < text.len and (text[result] & 0xc0) == 0x80) result -= 1; + return result; +} + +fn utf8EndAtOrAfter(text: []const u8, offset: usize) usize { + var result = @min(offset, text.len); + while (result < text.len and (text[result] & 0xc0) == 0x80) result += 1; + return result; +} + +fn previousEmojiBoundaryNear(bytes: []const u8, byte_offset: usize) ?usize { + const window = boundaryWindow(bytes, byte_offset); + if (previousEmojiBoundary(bytes[window.start..window.end], byte_offset - window.start)) |boundary| { + return window.start + boundary; + } + return null; +} + +fn nextEmojiBoundaryNear(bytes: []const u8, byte_offset: usize) ?usize { + const window = boundaryWindow(bytes, byte_offset); + if (nextEmojiBoundary(bytes[window.start..window.end], byte_offset - window.start)) |boundary| { + return window.start + boundary; + } + return null; +} + fn nextCodepoint(text: []const u8, offset: usize) usize { if (offset >= text.len) return text.len; return @min(text.len, offset + (std.unicode.utf8ByteSequenceLength(text[offset]) catch 1)); @@ -1421,6 +1622,44 @@ fn loadNativeEmojiBitmap(cluster: []const u8, physical_size: f32) ?NativeEmojiBi return bitmap; } +test "emoji safe layout text preserves byte offsets" { + if (!emojiOverlayEnabled()) return; + + const safe = (try emojiSafeLayoutText(std.testing.allocator, "a😂b")).?; + defer std.testing.allocator.free(safe); + + try std.testing.expectEqualStrings("a b", safe); +} + +test "emoji boundaries treat utf8 emoji as one cluster" { + const text = "a😂b"; + + try std.testing.expectEqual(@as(?usize, 1), previousEmojiBoundary(text, 5)); + try std.testing.expectEqual(@as(?usize, 5), nextEmojiBoundary(text, 1)); + try std.testing.expectEqual(@as(?usize, null), previousEmojiBoundary(text, 1)); + try std.testing.expectEqual(@as(?usize, null), nextEmojiBoundary(text, 5)); +} + +test "near emoji boundaries do not require scanning from the beginning" { + const prefix = "0123456789" ** 80; + const text = prefix ++ "a😂b"; + const emoji_start = prefix.len + 1; + const emoji_end = emoji_start + "😂".len; + + try std.testing.expectEqual(@as(?usize, emoji_start), previousEmojiBoundaryNear(text, emoji_end)); + try std.testing.expectEqual(@as(?usize, emoji_end), nextEmojiBoundaryNear(text, emoji_start)); +} + +test "boundary window is local and utf8 aligned" { + const text = ("a" ** 600) ++ "你" ++ ("b" ** 600); + const offset = 600 + "你".len; + const window = boundaryWindow(text, offset); + + try std.testing.expect(window.start > 0); + try std.testing.expect(window.end < text.len); + try std.testing.expectEqual(@as(usize, 600), previousCodepoint(text, offset)); +} + extern fn shellowo_render_emoji_bitmap( utf8: [*]const u8, utf8_len: usize, diff --git a/src/ui/features/files/panel_state.zig b/src/ui/features/files/panel_state.zig index 79a8ed2..e31d436 100644 --- a/src/ui/features/files/panel_state.zig +++ b/src/ui/features/files/panel_state.zig @@ -145,6 +145,15 @@ pub const PaneLayout = struct { dismissed_toast: [96]u8 = undefined, dismissed_toast_len: usize = 0, + pub fn deinitData(ptr: *anyopaque) void { + const self: *PaneLayout = @ptrCast(@alignCast(ptr)); + self.deinit(); + } + + pub fn deinit(self: *PaneLayout) void { + self.editor.deinit(); + } + pub fn isSelected(self: *const PaneLayout, name: []const u8) bool { for (0..self.selected_count) |idx| { if (std.mem.eql(u8, self.selectedName(idx), name)) return true; diff --git a/src/ui/workspace/file_panel.zig b/src/ui/workspace/file_panel.zig index 0c916c1..aadb9ce 100644 --- a/src/ui/workspace/file_panel.zig +++ b/src/ui/workspace/file_panel.zig @@ -323,6 +323,7 @@ fn filePane(kind: PaneKind, palette: theme.Palette, opts: PaneOptions, intent: * })); defer pane.deinit(); const layout = dvui.dataGetPtrDefault(null, pane.data().id, "file-pane-layout", PaneLayoutState, .{}); + dvui.dataSetDeinitFunction(null, pane.data().id, "file-pane-layout", &PaneLayoutState.deinitData); if (!layout.columns_initialized) { layout.columns = opts.columns.*; layout.columns_initialized = true; diff --git a/src/ui/workspace/file_panel_elements/remote_editor.zig b/src/ui/workspace/file_panel_elements/remote_editor.zig index 791522c..ee64ddf 100644 --- a/src/ui/workspace/file_panel_elements/remote_editor.zig +++ b/src/ui/workspace/file_panel_elements/remote_editor.zig @@ -20,8 +20,8 @@ const replace_capacity = 256; const search_match_count_unknown = std.math.maxInt(usize); const search_stats_chunk_bytes: usize = 256 * 1024; const search_stats_frame_us: i32 = 16_000; -const save_flash_total_s: f32 = 1.0; -const save_flash_fade_in_s: f32 = 0.3; +const save_flash_total_s: f32 = 0.45; +const save_flash_fade_in_s: f32 = 0.1; const save_flash_fade_out_s: f32 = save_flash_total_s - save_flash_fade_in_s; const save_flash_frame_us: i32 = 16_000; @@ -35,6 +35,9 @@ pub const State = struct { positioned: bool = false, fonts_loaded: bool = false, loaded_version: u64 = 0, + editor_text: []u8 = &.{}, + editor_text_allocator: ?std.mem.Allocator = null, + editor_text_len: usize = 0, dirty: bool = false, confirm_close: bool = false, close_after_save: bool = false, @@ -54,7 +57,6 @@ pub const State = struct { search_has_match: bool = false, search_match_count: usize = 0, search_active_index: usize = 0, - search_target_y: ?f32 = null, search_stats_dirty: bool = true, search_stats_text_len: usize = 0, search_stats_query_len: usize = 0, @@ -64,11 +66,31 @@ pub const State = struct { search_stats_scan_pos: usize = 0, search_stats_scan_count: usize = 0, search_stats_scan_active_index: usize = 0, + + pub fn deinit(self: *State) void { + if (self.editor_text.len > 0) { + if (self.editor_text_allocator) |allocator| { + allocator.free(self.editor_text); + } + } + self.editor_text = &.{}; + self.editor_text_allocator = null; + self.editor_text_len = 0; + } + + fn reset(self: *State) void { + self.deinit(); + self.* = .{}; + } + + fn text(self: *const State) []const u8 { + return self.editor_text[0..@min(self.editor_text_len, self.editor_text.len)]; + } }; pub fn show(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: theme.Palette, id_extra: usize) ?remote_file.FilePanelIntent { if (!snapshot.isOpen()) { - state.* = .{}; + state.reset(); return null; } @@ -123,8 +145,9 @@ pub fn show(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: th .loading => loading(palette, id_extra + 30), .failed => failed(snapshot, palette, id_extra + 30), .ready => { - current_text = editorBody(state, snapshot, palette, id_extra + 30); - dirty = !std.mem.eql(u8, current_text, snapshot.content); + const body = editorBody(state, snapshot, palette, id_extra + 30); + current_text = state.text(); + dirty = body.dirty; }, .closed => {}, } @@ -239,14 +262,18 @@ fn observeSaveFlash(state: *State, snapshot: remote_file.FileEditorSnapshot) voi } fn saveFlashLabel(state: *State, palette: theme.Palette, title_group_id: dvui.Id, id_extra: usize) void { - const alpha = saveFlashAlpha(state) orelse return; - dvui.timer(title_group_id.update("save_flash"), save_flash_frame_us); - dvui.label(@src(), "save", .{}, .{ - .font = theme.textFont("save", 10), + const active_alpha = saveFlashAlpha(state); + const alpha = active_alpha orelse 0; + if (active_alpha != null) { + dvui.timer(title_group_id.update("save_flash"), save_flash_frame_us); + } + dvui.label(@src(), "saved", .{}, .{ + .font = theme.textFont("saved", 10), .color_text = palette.network_rx.opacity(alpha), .gravity_y = 0.5, .margin = .{ .x = 8 }, .id_extra = id_extra, + .expand = .horizontal, }); } @@ -325,7 +352,6 @@ fn header(state: *State, snapshot: remote_file.FileEditorSnapshot, dirty: bool, dvui.label(@src(), "{s}", .{title}, .{ .font = theme.textFont(title, 15), .color_text = palette.text, - .expand = .horizontal, .gravity_y = 0.5, .id_extra = id_extra + 2, }); @@ -655,7 +681,11 @@ fn loadFraction(snapshot: remote_file.FileEditorSnapshot) f32 { return 0.15 + @as(f32, @floatFromInt(slot)) * 0.07; } -fn editorBody(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: theme.Palette, id_extra: usize) []const u8 { +const EditorBodyResult = struct { + dirty: bool, +}; + +fn editorBody(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: theme.Palette, id_extra: usize) EditorBodyResult { var body = dvui.box(@src(), .{ .dir = .vertical }, theme.panel(.{ .expand = .both, .padding = .all(0), @@ -672,6 +702,11 @@ fn editorBody(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: var naked_theme = dvui.themeGet(); naked_theme.focus = dvui.Color.transparent; + if (state.editor_text_allocator == null) { + state.editor_text_allocator = dvui.currentWindow().gpa; + } + const editor_text_allocator = state.editor_text_allocator.?; + var te_storage: dvui.TextEntryWidget = undefined; te_storage.init(@src(), .{ .multiline = true, @@ -681,25 +716,37 @@ fn editorBody(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: .scroll_vertical_bar = .auto_overlay, .scroll_horizontal = false, .scroll_horizontal_bar = .hide, - .text = .{ .internal = .{ .limit = remote_file.max_editor_bytes } }, + .text = .{ .buffer_dynamic = .{ + .backing = &state.editor_text, + .allocator = editor_text_allocator, + .limit = remote_file.max_editor_bytes, + } }, }, editorTextOptions(palette, &naked_theme, id_extra + 1)); var te = &te_storage; defer te.deinit(); + var reloaded_snapshot = false; if (dvui.firstFrame(te.data().id) or state.loaded_version != snapshot.version) { te.textSet(snapshot.content, false); state.loaded_version = snapshot.version; + state.dirty = false; + reloaded_snapshot = true; clearSearchMatch(state); markSearchStatsDirty(state); } applySearchAction(state, te); - applyPendingSearchScroll(state, te); te.processEvents(); te.draw(); - if (te.text_changed) markSearchStatsDirty(state); - updateSearchStatsLight(state, te.textGet()); - return te.textGet(); + var dirty = state.dirty; + if (te.text_changed) { + if (!reloaded_snapshot) dirty = true; + markSearchStatsDirty(state); + } + const current_text = te.textGet(); + state.editor_text_len = current_text.len; + updateSearchStatsLight(state, current_text); + return .{ .dirty = dirty }; } const SearchMatch = struct { @@ -893,12 +940,11 @@ fn selectMatch(state: *State, te: *dvui.TextEntryWidget, start: usize, end: usiz sel.start = start; sel.cursor = start; sel.end = end; - te.textLayout.scroll_to_cursor = false; + te.textLayout.scroll_to_cursor = true; state.search_active_start = start; state.search_active_end = end; state.search_has_match = true; - state.search_target_y = visualYOfOffset(te, start); setSearchStatsUnknown(state); dvui.refresh(null, @src(), te.data().id); } @@ -916,7 +962,6 @@ fn clearSearchMatch(state: *State) void { state.search_has_match = false; state.search_active_index = 0; state.search_match_count = 0; - state.search_target_y = null; markSearchStatsDirty(state); } @@ -933,132 +978,6 @@ fn setSearchStatsUnknown(state: *State) void { state.search_stats_scan_active_index = 0; } -fn applyPendingSearchScroll(state: *State, te: *dvui.TextEntryWidget) void { - const target_y = state.search_target_y orelse return; - state.search_target_y = null; - - const line_h = editorLineHeight(te); - const viewport_h = te.scroll.si.viewport.h; - if (viewport_h <= 0) return; - - const margin = @min(viewport_h * 0.25, line_h * 3); - const next_offset = target_y - margin; - te.scroll.si.virtual_size.h = @max(te.scroll.si.virtual_size.h, target_y + viewport_h); - te.scroll.si.scrollToOffset(.vertical, @max(0, next_offset)); - te.scroll.si.velocity.y = 0; - if (te.scroll.scroll) |*scroll| { - scroll.frame_viewport.y = te.scroll.si.viewport.y; - scroll.frame_viewport.x = te.scroll.si.viewport.x; - } - te.textLayout.scroll_to_cursor = false; - dvui.refresh(null, @src(), te.data().id); -} - -fn editorLineHeight(te: *dvui.TextEntryWidget) f32 { - return @max(te.textLayout.data().options.fontGet().lineHeight(), 1); -} - -const SearchScrollAnchor = struct { - byte: usize, - y: f32, -}; - -fn visualYOfOffset(te: *dvui.TextEntryWidget, offset: usize) f32 { - const text = te.textGet(); - const bounded = @min(offset, text.len); - if (bounded == 0) return 0; - - const line_h = editorLineHeight(te); - const wrap_cols = editorWrapColumns(te); - - if (byteHeightAnchorBefore(te, bounded)) |anchor| { - const start = @min(anchor.byte, bounded); - const local_rows = estimatedVisualRows(text[start..bounded], wrap_cols); - return anchor.y + @as(f32, @floatFromInt(local_rows)) * line_h; - } - - return @as(f32, @floatFromInt(estimatedVisualRows(text[0..bounded], wrap_cols))) * line_h; -} - -fn editorWrapColumns(te: *dvui.TextEntryWidget) usize { - const font = te.textLayout.data().options.fontGet(); - const msize = font.sizeM(1, 1); - const content_w = @max(@max(te.textLayout.data().contentRect().w, te.scroll.si.viewport.w), msize.w); - const cell_w = @max(msize.w, 1); - return @max(1, @as(usize, @intFromFloat(@max(1, @floor(content_w / cell_w))))); -} - -fn byteHeightAnchorBefore(te: *dvui.TextEntryWidget, offset: usize) ?SearchScrollAnchor { - const byte_heights = te.textLayout.byte_heights; - if (byte_heights.len == 0) return null; - - var lo: usize = 0; - var hi: usize = byte_heights.len; - while (lo < hi) { - const mid = lo + (hi - lo) / 2; - if (byte_heights[mid].byte <= offset) { - lo = mid + 1; - } else { - hi = mid; - } - } - if (lo == 0) return null; - - var idx = lo - 1; - while (true) { - const bh = byte_heights[idx]; - const is_final = idx == byte_heights.len - 1; - // Non-final byte-height entries are recorded immediately after a - // newline, which makes them safe y anchors. The final entry can be in - // the middle of a line when the layout cache only covered a visible - // prefix, so only trust it if it is the real EOF marker. - if (!is_final or bh.byte == te.len) { - return .{ .byte = @min(bh.byte, te.len), .y = bh.height }; - } - if (idx == 0) return null; - idx -= 1; - } -} - -fn estimatedVisualRows(text: []const u8, wrap_cols: usize) usize { - var rows: usize = 0; - var col: usize = 0; - var i: usize = 0; - while (i < text.len) { - const byte = text[i]; - if (byte == '\n') { - rows += 1; - col = 0; - i += 1; - continue; - } - - const width = estimatedColumnWidth(text, &i); - if (wrap_cols > 0 and col > 0 and col + width > wrap_cols) { - rows += 1; - col = 0; - } - col += width; - } - return rows; -} - -fn estimatedColumnWidth(text: []const u8, index: *usize) usize { - const byte = text[index.*]; - if (byte == '\t') { - index.* += 1; - return 4; - } - if (byte < 0x80) { - index.* += 1; - return 1; - } - - const len = std.unicode.utf8ByteSequenceLength(byte) catch 1; - index.* = @min(text.len, index.* + len); - return 2; -} - fn updateSearchStatsLight(state: *State, text: []const u8) void { const query = searchQuery(state); From 2bea991e30ca58604e5d86c9dc3457a13ba11f21 Mon Sep 17 00:00:00 2001 From: HissingRat <84263094+HissingRat@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:28:06 +0800 Subject: [PATCH 08/11] Fix remote editor search cycling --- src/ui/workspace/file_panel_elements/remote_editor.zig | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/ui/workspace/file_panel_elements/remote_editor.zig b/src/ui/workspace/file_panel_elements/remote_editor.zig index ee64ddf..efb0fcc 100644 --- a/src/ui/workspace/file_panel_elements/remote_editor.zig +++ b/src/ui/workspace/file_panel_elements/remote_editor.zig @@ -409,7 +409,7 @@ fn searchBox(state: *State, palette: theme.Palette, id_extra: usize) void { dvui.focusWidget(te.data().id, null, null); state.search_focus_requested = false; } - if (te.enterPressed()) state.search_action = .find_nearest; + if (te.enterPressed()) state.search_action = .find_next; if (te.textChanged()) { clearSearchMatch(state); } @@ -993,10 +993,8 @@ fn updateSearchStatsLight(state: *State, text: []const u8) void { if (!activeMatchValid(state, text, query)) { state.search_active_index = 0; state.search_has_match = false; - state.search_match_count = 0; - resetSearchStatsScan(state); - cacheSearchStatsKey(state, text, query); - return; + state.search_active_start = 0; + state.search_active_end = 0; } if (searchStatsNeedsRefresh(state, text, query)) { @@ -1083,7 +1081,7 @@ fn continueSearchStatsScan(state: *State, text: []const u8, query: []const u8) v fn finishSearchStatsScan(state: *State) void { state.search_match_count = state.search_stats_scan_count; - if (state.search_match_count == 0) { + if (!state.search_has_match or state.search_match_count == 0) { state.search_active_index = 0; } else if (state.search_stats_scan_active_index > 0) { state.search_active_index = state.search_stats_scan_active_index; From e2bb1f04757ff5f9e12ee2f6956ccf3d361d8a33 Mon Sep 17 00:00:00 2001 From: HissingRat <84263094+HissingRat@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:57:00 +0800 Subject: [PATCH 09/11] Fix editor and close prompt edge cases --- src/backends/text/sdl_ttf.zig | 89 +++++++++++++++---- src/ui/features/app_shell/screen.zig | 22 ++++- .../file_panel_elements/remote_editor.zig | 10 ++- 3 files changed, 94 insertions(+), 27 deletions(-) diff --git a/src/backends/text/sdl_ttf.zig b/src/backends/text/sdl_ttf.zig index 740d87c..3b545ee 100644 --- a/src/backends/text/sdl_ttf.zig +++ b/src/backends/text/sdl_ttf.zig @@ -5,6 +5,7 @@ const platform_fonts = @import("platform_fonts.zig"); const c = dvui.backend.c; const cjk_font_family = "Noto Sans CJK SC"; +const emoji_advance_padding_ratio: f32 = 0.16; pub const System = struct { allocator: std.mem.Allocator, @@ -244,6 +245,10 @@ pub const System = struct { return @max(1, @min(text_height * 0.82, em * 1.04)); } + fn emojiAdvancePhysical(self: *System, font: dvui.Font, scale: f32) f32 { + return emojiAdvanceForBox(self.emojiBoxPhysical(font, scale)); + } + fn measure( context: *anyopaque, font: dvui.Font, @@ -271,6 +276,7 @@ pub const System = struct { var end = line.bytes.len; const has_emoji = containsEmojiCluster(line.bytes); const emoji_box = self.emojiBoxPhysical(font, scale); + const emoji_advance = emojiAdvanceForBox(emoji_box); if (options.max_width) |max_width| { const point_x: c_int = @intFromFloat(@max(0, @round(max_width * scale))); if (has_emoji) { @@ -279,7 +285,7 @@ pub const System = struct { line.bytes, @floatFromInt(point_x), options.end_metric, - emoji_box, + emoji_advance, ); } else { var substring: c.TTF_SubString = undefined; @@ -290,7 +296,7 @@ pub const System = struct { } if (options.max_width) |max_width| { const max_width_px = @max(0, max_width * scale); - while (end > 0 and compensatedCaretXPhysical(layout_text, line.bytes, end, emoji_box) > max_width_px) { + while (end > 0 and compensatedCaretXPhysical(layout_text, line.bytes, end, emoji_advance) > max_width_px) { const previous = previousEmojiOrCodepoint(line.bytes, end); if (previous >= end) break; end = previous; @@ -307,9 +313,9 @@ pub const System = struct { } const measured_width_px = if (end < line.bytes.len) - compensatedCaretXPhysical(layout_text, line.bytes, end, emoji_box) + compensatedCaretXPhysical(layout_text, line.bytes, end, emoji_advance) else - @as(f32, @floatFromInt(width_px)) + emojiCompensationBefore(layout_text, line.bytes, line.bytes.len, emoji_box); + @as(f32, @floatFromInt(width_px)) + emojiCompensationBefore(layout_text, line.bytes, line.bytes.len, emoji_advance); const measured_height_px = if (has_emoji) @max(self.fontTextHeightPhysical(font, scale), emoji_box) else @@ -332,12 +338,13 @@ pub const System = struct { const face = self.faceFor(font, scale) orelse return null; const emoji_box = self.emojiBoxPhysical(font, scale); + const emoji_advance = emojiAdvanceForBox(emoji_box); const metrics = self.simpleMetricsFor(font, scale); const measured = if (options.max_width) |max_width| - measureEmojiWidth(face.primary, metrics, line.bytes, emoji_box, @max(0, max_width * scale), options.end_metric) + measureEmojiWidth(face.primary, metrics, line.bytes, emoji_advance, @max(0, max_width * scale), options.end_metric) else - measureEmojiWidth(face.primary, metrics, line.bytes, emoji_box, null, options.end_metric); + measureEmojiWidth(face.primary, metrics, line.bytes, emoji_advance, null, options.end_metric); var end = measured.end; if (line.has_newline and end == line.bytes.len) end += 1; @@ -525,9 +532,10 @@ pub const System = struct { else 16 * scale; const emoji_box = self.emojiBoxPhysical(options.font, scale); + const emoji_advance = emojiAdvanceForBox(emoji_box); const has_emoji = containsEmojiCluster(options.text); const use_emoji_overlay = has_emoji and emojiOverlayEnabled(); - const emoji_extra = if (use_emoji_overlay) emojiCompensationBefore(text, options.text, options.text.len, emoji_box) else 0; + const emoji_extra = if (use_emoji_overlay) emojiCompensationBefore(text, options.text, options.text.len, emoji_advance) else 0; const render_height = if (use_emoji_overlay) @max(self.fontTextHeightPhysical(options.font, scale), emoji_box) else default_h; if (options.background_color) |background| { @@ -545,8 +553,8 @@ pub const System = struct { const sel_end = @min(options.sel_end orelse 0, options.text.len); if (sel_start < sel_end and use_emoji_overlay) { const selection_color = options.sel_color orelse dvui.themeGet().focus; - const x0 = compensatedCaretXPhysical(text, options.text, sel_start, emoji_box); - const x1 = compensatedCaretXPhysical(text, options.text, sel_end, emoji_box); + const x0 = compensatedCaretXPhysical(text, options.text, sel_start, emoji_advance); + const x1 = compensatedCaretXPhysical(text, options.text, sel_end, emoji_advance); (dvui.Rect.Physical{ .x = start.x + @min(x0, x1), .y = start.y, @@ -596,9 +604,9 @@ pub const System = struct { const align_cjk = containsCjkBaselineCodepoint(options.text) and !isCjkFont(options.font); if (use_emoji_overlay or align_cjk) { - try self.renderVisualTextSegments(options.font, text, options.text, start, scale, options.color, emoji_box, use_emoji_overlay, align_cjk); + try self.renderVisualTextSegments(options.font, text, options.text, start, scale, options.color, emoji_advance, use_emoji_overlay, align_cjk); } else if (!c.TTF_DrawRendererText(text, start.x, start.y)) return error.SdlTtfDrawFailed; - if (use_emoji_overlay) self.renderEmojiOverlays(text, options.text, start, emoji_box, render_height); + if (use_emoji_overlay) self.renderEmojiOverlays(text, options.text, start, emoji_box, emoji_advance, render_height); } fn caretX( @@ -614,7 +622,7 @@ pub const System = struct { const shaped = self.layout(font, text, scale, null) orelse return 0; defer shaped.release(); const layout_text = shaped.text; - return compensatedCaretXPhysical(layout_text, text, byte_offset, self.emojiBoxPhysical(font, scale)) / scale; + return compensatedCaretXPhysical(layout_text, text, byte_offset, self.emojiAdvancePhysical(font, scale)) / scale; } fn caretPoint( @@ -635,11 +643,12 @@ pub const System = struct { var substring: c.TTF_SubString = undefined; if (!c.TTF_GetTextSubString(layout_text, @intCast(byte_offset), &substring)) { return .{ - .x = compensatedCaretXPhysical(layout_text, text, byte_offset, self.emojiBoxPhysical(font, scale)) / scale, + .x = compensatedCaretXPhysical(layout_text, text, byte_offset, self.emojiAdvancePhysical(font, scale)) / scale, .y = 0, }; } const emoji_box = self.emojiBoxPhysical(font, scale); + const emoji_advance = emojiAdvanceForBox(emoji_box); const rtl = (substring.flags & c.TTF_SUBSTRING_DIRECTION_MASK) == c.TTF_DIRECTION_RTL; const at_start = byte_offset <= @as(usize, @intCast(@max(0, substring.offset))); const x_px: f32 = @floatFromInt(if (at_start) @@ -647,7 +656,7 @@ pub const System = struct { else (if (rtl) substring.rect.x else substring.rect.x + substring.rect.w)); return .{ - .x = (x_px + emojiCompensationBefore(layout_text, text, byte_offset, emoji_box)) / scale, + .x = (x_px + emojiCompensationBeforeOnVisualLine(layout_text, text, byte_offset, substring.rect.y, emoji_advance)) / scale, .y = @as(f32, @floatFromInt(substring.rect.y)) / scale, }; } @@ -888,21 +897,27 @@ pub const System = struct { bytes: []const u8, start: dvui.Point.Physical, draw_h: f32, + emoji_advance: f32, line_h: f32, ) void { var index: usize = 0; while (nextEmojiCluster(bytes, &index)) |cluster| { const texture = self.emojiTexture(bytes[cluster.start..cluster.end], draw_h) orelse continue; - const cluster_x = compensatedCaretXPhysical(layout_text, bytes, cluster.start, draw_h); + const cluster_x = compensatedCaretXPhysical(layout_text, bytes, cluster.start, emoji_advance); const aspect = if (texture.height > 0) texture.width / texture.height else 1; - const draw_w = @max(1, draw_h * aspect); - const draw_x = start.x + cluster_x + (draw_h - draw_w) * 0.5; - const draw_y = start.y + @max(0, line_h - draw_h) * 0.5; + var draw_w = @max(1, draw_h * aspect); + var draw_actual_h = draw_h; + if (draw_w > emoji_advance) { + draw_w = @max(1, emoji_advance); + draw_actual_h = @max(1, draw_w / @max(aspect, 0.01)); + } + const draw_x = start.x + cluster_x + @max(0, (emoji_advance - draw_w) * 0.5); + const draw_y = start.y + @max(0, line_h - draw_actual_h) * 0.5; const dst: c.SDL_FRect = .{ .x = draw_x, .y = draw_y, .w = draw_w, - .h = draw_h, + .h = draw_actual_h, }; _ = c.SDL_RenderTexture(self.renderer, texture.texture, null, &dst); } @@ -1050,6 +1065,10 @@ fn safeScale(scale: f32) f32 { return if (scale > 0) scale else 1; } +fn emojiAdvanceForBox(emoji_box: f32) f32 { + return @max(1, emoji_box) + @max(1, emoji_box * emoji_advance_padding_ratio); +} + const TextLine = struct { bytes: []const u8, has_newline: bool, @@ -1252,6 +1271,38 @@ fn emojiCompensationBefore( return extra; } +fn emojiCompensationBeforeOnVisualLine( + layout_text: *c.TTF_Text, + bytes: []const u8, + byte_offset: usize, + line_y: c_int, + emoji_advance: f32, +) f32 { + if (!emojiOverlayEnabled()) return 0; + var extra: f32 = 0; + var index: usize = 0; + const offset = @min(byte_offset, bytes.len); + while (nextEmojiCluster(bytes, &index)) |cluster| { + if (cluster.start >= offset) break; + if (!emojiClusterOnVisualLine(layout_text, cluster, line_y)) continue; + + const delta = emojiClusterDeltaPhysical(layout_text, bytes, cluster, emoji_advance); + if (cluster.end <= offset) { + extra += delta; + } else { + extra += delta; + break; + } + } + return extra; +} + +fn emojiClusterOnVisualLine(layout_text: *c.TTF_Text, cluster: ByteRange, line_y: c_int) bool { + var substring: c.TTF_SubString = undefined; + if (!c.TTF_GetTextSubString(layout_text, @intCast(cluster.start), &substring)) return false; + return substring.rect.y == line_y; +} + fn compensatedCaretXPhysical( layout_text: *c.TTF_Text, bytes: []const u8, diff --git a/src/ui/features/app_shell/screen.zig b/src/ui/features/app_shell/screen.zig index 309b271..40bf233 100644 --- a/src/ui/features/app_shell/screen.zig +++ b/src/ui/features/app_shell/screen.zig @@ -1153,10 +1153,7 @@ fn windowClosePrompt(app: *App, palette: theme.Palette) void { const blockers = app.closeBlockers(); const window_rect = dvui.windowRect(); - const blocker_count: usize = - @intFromBool(blockers.active_sessions > 0) + - @intFromBool(blockers.active_transfers > 0) + - @intFromBool(blockers.dirty_editors > 0); + const blocker_count = closeBlockerRowCount(blockers); const popup_w: f32 = 290; const popup_h: f32 = 100 + @as(f32, @floatFromInt(blocker_count)) * 21; const rect: dvui.Rect.Natural = .{ @@ -1280,6 +1277,23 @@ fn windowClosePrompt(app: *App, palette: theme.Palette) void { } } +fn closeBlockerRowCount(blockers: App.CloseBlockers) usize { + var count: usize = 0; + if (blockers.active_sessions > 0) count += 1; + if (blockers.active_transfers > 0) count += 1; + if (blockers.dirty_editors > 0) count += 1; + return count; +} + +test "window close blocker row count handles multiple blockers" { + try std.testing.expectEqual(@as(usize, 0), closeBlockerRowCount(.{})); + try std.testing.expectEqual(@as(usize, 3), closeBlockerRowCount(.{ + .active_sessions = 2, + .active_transfers = 1, + .dirty_editors = 1, + })); +} + fn handleWindowClosePromptKeys(app: *App, data: *dvui.WidgetData) void { for (dvui.events()) |*event| { if (event.handled or event.evt != .key) continue; diff --git a/src/ui/workspace/file_panel_elements/remote_editor.zig b/src/ui/workspace/file_panel_elements/remote_editor.zig index efb0fcc..69852f7 100644 --- a/src/ui/workspace/file_panel_elements/remote_editor.zig +++ b/src/ui/workspace/file_panel_elements/remote_editor.zig @@ -725,12 +725,10 @@ fn editorBody(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: var te = &te_storage; defer te.deinit(); - var reloaded_snapshot = false; if (dvui.firstFrame(te.data().id) or state.loaded_version != snapshot.version) { te.textSet(snapshot.content, false); state.loaded_version = snapshot.version; state.dirty = false; - reloaded_snapshot = true; clearSearchMatch(state); markSearchStatsDirty(state); } @@ -738,17 +736,21 @@ fn editorBody(state: *State, snapshot: remote_file.FileEditorSnapshot, palette: applySearchAction(state, te); te.processEvents(); te.draw(); + const current_text = te.textGet(); var dirty = state.dirty; if (te.text_changed) { - if (!reloaded_snapshot) dirty = true; + dirty = editorBufferDirty(current_text, snapshot.content); markSearchStatsDirty(state); } - const current_text = te.textGet(); state.editor_text_len = current_text.len; updateSearchStatsLight(state, current_text); return .{ .dirty = dirty }; } +fn editorBufferDirty(current_text: []const u8, snapshot_content: []const u8) bool { + return current_text.len != snapshot_content.len or !std.mem.eql(u8, current_text, snapshot_content); +} + const SearchMatch = struct { start: usize, end: usize, From 22d9a668720acacd9721a9944b3c106728d61e7d Mon Sep 17 00:00:00 2001 From: HissingRat <84263094+HissingRat@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:01:09 +0800 Subject: [PATCH 10/11] Fix nearest search wrap distance --- .../workspace/file_panel_elements/remote_editor.zig | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/ui/workspace/file_panel_elements/remote_editor.zig b/src/ui/workspace/file_panel_elements/remote_editor.zig index 69852f7..b30e2b5 100644 --- a/src/ui/workspace/file_panel_elements/remote_editor.zig +++ b/src/ui/workspace/file_panel_elements/remote_editor.zig @@ -932,11 +932,19 @@ fn nearestMatch(prev: ?SearchMatch, next: ?SearchMatch, cursor: usize) ?SearchMa if (prev == null) return next; if (next == null) return prev; - const prev_distance = cursor -| prev.?.start; - const next_distance = next.?.start -| cursor; + const prev_distance = if (prev.?.start <= cursor) cursor - prev.?.start else std.math.maxInt(usize); + const next_distance = if (next.?.start >= cursor) next.?.start - cursor else std.math.maxInt(usize); return if (next_distance < prev_distance) next else prev; } +test "remote editor nearest search ignores wrapped distance" { + const first: SearchMatch = .{ .start = 6, .end = 9 }; + const last: SearchMatch = .{ .start = 32, .end = 35 }; + + try std.testing.expectEqual(first, nearestMatch(last, first, 0).?); + try std.testing.expectEqual(last, nearestMatch(last, first, 40).?); +} + fn selectMatch(state: *State, te: *dvui.TextEntryWidget, start: usize, end: usize) void { var sel = te.textLayout.selectionGet(te.len); sel.start = start; From fedb290a11c805b5d77591e8acddd111dabbe3de Mon Sep 17 00:00:00 2001 From: HissingRat <84263094+HissingRat@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:03:44 +0800 Subject: [PATCH 11/11] Document SDL_ttf layout lifecycle --- docs/integration-inventory.md | 3 ++- docs/plans/active/sdl3-ttf-font-system.md | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/integration-inventory.md b/docs/integration-inventory.md index 84209df..b8953ed 100644 --- a/docs/integration-inventory.md +++ b/docs/integration-inventory.md @@ -49,7 +49,8 @@ TextLayout 命中和 TextEntry cluster 边界可以由同一 shaped-text backend - SDL3_ttf 使用 DVUI 同一份 SDL3 3.4.4 renderer/backend。 - FreeType 使用 DVUI 已有的固定依赖,HarfBuzz 源码随 fork 固定。 - `src/backends/text/sdl_ttf.zig` 是 Shellowo-owned backend,负责 - `TTF_TextEngine`、字体、fallback 和 shaped layout cache 生命周期。 + `TTF_TextEngine`、字体、fallback、按需 `TTF_Text` layout 生命周期以及 + metrics/emoji texture cache。 - Zed Mono Extended 的 regular/bold/italic 是主字体,Noto Sans CJK SC 通过 SDL3_ttf fallback chain 提供中文 glyph。 - macOS 通过 `src/backends/text/platform_fonts_macos.c` 使用 CoreText diff --git a/docs/plans/active/sdl3-ttf-font-system.md b/docs/plans/active/sdl3-ttf-font-system.md index e378742..c6c7cb1 100644 --- a/docs/plans/active/sdl3-ttf-font-system.md +++ b/docs/plans/active/sdl3-ttf-font-system.md @@ -97,8 +97,8 @@ Responsibilities: - Open embedded fonts from memory. - Build normal, bold, and italic face variants. - Attach the CJK face as fallback. -- Cache `TTF_Text` layouts by text, face, size, scale, wrap width, direction, - and relevant style. +- Create and release `TTF_Text` layouts on demand, while caching reusable font + faces, simple metrics, font heights, and emoji textures. - Expose measurement, drawing, hit testing, caret, selection, and cluster boundaries through `dvui.TextEngine`. - Apply and restore the SDL renderer clip rectangle around direct text draws.