summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 19:36:58 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commit1df6bd12fafbf97062f7844a23de208add75a843 (patch)
tree78b61cfebeb12c40b679d053b457d2e28f232784 /src
parent1f8de151e382755c1ce597f166ea4552e7f7e8d1 (diff)
downloadpardes-1df6bd12fafbf97062f7844a23de208add75a843.tar.gz
pardes-1df6bd12fafbf97062f7844a23de208add75a843.zip
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 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/Terminal.zig2
-rw-r--r--src/draw.zig73
-rw-r--r--src/edit.zig24
-rw-r--r--src/gui/gui.zig5
-rw-r--r--src/look.zig32
-rw-r--r--src/mouse.zig5
-rw-r--r--src/ninep/pty.zig2
-rw-r--r--src/panes.zig5
-rw-r--r--src/pardes.zig31
-rw-r--r--src/tagline.zig96
10 files changed, 221 insertions, 54 deletions
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));
+}
+