From 40c19fd4f52d20df94c2dfa2d68102b89bd9d3f1 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 15 Sep 2026 14:46:38 -0300 Subject: Add optional compact tagline styling for source context --- src/pardes.zig | 272 ++++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 250 insertions(+), 22 deletions(-) (limited to 'src/pardes.zig') diff --git a/src/pardes.zig b/src/pardes.zig index 3965c200..b6d6ed8f 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -69,6 +69,8 @@ pub const pdf_raster_policy: PdfRasterPolicy = switch (platform) { pub const Pane = panes.Pane; pub const MAX_PANES = 16; +pub const BodyLayer = @import("body_layer.zig").Layer; +pub const RowMetrics = @import("body_layer.zig").Metrics; pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px; pub const MAX_COLS = 6; const column_weight_unit = layout.column_weight_unit; @@ -253,7 +255,9 @@ pub const Loc = struct { col: u32, }; -test {} +test { + _ = @import("body_layer.zig"); +} const pane_tail = " " ++ config.pane_builtins_str; const pdf_pane_tail = " " ++ config.pdf_pane_builtins_str; @@ -1118,6 +1122,45 @@ test "LocationsConfig command reports partial updates and survives restore" { try std.testing.expectEqual(expected, restored.locations_config); } +test "TreeContext tag style preserves message rows and its opt-out survives restore" { + if (comptime !syntax.enabled) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 16 }); + defer p.deinit(); + const pane = try p.setTestFile("pub const Box = struct {\n pub fn run() void {\n" ++ (" // body\n" ** 30) ++ " }\n};\n"); + p.gpa.free(pane.file.?.path); + pane.file.?.path = try p.gpa.dupe(u8, "/test.zig"); + if (!panes.File.supportsContext(pane)) return error.SkipZigTest; + p.update(.{ .resize = .{ .cols = 60, .rows = 16, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); + pane.file.?.tree_context = true; + pane.file.?.scroll = 8; + pane.msg_len = 0; + _ = try p.render(p.scratch.allocator()); + try std.testing.expect(p.settings.tree_context_tag_style); + const full_height = p.surface.body_layers[0].viewport.h; + try std.testing.expect(p.surface.body_layers[0].rows > full_height); + try std.testing.expectEqual(FontRole.tagline, p.surface.body_layers[0].cells[0].style.font_role); + pane.msg[0] = '!'; + pane.msg_len = 1; + _ = try p.render(p.scratch.allocator()); + try std.testing.expectEqual(full_height - 1, p.surface.body_layers[0].viewport.h); + const r = p.rects[0]; + try std.testing.expectEqualStrings("!", p.surface.at(r.x + config.GUTTER, r.y + r.h - 1).grapheme()); + pane.msg_len = 0; + p.leader_on = true; + _ = try p.render(p.scratch.allocator()); + try std.testing.expectEqual(full_height - 1, p.surface.body_layers[0].viewport.h); + p.leader_on = false; + try std.testing.expect(p.executeBuiltinLine(0, "TreeContextTagStyle")); + _ = try p.render(p.scratch.allocator()); + try std.testing.expectEqual(@as(u16, 0), p.surface.body_layers[0].rows); + try std.testing.expectEqual(@as(u16, 0), pane.body_rows); + try std.testing.expectEqual(FontRole.body, p.surface.at(r.x + config.GUTTER, r.y + BOX_H).style.font_role); + try p.dumpState(); + const restored = try Pardes.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?); + defer restored.deinit(); + try std.testing.expect(!restored.settings.tree_context_tag_style); +} + test "TreeContext command and custom tag survive a workspace round trip" { if (comptime !syntax.enabled) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); @@ -3496,6 +3539,7 @@ test "pixel attachment fit and pan do not invalidate cached pixels" { pub const Surface = struct { pub const PointerShape = enum(u8) { arrow, link }; pointer_shape: PointerShape = .arrow, + body_layers: [MAX_PANES]BodyLayer = @splat(.{}), cols: u16 = 0, rows: u16 = 0, cells: []Cell = &.{}, @@ -3506,8 +3550,13 @@ pub const Surface = struct { panel_tracks: [MAX_PANES * 2]layout.Track = undefined, npanel_tracks: usize = 0, previous_cells: []const Cell = &.{}, + previous_body_layers: []const BodyLayer = &.{}, cell_diffs: []const PanelCellDiff = &.{}, + pub fn bodyLayers(s: *const Surface) []const BodyLayer { + return &s.body_layers; + } + pub fn panelTracks(s: *const Surface) []const layout.Track { return s.panel_tracks[0..s.npanel_tracks]; } @@ -4388,6 +4437,16 @@ test "normal header Enter executes its command rather than searching for it" { } pub const Mouse = struct { + /// The host has inverted a compact body layer. Physical col/row still + /// route pane chrome; these coordinates address the pane's logical text. + pub const BodyHit = struct { + serial: u32, + row: u16, + col: u16, + pixel_x: f32, + pixel_y: f32, + metrics: RowMetrics, + }; // sel indexing relies on left/middle/right being 0/1/2 pub const Button = enum { left, middle, right, wheel_up, wheel_down, wheel_left, wheel_right, none, back, forward }; pub const Kind = enum { press, release, motion, drag }; @@ -4396,6 +4455,7 @@ pub const Mouse = struct { col: u16, row: u16, ctrl: bool = false, + body_hit: ?BodyHit = null, }; pub const Key = struct { @@ -4487,6 +4547,7 @@ pub const Event = union(enum) { cols: u16, rows: u16, cell_pixels: CellPixels = .{}, + row_metrics: ?RowMetrics = null, }, output: struct { pane: u8, bytes: []const u8 }, eof: struct { pane: u8 }, @@ -4577,6 +4638,7 @@ const LookHoverWait = struct { pane: usize, serial: u32, frames: u16 = 0, + body_hit: ?Mouse.BodyHit = null, }; const LookHoverPreview = struct { @@ -4586,6 +4648,7 @@ const LookHoverPreview = struct { serial: u32, sel: ?Pane.Sel, file_word: ?FileWordSpan = null, + body_hit: ?Mouse.BodyHit = null, }; const FileWordSpan = struct { @@ -5732,6 +5795,7 @@ pub const Pardes = struct { screen_w: u16, screen_h: u16, cell_pixels: CellPixels = .{}, + row_metrics: RowMetrics = .{}, resize_count: usize = 0, panes: [MAX_PANES]?*Pane = @splat(null), @@ -5779,6 +5843,7 @@ pub const Pardes = struct { hover_row: u16 = 0, pointer_raw_col: u16 = 0, pointer_raw_row: u16 = 0, + pointer_body_hit: ?Mouse.BodyHit = null, raw_hover_intent: bool = false, pointer_inside: bool = false, look_hover_wait: ?LookHoverWait = null, @@ -6009,6 +6074,7 @@ pub const Pardes = struct { p.scratch.deinit(); p.frame_arena.deinit(); gpa.free(p.surface.cells); + for (&p.surface.body_layers) |*layer| gpa.free(layer.cells); p.presentation.deinit(gpa); if (p.surface.images.len > 0) gpa.free(p.surface.images); gpa.destroy(p); @@ -6871,6 +6937,7 @@ pub const Pardes = struct { switch (ev) { .resize => |sz| { p.presentation.snap_once = true; + if (sz.row_metrics) |metrics| p.row_metrics = metrics; if (comptime pdf_enabled) { var before: [MAX_PANES]?panes.Pdf.Viewport = @splat(null); for (p.panes, 0..) |slot, id| { @@ -6933,7 +7000,10 @@ pub const Pardes = struct { .fs_req => |r| p.emit(.{ .fs_reply = filesystem.handle(p, r) }), .pinch => |scale| p.ov_pinch_scale = scale, .touch_scroll => |delta| p.ov_touch_scroll_delta = delta, - .pointer_leave => p.pointer_inside = false, + .pointer_leave => { + p.pointer_inside = false; + p.pointer_body_hit = null; + }, .tick => { p.chrome_animation.advance(); p.presentation.advance(); @@ -7594,7 +7664,8 @@ pub const Pardes = struct { const body = try p.bodyText(p.scratch.allocator(), pane); const terminal_lines = if (pane.file == null and !raw) try panes.Terminal.cursorLines(p, pane) else &.{}; const first = @max(0, @min(gesture.r0, gesture.r1) - BOX_H); - const last = @min(@as(i32, pane.rows) - 1, @max(gesture.r0, gesture.r1) - BOX_H); + const logical_rows = if (pane.body_rows > 0) pane.body_rows else pane.rows; + const last = @min(@as(i32, logical_rows) - 1, @max(gesture.r0, gesture.r1) - BOX_H); if (first > last) return; const rows = try p.gpa.alloc(Pane.PointerRow, @intCast(last - first + 1)); errdefer p.gpa.free(rows); @@ -10812,13 +10883,43 @@ pub const Pardes = struct { on_tag: bool, }; - fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16) ?PointerTextSelection { + fn bodyHitForPane(p: *const Pardes, id: usize, supplied: Mouse.BodyHit, clamp: bool) ?Mouse.BodyHit { + const pane = p.panes[id] orelse return null; + if (pane.file == null or (!clamp and supplied.serial != pane.serial)) return null; + if (!std.math.isFinite(supplied.pixel_x) or !std.math.isFinite(supplied.pixel_y) or + supplied.metrics.body_w == 0 or supplied.metrics.body_h == 0 or + supplied.metrics.tagline_w == 0 or supplied.metrics.tagline_h == 0) return null; + var mapped_hit = supplied; + if (clamp) { + mapped_hit.serial = pane.serial; + const layer = for (&p.surface.body_layers) |*layer| { + if (layer.rows > 0 and layer.serial == pane.serial) break layer; + } else return null; + const bw: f32 = @floatFromInt(mapped_hit.metrics.body_w); + const bh: f32 = @floatFromInt(mapped_hit.metrics.body_h); + const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * bw; + const y0 = @as(f32, @floatFromInt(layer.viewport.y)) * bh; + const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * bw; + const y1 = y0 + @as(f32, @floatFromInt(layer.viewport.h)) * bh; + mapped_hit.pixel_x = std.math.clamp(mapped_hit.pixel_x, x0, @max(x0, x1 - 0.001)); + mapped_hit.pixel_y = std.math.clamp(mapped_hit.pixel_y, y0, @max(y0, y1 - 0.001)); + } + return p.reprojectBodyHit(mapped_hit); + } + + fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16, body_hit: ?Mouse.BodyHit) ?PointerTextSelection { if (p.panes[id] == null) return null; const r = p.rects[id]; if (col < r.x + config.GUTTER or col >= r.x + r.w or row < r.y or row >= r.y + r.h) return null; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; const on_tag = row >= tag_y and row < tag_y + BOX_H; + if (!on_tag) if (body_hit) |supplied| { + const mapped_hit = p.bodyHitForPane(id, supplied, false) orelse return null; + const c = @as(i32, mapped_hit.col) - config.GUTTER; + const v = @as(i32, mapped_hit.row) + BOX_H; + return .{ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }, .on_tag = false }; + }; const c: i32 = @as(i32, col) - @as(i32, r.x + config.GUTTER) + (if (on_tag) @as(i32, p.panes[id].?.tag_scroll) else 0); const v: i32 = if (on_tag) 0 else @as(i32, row) - @as(i32, body_y) + @as(i32, BOX_H); @@ -10848,22 +10949,28 @@ pub const Pardes = struct { return null; } - fn noteLookHover(p: *Pardes, col: u16, row: u16) void { + fn sameBodyCell(a: ?Mouse.BodyHit, b: ?Mouse.BodyHit) bool { + const first = a orelse return b == null; + const second = b orelse return false; + return first.serial == second.serial and first.row == second.row and first.col == second.col; + } + + fn noteLookHover(p: *Pardes, col: u16, row: u16, body_hit: ?Mouse.BodyHit) void { const delay = config.look_preview_delay_frames orelse return; _ = delay; const id = p.paneAt(col, row) orelse return p.cancelLookHover(); const pane = p.panes[id] orelse return p.cancelLookHover(); - _ = p.pointerTextSelection(id, col, row) orelse return p.cancelLookHover(); + _ = p.pointerTextSelection(id, col, row, body_hit) orelse return p.cancelLookHover(); if (comptime pdf_enabled) if (p.pdf_hover_preview) |shown| if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return; if (p.look_hover_preview) |shown| - if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return; + if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and sameBodyCell(shown.body_hit, body_hit)) return; if (p.look_hover_wait) |waiting| - if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial) return; + if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and sameBodyCell(waiting.body_hit, body_hit)) return; p.cancelLookHover(); - p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial }; + p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial, .body_hit = body_hit }; } fn refreshLookHoverFromRaw(p: *Pardes) void { @@ -10872,7 +10979,11 @@ pub const Pardes = struct { return p.cancelLookHover(); p.hover_col = mapped.col; p.hover_row = mapped.row; - p.noteLookHover(mapped.col, mapped.row); + const body_hit: ?Mouse.BodyHit = if (p.pointer_body_hit) |mapped_hit| + p.bodyHitForPane(p.paneAt(mapped.col, mapped.row) orelse return p.cancelLookHover(), mapped_hit, false) orelse return p.cancelLookHover() + else + null; + p.noteLookHover(mapped.col, mapped.row, body_hit); } fn advanceLookHover(p: *Pardes) void { @@ -10882,7 +10993,7 @@ pub const Pardes = struct { if (waiting.frames < delay) return; const pane = p.panes[waiting.pane] orelse return p.cancelLookHover(); if (pane.serial != waiting.serial) return p.cancelLookHover(); - const pointed = p.pointerTextSelection(waiting.pane, waiting.col, waiting.row) orelse + const pointed = p.pointerTextSelection(waiting.pane, waiting.col, waiting.row, waiting.body_hit) orelse return p.cancelLookHover(); if (comptime pdf_enabled) if (!pointed.on_tag and panes.Pdf.paneNativeReady(p, pane)) { const probe = panes.Pdf.probeAt(p, pane, waiting.col, waiting.row) orelse @@ -10910,6 +11021,7 @@ pub const Pardes = struct { .serial = waiting.serial, .sel = preview, .file_word = operand.file_word, + .body_hit = waiting.body_hit, }; p.look_hover_wait = null; } @@ -10920,7 +11032,7 @@ pub const Pardes = struct { if (p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row)) |mapped| { p.hover_col = mapped.col; p.hover_row = mapped.row; - p.dragUpdate(mapped.col, mapped.row); + p.dragUpdate(mapped.col, mapped.row, p.pointer_body_hit); } } p.refreshLookHoverFromRaw(); @@ -10946,6 +11058,18 @@ pub const Pardes = struct { const raw_row = @min(m.row, p.screen_h -| 1); p.pointer_raw_col = raw_col; p.pointer_raw_row = raw_row; + const previous_hit = p.pointer_body_hit; + p.pointer_body_hit = m.body_hit; + // A drag can leave the compact text viewport. Preserve its metrics so + // the endpoint still clamps to that body's last visible logical row. + if (m.body_hit == null and (m.kind == .drag or m.kind == .release) and p.drag == .select) { + if (previous_hit) |previous| { + var continued = previous; + continued.pixel_x = (@as(f32, @floatFromInt(raw_col)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_w)); + continued.pixel_y = (@as(f32, @floatFromInt(raw_row)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_h)); + p.pointer_body_hit = continued; + } + } p.pointer_inside = true; if (m.button == .none and m.kind == .motion) { p.raw_hover_intent = true; @@ -11137,10 +11261,9 @@ pub const Pardes = struct { } else { p.active = id; if (pane.tag_edit) exitTagEdit(pane); - const sc: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); - const v: i32 = @as(i32, mrow) - @as(i32, body_y) + @as(i32, BOX_H); + const pointed = p.pointerTextSelection(id, mcol, mrow, m.body_hit) orelse return; pane.clearPointerSelection(sel_slot); - pane.sel[sel_slot] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v }; + pane.sel[sel_slot] = pointed.sel; p.drag = .{ .select = .{ .id = id, .button = config.select_button, @@ -11156,8 +11279,11 @@ pub const Pardes = struct { } }; } }, - .drag => p.dragUpdate(mcol, mrow), - .release => p.dragRelease(config.select_button), + .drag => p.dragUpdate(mcol, mrow, p.pointer_body_hit), + .release => { + if (p.pointer_body_hit != null and p.drag.button() == m.button) p.dragUpdate(mcol, mrow, p.pointer_body_hit); + p.dragRelease(config.select_button); + }, .motion => {}, }, config.exec_button, config.look_button => switch (m.kind) { @@ -11191,7 +11317,7 @@ pub const Pardes = struct { const local = @as(i32, mrow) - @as(i32, body_y); if (m.button == config.look_button) p.active = id; pane.scrollBy(if (m.button == config.look_button) local else -local); - } else if (p.pointerTextSelection(id, mcol, mrow)) |pointed| { + } else if (p.pointerTextSelection(id, mcol, mrow, m.body_hit)) |pointed| { if (m.button == config.look_button) p.active = id; pane.clearPointerSelection(@intFromEnum(m.button)); pane.sel[@intFromEnum(m.button)] = pointed.sel; @@ -11213,8 +11339,11 @@ pub const Pardes = struct { }; } }, - .drag => p.dragUpdate(mcol, mrow), - .release => p.dragRelease(m.button), + .drag => p.dragUpdate(mcol, mrow, p.pointer_body_hit), + .release => { + if (p.pointer_body_hit != null and p.drag.button() == m.button) p.dragUpdate(mcol, mrow, p.pointer_body_hit); + p.dragRelease(m.button); + }, .motion => {}, }, } @@ -11228,7 +11357,7 @@ pub const Pardes = struct { return null; } - fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16) void { + fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) void { switch (p.drag) { .border_v => |*d| { const c = d.left_col; @@ -11261,6 +11390,12 @@ pub const Pardes = struct { if (p.panes[s.id]) |pane| { const r = p.rects[s.id]; const b = @intFromEnum(s.button); + if (pane.body_rows > 0 and pane.sel[b].r0 >= BOX_H) if (body_hit) |supplied| { + const mapped_hit = p.bodyHitForPane(s.id, supplied, true) orelse return; + pane.sel[b].c1 = @as(i32, mapped_hit.col) - config.GUTTER; + pane.sel[b].r1 = @as(i32, mapped_hit.row) + BOX_H; + return; + }; pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); if (p.settings.tag_bottom) { const body_h = r.h -| BOX_H; @@ -12095,6 +12230,7 @@ pub const Pardes = struct { pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]const u8) void { const previous_colors = p.settings.colors; + const previous_context_style = p.settings.tree_context_tag_style; const previous_transition = p.settings.panel_transition; const previous_tagline_percent = p.settings.font.tagline_percent; const previous_column_tags = p.settings.column_tags; @@ -12122,6 +12258,17 @@ pub const Pardes = struct { if (pane.file) |*file| file.syntax_dirty = true; } } + if (p.settings.tree_context_tag_style != previous_context_style) { + for (p.panes, 0..) |slot, id| { + p.surface.body_layers[id].rows = 0; + const pane = slot orelse continue; + pane.body_rows = 0; + pane.body_visible_rows = 0; + pane.context_row_limit = null; + pane.wrap_n = 0; + } + p.abandonPanelAnimations(); + } const transition_changed = setting.action == .transition and p.settings.panel_transition != previous_transition; const tagline_metrics_changed = setting.action == .tagline_size and @@ -12242,6 +12389,7 @@ pub const Pardes = struct { .topbar_custom = if (p.global_tag.custom) p.global_tag.slice("") else null, .theme = p.theme().name, .locations_config = p.locations_config, + .tree_context_tag_style = p.settings.tree_context_tag_style, .columns = columns[0..columns_len], .panes = dump_panes[0..panes_len], .mounts = mounts[0..p.fs.mounts.items.len], @@ -12294,6 +12442,7 @@ pub const Pardes = struct { p.lsp_seq = old.lsp_seq; p.pipe_seq = old.pipe_seq; p.cell_pixels = old.cell_pixels; + p.row_metrics = old.row_metrics; p.native_images = old.native_images; p.fs.socket_path = old.fs.socket_path; p.fs.tcp_address = old.fs.tcp_address; @@ -12306,6 +12455,7 @@ pub const Pardes = struct { defer parsed.deinit(); const st = parsed.value; p.locations_config = st.locations_config; + p.settings.tree_context_tag_style = st.tree_context_tag_style; for (st.mounts) |mount| { var already_mounted = false; for (p.fs.mounts.items) |existing| { @@ -12722,6 +12872,14 @@ pub const Pardes = struct { p.invalidateThemeDependentRasters(); } + pub fn reprojectBodyHit(p: *const Pardes, body_hit: Mouse.BodyHit) ?Mouse.BodyHit { + for (&p.surface.body_layers) |*layer| { + if (layer.rows == 0 or layer.serial != body_hit.serial) continue; + return layer.hitAt(body_hit.pixel_x, body_hit.pixel_y, @floatFromInt(body_hit.metrics.body_w), @floatFromInt(body_hit.metrics.body_h), @floatFromInt(body_hit.metrics.tagline_w), @floatFromInt(body_hit.metrics.tagline_h)); + } + return null; + } + // ---- render: build the canonical surface ---- pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { @@ -12745,9 +12903,11 @@ pub const Pardes = struct { } } }; + for (&s.body_layers) |*layer| layer.rows = 0; s.nimages = 0; s.npanel_tracks = 0; s.previous_cells = &.{}; + s.previous_body_layers = &.{}; s.cell_diffs = &.{}; const chrome = p.chromeTheme(); const th = p.theme(); // the message row paints in the editor's colours @@ -12756,7 +12916,12 @@ pub const Pardes = struct { for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; + pane.body_rows = 0; + pane.body_visible_rows = 0; + pane.context_row_limit = null; + s.body_layers[id].rows = 0; try p.renderPane(arena, pane, p.rects[id], id, id == p.active); + try p.renderBodyLayer(arena, pane, p.rects[id], id); } for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; @@ -13190,6 +13355,56 @@ pub const Pardes = struct { } } + fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize) !void { + if (!p.settings.tree_context_tag_style or pane.context_rows == 0 or pane.file == null or pane.collapsed) return; + if (r.w <= config.GUTTER or r.h <= BOX_H) return; + const message = if (pane.promptAt()) |at| pane.tagSlice()[@min(at, pane.tag_tail_len)..].len > 0 else pane.msg_len > 0; + const reserve: u16 = if (message or (id == p.active and p.leader_on)) 1 else 0; + const viewport_rows = r.h - BOX_H -| reserve; + if (viewport_rows == 0) return; + // The canonical fixed grid remains available to terminal clients. The + // separate body layer reserves enough rows for any compact font size. + const logical_rows = @min(@as(usize, pane.rows) + pane.context_rows, pane.wrap_line.len); + if (logical_rows <= pane.context_rows) return; + pane.body_rows = @intCast(logical_rows); + const bh: usize = @max(1, p.row_metrics.body_h); + const th: usize = std.math.clamp(p.row_metrics.tagline_h, 1, bh); + const body_pixels = @as(usize, viewport_rows) * bh; + pane.context_row_limit = @intCast(@min(pane.context_rows, (body_pixels -| bh) / th)); + const context_pixels = @as(usize, pane.context_row_limit.?) * th; + pane.body_visible_rows = @intCast(@min(logical_rows, pane.context_row_limit.? + @max(1, (body_pixels -| context_pixels) / bh))); + const width = r.w - config.GUTTER; + const layer = &p.surface.body_layers[id]; + const count = logical_rows * width; + if (layer.cells.len != count) layer.cells = try p.gpa.realloc(layer.cells, count); + const temporary_cells = try arena.alloc(Cell, @as(usize, r.w) * (logical_rows + BOX_H)); + @memset(temporary_cells, .{}); + var temporary: Surface = .{ .cols = r.w, .rows = @intCast(logical_rows + BOX_H), .cells = temporary_cells }; + std.mem.swap(Surface, &p.surface, &temporary); + const synthetic: Rect = .{ .x = 0, .y = 0, .w = r.w, .h = @intCast(logical_rows + BOX_H) }; + p.renderBody(arena, pane, synthetic, id, id == p.active, false) catch |err| { + std.mem.swap(Surface, &p.surface, &temporary); + return err; + }; + std.mem.swap(Surface, &p.surface, &temporary); + const first_row: usize = if (p.settings.tag_bottom) 0 else BOX_H; + for (0..logical_rows) |row| { + const start = (row + first_row) * r.w + config.GUTTER; + @memcpy(layer.cells[row * width ..][0..width], temporary_cells[start..][0..width]); + } + layer.pane = @intCast(id); + layer.serial = pane.serial; + layer.viewport = .{ .x = r.x + config.GUTTER, .y = if (p.settings.tag_bottom) r.y else r.y + BOX_H, .w = width, .h = viewport_rows }; + layer.cols = width; + layer.rows = @intCast(logical_rows); + layer.context_rows = pane.context_rows; + layer.cursor = null; + if (temporary.cursor) |cursor| { + if (cursor.x >= config.GUTTER and cursor.y >= first_row) + layer.cursor = .{ .x = cursor.x - config.GUTTER, .y = @intCast(cursor.y - first_row), .bar = cursor.bar }; + } + } + fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void { const tz = tracy.zone(@src(), "renderPane"); defer tz.end(); @@ -13315,6 +13530,19 @@ pub const Pardes = struct { return; } + try p.renderBody(arena, pane, r, id, active, true); + } + + fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, scrollbar: bool) !void { + const s = &p.surface; + const th = p.theme(); + const chrome = p.chromeTheme(); + const tx = r.x + config.GUTTER; + const tw = r.w - config.GUTTER; + const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; + const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; + const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; + const page_rgb = th.bg orelse th.tag_bg; // body: emulator rows, prompt rows hidden outside tty mode const body_h = if (r.h > BOX_H) r.h - BOX_H else 0; const body_style: CellStyle = .{ @@ -13503,7 +13731,7 @@ pub const Pardes = struct { } } - if (r.h > BOX_H) { + if (scrollbar and r.h > BOX_H) { s.fill(r.x, body_y, 1, r.h - BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); s.fill(r.x + 1, body_y, 1, r.h - BOX_H, .{ .bg = pane_bg }); const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{ -- cgit v1.3