diff --git a/CLAUDE.md b/CLAUDE.md index 1999dc7..f47be0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,16 @@ Guidance for any code agent working on the Architect repo. Keep this file instru ## Coding Conventions - Favor self-documenting code; keep comments minimal and meaningful. - Default to ASCII unless the file already uses non-ASCII. -- Always handle errors explicitly: propagate, recover, or log; do not swallow errors with bare `catch {}` / `catch unreachable` unless proven impossible. +- **Error handling**: Always handle errors explicitly—propagate, recover, or log. Never use bare `catch {}` or `catch unreachable`. Even for "impossible" failures like action queue appends, log them: + ```zig + // WRONG: silently swallows the error + actions.append(.SomeAction) catch {}; + + // CORRECT: log the error for debugging + actions.append(.SomeAction) catch |err| { + log.warn("failed to queue action: {}", .{err}); + }; + ``` - Run `zig fmt src/` (or `zig fmt` on touched Zig files) before wrapping up changes. - Avoid destructive git commands and do not revert user changes. diff --git a/docs/architecture.md b/docs/architecture.md index 96bb042..35b4707 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,6 +77,7 @@ src/ ├── colors.zig # Theme and color palette management (ANSI 16/256) ├── config.zig # TOML config persistence ├── geom.zig # Rect + point containment +├── metrics.zig # Metrics collection framework (glyph cache stats, frame count) ├── font.zig # Font rendering, glyph caching, HarfBuzz shaping ├── font_cache.zig # Shared font cache (terminal + UI) ├── font_paths.zig # Font path resolution for system fonts @@ -128,6 +129,7 @@ src/ │ ├── help_overlay.zig # Keyboard shortcut overlay (? pill) │ ├── hotkey_indicator.zig # Hotkey visual feedback indicator │ ├── marquee_label.zig # Reusable scrolling text label + │ ├── metrics_overlay.zig # Metrics overlay (Cmd+Shift+M) │ ├── pill_group.zig # Pill overlay coordinator (collapses others) │ ├── quit_confirm.zig # Quit confirmation dialog │ ├── restart_buttons.zig # Dead session restart buttons @@ -208,6 +210,7 @@ union(enum) { CreateWorktree: CreateWorktreeAction, // git worktree add .architect/ -b && cd there RemoveWorktree: RemoveWorktreeAction, // Remove a git worktree DespawnSession: usize, // Despawn/kill a session at index + ToggleMetrics: void, // Toggle metrics overlay visibility } ``` @@ -262,6 +265,7 @@ Components that consume events: - `ConfirmDialogComponent`: Generic confirmation dialog (used by worktree removal, etc.) - `PillGroupComponent`: Coordinates pill overlays (collapses one when another expands) - `GlobalShortcutsComponent`: Handles global shortcuts like Cmd+, to open config +- `MetricsOverlayComponent`: Cmd+Shift+M to toggle metrics overlay (when enabled in config) ## Rendering Order diff --git a/docs/configuration.md b/docs/configuration.md index a56baa7..577a51d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -117,6 +117,23 @@ vsync = true # Enable vertical sync (default: true) Disabling vsync may reduce input latency but can cause screen tearing. +### Metrics Configuration + +```toml +[metrics] +enabled = false # Enable metrics collection overlay (default: false) +``` + +When enabled, press `Cmd+Shift+M` to toggle the metrics overlay in the bottom-right corner. The overlay displays: +- **Frames**: Total rendered frame count +- **FPS**: Frames per second +- **Glyph cache**: Number of cached glyph textures +- **Glyph hits/s**: Glyph cache hits per second +- **Glyph misses/s**: Glyph cache misses per second +- **Glyph evictions/s**: Glyph cache evictions per second + +Metrics collection has zero overhead when disabled (no allocations, null pointer checks compile away). + ### Complete Example ```toml @@ -161,6 +178,9 @@ enable_animations = true [rendering] vsync = true + +[metrics] +enabled = false ``` ## persistence.toml diff --git a/src/c.zig b/src/c.zig index 343ccaf..aa52a16 100644 --- a/src/c.zig +++ b/src/c.zig @@ -125,6 +125,7 @@ pub const SDLK_KP_MINUS = c_import.SDLK_KP_MINUS; pub const SDLK_A = c_import.SDLK_A; pub const SDLK_C = c_import.SDLK_C; pub const SDLK_K = c_import.SDLK_K; +pub const SDLK_M = c_import.SDLK_M; pub const SDLK_N = c_import.SDLK_N; pub const SDLK_O = c_import.SDLK_O; pub const SDLK_T = c_import.SDLK_T; diff --git a/src/config.zig b/src/config.zig index 92bb1cd..c8de927 100644 --- a/src/config.zig +++ b/src/config.zig @@ -232,6 +232,10 @@ pub const Rendering = struct { vsync: bool = true, }; +pub const MetricsConfig = struct { + enabled: bool = false, +}; + pub const Persistence = struct { const TerminalKeyPrefix = "terminal_"; @@ -437,6 +441,7 @@ pub const Config = struct { theme: ThemeConfig = .{}, ui: UiConfig = .{}, rendering: Rendering = .{}, + metrics: MetricsConfig = .{}, pub fn load(allocator: std.mem.Allocator) LoadError!Config { const config_path = try getConfigPath(allocator); @@ -513,6 +518,10 @@ pub const Config = struct { \\# bright_cyan = "#56B6C2" \\# bright_white = "#CDD6E0" \\ + \\# Metrics overlay (Cmd+Shift+M to toggle when enabled) + \\# [metrics] + \\# enabled = false + \\ ; const file = try fs.createFileAbsolute(config_path, .{ .truncate = true }); diff --git a/src/font.zig b/src/font.zig index 424159b..263bf9f 100644 --- a/src/font.zig +++ b/src/font.zig @@ -2,6 +2,7 @@ // efficiently at varying scales. const std = @import("std"); const c = @import("c.zig"); +const metrics_mod = @import("metrics.zig"); const log = std.log.scoped(.font); @@ -53,6 +54,7 @@ pub const Font = struct { cell_width: c_int, cell_height: c_int, owns_fonts: bool, + metrics: ?*metrics_mod.Metrics = null, /// Limit cached glyph textures to avoid unbounded GPU/heap growth. const MAX_GLYPH_CACHE_ENTRIES: usize = 4096; @@ -486,6 +488,7 @@ pub const Font = struct { if (self.glyph_cache.getEntry(key)) |entry| { entry.value_ptr.seq = self.nextSeq(); + if (self.metrics) |m| m.increment(.glyph_cache_hits); return entry.value_ptr.texture; } @@ -519,6 +522,10 @@ pub const Font = struct { _ = c.SDL_SetTextureScaleMode(texture, c.SDL_SCALEMODE_LINEAR); try self.glyph_cache.put(key, .{ .texture = texture, .seq = self.nextSeq() }); + if (self.metrics) |m| { + m.increment(.glyph_cache_misses); + m.set(.glyph_cache_size, self.glyph_cache.count()); + } self.evictIfNeeded(); return texture; } @@ -534,10 +541,15 @@ pub const Font = struct { if (findOldestKey(&self.glyph_cache)) |victim| { if (self.glyph_cache.fetchRemove(victim)) |removed| { c.SDL_DestroyTexture(removed.value.texture); + if (self.metrics) |m| m.increment(.glyph_cache_evictions); } } } + // O(n) linear scan to find the oldest entry. A proper LRU list would give O(1) eviction, + // but in practice the 4096-entry cache is large enough that evictions are rare during + // normal terminal usage—even with diverse Unicode, emoji, and multiple font sizes. + // The simplicity of a flat hashmap outweighs the cost of occasional linear scans. fn findOldestKey(map: *std.AutoHashMap(GlyphKey, CacheEntry)) ?GlyphKey { var it = map.iterator(); var oldest_key: ?GlyphKey = null; diff --git a/src/main.zig b/src/main.zig index 95b706a..f9465a7 100644 --- a/src/main.zig +++ b/src/main.zig @@ -22,6 +22,7 @@ const ui_mod = @import("ui/mod.zig"); const font_cache_mod = @import("font_cache.zig"); const ghostty_vt = @import("ghostty-vt"); const c = @import("c.zig"); +const metrics_mod = @import("metrics.zig"); const open_url = @import("os/open.zig"); const url_matcher = @import("url_matcher.zig"); @@ -255,8 +256,13 @@ pub fn main() !void { font_paths.emoji_fallback, ); + var metrics_storage: metrics_mod.Metrics = metrics_mod.Metrics.init(); + const metrics_ptr: ?*metrics_mod.Metrics = if (config.metrics.enabled) &metrics_storage else null; + metrics_mod.global = metrics_ptr; + var font = try initSharedFont(allocator, renderer, &shared_font_cache, scaledFontSize(font_size, ui_scale)); defer font.deinit(); + font.metrics = metrics_ptr; var ui_font = try initSharedFont(allocator, renderer, &shared_font_cache, scaledFontSize(UI_FONT_SIZE, ui_scale)); defer ui_font.deinit(); @@ -381,6 +387,8 @@ pub fn main() !void { try ui.register(global_shortcuts_component); const cwd_bar_component = try ui_mod.cwd_bar.CwdBarComponent.init(allocator); try ui.register(cwd_bar_component.asComponent()); + const metrics_overlay_component = try ui_mod.metrics_overlay.MetricsOverlayComponent.init(allocator); + try ui.register(metrics_overlay_component.asComponent()); // Main loop: handle SDL input, feed PTY output into terminals, apply async // notifications, drive animations, and render at ~60 FPS. @@ -455,6 +463,7 @@ pub fn main() !void { font.deinit(); ui_font.deinit(); font = try initSharedFont(allocator, renderer, &shared_font_cache, scaledFontSize(font_size, ui_scale)); + font.metrics = metrics_ptr; ui_font = try initSharedFont(allocator, renderer, &shared_font_cache, scaledFontSize(UI_FONT_SIZE, ui_scale)); ui.assets.ui_font = &ui_font; const new_term_size = calculateTerminalSizeForMode(&font, render_width, render_height, anim_state.mode, config.grid.font_scale, grid_cols, grid_rows); @@ -637,6 +646,7 @@ pub fn main() !void { const new_font = try initSharedFont(allocator, renderer, &shared_font_cache, scaledFontSize(target_size, ui_scale)); font.deinit(); font = new_font; + font.metrics = metrics_ptr; font_size = target_size; const term_size = calculateTerminalSizeForMode(&font, render_width, render_height, anim_state.mode, config.grid.font_scale, grid_cols, grid_rows); @@ -1296,6 +1306,14 @@ pub fn main() !void { session.attention = false; ui.showToast("Removing worktree…", now); }, + .ToggleMetrics => { + if (config.metrics.enabled) { + metrics_overlay_component.toggle(); + if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘⇧M", now); + } else { + ui.showToast("Metrics disabled in config", now); + } + }, }; if (anim_state.mode == .Expanding or anim_state.mode == .Collapsing or @@ -1353,6 +1371,7 @@ pub fn main() !void { try renderer_mod.render(renderer, sessions, cell_width_pixels, cell_height_pixels, grid_cols, grid_rows, &anim_state, now, &font, full_cols, full_rows, render_width, render_height, &theme, config.grid.font_scale); ui.render(&ui_render_host, renderer); _ = c.SDL_RenderPresent(renderer); + metrics_mod.increment(.frame_count); last_render_ns = std.time.nanoTimestamp(); } diff --git a/src/metrics.zig b/src/metrics.zig new file mode 100644 index 0000000..c1f1d27 --- /dev/null +++ b/src/metrics.zig @@ -0,0 +1,99 @@ +const std = @import("std"); + +pub const MetricKind = enum(u8) { + glyph_cache_hits, + glyph_cache_misses, + glyph_cache_evictions, + glyph_cache_size, + frame_count, +}; + +const METRIC_COUNT = @typeInfo(MetricKind).@"enum".fields.len; + +pub const Metrics = struct { + values: [METRIC_COUNT]u64, + prev_values: [METRIC_COUNT]u64, + last_sample_ms: i64, + + pub fn init() Metrics { + return .{ + .values = [_]u64{0} ** METRIC_COUNT, + .prev_values = [_]u64{0} ** METRIC_COUNT, + .last_sample_ms = 0, + }; + } + + pub fn increment(self: *Metrics, kind: MetricKind) void { + self.values[@intFromEnum(kind)] +%= 1; + } + + pub fn set(self: *Metrics, kind: MetricKind, value: u64) void { + self.values[@intFromEnum(kind)] = value; + } + + pub fn get(self: *const Metrics, kind: MetricKind) u64 { + return self.values[@intFromEnum(kind)]; + } + + /// Returns elapsed time since last sample. Call getRate() after this, + /// then commitSample() to prepare for the next interval. + pub fn sampleRates(self: *const Metrics, now_ms: i64) i64 { + return now_ms - self.last_sample_ms; + } + + /// Copies current values to prev_values for the next rate calculation. + /// Call this AFTER getRate() to avoid zeroing the delta. + pub fn commitSample(self: *Metrics, now_ms: i64) void { + @memcpy(&self.prev_values, &self.values); + self.last_sample_ms = now_ms; + } + + pub fn getRate(self: *const Metrics, kind: MetricKind, elapsed_ms: i64) f64 { + if (elapsed_ms <= 0) return 0.0; + const idx = @intFromEnum(kind); + const delta = self.values[idx] -% self.prev_values[idx]; + return @as(f64, @floatFromInt(delta)) / (@as(f64, @floatFromInt(elapsed_ms)) / 1000.0); + } +}; + +pub var global: ?*Metrics = null; + +pub inline fn increment(kind: MetricKind) void { + if (global) |m| m.increment(kind); +} + +pub inline fn set(kind: MetricKind, value: u64) void { + if (global) |m| m.set(kind, value); +} + +test "Metrics.increment" { + var m = Metrics.init(); + try std.testing.expectEqual(@as(u64, 0), m.get(.glyph_cache_hits)); + m.increment(.glyph_cache_hits); + try std.testing.expectEqual(@as(u64, 1), m.get(.glyph_cache_hits)); + m.increment(.glyph_cache_hits); + try std.testing.expectEqual(@as(u64, 2), m.get(.glyph_cache_hits)); +} + +test "Metrics.set" { + var m = Metrics.init(); + m.set(.glyph_cache_size, 42); + try std.testing.expectEqual(@as(u64, 42), m.get(.glyph_cache_size)); + m.set(.glyph_cache_size, 100); + try std.testing.expectEqual(@as(u64, 100), m.get(.glyph_cache_size)); +} + +test "Metrics.getRate" { + var m = Metrics.init(); + m.last_sample_ms = 0; + m.prev_values = [_]u64{0} ** METRIC_COUNT; + m.values[@intFromEnum(MetricKind.glyph_cache_hits)] = 10; + const rate = m.getRate(.glyph_cache_hits, 1000); + try std.testing.expectApproxEqAbs(@as(f64, 10.0), rate, 0.001); +} + +test "global metrics null check" { + global = null; + increment(.frame_count); + set(.glyph_cache_size, 100); +} diff --git a/src/ui/components/metrics_overlay.zig b/src/ui/components/metrics_overlay.zig new file mode 100644 index 0000000..a0bb42b --- /dev/null +++ b/src/ui/components/metrics_overlay.zig @@ -0,0 +1,271 @@ +const std = @import("std"); +const c = @import("../../c.zig"); +const types = @import("../types.zig"); +const FirstFrameGuard = @import("../first_frame_guard.zig").FirstFrameGuard; +const UiComponent = @import("../component.zig").UiComponent; +const metrics_mod = @import("../../metrics.zig"); +const font_cache = @import("../../font_cache.zig"); + +const log = std.log.scoped(.metrics_overlay); + +pub const MetricsOverlayComponent = struct { + allocator: std.mem.Allocator, + visible: bool = false, + first_frame: FirstFrameGuard = .{}, + + font_generation: u64 = 0, + texture: ?*c.SDL_Texture = null, + tex_w: c_int = 0, + tex_h: c_int = 0, + dirty: bool = true, + + last_sample_ms: i64 = 0, + cached_elapsed_ms: i64 = 0, + + const OVERLAY_FONT_SIZE: c_int = 14; + const SAMPLE_INTERVAL_MS: i64 = 1000; + const PADDING: c_int = 10; + const BG_PADDING: c_int = 8; + const BG_ALPHA: u8 = 180; + const BORDER_ALPHA: u8 = 120; + const MAX_LINES: usize = 8; + const MAX_LINE_LENGTH: usize = 64; + + pub fn init(allocator: std.mem.Allocator) !*MetricsOverlayComponent { + const comp = try allocator.create(MetricsOverlayComponent); + comp.* = .{ .allocator = allocator }; + return comp; + } + + pub fn asComponent(self: *MetricsOverlayComponent) UiComponent { + return .{ + .ptr = self, + .vtable = &vtable, + .z_index = 950, + }; + } + + pub fn toggle(self: *MetricsOverlayComponent) void { + self.visible = !self.visible; + self.dirty = true; + self.first_frame.markTransition(); + } + + pub fn destroy(self: *MetricsOverlayComponent, renderer: *c.SDL_Renderer) void { + if (self.texture) |tex| { + c.SDL_DestroyTexture(tex); + self.texture = null; + } + self.allocator.destroy(self); + _ = renderer; + } + + fn handleEvent(_: *anyopaque, _: *const types.UiHost, event: *const c.SDL_Event, actions: *types.UiActionQueue) bool { + if (event.type == c.SDL_EVENT_KEY_DOWN) { + const key_event = event.key; + const mods = key_event.mod; + const has_cmd = (mods & c.SDL_KMOD_GUI) != 0; + const has_shift = (mods & c.SDL_KMOD_SHIFT) != 0; + const has_blocking = (mods & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT)) != 0; + + if (has_cmd and has_shift and !has_blocking and key_event.key == c.SDLK_M) { + actions.append(.{ .ToggleMetrics = {} }) catch |err| { + log.warn("failed to queue ToggleMetrics action: {}", .{err}); + }; + return true; + } + } + return false; + } + + fn update(_: *anyopaque, _: *const types.UiHost, _: *types.UiActionQueue) void {} + + fn wantsFrame(self_ptr: *anyopaque, _: *const types.UiHost) bool { + const self: *MetricsOverlayComponent = @ptrCast(@alignCast(self_ptr)); + return self.first_frame.wantsFrame() or self.visible; + } + + fn render(self_ptr: *anyopaque, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets) void { + const self: *MetricsOverlayComponent = @ptrCast(@alignCast(self_ptr)); + if (!self.visible) return; + + const metrics_ptr = metrics_mod.global orelse return; + + var needs_commit = false; + if (host.now_ms - self.last_sample_ms >= SAMPLE_INTERVAL_MS) { + self.cached_elapsed_ms = metrics_ptr.sampleRates(host.now_ms); + self.last_sample_ms = host.now_ms; + self.dirty = true; + needs_commit = true; + } + + const cache = assets.font_cache orelse return; + if (self.font_generation != cache.generation) { + self.font_generation = cache.generation; + self.dirty = true; + } + + self.ensureTexture(renderer, cache, host.theme, metrics_ptr) catch return; + + if (needs_commit) { + metrics_ptr.commitSample(host.now_ms); + } + const texture = self.texture orelse return; + + var text_width_f: f32 = 0; + var text_height_f: f32 = 0; + _ = c.SDL_GetTextureSize(texture, &text_width_f, &text_height_f); + + const text_width: c_int = @intFromFloat(text_width_f); + const text_height: c_int = @intFromFloat(text_height_f); + + const x = host.window_w - text_width - PADDING - BG_PADDING; + const y = host.window_h - text_height - PADDING - BG_PADDING; + + const bg_rect = c.SDL_FRect{ + .x = @as(f32, @floatFromInt(x - BG_PADDING)), + .y = @as(f32, @floatFromInt(y - BG_PADDING)), + .w = @as(f32, @floatFromInt(text_width + BG_PADDING * 2)), + .h = @as(f32, @floatFromInt(text_height + BG_PADDING * 2)), + }; + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + const sel = host.theme.selection; + _ = c.SDL_SetRenderDrawColor(renderer, sel.r, sel.g, sel.b, BG_ALPHA); + _ = c.SDL_RenderFillRect(renderer, &bg_rect); + + const acc = host.theme.accent; + _ = c.SDL_SetRenderDrawColor(renderer, acc.r, acc.g, acc.b, BORDER_ALPHA); + _ = c.SDL_RenderRect(renderer, &bg_rect); + + _ = c.SDL_SetTextureBlendMode(texture, c.SDL_BLENDMODE_BLEND); + const dest_rect = c.SDL_FRect{ + .x = @floatFromInt(x), + .y = @floatFromInt(y), + .w = text_width_f, + .h = text_height_f, + }; + + _ = c.SDL_RenderTexture(renderer, texture, null, &dest_rect); + self.first_frame.markDrawn(); + } + + fn ensureTexture( + self: *MetricsOverlayComponent, + renderer: *c.SDL_Renderer, + cache: *font_cache.FontCache, + theme: *const @import("../../colors.zig").Theme, + metrics_ptr: *metrics_mod.Metrics, + ) !void { + if (!self.dirty and self.texture != null) return; + + const fonts = try cache.get(OVERLAY_FONT_SIZE); + const overlay_font = fonts.regular; + const fg = theme.foreground; + const fg_color = c.SDL_Color{ .r = fg.r, .g = fg.g, .b = fg.b, .a = 255 }; + + var line_bufs: [MAX_LINES][MAX_LINE_LENGTH]u8 = undefined; + var lines: [MAX_LINES][]const u8 = undefined; + var line_count: usize = 0; + + const frame_count = metrics_ptr.get(.frame_count); + const fps = metrics_ptr.getRate(.frame_count, self.cached_elapsed_ms); + const cache_size = metrics_ptr.get(.glyph_cache_size); + const hit_rate = metrics_ptr.getRate(.glyph_cache_hits, self.cached_elapsed_ms); + const miss_rate = metrics_ptr.getRate(.glyph_cache_misses, self.cached_elapsed_ms); + const evict_rate = metrics_ptr.getRate(.glyph_cache_evictions, self.cached_elapsed_ms); + + lines[line_count] = std.fmt.bufPrint(&line_bufs[line_count], "Frames: {d}", .{frame_count}) catch "Frames: ?"; + line_count += 1; + + lines[line_count] = std.fmt.bufPrint(&line_bufs[line_count], "FPS: {d:.1}", .{fps}) catch "FPS: ?"; + line_count += 1; + + lines[line_count] = std.fmt.bufPrint(&line_bufs[line_count], "Glyph cache: {d}", .{cache_size}) catch "Glyph cache: ?"; + line_count += 1; + + lines[line_count] = std.fmt.bufPrint(&line_bufs[line_count], "Glyph hits/s: {d:.1}", .{hit_rate}) catch "Glyph hits/s: ?"; + line_count += 1; + + lines[line_count] = std.fmt.bufPrint(&line_bufs[line_count], "Glyph misses/s: {d:.1}", .{miss_rate}) catch "Glyph misses/s: ?"; + line_count += 1; + + lines[line_count] = std.fmt.bufPrint(&line_bufs[line_count], "Glyph evictions/s: {d:.1}", .{evict_rate}) catch "Glyph evictions/s: ?"; + line_count += 1; + + var max_width: c_int = 0; + var line_surfaces: [MAX_LINES]?*c.SDL_Surface = [_]?*c.SDL_Surface{null} ** MAX_LINES; + var line_heights: [MAX_LINES]c_int = undefined; + defer { + for (line_surfaces[0..line_count]) |surf_opt| { + if (surf_opt) |surf| { + c.SDL_DestroySurface(surf); + } + } + } + + for (lines[0..line_count], 0..) |line, idx| { + var render_buf: [MAX_LINE_LENGTH]u8 = undefined; + @memcpy(render_buf[0..line.len], line); + render_buf[line.len] = 0; + + const surface = c.TTF_RenderText_Blended(overlay_font, @ptrCast(&render_buf), line.len, fg_color) orelse continue; + line_surfaces[idx] = surface; + line_heights[idx] = surface.*.h; + max_width = @max(max_width, surface.*.w); + } + + var total_height: c_int = 0; + for (line_heights[0..line_count]) |h| { + total_height += h; + } + + if (max_width == 0 or total_height == 0) return; + + const composite_surface = c.SDL_CreateSurface(max_width, total_height, c.SDL_PIXELFORMAT_RGBA8888) orelse return error.SurfaceFailed; + defer c.SDL_DestroySurface(composite_surface); + + _ = c.SDL_SetSurfaceBlendMode(composite_surface, c.SDL_BLENDMODE_BLEND); + _ = c.SDL_FillSurfaceRect(composite_surface, null, 0); + + var y_offset: c_int = 0; + for (line_surfaces[0..line_count], 0..) |surf_opt, idx| { + if (surf_opt) |line_surface| { + const dest_rect = c.SDL_Rect{ + .x = 0, + .y = y_offset, + .w = line_surface.*.w, + .h = line_surface.*.h, + }; + _ = c.SDL_BlitSurface(line_surface, null, composite_surface, &dest_rect); + y_offset += line_heights[idx]; + } + } + + const texture = c.SDL_CreateTextureFromSurface(renderer, composite_surface) orelse return error.TextureFailed; + if (self.texture) |old| { + c.SDL_DestroyTexture(old); + } + self.texture = texture; + self.dirty = false; + + var w: f32 = 0; + var h: f32 = 0; + _ = c.SDL_GetTextureSize(texture, &w, &h); + self.tex_w = @intFromFloat(w); + self.tex_h = @intFromFloat(h); + } + + fn deinitComp(self_ptr: *anyopaque, renderer: *c.SDL_Renderer) void { + const self: *MetricsOverlayComponent = @ptrCast(@alignCast(self_ptr)); + self.destroy(renderer); + } + + const vtable = UiComponent.VTable{ + .handleEvent = handleEvent, + .update = update, + .render = render, + .deinit = deinitComp, + .wantsFrame = wantsFrame, + }; +}; diff --git a/src/ui/mod.zig b/src/ui/mod.zig index d69b1e8..83fd178 100644 --- a/src/ui/mod.zig +++ b/src/ui/mod.zig @@ -16,3 +16,4 @@ pub const confirm_dialog = @import("components/confirm_dialog.zig"); pub const hotkey_indicator = @import("components/hotkey_indicator.zig"); pub const global_shortcuts = @import("components/global_shortcuts.zig"); pub const cwd_bar = @import("components/cwd_bar.zig"); +pub const metrics_overlay = @import("components/metrics_overlay.zig"); diff --git a/src/ui/types.zig b/src/ui/types.zig index 8405889..e4da372 100644 --- a/src/ui/types.zig +++ b/src/ui/types.zig @@ -42,6 +42,7 @@ pub const UiAction = union(enum) { CreateWorktree: CreateWorktreeAction, RemoveWorktree: RemoveWorktreeAction, DespawnSession: usize, + ToggleMetrics: void, }; pub const SwitchWorktreeAction = struct {