diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/gui/gui.zig | 25 | ||||
| -rw-r--r-- | src/panes.zig | 46 | ||||
| -rw-r--r-- | src/pardes.zig | 298 |
3 files changed, 193 insertions, 176 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig index c26629ab..3c2fd693 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1788,7 +1788,6 @@ const Gui = struct { scroll_pane: ?usize = null, scroll_rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, scroll_body_y: u16 = 0, // that rect's first BODY row (Tagbottom moves it) - scroll_body_h: u16 = 0, // and how many rows it has, notice bands taken off scroll_col: u16 = 0, // where the wheel turned: crossed rows are delivered scroll_row: u16 = 0, // THERE, not wherever the pointer has drifted to since scroll_delta: f32 = 0, // finite raw distance waiting for the render batch @@ -4293,15 +4292,14 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { } g.scroll_lag = st.lag; g.scroll_rect = r; - const body_y = core.bodyTop(pane, r); + const body_y = core.bodyTop(r); g.scroll_body_y = body_y; - g.scroll_body_h = (r.h -| pardes.BOX_H) -| pane.body_offset; if (st.rows != 0) { const s = &core.surface; g.scroll_edge_len = 0; - if (r.w > config.GUTTER and r.h > pardes.BOX_H + pane.body_offset and r.x + r.w <= s.cols) { + if (r.w > config.GUTTER and r.h > pardes.BOX_H and r.x + r.w <= s.cols) { const bw = r.w - config.GUTTER; - const bh = (r.h - pardes.BOX_H) - pane.body_offset; + const bh = r.h - pardes.BOX_H; const k: u16 = @intCast(@min(@abs(st.rows), @as(i32, bh))); const srow = body_y + (if (st.rows > 0) k - 1 else bh - k); if (srow < s.rows) { @@ -4329,11 +4327,7 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard const x0 = r.x + config.GUTTER; const y0 = g.scroll_body_y; const bw = r.w - config.GUTTER; - // The body the editor actually painted: measuring it from the rect while - // the origin comes from bodyTop reads past the pane by the band rows, and - // for the bottom-most pane past the surface, which drops the animation. - const bh = g.scroll_body_h; - if (bh == 0) return 0; + const bh = r.h - pardes.BOX_H; if (x0 + bw > surface.cols or y0 + bh > surface.rows) return 0; // resized under us var shifted = layout; shifted.y_off -= g.scroll_lag * layout.h; @@ -4365,7 +4359,7 @@ fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect const px = layout.x_off + @as(f32, @floatFromInt(r.x + config.GUTTER)) * layout.w; const py = layout.y_off + @as(f32, @floatFromInt(g.scroll_body_y)) * layout.h; const pw = @as(f32, @floatFromInt(r.w - config.GUTTER)) * layout.w; - const ph = @as(f32, @floatFromInt(g.scroll_body_h)) * layout.h; + const ph = @as(f32, @floatFromInt(r.h - pardes.BOX_H)) * layout.h; const x0 = std.math.clamp(@as(i32, @intFromFloat(@floor(px))), 0, @as(i32, @intCast(sw))); const y0 = std.math.clamp(@as(i32, @intFromFloat(@floor(py))), 0, @as(i32, @intCast(sh))); const x1 = std.math.clamp(@as(i32, @intFromFloat(@ceil(px + pw))), x0, @as(i32, @intCast(sw))); @@ -6691,14 +6685,11 @@ fn appendPaneChrome( const chrome = core.chromeTheme(); const page = overlayRgb(core.theme().bg orelse bg_default); for (core.panes, core.rects) |slot, r| { - const pane = slot orelse continue; - if (r.w <= config.GUTTER or r.h <= pardes.BOX_H) continue; + if (slot == null or r.w <= config.GUTTER or r.h <= pardes.BOX_H) continue; if (@as(u32, r.x) + r.w > surface.cols or @as(u32, r.y) + r.h > surface.rows) continue; const tag_y = if (core.settings.tag_bottom) r.y + r.h - pardes.BOX_H else r.y; - // The rail the editor painted, notice bands included -- overlaying the - // rows above it would tint the bands instead of the scrollbar. - const body_y = core.bodyTop(pane, r); - const body_end = body_y + ((r.h -| pardes.BOX_H) -| pane.body_offset); + const body_y = core.bodyTop(r); + const body_end = body_y + (r.h -| pardes.BOX_H); const x0 = @as(f32, @floatFromInt(r.x)) * layout.w; const x1 = x0 + @as(f32, @floatFromInt(r.w)) * layout.w; const tag_cells = surface.cells[@as(usize, tag_y) * surface.cols + r.x + pardes.TAG_TEXT_INSET ..][0 .. r.w - pardes.TAG_TEXT_INSET]; diff --git a/src/panes.zig b/src/panes.zig index 4b5c6721..f995986b 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -191,11 +191,6 @@ pub const Pane = struct { /// body layer reserves exactly `len` rows for them, the way it already /// reserves rows for sticky context headers. notices: Notices = .{}, - /// Rows the notice bands take off the TOP of the body, the way tree-sitter - /// context rows do: the body starts below them rather than being painted - /// under them. Zero while the body LAYER renders, which paints into a - /// surface of its own that carries no bands. - body_offset: u16 = 0, pub const Notices = struct { /// Painted bottom-up in this order, so a prompt keeps the row nearest @@ -204,14 +199,25 @@ pub const Pane = struct { pub const max = @typeInfo(Kind).@"enum".fields.len; kinds: [max]Kind = undefined, + /// Where each band's chip starts, as an absolute grid column. A chip + /// is only as wide as its own text, so the rest of the row underneath + /// is ordinary body text and a click there has to reach it. + left: [max]u16 = @splat(std.math.maxInt(u16)), len: u8 = 0, pub fn push(n: *Notices, kind: Kind) void { if (n.len >= max) return; n.kinds[n.len] = kind; + n.left[n.len] = std.math.maxInt(u16); n.len += 1; } + /// Is this cell inside the chip drawn for band `i`? Nothing else on + /// that row belongs to the notice. + pub fn covers(n: *const Notices, i: usize, col: u16) bool { + return i < n.len and col >= n.left[i]; + } + pub fn slice(n: *const Notices) []const Kind { return n.kinds[0..n.len]; } @@ -2041,7 +2047,7 @@ pub const File = struct { const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, gcur.y) + goff; const cur_line: i32 = if (active and !pane.tag_edit) gcrow else std.math.minInt(i32); // the body's first row, the way renderPane derives it (Tagbottom) - const body_y = p.bodyTop(pane, r); + const body_y = p.bodyTop(r); const context_bg = p.theme().tag_bg; for (0..@min(pane.context_rows, body_h)) |context_row| { for (0..tw) |col| { @@ -2095,7 +2101,7 @@ pub const File = struct { // indexed start, same as bodyText — an empty tail simply paints nothing var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n'); const total = nlines(p.gpa, f); - const body_y = p.bodyTop(pane, r); + const body_y = p.bodyTop(r); var vr: u16 = 0; while (vr < body_h) : (vr += 1) { var base: usize = undefined; @@ -2171,7 +2177,7 @@ pub const File = struct { pane_bg: pardes.Color, ) void { if (tw <= gutterWidth(pane) + 1) return; - const body_y = p.bodyTop(pane, r); + const body_y = p.bodyTop(r); const marker_fg = p.chromeTheme().lineno; var row: u16 = 0; while (row + 1 < pane.wrap_n and row + 1 < body_h) : (row += 1) { @@ -2195,9 +2201,9 @@ pub const File = struct { const tx = r.x + config.GUTTER; const tw = r.w - config.GUTTER; const prefix_width = gutterWidth(pane); - const body_y = p.bodyTop(pane, r); + const body_y = p.bodyTop(r); var vr: i32 = 0; - while (vr + @as(i32, pardes.BOX_H) < @as(i32, r.h -| pane.body_offset)) : (vr += 1) { + while (vr + @as(i32, pardes.BOX_H) < @as(i32, r.h)) : (vr += 1) { const here = pane.wrapAt(vr); if (here.line != row) continue; var hi = word_hi; @@ -5482,7 +5488,7 @@ pub const Pdf = struct { if (slot == pane) break core.rects[id]; } else return null; const cols = rect.w -| config.GUTTER; - const rows = (rect.h -| pardes.BOX_H) -| pane.body_offset; + const rows = rect.h -| pardes.BOX_H; if (cols == 0 or rows == 0) return null; return .{ .pixel_w = @as(u32, cols) * @as(u32, core.cell_pixels.w), @@ -5520,8 +5526,8 @@ pub const Pdf = struct { const rect = for (core.panes, 0..) |slot, id| { if (slot == pane) break core.rects[id]; } else return null; - const body_y = core.bodyTop(pane, rect); - if (row < body_y or row >= body_y + ((rect.h -| pardes.BOX_H) -| pane.body_offset)) return null; + const body_y = core.bodyTop(rect); + if (row < body_y or row >= body_y + (rect.h -| pardes.BOX_H)) return null; const local_y = @as(f64, @floatFromInt( @as(u32, row - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2, )); @@ -5577,7 +5583,7 @@ pub const Pdf = struct { if (slot == pane) break core.rects[id]; } else return null; const body_x = @as(i64, rect.x + config.GUTTER); - const body_y = @as(i64, core.bodyTop(pane, rect)); + const body_y = @as(i64, core.bodyTop(rect)); const px = (@as(i64, col) - body_x) * core.cell_pixels.w + core.cell_pixels.w / 2; const py = (@as(i64, row) - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2; return pointAtPage(state, view, page, px, py, clamp_to_page); @@ -5860,7 +5866,7 @@ pub const Pdf = struct { text_width: u16, ) bool { if (comptime !enabled) return false; - if (!core.native_images or rect.h <= pardes.BOX_H + pane.body_offset) return false; + if (!core.native_images or rect.h <= pardes.BOX_H) return false; const state = &(pane.pdf orelse return false); const view = paneViewport(core, pane) orelse return false; const key = TintKey{ .mode = state.tint, .colors = tintColors(core) }; @@ -5897,9 +5903,9 @@ pub const Pdf = struct { null, }, .x = text_x, - .y = core.bodyTop(pane, rect), + .y = core.bodyTop(rect), .w = text_width, - .h = (rect.h -| pardes.BOX_H) -| pane.body_offset, + .h = rect.h -| pardes.BOX_H, .rgba = placed.rgba, .iw = placed.width, // The texture contains the retained band, not the full page. @@ -5911,8 +5917,8 @@ pub const Pdf = struct { // This frame has spent the motion used to choose its raster band. state.scroll_travel = 0; - const body_y = core.bodyTop(pane, rect); - const body_h = (rect.h -| pardes.BOX_H) -| pane.body_offset; + const body_y = core.bodyTop(rect); + const body_h = rect.h -| pardes.BOX_H; const chrome = core.chromeTheme(); const theme = core.theme(); const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default; @@ -7579,7 +7585,7 @@ pub const Terminal = struct { if (comptime !enabled) return; const s = &p.surface; if (pane.terminal == null) return; - const body_y = p.bodyTop(pane, r); + const body_y = p.bodyTop(r); var filtered_storage: FilteredColors = undefined; const filtered: ?*FilteredColors = if (pane.tty_filter) blk: { const tz_filter = tracy.zone(@src(), "filterInit"); diff --git a/src/pardes.zig b/src/pardes.zig index c271a16f..0ac16162 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1168,29 +1168,30 @@ test "TreeContext tag style preserves message rows and its opt-out survives rest 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); + // An overlay takes nothing: the message is a chip OVER the body's first + // row, so the viewport keeps every row it had. + try std.testing.expectEqual(full_height, p.surface.body_layers[0].viewport.h); const r = p.rects[0]; - // The message is a tagline band at the top of the body now, not a body row - // at the bottom: same reservation, different carrier. const band = for (p.surface.tagLayers()) |*layer| { if (layer.cols != 0 and layer.kind == .notice) break layer; } else return error.NoNoticeBand; - try std.testing.expectEqual(r.y + BOX_H, band.viewport.y); - // Right aligned: the text sits in the last cell the viewport actually - // shows, which is a column short of the layer's cursor slack. - try std.testing.expectEqualStrings("!", band.cells[p.tagCapacity(band.viewport.w) - 1].grapheme()); - // ...and the body starts below it rather than under it. - try std.testing.expectEqual(band.viewport.y + 1, p.surface.body_layers[0].viewport.y); + try std.testing.expectEqual(p.bodyTop(r), band.viewport.y); + try std.testing.expectEqual(p.surface.body_layers[0].viewport.y, band.viewport.y); + // Sized to its message, flush with the pane's right edge, and the text + // right aligned a cell short of that edge. + try std.testing.expectEqual(r.x + r.w, band.viewport.x + band.viewport.w); + try std.testing.expect(band.viewport.w < r.w - config.GUTTER); + try std.testing.expectEqualStrings("!", band.cells[p.tagCapacity(band.viewport.w) - 2].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); + try std.testing.expectEqual(full_height, 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, p.bodyTop(pane, r)).style.font_role); + try std.testing.expectEqual(FontRole.body, p.surface.at(r.x + config.GUTTER, p.bodyTop(r)).style.font_role); try p.dumpState(); const restored = try Pardes.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); @@ -6282,6 +6283,33 @@ pub const Pardes = struct { return p; } + /// `SPC` plus the chord keys typed so far, spaced out the way the help + /// index spells a leader path. + fn leaderText(p: *const Pardes, buf: *[16]u8) []const u8 { + buf.* = @splat(' '); + @memcpy(buf[0..3], "SPC"); + var at: usize = 3; + for (p.leader_keys[0..p.leader_n]) |ch| { + if (at + 2 > buf.len) break; + buf[at + 1] = ch; + at += 2; + } + return buf[0..at]; + } + + /// How wide a notice chip is, in grid columns: its own text plus a blank + /// cell either side. A notice is an OVERLAY over the body's top rows, not + /// a row taken from them, so it is sized to its message and never to the + /// pane. Counted in GRID columns rather than scaled into the tagline face + /// it is drawn in, because the canonical grid is what a terminal client + /// draws and a chip narrower than its own text there would clip it; the + /// tagline face simply leaves a little more room inside the chip. + fn noticeCols(p: *const Pardes, text: []const u8, limit: u16) u16 { + _ = p; + if (limit == 0) return 0; + return @intCast(std.math.clamp(panes.File.displayWidth(text) + 2, 1, @as(usize, limit))); + } + /// Prints `text` flush with the right edge of the band, and answers the /// column it started at so a cursor can follow it. fn printRight(s: *Surface, x: u16, row: u16, w: u16, text: []const u8, style: CellStyle) u16 { @@ -11264,13 +11292,14 @@ pub const Pardes = struct { 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 = p.bodyTop(p.panes[id].?, r); + const body_y = p.bodyTop(r); const on_tag = row >= tag_y and row < tag_y + BOX_H; if (on_tag and col < r.x + TAG_TEXT_INSET) return null; - // A notice band is chrome. Without this it maps to Sel row 0 or less, - // which is the TAG row -- so a click on a leader band would expand a - // word out of the tag text and run it as a builtin. - if (!on_tag and row >= body_y -| p.panes[id].?.body_offset and row < body_y) return null; + // A notice chip is chrome: it sits OVER the body's top rows, so a + // click inside one belongs to nobody. The rest of those rows is + // ordinary body text and still answers normally. + if (!on_tag and row >= body_y and row - body_y < p.panes[id].?.notices.len and + p.panes[id].?.notices.covers(row - body_y, col)) return null; 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; @@ -11657,7 +11686,7 @@ pub const Pardes = struct { if (previous.tag_edit) exitTagEdit(previous); }; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; - const body_y = p.bodyTop(pane, r); + const body_y = p.bodyTop(r); if (mcol < r.x + config.GUTTER) { // gutter scrollbar: a left click scrolls UP to that row p.active = id; @@ -11721,7 +11750,7 @@ pub const Pardes = struct { // the tag row and the body's first, exactly as the select // arm above derives them const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; - const body_y = p.bodyTop(pane, r); + const body_y = p.bodyTop(r); const on_tag = mrow >= tag_y and mrow < tag_y + BOX_H; if (mcol < r.x + config.GUTTER and !on_tag) { const local = @as(i32, mrow) - @as(i32, body_y); @@ -11818,8 +11847,8 @@ pub const Pardes = struct { // measure from the body's top, notice bands included, or a // selection made while a message is up lands rows away // from the pointer. - const body_y = p.bodyTop(pane, r); - const body_h = (r.h -| BOX_H) -| pane.body_offset; + const body_y = p.bodyTop(r); + const body_h = r.h -| BOX_H; pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0) 0 else @@ -13370,12 +13399,11 @@ pub const Pardes = struct { return null; } - /// The pane's first body row: under the tag when it is on top, and under - /// whatever notice bands `collectNotices` stacked above the body. Every - /// painter and hit test derives the body from here so the bands cannot - /// come adrift from the text they sit over. - pub fn bodyTop(p: *const Pardes, pane: *const Pane, r: Rect) u16 { - return (if (p.settings.tag_bottom) r.y else r.y + BOX_H) + pane.body_offset; + /// The pane's first body row: under the tag when it is on top, over it + /// when it is at the bottom. Notice bands do NOT move it -- they are an + /// overlay, drawn over the top rows rather than taking them. + pub fn bodyTop(p: *const Pardes, r: Rect) u16 { + return if (p.settings.tag_bottom) r.y else r.y + BOX_H; } fn tagCapacity(p: *const Pardes, physical_width: u16) u16 { @@ -13461,12 +13489,13 @@ pub const Pardes = struct { .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }; - // Sticky at the TOP of the body, where the tree-sitter context - // headers sit, one line to a row in the order collectNotices chose. - // Text is right aligned: the left edge is where the body's own text - // begins, and a notice that shared it would read as a line of the - // file rather than as chrome over it. - const first = if (p.settings.tag_bottom) r.y else r.y + BOX_H; + // An OVERLAY over the body's top rows, at the right edge, one + // chip to a row in the order collectNotices chose -- not a row + // taken out of the body the way a tree-sitter context header is. + // The chip is as wide as its own message and no wider, so the text + // beneath the rest of the row keeps reading as the file it is. + const first = p.bodyTop(r); + var leader_buf: [16]u8 = undefined; for (pane.notices.slice(), 0..) |kind, i| { const row = first + @as(u16, @intCast(i)); // Every notice is painted on the canonical grid as well as @@ -13474,39 +13503,34 @@ pub const Pardes = struct { // draws, and a band it cannot see is a message it never gets. // The GUI skips grid cells a tag layer covers, so nothing is // drawn twice there. - s.fill(tx, row, tw, 1, .{ .bg = msg_style.bg, .font_role = .tagline }); - switch (kind) { - .message => _ = printRight(s, tx, row, tw, pane.msg[0..pane.msg_len], msg_style), - .leader => { - var ibuf: [16]u8 = @splat(' '); - @memcpy(ibuf[0..3], "SPC"); - var iw: usize = 3; - for (p.leader_keys[0..p.leader_n]) |ch| { - ibuf[iw + 1] = ch; - iw += 2; - } - _ = printRight(s, tx, row, tw, ibuf[0..iw], msg_style); - }, - .prompt => { + const text = switch (kind) { + .message => pane.msg[0..pane.msg_len], + .leader => p.leaderText(&leader_buf), + .prompt => blk: { const at = pane.promptAt() orelse continue; - const text = pane.tagSlice()[@min(at, pane.tag_tail_len)..]; - // One column short of the edge: the caret sits AFTER - // the last character the moment a prompt opens, and - // flush right would put it at r.x + r.w -- off the - // pane, off the grid, and past what the detached wire - // will encode, which drops every frame while it is up. - const left = printRight(s, tx, row, tw -| 1, text, msg_style); - if (id != p.active) continue; - const prompt0 = (p.tagPrefix(pane) catch continue).len + at; - const col = @as(usize, pane.tag_col); - if (col >= prompt0) { - // The cursor follows the text to wherever it landed. - const prompt_col = panes.File.displayWidth(text[0..@min(col - prompt0, text.len)]); - if (prompt_col < tw) - s.cursor = .{ .x = left + @as(u16, @intCast(prompt_col)), .y = row, .bar = pane.mode == .insert }; - } + break :blk pane.tagSlice()[@min(at, pane.tag_tail_len)..]; }, - } + }; + const chip = p.noticeCols(text, tw); + if (chip == 0) continue; + const cx = tx + tw - chip; + pane.notices.left[i] = cx; + s.fill(cx, row, chip, 1, .{ .bg = msg_style.bg, .font_role = .tagline }); + // One column short of the chip's right edge: a prompt's caret + // sits AFTER its last character the moment it opens, and flush + // right would put it past the pane, off the grid, and past what + // the detached wire will encode -- which drops every frame for + // as long as the prompt is up. + const left = printRight(s, cx, row, chip -| 1, text, msg_style); + if (kind != .prompt or id != p.active) continue; + const at = pane.promptAt() orelse continue; + const prompt0 = (p.tagPrefix(pane) catch continue).len + at; + const col = @as(usize, pane.tag_col); + if (col < prompt0) continue; + // The cursor follows the text to wherever it landed. + const prompt_col = panes.File.displayWidth(text[0..@min(col - prompt0, text.len)]); + if (left + prompt_col < tx + tw) + s.cursor = .{ .x = left + @as(u16, @intCast(prompt_col)), .y = row, .bar = pane.mode == .insert }; } } @@ -13829,11 +13853,11 @@ pub const Pardes = struct { const raw = pane.mode == .tty; const tx = r.x + config.GUTTER; const width = r.w -| config.GUTTER; - const body_y = p.bodyTop(pane, r); + const body_y = p.bodyTop(r); var lines = std.mem.splitScalar(u8, body, '\n'); var visible: i32 = 0; while (lines.next()) |line| : (visible += 1) { - if (visible >= (r.h -| BOX_H) -| pane.body_offset) break; + if (visible >= r.h -| BOX_H) break; const source_row = if (raw) panes.Terminal.gridOffset(pane) + visible else pane.wrapAt(visible).line; for (rows) |row| { const target = if (row.raw_terminal) @@ -13871,10 +13895,10 @@ pub const Pardes = struct { const terminal_lines = if (pane.file == null) panes.Terminal.cursorLines(p, pane) catch return else &.{}; const tx = r.x + config.GUTTER; const width = r.w -| config.GUTTER; - const body_y = p.bodyTop(pane, r); + const body_y = p.bodyTop(r); const prefix: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; var visible: i32 = 0; - while (visible < @as(i32, (r.h -| BOX_H) -| pane.body_offset)) : (visible += 1) { + while (visible < @as(i32, r.h -| BOX_H)) : (visible += 1) { const source = pane.wrapAt(visible); const line = pointerSourceLine(pane, terminal_lines, source.line); const start = @min(line.len, @as(usize, @intCast(@max(0, source.at)))); @@ -13919,11 +13943,6 @@ pub const Pardes = struct { while (row < r.h) : (row += 1) { if (@as(i32, row) < r0 or @as(i32, row) > r1) continue; const sy = if (row < BOX_H) tag_y else body_y + row - BOX_H; - // The body sits `body_offset` rows lower than it used to, so the - // last rows of Sel space now fall past the pane. Bounding the loop - // instead would break the caller that paints a tag into a - // one-row surface of its own, where the offset means nothing. - if (row >= BOX_H and sy >= r.y + r.h) break; // File line numbers occupy a gutter only in the body. The tag is // row zero in Sel space and starts at its real first text cell. const scroll: i32 = if (row < BOX_H) pane.tag_scroll else 0; @@ -13972,48 +13991,45 @@ pub const Pardes = struct { // workspace layer to render; the menu bar carries its commands. if (p.settings.workspace_tag) try p.renderHeaderLayer(arena, MAX_PANES + MAX_COLS, .workspace, 0, .{ .x = 0, .y = 0, .w = p.screen_w, .h = TOPBAR_H }, p.topbar(&text_buf), chrome.tag_bg, chrome.tag_fg); - // Each notice is its own tagline-shaped band at the top of the body. + // Each notice is its own tagline-shaped chip over the top of the body. // Going through the same layer machinery the pane and column tags use // is what gives it the tagline's height, its small-font metrics, the // band offset and the border -- none of which a body-grid row can have - // just by wearing a tagline font. + // just by wearing a tagline font. The geometry is the one the grid + // pass already chose and recorded, so the two cannot disagree and the + // GUI's "skip the cells a tag layer covers" leaves no stub behind. for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; const r = p.rects[id]; if (r.w <= TAG_TEXT_INSET or r.h <= BOX_H) continue; - const first = if (p.settings.tag_bottom) r.y else r.y + BOX_H; - var band: u16 = 0; - for (pane.notices.slice()) |kind| { + const first = p.bodyTop(r); + var leader_buf: [16]u8 = undefined; + for (pane.notices.slice(), 0..) |kind, i| { const text = switch (kind) { .message => pane.msg[0..pane.msg_len], - .leader => blk: { - var ibuf: [16]u8 = @splat(' '); - @memcpy(ibuf[0..3], "SPC"); - var iw: usize = 3; - for (p.leader_keys[0..p.leader_n]) |ch| { - ibuf[iw + 1] = ch; - iw += 2; - } - break :blk try arena.dupe(u8, ibuf[0..iw]); - }, + .leader => p.leaderText(&leader_buf), // A prompt is an editing line with a cursor in it, so it // stays on the grid where the cursor can sit on a cell. .prompt => continue, }; if (text.len == 0) continue; - const room = p.tagCapacity(r.w -| TAG_TEXT_INSET); + const cx = pane.notices.left[i]; + if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue; + const chip = r.x + r.w - cx; + // Right aligned inside the chip, a blank cell short of its + // edge: the same one the grid pass leaves for a prompt caret. + const room = p.tagCapacity(chip) -| 1; const shown = panes.File.displayWidth(text); const pad = room -| shown; const line = try arena.alloc(u8, pad + text.len); @memset(line[0..pad], ' '); @memcpy(line[pad..], text); - try p.renderHeaderLayer(arena, NOTICE_LAYER_BASE + id * Pane.Notices.max + band, .notice, @intCast(id), .{ - .x = r.x + TAG_TEXT_INSET, - .y = first + band, - .w = r.w -| TAG_TEXT_INSET, + try p.renderHeaderLayer(arena, NOTICE_LAYER_BASE + id * Pane.Notices.max + i, .notice, @intCast(id), .{ + .x = cx, + .y = first + @as(u16, @intCast(i)), + .w = chip, .h = BOX_H, }, line, chrome.tag_bg, chrome.tag_fg); - band += 1; } } if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { @@ -14087,7 +14103,6 @@ pub const Pardes = struct { /// them instead of letting the last one win the row. fn collectNotices(p: *Pardes, pane: *Pane, r: Rect, id: usize) void { pane.notices.len = 0; - pane.body_offset = 0; if (r.w <= config.GUTTER or r.h <= BOX_H) return; if (pane.msg_len > 0) pane.notices.push(.message); if (id == p.active and p.leader_on) pane.notices.push(.leader); @@ -14105,13 +14120,12 @@ pub const Pardes = struct { for (0..room) |i| pane.notices.kinds[i] = pane.notices.kinds[i + drop]; pane.notices.len = room; } - pane.body_offset = pane.notices.len; } 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 viewport_rows = r.h - BOX_H -| @as(u16, pane.notices.len); + const viewport_rows = r.h - BOX_H; 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. @@ -14132,9 +14146,6 @@ pub const Pardes = struct { @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); - // This surface carries no notice bands, so the body starts at its top. - pane.body_offset = 0; - defer pane.body_offset = pane.notices.len; 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); @@ -14148,11 +14159,7 @@ pub const Pardes = struct { } layer.pane = @intCast(id); layer.serial = pane.serial; - // Below the notice bands, which are stacked at the TOP of the body - // now: the rows they took came off this viewport, so its origin has to - // move with them or the first context header renders under a band. - const body_top = (if (p.settings.tag_bottom) r.y else r.y + BOX_H) + @as(u16, pane.notices.len); - layer.viewport = .{ .x = r.x + config.GUTTER, .y = body_top, .w = width, .h = viewport_rows }; + layer.viewport = .{ .x = r.x + config.GUTTER, .y = p.bodyTop(r), .w = width, .h = viewport_rows }; layer.cols = width; layer.rows = @intCast(logical_rows); layer.context_rows = pane.context_rows; @@ -14174,7 +14181,7 @@ pub const Pardes = struct { const tx = r.x + config.GUTTER; // text area (tag + body), right of the 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 = p.bodyTop(pane, r); + const body_y = p.bodyTop(r); const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; // text area resets to terminal-default cells (vaxis clear semantics); @@ -14214,7 +14221,7 @@ pub const Pardes = struct { if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return; if (pane.image) |*iv| { - const image_h = (r.h -| BOX_H) -| pane.body_offset; + const image_h = r.h -| BOX_H; panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y, tw, image_h); // thumbless, but the same one column as the real scrollbar below — // that is the whole point of drawing it, and like that one it runs @@ -14235,7 +14242,7 @@ pub const Pardes = struct { const tx = r.x + @min(TAG_TEXT_INSET, r.w); const tw = r.w -| TAG_TEXT_INSET; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; - const body_y = p.bodyTop(pane, r); + const body_y = p.bodyTop(r); const page_rgb = th.bg orelse th.tag_bg; // Focus is visible along the tag without changing its live text grid. const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; @@ -14322,12 +14329,12 @@ pub const Pardes = struct { // Below the notice bands: they are stacked at the top of the body the // way tree-sitter context rows are, so the body starts under them // rather than being painted over by them. - const body_y = p.bodyTop(pane, r); + const body_y = p.bodyTop(r); 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 = (r.h -| BOX_H) -| pane.body_offset; - const body_bottom = r.h -| pane.body_offset; + const body_h = r.h -| BOX_H; + const body_bottom = r.h; const body_style: CellStyle = .{ .fg = if (th.fg) |c| .{ .rgb = c } else .default, .bg = pane_bg, @@ -14820,7 +14827,7 @@ test "a builtin announces itself on the message row, and Verbose silences it" { try std.testing.expectEqual(@as(u16, 0), pane.msg_len); } -test "notices stack downward from the top of the body, which starts below them" { +test "notices stack over the body's top rows without moving them" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); @@ -14831,44 +14838,45 @@ test "notices stack downward from the top of the body, which starts below them" defer frame.deinit(); const r = p.rects[id]; - const plain = p.bodyTop(pane, r); - try std.testing.expectEqual(@as(u16, 0), pane.body_offset); + const top = p.bodyTop(r); - // A message and a leader prefix at once: two bands, stacked in the order + // A message and a leader prefix at once: two chips, in the order // collectNotices chose, each one row tall and each one row further down. try std.testing.expect(p.executeBuiltinLine(id, "Msg first")); p.leader_on = true; p.leader_n = 0; _ = frame.reset(.retain_capacity); _ = try p.render(frame.allocator()); - try std.testing.expectEqual(@as(u16, 2), pane.body_offset); - try std.testing.expectEqual(plain + 2, p.bodyTop(pane, r)); + try std.testing.expectEqual(@as(u8, 2), pane.notices.len); + // An overlay, not a reservation: the body still starts where it did. + try std.testing.expectEqual(top, p.bodyTop(r)); - var rows: [Pane.Notices.max]u16 = @splat(0); - var found: usize = 0; + var seen: usize = 0; for (p.surface.tagLayers()) |*layer| { if (layer.cols == 0 or layer.kind != .notice) continue; try std.testing.expectEqual(@as(u16, 1), layer.viewport.h); - rows[found] = layer.viewport.y; - found += 1; + // Sized to its own message, right up against the pane's right edge -- + // not the width of the pane. + try std.testing.expectEqual(r.x + r.w, layer.viewport.x + layer.viewport.w); + try std.testing.expect(layer.viewport.w < r.w - config.GUTTER); + try std.testing.expect(layer.viewport.y == top or layer.viewport.y == top + 1); + try std.testing.expectEqual(pane.notices.left[layer.viewport.y - top], layer.viewport.x); + seen += 1; } - try std.testing.expectEqual(@as(usize, 2), found); - std.mem.sort(u16, rows[0..2], {}, std.sort.asc(u16)); - try std.testing.expectEqual(plain, rows[0]); - try std.testing.expectEqual(plain + 1, rows[1]); + try std.testing.expectEqual(@as(usize, 2), seen); + // "first" is longer than "SPC", so its chip is wider. + try std.testing.expect(pane.notices.left[0] < pane.notices.left[1]); - // Gone with the notices: no stale band, and the body back at the top. + // Gone with the notices: no stale chip left behind. p.leader_on = false; pane.msg_len = 0; _ = frame.reset(.retain_capacity); _ = try p.render(frame.allocator()); - try std.testing.expectEqual(@as(u16, 0), pane.body_offset); - try std.testing.expectEqual(plain, p.bodyTop(pane, r)); for (p.surface.tagLayers()) |*layer| try std.testing.expect(layer.cols == 0 or layer.kind != .notice); } -test "a prompt band keeps its caret on the grid, and a band is not a click target" { +test "a prompt chip keeps its caret on the grid, and only the chip swallows a click" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); @@ -14879,24 +14887,36 @@ test "a prompt band keeps its caret on the grid, and a band is not a click targe defer frame.deinit(); // The caret sits AFTER the prompt text the moment the prompt opens, and - // the band is flush with the pane's right edge -- so this is the frame - // that used to put it one column past the pane, off the grid and past - // what the detached wire will encode. + // the chip ends at the pane's right edge -- so this is the frame that + // used to put it one column past the pane, off the grid and past what the + // detached wire will encode. p.startPrompt(pane, .{ .search = "/" }); _ = try p.render(frame.allocator()); const r = p.rects[id]; const cursor = p.surface.cursor orelse return error.MissingPromptCursor; - try std.testing.expect(pane.body_offset > 0); - try std.testing.expectEqual(p.bodyTop(pane, r) - 1, cursor.y); + const row = p.bodyTop(r); + try std.testing.expectEqual(@as(u8, 1), pane.notices.len); + try std.testing.expectEqual(row, cursor.y); try std.testing.expect(cursor.x < r.x + r.w); try std.testing.expect(cursor.x < p.surface.cols); - // The band is chrome. Mapped as body it would land on Sel row 0, which is - // the TAG row, so a click on it would run a word out of the tag as a - // builtin. - const band_row = p.bodyTop(pane, r) - 1; - p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = r.x + r.w - 4, .row = band_row } }); - p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = r.x + r.w - 4, .row = band_row } }); + // The chip covers only its own columns. Left of it is ordinary body, and + // a sweep there still selects. + const left = pane.notices.left[0]; + try std.testing.expect(left > r.x + config.GUTTER + 2); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = left - 3, .row = row } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = left - 1, .row = row } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = left - 1, .row = row } }); + try std.testing.expect(pane.sel[0].state != .none); + try std.testing.expect(pane.sel[0].r0 >= BOX_H); // the body, not the tag + + // Inside it is chrome: mapped as body it would land on Sel row 0, which is + // the TAG row, so a sweep there would highlight the tagline instead. + pane.clearPointerSelection(0); + pane.sel[0] = .{}; + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = r.x + r.w - 3, .row = row } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = r.x + r.w - 2, .row = row } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = r.x + r.w - 2, .row = row } }); for (pane.sel) |selection| try std.testing.expect(selection.state == .none); } |
