diff --git a/src/tui.zig b/src/tui.zig index 04039c3..579e65d 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -31,6 +31,26 @@ pub const Viewport = struct { } }; +pub const RenderDiagnostics = struct { + viewport_width: usize, + viewport_height: usize, + frame_bytes: usize, + frame_lines: usize, + max_line_cells: usize, + clipped_sources: usize, +}; + +pub const RenderResult = struct { + frame: []u8, + diagnostics: RenderDiagnostics, + warning: []u8, + + pub fn deinit(self: RenderResult, allocator: std.mem.Allocator) void { + allocator.free(self.frame); + allocator.free(self.warning); + } +}; + pub fn runTrace(allocator: std.mem.Allocator, viewport: Viewport, trace: []const u8) !TraceResult { var client = try Client.init(allocator, viewport); defer client.deinit(); @@ -127,6 +147,15 @@ pub const Client = struct { return self.renderEditor(allocator, snap); } + pub fn renderWithDiagnostics(self: *const Client, allocator: std.mem.Allocator) !RenderResult { + const frame = try self.render(allocator); + errdefer allocator.free(frame); + var diagnostics = diagnoseFrame(self.viewport, frame); + diagnostics.clipped_sources = try self.countClippedSources(allocator); + const warning = try renderWarningAlloc(allocator, diagnostics); + return .{ .frame = frame, .diagnostics = diagnostics, .warning = warning }; + } + fn renderEditor(self: *const Client, allocator: std.mem.Allocator, snap: session_mod.Snapshot) ![]u8 { var out = std.ArrayList(u8).empty; errdefer out.deinit(allocator); @@ -334,6 +363,51 @@ pub const Client = struct { self.saved_bytes = copy; self.message = "saved"; } + + fn countClippedSources(self: *const Client, allocator: std.mem.Allocator) !usize { + const snap = try self.session.snapshot(); + var clipped: usize = 0; + if (snap.active_panel_title == null) { + var line_iter = std.mem.splitScalar(u8, snap.bytes, '\n'); + while (line_iter.next()) |line| { + if (session_mod.cellWidth(line) > self.viewport.width) clipped += 1; + } + if (self.message) |message| { + if (session_mod.cellWidth(message) > self.viewport.width) clipped += 1; + } + return clipped; + } + + const path = try self.session.panelPathAlloc(allocator); + defer allocator.free(path); + const header = try std.fmt.allocPrint(allocator, "panel {s}", .{path}); + defer allocator.free(header); + if (session_mod.cellWidth(header) > self.viewport.width) clipped += 1; + + const remaining_rows = self.viewport.height - 1; + const rows = self.session.activeListRowsAlloc(allocator, remaining_rows) catch null; + if (rows) |list_rows| { + defer { + for (list_rows) |row| allocator.free(row); + allocator.free(list_rows); + } + for (list_rows) |row| { + if (session_mod.cellWidth(row) > self.viewport.width) clipped += 1; + } + } else if (snap.active_panel_title) |title| { + const detail = try std.fmt.allocPrint(allocator, "{s}: no content yet", .{title}); + defer allocator.free(detail); + if (session_mod.cellWidth(detail) > self.viewport.width) clipped += 1; + } + + const status = if (self.message) |message| + try std.fmt.allocPrint(allocator, "{s}", .{message}) + else + try std.fmt.allocPrint(allocator, "panel {d}/{d} x close", .{ snap.active_panel_index.? + 1, snap.panel_depth }); + defer allocator.free(status); + if (session_mod.cellWidth(status) > self.viewport.width) clipped += 1; + return clipped; + } }; fn appendVisibleCells(allocator: std.mem.Allocator, out: *std.ArrayList(u8), bytes: []const u8, max_cells: usize) !void { @@ -369,6 +443,52 @@ fn columnAt(bytes: []const u8, cursor_byte: usize) usize { return session_mod.cellWidth(prefix[line_start..]); } +fn diagnoseFrame(viewport: Viewport, frame: []const u8) RenderDiagnostics { + var diagnostics = RenderDiagnostics{ + .viewport_width = viewport.width, + .viewport_height = viewport.height, + .frame_bytes = frame.len, + .frame_lines = 0, + .max_line_cells = 0, + .clipped_sources = 0, + }; + var lines = std.mem.splitScalar(u8, frame, '\n'); + while (lines.next()) |line| { + diagnostics.frame_lines += 1; + diagnostics.max_line_cells = @max(diagnostics.max_line_cells, session_mod.cellWidth(line)); + } + return diagnostics; +} + +fn renderWarningAlloc(allocator: std.mem.Allocator, diagnostics: RenderDiagnostics) ![]u8 { + if (diagnostics.frame_lines != diagnostics.viewport_height) { + return std.fmt.allocPrint( + allocator, + "render warning: expected {d} lines, rendered {d}", + .{ diagnostics.viewport_height, diagnostics.frame_lines }, + ); + } + if (diagnostics.max_line_cells > diagnostics.viewport_width) { + return std.fmt.allocPrint( + allocator, + "render warning: line width {d} exceeds viewport {d}", + .{ diagnostics.max_line_cells, diagnostics.viewport_width }, + ); + } + if (diagnostics.clipped_sources > 0) { + return std.fmt.allocPrint( + allocator, + "render warning: clipped {d} source line(s) to fit {d} columns", + .{ diagnostics.clipped_sources, diagnostics.viewport_width }, + ); + } + return std.fmt.allocPrint( + allocator, + "render ok: {d} lines, {d} bytes, max {d}/{d} cells", + .{ diagnostics.frame_lines, diagnostics.frame_bytes, diagnostics.max_line_cells, diagnostics.viewport_width }, + ); +} + fn assertLinesFit(frame: []const u8, width: usize) !void { var lines = std.mem.splitScalar(u8, frame, '\n'); while (lines.next()) |line| { @@ -737,3 +857,68 @@ test "adversarial: long feedback messages are clipped to viewport width" { try std.testing.expect(std.mem.indexOf(u8, frame, "this-message-is-") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "phone-width") == null); } + +test "regular: render diagnostics report ok for representative narrow editor frame" { + var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 5 }); + defer client.deinit(); + + try client.handleTraceLine("open fn main() {}"); + try client.handleTraceLine("key space"); + try client.handleTraceLine("key p"); + try client.handleTraceLine("key c"); + try client.handleTraceLine("type x"); + const result = try client.renderWithDiagnostics(std.testing.allocator); + defer result.deinit(std.testing.allocator); + + try std.testing.expectEqual(@as(usize, 5), result.diagnostics.frame_lines); + try std.testing.expect(result.diagnostics.frame_bytes <= 32 * 5 * 4); + try std.testing.expect(result.diagnostics.max_line_cells <= 32); + try std.testing.expectEqual(@as(usize, 0), result.diagnostics.clipped_sources); + try std.testing.expect(std.mem.indexOf(u8, result.warning, "render ok") != null); +} + +test "regular: render diagnostics report ok for representative list panel frame" { + var client = try Client.init(std.testing.allocator, .{ .width = 36, .height = 5 }); + defer client.deinit(); + + try client.handleTraceLine("list_open files src/main.zig|src/panel.zig|README.md"); + try client.handleTraceLine("list_filter src"); + try client.handleTraceLine("list_down"); + const result = try client.renderWithDiagnostics(std.testing.allocator); + defer result.deinit(std.testing.allocator); + + try std.testing.expectEqual(@as(usize, 5), result.diagnostics.frame_lines); + try std.testing.expect(result.diagnostics.frame_bytes <= 36 * 5 * 4); + try std.testing.expect(result.diagnostics.max_line_cells <= 36); + try std.testing.expectEqual(@as(usize, 0), result.diagnostics.clipped_sources); + try std.testing.expect(std.mem.indexOf(u8, result.warning, "render ok") != null); +} + +test "adversarial: render diagnostics name clipped editor sources" { + var client = try Client.init(std.testing.allocator, .{ .width = 12, .height = 4 }); + defer client.deinit(); + + try client.handleTraceLine("open this-line-is-far-too-long-for-phone"); + const result = try client.renderWithDiagnostics(std.testing.allocator); + defer result.deinit(std.testing.allocator); + + try std.testing.expectEqual(@as(usize, 4), result.diagnostics.frame_lines); + try std.testing.expect(result.diagnostics.max_line_cells <= 12); + try std.testing.expect(result.diagnostics.clipped_sources >= 1); + try std.testing.expect(std.mem.indexOf(u8, result.warning, "clipped") != null); + try std.testing.expect(std.mem.indexOf(u8, result.warning, "12 columns") != null); +} + +test "adversarial: render diagnostics name clipped panel sources" { + var client = try Client.init(std.testing.allocator, .{ .width = 14, .height = 4 }); + defer client.deinit(); + + try client.handleTraceLine("list_open files src/very-long-mobile-render-file-name.zig|b.zig"); + const result = try client.renderWithDiagnostics(std.testing.allocator); + defer result.deinit(std.testing.allocator); + + try std.testing.expectEqual(@as(usize, 4), result.diagnostics.frame_lines); + try std.testing.expect(result.diagnostics.max_line_cells <= 14); + try std.testing.expect(result.diagnostics.clipped_sources >= 1); + try std.testing.expect(std.mem.indexOf(u8, result.warning, "clipped") != null); +}