diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 00:30:46 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 232bfbbc4371a1fe9df210874118b4450dce7f9d (patch) | |
| tree | 6af9cfcc6e741082b15a270a6d80d3d182301ce5 /src/pardes.zig | |
| parent | 42748f92b77367db415c8b4d0f2e537a9e1ddde7 (diff) | |
| download | pardes-232bfbbc4371a1fe9df210874118b4450dce7f9d.tar.gz pardes-232bfbbc4371a1fe9df210874118b4450dce7f9d.zip | |
Let a pane's tag take a row for each line it holds
A tag that is a text can hold a newline, and the tagline, the body under it
and the pointer all assumed one row. The pane's tag now takes a row per line
(up to eight, and never the body's last row); the body starts below it, the
rows a pointer counts start the body after the tag's lines, a click on any
line puts the tag's cursor there, and a pixel shell gets a tag layer per
line. A tag of one line draws exactly as before.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 237 |
1 files changed, 143 insertions, 94 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 60c41f50..72a282c2 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -249,8 +249,13 @@ pub const TagLayer = @import("tag_layer.zig").Layer; pub const TagHit = @import("tag_layer.zig").Hit; pub const TagKind = @import("tag_layer.zig").Kind; /// Panes, then columns, then the workspace bar, then each pane's notice bands. -pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max; +/// A tag shows at most this many lines; past them it is cut, like a line +/// too long for its width. +pub const MAX_TAG_ROWS = 8; +pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max + MAX_PANES * (MAX_TAG_ROWS - 1); pub const NOTICE_LAYER_BASE = MAX_PANES + MAX_COLS + 1; +/// Where the layers for a pane tag's second and later lines start. +pub const TAG_LINE_LAYER_BASE = NOTICE_LAYER_BASE + MAX_PANES * Pane.Notices.max; pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px; pub const MAX_COLS = 6; const column_weight_unit = layout.column_weight_unit; @@ -285,16 +290,17 @@ pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?layout.Tra } if (track) |active| { const box = active.contentBox(); - const tag_y = if (p.settings.tag_bottom) box.y + box.h - @as(f32, @floatFromInt(BOX_H)) else box.y; + const rows: f32 = @floatFromInt(if (active.pane < MAX_PANES) if (p.panes[active.pane]) |pane| pane.tag_rows else BOX_H else BOX_H); + const tag_y = if (p.settings.tag_bottom) box.y + box.h - rows else box.y; if (@as(f32, @floatFromInt(row)) >= tag_y and - @as(f32, @floatFromInt(row)) < tag_y + @as(f32, @floatFromInt(BOX_H))) + @as(f32, @floatFromInt(row)) < tag_y + rows) return box.x; } for (p.panes, 0..) |slot, id| { - if (slot == null) continue; + const pane = slot orelse continue; const r = p.rects[id]; - const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; - if (row == tag_y and col >= r.x and col < r.x + r.w) return @floatFromInt(r.x); + const tag_y = p.tagTop(pane, r); + if (row >= tag_y and row < tag_y + pane.tag_rows and col >= r.x and col < r.x + r.w) return @floatFromInt(r.x); } return @floatFromInt(col); } @@ -352,10 +358,10 @@ pub fn gridColAt(p: ?*const Pardes, x: f32, row: u16, body_w: f32, tagline_w: f3 } }; if (p) |core| for (core.panes, 0..) |slot, id| { - if (slot == null) continue; + const pane = slot orelse continue; const r = core.rects[id]; - const tag_y = if (core.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; - if (row != tag_y or r.w == 0) continue; + const tag_y = core.tagTop(pane, r); + if (row < tag_y or row >= tag_y + pane.tag_rows or r.w == 0) continue; const left = @as(f32, @floatFromInt(r.x)) * body; const right = @as(f32, @floatFromInt(r.x + r.w)) * body; if (x < left or x >= right) continue; @@ -813,7 +819,7 @@ test "TreeContext tag style preserves message rows and its opt-out survives rest 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(p.bodyTop(r), band.viewport.y); + try std.testing.expectEqual(p.bodyTop(pane, 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. @@ -829,7 +835,7 @@ test "TreeContext tag style preserves message rows and its opt-out survives rest _ = 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(r)).style.font_role); + try std.testing.expectEqual(FontRole.body, p.surface.at(r.x + config.GUTTER, p.bodyTop(pane, r)).style.font_role); try dump.dumpState(p); const restored = try dump.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); @@ -4915,15 +4921,14 @@ pub const Pardes = struct { pane.focus = .tag; } - /// Where display column `col` of the tagline (tag scroll included) lands - /// in the tag's own text, or null when it is on the prefix. - pub fn tagOffsetAt(p: *Pardes, pane: *Pane, col: usize) ?usize { - const prefix = p.tagPrefix(pane) catch return null; - const width = panes.File.displayWidth(prefix); + /// Where display column `col` of tag line `row` (tag scroll included) + /// lands in the tag's own text, or null when it is on the prefix, which + /// only the first line has. + pub fn tagOffsetAt(p: *Pardes, pane: *Pane, row: usize, col: usize) ?modal.Cursor { + const width = if (row == 0) panes.File.displayWidth(p.tagPrefix(pane) catch return null) else 0; if (col < width) return null; - const tail = curTail(pane); - const line = tail[0 .. std.mem.indexOfScalar(u8, tail, '\n') orelse tail.len]; - return modal.graphemeStart(line, panes.File.rawAtDisplay(line, col - width)); + const line = modal.lineSlice(curTail(pane), row); + return .{ .row = row, .col = modal.graphemeStart(line, panes.File.rawAtDisplay(line, col - width)) }; } /// Start a name draft for a file that can take one, its caret at display @@ -5822,7 +5827,7 @@ pub const Pardes = struct { } // A sweep over the tag is the tag's while it has the keyboard. const swept = pane.sel[sel_slot]; - if (pane.focus == .body and swept.state == .done and @min(swept.r0, swept.r1) < BOX_H) { + if (pane.focus == .body and swept.state == .done and @min(swept.r0, swept.r1) < pane.tag_rows) { pane.clearPointerSelection(sel_slot); pane.sel[sel_slot].state = .none; } @@ -5836,9 +5841,14 @@ pub const Pardes = struct { state.reply_len = 0; }; const r = p.rects[id]; + // The tag takes a row per line, leaving the body one row if it + // had any; a folded pane shows its first line only. + const lines = std.mem.count(u8, curTail(pane), "\n") + 1; + pane.tag_rows = @intCast(@min(lines, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1))); + if (pane.collapsed) pane.tag_rows = BOX_H; const cols = @max(1, r.w -| config.GUTTER); - const rows = @max(1, r.h -| BOX_H); // the tag steals the top row - if ((cols != pane.cols or rows != pane.rows) and r.h > BOX_H) { + const rows = @max(1, r.h -| pane.tag_rows); // the tag steals the top rows + if ((cols != pane.cols or rows != pane.rows) and r.h > pane.tag_rows) { // doc panes have no pty/emulator grid to reflow; just record // the size so bodyText renders the right number of rows if (pane.isTerminal()) { @@ -5905,7 +5915,8 @@ pub const Pardes = struct { if (pane.serial != layer.serial) return null; } if (layer.kind == .column and (layer.id >= p.ncol or p.col_serial[layer.id] != layer.serial)) return null; - return layer.hitAt(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)); + // A taller tag has a layer per line; the point is on one of them. + return layer.hitAt(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)) orelse continue; } return null; } @@ -5913,8 +5924,13 @@ pub const Pardes = struct { /// 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; + pub fn bodyTop(p: *const Pardes, pane: *const Pane, r: Rect) u16 { + return if (p.settings.tag_bottom) r.y else r.y + pane.tag_rows; + } + + /// The tag's first row: the pane's top, or its last rows with Tagbottom. + pub fn tagTop(p: *const Pardes, pane: *const Pane, r: Rect) u16 { + return if (p.settings.tag_bottom) r.y + r.h -| pane.tag_rows else r.y; } fn tagCapacity(p: *const Pardes, physical_width: u16) u16 { @@ -6005,7 +6021,7 @@ pub const Pardes = struct { // 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); + const first = p.bodyTop(pane, r); var leader_buf: [16]u8 = undefined; for (pane.notices.slice(), 0..) |kind, i| { const row = first + @as(u16, pane.notices.row[i]); @@ -6385,26 +6401,41 @@ pub const Pardes = struct { const r = p.rects[id]; if (r.w <= TAG_TEXT_INSET or r.h == 0) continue; const tag = try p.tagText(arena, pane); - const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), panes.File.displayWidth(tag) + 1))); - const layer = &p.surface.tag_layers[id]; - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); - const storage = try arena.alloc(Cell, @as(usize, columns) + TAG_TEXT_INSET); + const rows = pane.tag_rows; + // As wide as the widest line it shows, and never narrower than + // its viewport. + var widest: usize = 0; + var split = std.mem.splitScalar(u8, tag, '\n'); + for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse "")); + const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), widest + 1))); + const pitch = @as(usize, columns) + TAG_TEXT_INSET; + const storage = try arena.alloc(Cell, pitch * rows); @memset(storage, .{}); - var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = 1, .cells = storage }; + var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage }; std.mem.swap(Surface, &p.surface, &temporary); - p.paintPaneTag(arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = 1 }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| { + p.paintPaneTag(arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| { std.mem.swap(Surface, &p.surface, &temporary); return err; }; std.mem.swap(Surface, &p.surface, &temporary); - @memcpy(layer.cells, storage[TAG_TEXT_INSET..]); - layer.kind = .pane; - layer.id = @intCast(id); - layer.serial = pane.serial; - layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = if (p.settings.tag_bottom) r.y + r.h - BOX_H else r.y, .w = r.w - TAG_TEXT_INSET, .h = BOX_H }; - layer.cols = columns; - layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; - layer.cursor = if (!p.header_focus and temporary.cursor != null) .{ .col = temporary.cursor.?.x - TAG_TEXT_INSET, .bar = temporary.cursor.?.bar } else null; + // A layer to a line: the first in the pane's own slot, the rest + // in the slots kept for the lines of taller tags. + for (0..rows) |line| { + const layer = &p.surface.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; + if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); + @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); + layer.kind = .pane; + layer.id = @intCast(id); + layer.serial = pane.serial; + layer.line = @intCast(line); + layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H }; + layer.cols = columns; + layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| + (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null) + else + null; + } } // With the workspace tag row handed to native chrome there is no // workspace layer to render; the menu bar carries its commands. @@ -6422,8 +6453,8 @@ pub const Pardes = struct { 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 = p.bodyTop(r); + if (r.w <= TAG_TEXT_INSET or r.h <= pane.tag_rows) continue; + const first = p.bodyTop(pane, r); var leader_buf: [16]u8 = undefined; for (pane.notices.slice(), 0..) |kind, i| { const text = Messages.noticeText(p, pane, i, &leader_buf); @@ -6556,8 +6587,8 @@ pub const Pardes = struct { const chrome = p.chromeTheme(); 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(r); + const tag_y = p.tagTop(pane, r); + const body_y = p.bodyTop(pane, r); const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; // text area resets to terminal-default cells (vaxis clear semantics); @@ -6579,7 +6610,7 @@ pub const Pardes = struct { // Grips keep physical grid dimensions, independently of tag text. .font_role = .tagline, }; - s.fill(r.x, tag_y, config.GUTTER, BOX_H, box_style); + s.fill(r.x, tag_y, config.GUTTER, pane.tag_rows, box_style); s.set(r.x, tag_y, switch (pane.body.mode) { .normal => config.tag_normal, .insert => config.tag_insert, @@ -6600,14 +6631,13 @@ pub const Pardes = struct { // GUI's image pass, kitty's z-order), so a chip over it would be // painted out. Text gets the overlay; a picture gives up the rows. const shown = pane.notices.span(); - const image_h = (r.h -| BOX_H) -| shown; + const image_h = (r.h -| pane.tag_rows) -| shown; panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y + shown, 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 // past the notice bands so the gutter has no notch in it - const rail_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; - s.fill(r.x, rail_y, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); - s.fill(r.x + 1, rail_y, 1, r.h -| BOX_H, .{ .bg = pane_bg }); + s.fill(r.x, body_y, 1, r.h -| pane.tag_rows, .{ .bg = .{ .rgb = chrome.scroll_track } }); + s.fill(r.x + 1, body_y, 1, r.h -| pane.tag_rows, .{ .bg = pane_bg }); return; } @@ -6620,8 +6650,9 @@ pub const Pardes = struct { const chrome = p.chromeTheme(); 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(r); + const tag_y = p.tagTop(pane, r); + const body_y = p.bodyTop(pane, r); + const rows = pane.tag_rows; 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; @@ -6629,44 +6660,53 @@ pub const Pardes = struct { const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline }; // Padding belongs to the same font row but keeps its default ink: a // role must not manufacture a foreground color for blank cells. - s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), BOX_H, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline }); - s.fill(tx, tag_y, tw, BOX_H, .{ + s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline }); + s.fill(tx, tag_y, tw, rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline, }); const whole = try p.tagText(arena, pane); const tail = curTail(pane); const prefix_len = whole.len - tail.len; - // The tagline shows the tag's first line: its prefix and the first - // line of its own text. - const tag = whole[0 .. std.mem.indexOfScalar(u8, whole, '\n') orelse whole.len]; - // The keyboard's caret on it, as a byte offset: in its own text when - // the tag has the keyboard, in the path while a name is drafted. - const caret_at: ?usize = if (!active) + // A row per line: the first carries the prefix, the rest are the + // tag's own text alone. + var lines: [MAX_TAG_ROWS][]const u8 = @splat(""); + var split = std.mem.splitScalar(u8, whole, '\n'); + for (lines[0..rows]) |*line| line.* = split.next() orelse ""; + // The keyboard's caret, as a row and a byte offset in that row's + // line: in the tag's own text when the tag has the keyboard, in the + // path while a name is drafted. + const Caret = struct { row: usize, at: usize }; + const caret: ?Caret = if (!active) null else if (pane.prompt == .name) - @min(tag.len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) - else if (pane.focus == .tag and pane.tag.cur_row == 0) - @min(tag.len, prefix_len + @as(usize, @intCast(@max(0, pane.tag.cur_col)))) - else - null; - if (caret_at) |at| { - const caret = panes.File.rawDisplayCol(tag, at); + .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) } + else if (pane.focus == .tag and pane.tag.cur_row < rows) caret: { + const row: usize = @intCast(@max(0, pane.tag.cur_row)); + const lead: usize = if (row == 0) prefix_len else 0; + break :caret .{ .row = row, .at = @min(lines[row].len, lead + @as(usize, @intCast(@max(0, pane.tag.cur_col)))) }; + } else null; + if (caret) |at| { + const line = lines[at.row]; + const col = panes.File.rawDisplayCol(line, at.at); var scroll: usize = pane.tag_scroll; - if (caret < scroll) scroll = caret; - if (caret >= scroll + capacity) scroll = caret - capacity + 1; - var raw = panes.File.rawAtDisplay(tag, scroll); - scroll = panes.File.rawDisplayCol(tag, raw); - if (caret >= scroll + capacity) { - raw = modal.nextGrapheme(tag, raw); - scroll = panes.File.rawDisplayCol(tag, raw); + if (col < scroll) scroll = col; + if (col >= scroll + capacity) scroll = col - capacity + 1; + var raw = panes.File.rawAtDisplay(line, scroll); + scroll = panes.File.rawDisplayCol(line, raw); + if (col >= scroll + capacity) { + raw = modal.nextGrapheme(line, raw); + scroll = panes.File.rawDisplayCol(line, raw); } pane.tag_scroll = @intCast(scroll); } else if (pane.focus != .tag) pane.tag_scroll = 0; - const tag_start = panes.File.rawAtDisplay(tag, pane.tag_scroll); - _ = s.print(tx, tag_y, tw, tag[tag_start..], tag_style); + for (lines[0..rows], 0..) |line, row| { + const start = panes.File.rawAtDisplay(line, pane.tag_scroll); + _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style); + } + const tag = lines[0]; if (tagTintRange(pane, tag, prefix_len)) |name| { - const lo = @max(name.lo, tag_start); + const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll)); const hi = @min(name.hi, tag.len); if (lo < hi) { var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll; @@ -6677,41 +6717,50 @@ pub const Pardes = struct { } } if (p.look_hover_preview) |preview| { - if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < BOX_H) { + if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); }; } - // The tag's own selections, while it has the keyboard. A range of one - // grapheme is the block cursor, not a selection. + // The tag's own selections, while it has the keyboard, a line at a + // time. A range of one grapheme is the block cursor, not a selection. if (pane.focus == .tag) { var ranges: [Text.max_selections]modal.Selection = undefined; const got = pane.tag.ranges(tail, 0, &ranges); - const shown = tag.len - prefix_len; for (ranges[0..got.n]) |range| { const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); - if (modal.nextGrapheme(tail, lo) >= hi or lo >= shown) continue; - var col = panes.File.rawDisplayCol(tag, prefix_len + lo) -| pane.tag_scroll; - const end = panes.File.rawDisplayCol(tag, prefix_len + @min(hi, shown)); - while (col + pane.tag_scroll < end and col < tw) : (col += 1) { - const cell = s.at(tx + @as(u16, @intCast(col)), tag_y); - cell.default = false; - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; + if (modal.nextGrapheme(tail, lo) >= hi) continue; + const first = modal.positionAt(tail, lo); + const last = modal.positionAt(tail, hi); + var row = first.row; + while (row <= last.row and row < rows) : (row += 1) { + const line = lines[row]; + const lead: usize = if (row == 0) prefix_len else 0; + const from = lead + if (row == first.row) first.col else 0; + const to = @min(line.len, lead + if (row == last.row) last.col else modal.lineSlice(tail, row).len); + var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll; + const end = panes.File.rawDisplayCol(line, to); + const y = tag_y + @as(u16, @intCast(row)); + while (col + pane.tag_scroll < end and col < tw) : (col += 1) { + const cell = s.at(tx + @as(u16, @intCast(col)), y); + cell.default = false; + cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.fg = .{ .rgb = th.sel_fg }; + } } } } // the caret: bar while typing, block in normal mode (same rule as a body) - if (caret_at) |at| { - const col = panes.File.rawDisplayCol(tag, at) -| pane.tag_scroll; + if (caret) |at| { + const col = panes.File.rawDisplayCol(lines[at.row], at.at) -| pane.tag_scroll; const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode; - if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y, .bar = mode == .insert }; + if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y + @as(u16, @intCast(at.row)), .bar = mode == .insert }; } for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; - if (@min(selection.r0, selection.r1) >= BOX_H) continue; + if (@min(selection.r0, selection.r1) >= rows) continue; body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); } } @@ -6945,7 +6994,7 @@ test "a prompt chip is a band with its caret, still on the grid, and only the ch _ = try p.render(frame.allocator()); const r = p.rects[id]; const cursor = p.surface.cursor orelse return error.MissingPromptCursor; - const row = p.bodyTop(r); + const row = p.bodyTop(pane, 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); @@ -7010,7 +7059,7 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro try std.testing.expectEqualStrings("t", band.cells[caret.col - 1].grapheme()); try std.testing.expectEqualStrings("w", band.cells[caret.col].grapheme()); const grid = p.surface.cursor orelse return error.MissingPromptCursor; - try std.testing.expectEqual(p.bodyTop(p.rects[0]), grid.y); + try std.testing.expectEqual(p.bodyTop(pane, p.rects[0]), grid.y); // Submitting searches for what was typed and gives the prompt its line back. p.update(.{ .key = .{ .cp = Key.enter } }); |
