From 1df6bd12fafbf97062f7844a23de208add75a843 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 19:36:58 -0300 Subject: A pane tag wraps onto rows and can collapse to one, as acme's does acme's frame wraps a tag at its right edge and the window grows to show every row (wind.c wintaglines); Tagup/Tagdown shrink it to one row and back. Pane tags used to be one row, scrolled sideways to the caret. Now a tag's lines wrap at its width (one cell kept for the caret past a full row) and it starts expanded; insert-mode Up on the first row collapses it, Down on the last row expands it, and Alt-Up/Alt-Down do so in either mode. A word the wrap breaks across rows is still one word to a click and a selection. Column and workspace tags keep scrolling sideways. Tests read body rows relative to the body's first row; the 41 snapshot goldens that moved were re-recorded by name after a programmatic check that each differs only by body rows shifting under the taller tags, and twelve scripts whose clicks targeted fixed rows now click the same text. Co-Authored-By: Claude Opus 5.5 --- src/Terminal.zig | 2 ++ src/draw.zig | 73 ++++++++++++++++++++++-------------------- src/edit.zig | 24 ++++++++++++-- src/gui/gui.zig | 5 +-- src/look.zig | 32 +++++++++++++++++++ src/mouse.zig | 5 +-- src/ninep/pty.zig | 2 ++ src/panes.zig | 5 +++ src/pardes.zig | 31 ++++++++++++++++-- src/tagline.zig | 96 ++++++++++++++++++++++++++++++++++++++++++++++++------- 10 files changed, 221 insertions(+), 54 deletions(-) (limited to 'src') diff --git a/src/Terminal.zig b/src/Terminal.zig index 91c23305..19e23624 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -2111,6 +2111,8 @@ test "terminal Filter keys indexed truecolor OSC and background-only cells throu defer p.deinit(); while (p.nextEffect()) |_| {} const pane = p.panes[0].?; + pane.tag_expanded = false; // a one-row tag keeps this small screen's rows + p.sync(); try testing.expect(pane.tty_filter); pane.tty_filter = false; diff --git a/src/draw.zig b/src/draw.zig index 2d757635..280b5e90 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -712,9 +712,9 @@ fn joinPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r if (r.w <= TAG_TEXT_INSET) return paintPaneTag(p, s, arena, pane, r, id, active, capacity); const chrome = p.chromeTheme(); const region = s.region(.tag, id).?; - const tag = try tagline.tagText(p, arena, pane); + const tag = (try tagline.shownTag(p, arena, pane)).text; const rows = pane.tag_rows; - // As wide as the widest line it shows, and never narrower than its + // As wide as the widest row it shows, and never narrower than its // viewport. var widest: usize = 0; var split = std.mem.splitScalar(u8, tag, '\n'); @@ -858,8 +858,10 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa // A row per line shown, from `tag_top`: the tag's first line carries // the prefix, the rest are the tag's own text alone. const top = pane.tag_top; + // Its lines wrapped at its width (tagline.shownTag): a row per row shown. + const shown = try tagline.shownTag(p, arena, pane); var lines: [MAX_TAG_ROWS][]const u8 = @splat(""); - var split = std.mem.splitScalar(u8, whole, '\n'); + var split = std.mem.splitScalar(u8, shown.text, '\n'); for (0..top) |_| _ = split.next(); for (lines[0..rows]) |*line| line.* = split.next() orelse ""; // The keyboard's caret, as a row and a byte offset in that row's @@ -868,42 +870,45 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa const Caret = struct { row: usize, at: usize }; const caret: ?Caret = if (!active) null - else if (pane.prompt == .name) - .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) } - else if (pane.focus == .tag and pane.prompt == .none and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: { - const row: usize = @intCast(pane.tag.cur_row - top); - break :caret .{ .row = row, .at = @min(lines[row].len, @as(usize, @intCast(@max(0, pane.tag.cur_col)))) }; + else if (pane.prompt == .name) caret: { + // A drafted name is the prefix: its caret is on row 0 of the tag, + // the rows it wrapped onto included. + const at = modal.positionAt(shown.text, shown.fromReal(@intCast(@max(0, pane.input.cur_col)))); + if (at.row < top or at.row >= top + rows) break :caret null; + break :caret .{ .row = at.row - top, .at = @min(lines[at.row - top].len, at.col) }; + } + else if (pane.focus == .tag and pane.prompt == .none) caret: { + const real = modal.offsetAt(whole, .{ .row = @intCast(@max(0, pane.tag.cur_row)), .col = @intCast(@max(0, pane.tag.cur_col)) }); + const at = modal.positionAt(shown.text, shown.fromReal(real)); + if (at.row < top or at.row >= top + rows) break :caret null; + const row: usize = at.row - top; + break :caret .{ .row = row, .at = @min(lines[row].len, at.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 (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); - } - // A line past a u16 of columns keeps its caret off the band. - pane.tag_scroll = @intCast(@min(scroll, std.math.maxInt(u16))); - } else if (pane.focus != .tag) pane.tag_scroll = 0; + // Its rows are wrapped to fit: a pane tag never scrolls sideways. + _ = capacity; + pane.tag_scroll = 0; for (lines[0..rows], 0..) |line, row| { // The scroll is the caret's line's: a shorter row shows nothing. const start = @min(line.len, 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 (top == 0) if (tagTintRange(pane, tag, prefix_len)) |name| { - 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; - const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll; - const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg; + // The name's tint, in the tag's first line, over whichever rows it + // wrapped onto. + const first_line = whole[0 .. std.mem.indexOfScalar(u8, whole, '\n') orelse whole.len]; + if (tagTintRange(pane, first_line, prefix_len)) |name| if (name.lo < name.hi) { + const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg; + const a = modal.positionAt(shown.text, shown.fromReal(name.lo)); + const b = modal.positionAt(shown.text, shown.fromReal(@min(name.hi, first_line.len))); + var row = @max(a.row, top); + while (row <= b.row and row < top + rows) : (row += 1) { + const line = lines[row - top]; + const from = if (row == a.row) a.col else 0; + const to = if (row == b.row) b.col else line.len; + var col = panes.File.rawDisplayCol(line, @min(from, line.len)); + const end = panes.File.rawDisplayCol(line, @min(to, line.len)); + const y = tag_y + @as(u16, @intCast(row - top)); while (col < end and col < tw) : (col += 1) - s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg }; + s.at(tx + @as(u16, @intCast(col)), y).style.fg = .{ .rgb = name_fg }; } }; if (p.look_hover_preview) |preview| { @@ -921,8 +926,8 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); if (modal.nextGrapheme(whole, lo) >= hi) continue; - const first = modal.positionAt(whole, lo); - const last = modal.positionAt(whole, hi); + const first = modal.positionAt(shown.text, shown.fromReal(lo)); + const last = modal.positionAt(shown.text, shown.fromReal(hi)); var row = @max(first.row, top); while (row <= last.row and row < top + rows) : (row += 1) { const line = lines[row - top]; diff --git a/src/edit.zig b/src/edit.zig index 1e480785..a194ef48 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -202,7 +202,7 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { /// (the tag's lines first, the ones it shows) pub fn paneText(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); - const tag = try tagline.tagText(p, arena, pane); + const tag = (try tagline.shownTag(p, arena, pane)).text; const from = modal.lineStartOffset(tag, pane.tag_top); const cut = modal.lineStartOffset(tag, pane.tag_top + pane.tag_rows); const shown = if (cut < tag.len) tag[from .. cut - 1] else tag[from..]; @@ -299,13 +299,20 @@ pub fn selectionText(p: *Pardes, pane: *Pane, sl: Pane.Sel) ![]const u8 { return result; } const text = try paneText(p, pane); + const shown = try tagline.shownTag(p, arena, pane); var out: std.ArrayList(u8) = .empty; var it = std.mem.splitAny(u8, text, "\n"); var v: i32 = 0; var first = true; while (it.next()) |line| : (v += 1) { const cols = sweepCols(sl, v) orelse continue; - if (!first) try out.append(arena, '\n'); + // A row the tag's wrapping began carries on its text: no newline. + const soft = v > 0 and v < pane.tag_rows and std.mem.indexOfScalar( + usize, + shown.soft, + modal.lineStartOffset(shown.text, @as(usize, @intCast(v)) + pane.tag_top) -| 1, + ) != null; + if (!first and !soft) try out.append(arena, '\n'); first = false; // Past the first row a body row starts after its line numbers. const gutter: i32 = if (pane.file != null and v >= pane.tag_rows) panes.File.gutterWidth(pane) else 0; @@ -750,6 +757,19 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { Key.left, Key.right, Key.up, Key.down => { const lines = paneCursorLines(p, t) catch return; const cur0 = t.toModalCursor(); + // In a pane's tag, Up on its first line collapses it and Down + // on its last expands it, where they would move nothing; acme's + // arrows in a tag always do (plan9port text.c:776, Tagdown/Tagup). + if (t.what == .tag) if (t.pane()) |pane| { + if (key.cp == Key.up and cur0.row == 0) { + pane.tag_expanded = false; + return; + } + if (key.cp == Key.down and cur0.row + 1 >= lines.len) { + pane.tag_expanded = true; + return; + } + }; const nc = switch (key.cp) { Key.left => modal.charLeft(lines, cur0), Key.right => modal.charRight(lines, cur0), diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 84ec1c5c..321c80b5 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -3741,9 +3741,10 @@ test "tagline pointer mapping retains physical grips and carries logical text co try std.testing.expect(grip.tag_hit == null); try std.testing.expectEqual(r.x + config.GUTTER - 1, grip.col); - const body = gridCellAtDimensions(core, x, (@as(f32, @floatFromInt(tag_y + 1)) + 0.5) * body_h, body_w, body_h, tag_w); + const body_y = tag_y + core.panes[core.active].?.tag_rows; // a long tag wraps + const body = gridCellAtDimensions(core, x, (@as(f32, @floatFromInt(body_y)) + 0.5) * body_h, body_w, body_h, tag_w); try std.testing.expectEqual(pixelCell(x, 10), body.col); - try std.testing.expectEqual(tag_y + 1, body.row); + try std.testing.expectEqual(body_y, body.row); try std.testing.expect(body.tag_hit == null); const topbar = gridCellAtDimensions(core, 3.5 * tag_w, 0.5 * body_h, body_w, body_h, tag_w); diff --git a/src/look.zig b/src/look.zig index 6cf01dd2..537a77fa 100644 --- a/src/look.zig +++ b/src/look.zig @@ -10,6 +10,7 @@ const platform_has_fs = fs.platform_has_fs; const layout = @import("layout.zig"); const animation = @import("animation.zig"); const modal = @import("modal.zig"); +const tagline = @import("tagline.zig"); const filesystem = @import("fs.zig"); const panes = @import("panes.zig"); const mouse = @import("mouse.zig"); @@ -466,6 +467,7 @@ pub fn expandedWord(line: []const u8, col: usize) ?ExpandedWord { pub fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel { var sl = at; if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return sl; + if (sl.r0 >= 0 and sl.r0 < pane.tag_rows) return tagWordSel(p, pane, sl); const text = edit.paneText(p, pane) catch return null; var it = std.mem.splitAny(u8, text, "\n"); var v: i32 = 0; @@ -481,6 +483,34 @@ pub fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel { return null; } +/// A tag word is expanded in the tag's own text, so a word the wrapping +/// broke across rows is still one word, as acme's frame wraps a tag +/// without breaking its text. +fn tagWordSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel { + const arena = p.scratch.allocator(); + const shown = tagline.shownTag(p, arena, pane) catch return null; + const whole = tagline.tagText(p, arena, pane) catch return null; + const row: usize = @as(usize, @intCast(at.r0)) + pane.tag_top; + const shown_line = modal.lineSlice(shown.text, row); + const real = shown.toReal(modal.lineStartOffset(shown.text, row) + panes.File.rawAtDisplay(shown_line, @intCast(@max(0, at.c0)))); + const start = if (std.mem.lastIndexOfScalar(u8, whole[0..@min(real, whole.len)], '\n')) |nl| nl + 1 else 0; + const end = std.mem.indexOfScalarPos(u8, whole, start, '\n') orelse whole.len; + const b = expandedWord(whole[start..end], real - start) orelse return null; + const lo = shownCell(shown.text, shown.fromReal(start + b.lo), pane.tag_top); + const hi = shownCell(shown.text, shown.fromReal(start + b.hi - 1), pane.tag_top); + return .{ .state = at.state, .r0 = lo.row, .c0 = lo.col, .r1 = hi.row, .c1 = hi.col }; +} + +fn shownCell(text: []const u8, off: usize, top: usize) struct { row: i32, col: i32 } { + const before = text[0..@min(off, text.len)]; + const row = std.mem.count(u8, before, "\n"); + const line_start = if (std.mem.lastIndexOfScalar(u8, before, '\n')) |nl| nl + 1 else 0; + return .{ + .row = @as(i32, @intCast(row)) - @as(i32, @intCast(top)), + .col = @intCast(panes.File.rawDisplayCol(text[line_start..], off - line_start)), + }; +} + /// the word under the modal cursor as a pane-local selection (paneText /// coords: row 0 is the tag; file panes carry the line-number prefix) pub fn cursorWordSel(p: *Pardes, pane: *Pane) Pane.Sel { @@ -1347,6 +1377,8 @@ test "Look hover waits without mutating the pane and input cancels it" { defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.setTestFile("alpha beta gamma\n"); + pane.tag_expanded = false; // a one-row tag keeps this small screen's rows + p.sync(); while (p.nextEffect()) |_| {} const other_id = p.freeSlot() orelse return error.NoSparePaneForHoverTest; _ = try p.newShell(other_id, ""); diff --git a/src/mouse.zig b/src/mouse.zig index 790dc9d1..beea68df 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -685,11 +685,12 @@ fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: const pane = p.panes[id] orelse return .{}; if (on_tag) { const tag = tagline.tagText(p, p.scratch.allocator(), pane) catch return .{}; + const shown = tagline.shownTag(p, p.scratch.allocator(), pane) catch return .{}; const sel = operand.expanded orelse operand.preview orelse return .{}; const lead = if (sel.r0 < sel.r1 or (sel.r0 == sel.r1 and sel.c0 <= sel.c1)) sel else Pane.Sel{ .r0 = sel.r1, .c0 = sel.c1 }; const row: usize = @as(usize, @intCast(@max(0, lead.r0))) + pane.tag_top; - const start = modal.lineStartOffset(tag, row); - const at = start + panes.File.rawAtDisplay(modal.lineSlice(tag, row), @intCast(@max(0, lead.c0))); + const start = modal.lineStartOffset(shown.text, row); + const at = shown.toReal(start + panes.File.rawAtDisplay(modal.lineSlice(shown.text, row), @intCast(@max(0, lead.c0)))); const q0: u32 = @intCast(@min(at, tag.len)); return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, tag.len)) }; } diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index 3599b612..7a5187fe 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -527,6 +527,8 @@ test "owned cwd pty exec rejects long paths before applying its batch" { const p = try withTerm(testing.allocator); defer p.deinit(); const pane = p.panes[0].?; + // A one-row tag: the long path would wrap it onto rows and resize the pty. + pane.tag_expanded = false; const ctl = Node.of(pane.serial, .pty_ctl); var path: [1025]u8 = @splat('d'); path[0] = '/'; diff --git a/src/panes.zig b/src/panes.zig index e4621311..b812b310 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -186,6 +186,11 @@ pub const Pane = struct { /// scrolls to keep its caret in view while it has the keyboard; pointer /// rows count from this line, not the tag's first. tag_top: u16 = 0, + /// The tag shows all its rows (acme's `tagexpand`, on by default); off, + /// one row, as acme's Tagup leaves it. Down on the + /// tag's last line and Up on its first, typing, or Alt-Down and Alt-Up + /// in the tag, as acme's arrows in a tag (plan9port text.c:776). + tag_expanded: bool = true, /// How long the tag's computed prefix was when its positions were last /// placed. They count from the start of the prefix, so when it grows or /// shrinks (a rename, the dirty marker, a PDF's page) what follows it diff --git a/src/pardes.zig b/src/pardes.zig index a85c97ab..992311a2 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -5319,6 +5319,12 @@ pub const Pardes = struct { return; } const pane = p.panes[p.active] orelse return; + // Alt-Down and Alt-Up in a pane's tag expand and collapse it, in + // either mode: acme's Tagdown and Tagup (plan9port text.c:776). + if (pane.focus == .tag and key.alt and !key.ctrl and (key.cp == Key.down or key.cp == Key.up) and tagline.focusedHeader(p) == null) { + pane.tag_expanded = key.cp == Key.down; + return; + } // An open prompt takes the keys even when it answers for a header. const header = if (pane.prompt != .none) null else tagline.focusedHeader(p); if (header == null and pane.isTerminal() and pane.body.mode == .tty and pane.focus == .body and pane.prompt == .none) { @@ -6142,11 +6148,30 @@ pub const Pardes = struct { } pane.tag_lead = lead; } - const lines = std.mem.count(u8, tagline.curTail(pane), "\n") + 1; - pane.tag_rows = @intCast(@min(lines, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1))); + // A row per shown row: the tag's lines wrapped at its width. + var wrap_room: [32 * 1024]u8 = undefined; + var wrap_fba = std.heap.FixedBufferAllocator.init(&wrap_room); + const wa = wrap_fba.allocator(); + var lines = std.mem.count(u8, tagline.curTail(pane), "\n") + 1; + var wanted = lines; + var caret_row: usize = @intCast(@max(0, pane.tag.cur_row)); + if (tagline.prefixIn(wa, pane)) |prefix| { + if (std.mem.concat(wa, u8, &.{ prefix, tagline.curTail(pane) })) |whole| { + if (tagline.wrapTag(wa, whole, tagline.tagWidth(p, id))) |shown| { + lines = shown.rows(); + // Collapsed, one row, as acme's Tagup sets taglines to 1 + // (plan9port text.c:786). + wanted = if (pane.tag_expanded) lines else 1; + const cur = modal.offsetAt(whole, .{ .row = caret_row, .col = @intCast(@max(0, pane.tag.cur_col)) }); + caret_row = std.mem.count(u8, shown.text[0..@min(shown.fromReal(cur), shown.text.len)], "\n"); + } else |_| {} + } else |_| {} + } else |_| {} + if (!pane.tag_expanded) wanted = 1; + pane.tag_rows = @intCast(@min(wanted, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1))); if (pane.collapsed) pane.tag_rows = BOX_H; if (pane.focus == .tag and pane.prompt != .name) { - const cur: usize = @intCast(@max(0, pane.tag.cur_row)); + const cur: usize = caret_row; var top: usize = pane.tag_top; if (cur < top) top = cur; if (cur >= top + pane.tag_rows) top = cur + 1 - pane.tag_rows; diff --git a/src/tagline.zig b/src/tagline.zig index d0d0b413..5ed65c26 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -206,13 +206,6 @@ pub fn enterTag(p: *Pardes, pane: *Pane) void { t.cur_row = @intCast(cursor.row); t.cur_col = @intCast(cursor.col); t.cur_pinned = true; - // Reveal the suffix together with its commands, not merely the - // first command letter at the viewport's far right edge. - if (p.paneIdOf(pane)) |id| { - const line = whole[0 .. std.mem.indexOfScalar(u8, whole, '\n') orelse whole.len]; - const width = p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET); - pane.tag_scroll = @intCast(panes.File.displayWidth(line) + 1 -| width); - } } // The text may have changed since (a 9P write, a rename), so the // remembered cursor is pulled back inside it. It is the cursor that @@ -232,12 +225,83 @@ pub fn enterTag(p: *Pardes, pane: *Pane) void { pane.focus = .tag; } -/// Where display column `col` of the tag's shown row `row` (tag scroll -/// included) puts its cursor: the tag as shown, prefix and all. +/// A pane's tag as it is shown: its lines, each wrapped onto more rows at +/// the tag's width as acme's frame wraps a tag at its right edge (plan9port +/// acme wind.c:140, wintaglines, counts the frame's lines). `soft` are the +/// offsets in `text` of the newlines the wrapping put in. +pub const Shown = struct { + text: []u8, + soft: []const usize, + + /// The tag's own offset of a shown one. + pub fn toReal(s: Shown, off: usize) usize { + var n: usize = 0; + for (s.soft) |b| { + if (b < off) n += 1 else break; + } + return off - n; + } + + /// The shown offset of the tag's own one. + pub fn fromReal(s: Shown, real: usize) usize { + var off = real; + for (s.soft) |b| { + if (b <= off) off += 1 else break; + } + return off; + } + + pub fn rows(s: Shown) usize { + return modal.cursorLineCount(s.text); + } +}; + +/// `whole` wrapped at `width` display columns. +pub fn wrapTag(arena: std.mem.Allocator, whole: []const u8, width: usize) !Shown { + var out: std.ArrayList(u8) = .empty; + var soft: std.ArrayList(usize) = .empty; + const w = @max(1, width); + var lines = std.mem.splitScalar(u8, whole, '\n'); + var first = true; + while (lines.next()) |line| { + if (!first) try out.append(arena, '\n'); + first = false; + var rest = line; + while (panes.File.displayWidth(rest) > w) { + var cut = modal.graphemeStart(rest, panes.File.rawAtDisplay(rest, w)); + if (cut == 0) cut = modal.nextGrapheme(rest, 0); + try out.appendSlice(arena, rest[0..cut]); + try soft.append(arena, out.items.len); + try out.append(arena, '\n'); + rest = rest[cut..]; + } + try out.appendSlice(arena, rest); + } + return .{ .text = out.items, .soft = soft.items }; +} + +/// The tag's width for wrapping: its columns, one kept for the caret past +/// a full row's end. +pub fn tagWidth(p: *const Pardes, id: usize) usize { + return @max(1, @as(usize, p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET)) -| 1); +} + +pub fn shownTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) !Shown { + const whole = try tagText(p, arena, pane); + const id = p.paneIdOf(pane) orelse return .{ .text = whole, .soft = &.{} }; + return wrapTag(arena, whole, tagWidth(p, id)); +} + +/// Where display column `col` of the tag's shown row `row` puts its +/// cursor: the tag as shown, prefix and all, wrapped rows included. pub fn tagOffsetAt(p: *Pardes, pane: *Pane, row: usize, col: usize) ?modal.Cursor { + const arena = p.scratch.allocator(); + const shown = shownTag(p, arena, pane) catch return null; + const whole = tagText(p, arena, pane) catch return null; const at = row + pane.tag_top; - const line = modal.lineSlice(tagText(p, p.scratch.allocator(), pane) catch return null, at); - return .{ .row = at, .col = modal.graphemeStart(line, panes.File.rawAtDisplay(line, col)) }; + const line = modal.lineSlice(shown.text, at); + const off = modal.lineStartOffset(shown.text, at) + modal.graphemeStart(line, panes.File.rawAtDisplay(line, col)); + return modal.positionAt(whole, @min(shown.toReal(off), whole.len)); } /// Whether display column `col` of the tag's shown row `row` is on a name @@ -490,3 +554,13 @@ test "a column or workspace tag is one line: a newline in it is a space" { try std.testing.expectEqual(pardes.TOPBAR_H, p.topBarHeight()); } +test "a pane tag wraps a long line onto rows, and offsets map both ways" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const shown = try wrapTag(arena_state.allocator(), "/a/b.txt Del Snarf Undo Put\nMore", 10); + try std.testing.expectEqualStrings("/a/b.txt D\nel Snarf U\nndo Put\nMore", shown.text); + try std.testing.expectEqual(@as(usize, 4), shown.rows()); + try std.testing.expectEqual(@as(usize, 10), shown.toReal(11)); // the 'e' of Del + try std.testing.expectEqual(@as(usize, 11), shown.fromReal(10)); +} + -- cgit v1.3