summaryrefslogtreecommitdiff
path: root/src/tagline.zig
blob: 1923871acd5085711c20ac6a7a2aef6cfc94be62 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! The tags (acme's tag half of wind.c): a pane's computed prefix and its
//! default and saved tails, going into a tag and out again, the path drafted
//! as a new name, and the column and workspace tags above the panes.
const pardes = @import("pardes.zig");
const std = @import("std");
const modal = @import("modal.zig");
const ctlfs = @import("ninep/tree.zig");
const panes = @import("panes.zig");
const edit = @import("edit.zig");
const exec = @import("exec.zig");
const config = @import("config.zig");
const pdf_enabled = panes.Pdf.enabled;
const dump = @import("dump.zig");
const Pane = panes.Pane;
const Text = panes.Text;
const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET;
const Pardes = pardes.Pardes;

pub const pane_tail = " " ++ config.pane_builtins_str;
pub const pdf_pane_tail = " " ++ config.pdf_pane_builtins_str;
pub const file_pane_tail = " " ++ config.file_pane_builtins_str;
pub const context_pane_tail = file_pane_tail ++ " TreeContext";
pub const locations_pane_tail = file_pane_tail ++ " LocationsConfig";
pub const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str;
pub const command_pane_tail = " " ++ config.command_pane_builtins_str;
/// A file with changes it has not saved.
pub fn dirty(pane: *const panes.Pane) bool {
    const f = pane.file orelse return false;
    return panes.Output.fileTraits(f.output).saves and (f.revision != f.saved_revision or f.disk_gone);
}
/// What a column's tag says until someone edits it.
pub const column_tag_default = "New Tty Find Grep Joincol Delcol";

/// What a header says until someone edits it: for the workspace the
/// fixed builtins, plus `Restore <path>` once a dump exists.
pub fn headerDefault(p: *Pardes, what: Text.What) []const u8 {
    if (what == .column) return column_tag_default;
    const d = p.last_dump orelse return config.topbar_str;
    return std.fmt.allocPrint(p.scratch.allocator(), config.topbar_str ++ " Restore {s}", .{d}) catch config.topbar_str;
}

/// the topbar line -- render and click dispatch must agree on this exact
/// string
pub fn topbar(p: *Pardes) []const u8 {
    return p.global_tag.own orelse headerDefault(p, .workspace);
}

pub fn columnTag(p: *const Pardes, c: usize) []const u8 {
    return p.col_tags[c].own orelse column_tag_default;
}

/// The header with the keyboard, when one has it.
pub fn focusedHeader(p: *Pardes) ?*Text {
    if (!p.header_focus) return null;
    if (p.header_column) |c| return if (c < p.ncol) &p.col_tags[c] else null;
    return &p.global_tag;
}

pub fn revealHeader(p: *Pardes) void {
    const t = focusedHeader(p) orelse {
        p.header_scroll = 0;
        p.header_top = 0;
        return;
    };
    const text = edit.flatSurface(p, t) catch return;
    const cur: usize = @intCast(@max(0, t.cur_row));
    const rows: usize = if (p.header_column != null) p.columnBarHeight() else p.topBarHeight();
    if (cur < p.header_top) p.header_top = cur;
    if (rows > 0 and cur >= p.header_top + rows) p.header_top = cur + 1 - rows;
    const line = modal.lineSlice(text, cur);
    const width = p.tagCapacity(if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else p.screen_w);
    const col = panes.File.rawDisplayCol(line, @min(@as(usize, @intCast(@max(0, t.cur_col))), line.len));
    if (col < p.header_scroll) p.header_scroll = col;
    if (col >= p.header_scroll + width) p.header_scroll = col -| (width -| 1);
    var raw = panes.File.rawAtDisplay(line, p.header_scroll);
    p.header_scroll = panes.File.rawDisplayCol(line, raw);
    if (width > 0 and col >= p.header_scroll + width) {
        raw = modal.nextGrapheme(line, raw);
        p.header_scroll = panes.File.rawDisplayCol(line, raw);
    }
}

/// A terminal's directory as its tag shows it: one line, as /index shows
/// it (a newline `\n`), never a raw newline breaking the tag.
fn shownDir(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
    const dir = pane.cwdSlice();
    if (std.mem.indexOfAny(u8, dir, "\n\\\x7f") == null and for (dir) |c| {
        if (c < ' ') break false;
    } else true) return dir;
    const out = try arena.alloc(u8, dir.len * 4 + 4);
    return pardes.ctlfs.events.shown(dir, out);
}

/// The computed start of a pane's tag -- its path and dirty marker, a
/// PDF's page, a terminal's directory -- without the space that parts it
/// from the tag's own text. It is never stored, and never edited.
pub fn pathPrefix(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
    if (comptime pdf_enabled) if (pane.pdf) |pv|
        return std.fmt.allocPrint(arena, "{s} [{d}/{d}]", .{ pv.path, pv.page + 1, pv.page_count });
    if (pane.image) |*state| return panes.Image.tagPrefix(arena, state);
    // A command pane: its directory, the line it runs, and how that went.
    // Its first line only, cut to 48 bytes at a character's start: a tag's
    // first row is where the prefix ends, and a line that ran over to a
    // second (acme runs a multi-line selection as one command) would move
    // it (body_layer.zig tagTintRange).
    if (pane.command) |line| {
        const first = line[0 .. std.mem.indexOfAny(u8, line, "\r\n") orelse line.len];
        var cut = @min(first.len, 48);
        while (cut < first.len and cut > 0 and first[cut] & 0xC0 == 0x80) cut -= 1;
        const shown = first[0..cut];
        const more = if (shown.len < line.len) "..." else "";
        var idbuf: [48]u8 = undefined;
        const id = exec.replId(&idbuf, pane);
        const gap = if (id.len > 0) " " else "";
        if (!pane.command_done) return std.fmt.allocPrint(arena, "{s} ({s}{s}) running{s}{s}", .{ (try shownDir(arena, pane)), shown, more, gap, id });
        if (pane.command_status) |n| return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit {d}{s}{s}", .{ (try shownDir(arena, pane)), shown, more, n, gap, id });
        return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit ?{s}{s}", .{ (try shownDir(arena, pane)), shown, more, gap, id });
    }
    // A bound REPL says what it is bound as, beside its Tty word.
    if (pane.repl != null) {
        var idbuf: [48]u8 = undefined;
        return std.fmt.allocPrint(arena, "{s} {s}", .{ (try shownDir(arena, pane)), exec.replId(&idbuf, pane) });
    }
    // Unsaved text shows on the grip, as acme's modbutton does, not as a
    // mark in the text: the prefix is the name alone.
    if (pane.file) |f| return if (pane.prompt == .name) pane.input.own orelse f.path else f.path;
    return (try shownDir(arena, pane));
}

pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 {
    return prefixIn(p.scratch.allocator(), pane);
}

/// The prefix as the tag shows it, built in `arena`.
pub fn prefixIn(arena: std.mem.Allocator, pane: *Pane) ![]u8 {
    const prefix = try pathPrefix(arena, pane);
    // The space between the prefix and the tag's own text is the text's
    // (every default starts with one), but the prefix is computed, so it
    // cannot trust the text to keep it: with it deleted, Look and Exec
    // would see the path and the first word as one. The prefix brings its
    // own then.
    const tail = curTail(pane);
    if (tail.len > 0 and std.mem.indexOfScalar(u8, " \t\n", tail[0]) == null)
        return std.fmt.allocPrint(arena, "{s} ", .{prefix});
    return arena.dupe(u8, prefix);
}

/// The tag's own text: what the user made of it, or until then the
/// default a pane of its kind starts with.
pub fn curTail(pane: *Pane) []const u8 {
    return pane.tag.own orelse defaultTail(pane);
}

pub fn defaultTail(pane: *const Pane) []const u8 {
    if (comptime pdf_enabled) if (pane.pdf != null) return pdf_pane_tail;
    if (pane.file) |file| if (file.output) |out|
        if (panes.Output.usesLocationsConfig(out.from)) return locations_pane_tail;
    if (panes.File.supportsContext(pane)) return context_pane_tail;
    if (pane.file != null) return file_pane_tail;
    if (pane.command != null) return command_pane_tail;
    if (pane.isTerminal()) return if (pane.shell_tail_len > 0) pane.shell_tail[0..pane.shell_tail_len] else terminal_pane_tail;
    return pane_tail;
}

/// The tag as it is shown, read and pointed at: its computed prefix and
/// its own text after it.
pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 {
    const prefix = try tagPrefix(p, pane);
    const tail = curTail(pane);
    const out = try arena.alloc(u8, prefix.len + tail.len);
    @memcpy(out[0..prefix.len], prefix);
    @memcpy(out[prefix.len..], tail);
    return out;
}

/// Give the keyboard to the tag in normal mode (`:`), its cursor where
/// the tag last had it. The first time there is nothing to remember, and
/// it starts on Save, so that `:` and the execute key write the file.
pub fn enterTag(p: *Pardes, pane: *Pane) void {
    const t = &pane.tag;
    const whole = tagText(p, p.scratch.allocator(), pane) catch return;
    if (!t.cur_pinned) {
        // Save is looked for in the tag's own text, after the prefix.
        const lead = whole.len - curTail(pane).len;
        const tail = whole[lead..];
        var words = std.mem.tokenizeAny(u8, tail, " \t\n");
        const at = lead + while (words.next()) |word| {
            if (std.mem.eql(u8, word, "Save")) break @intFromPtr(word.ptr) - @intFromPtr(tail.ptr);
        } else tail.len - std.mem.trimStart(u8, tail, " ").len;
        const cursor = modal.positionAt(whole, @min(at, whole.len));
        t.cur_row = @intCast(cursor.row);
        t.cur_col = @intCast(cursor.col);
        t.cur_pinned = true;
    }
    // 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
    // is remembered, not a selection.
    const rows = modal.cursorLineCount(whole);
    t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1));
    const line = modal.lineSlice(whole, @intCast(t.cur_row));
    t.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, t.cur_col))))));
    t.vsel.active = false;
    t.msel.active = false;
    t.nsel = 0;
    t.select = false;
    t.restore_cursor = false;
    t.sticky_col = -1;
    t.mode = .normal;
    t.normal.clear();
    pane.focus = .tag;
}

/// 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: all its columns. A row the text fills
/// exactly stays one row, as acme's frame wraps a box only when it is wider
/// than what is left (libframe frutil.c _frcklinewrap), and the caret past
/// its end sits at the edge (frptofchar.c clamps it to r.max.x).
pub fn tagWidth(p: *const Pardes, id: usize) usize {
    return @max(1, @as(usize, p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET)));
}

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(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
/// a click drafts: a saving file's path.
pub fn onName(pane: *Pane, row: usize, col: usize) bool {
    if (row + pane.tag_top != 0) return false;
    const f = pane.file orelse return false;
    if (!panes.Output.fileTraits(f.output).saves) return false;
    const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path;
    return col < panes.File.displayWidth(name);
}

/// A key typed into a saving file's path, with the tag's cursor there,
/// drafts a new name as a click on it does: the path is computed, so it is
/// never the tag's own text to edit, but a buffer's name can be drafted.
/// True when it did.
pub fn typedIntoName(p: *Pardes, pane: *Pane, key: pardes.Key) bool {
    const f = pane.file orelse return false;
    if (!panes.Output.fileTraits(f.output).saves or pane.tag.cur_row != 0) return false;
    const at: usize = @intCast(@max(0, pane.tag.cur_col));
    const back = key.cp == pardes.Key.backspace or pardes.hit(key, config.insert_backspace_alias);
    const forward = key.cp == pardes.Key.delete or pardes.hit(key, config.insert_delete_alias);
    const typed = key.text.len > 0 and !key.ctrl and !key.alt;
    const inside = if (back) at > 0 and at <= f.path.len else if (forward or typed) at < f.path.len else false;
    if (!inside) return false;
    draftName(p, pane, panes.File.displayWidth(f.path[0..at]));
    edit.handleInsert(p, &pane.input, key);
    return true;
}

/// Start a name draft for a file that can take one, its caret at display
/// column `col` of the path. Nothing changes until it is typed into.
pub fn draftName(p: *Pardes, pane: *Pane, col: usize) void {
    const f = pane.file orelse return;
    if (!panes.Output.fileTraits(f.output).saves) return;
    const name = p.gpa.dupe(u8, f.path) catch return;
    pane.closePrompt();
    pane.prompt = .name;
    pane.input = .{ .what = .input, .own = name, .mode = .insert, .cur_pinned = true, .cur_col = @intCast(@min(name.len, panes.File.rawAtDisplay(name, col))) };
}

/// Commit a drafted buffer name, never a disk rename or write. Keep the
/// draft visible on failure so the user can correct it or cancel with Esc.
pub fn commitTagName(p: *Pardes, pane: *Pane) bool {
    if (pane.prompt != .name) return true;
    const id = p.paneIdOf(pane) orelse return false;
    const name = pane.input.own orelse "";
    if (name.len == 0 or name[name.len - 1] == '/' or !std.unicode.utf8ValidateSlice(name)) {
        p.reportError(id, "name", error.InvalidName);
        return false;
    }
    for (name) |byte| if (byte < ' ') {
        p.reportError(id, "name", error.InvalidName);
        return false;
    };
    const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ Pardes.paneDir(pane), name }) catch |err| {
        p.reportError(id, "name", err);
        return false;
    };
    ctlfs.pane.nameBuffer(p, id, full, true) catch |err| {
        p.reportError(id, "name", err);
        return false;
    };
    pane.closePrompt();
    return true;
}

pub fn exitHeader(p: *Pardes) void {
    p.header_focus = false;
    p.header_column = null;
    p.header_drag = false;
    p.header_scroll = 0;
    p.header_top = 0;
}

/// Give the keyboard to a column's tag, or the workspace's for null, in
/// normal mode, its cursor where that header last had it.
pub fn enterHeader(p: *Pardes, column: ?usize) void {
    exitHeader(p);
    if (p.panes[p.active]) |pane| pane.focus = .body;
    p.header_focus = true;
    p.header_column = column;
    if (column) |c| p.setActiveColumn(c);
    const t = focusedHeader(p) orelse return exitHeader(p);
    const text = edit.flatSurface(p, t) catch "";
    const rows = modal.cursorLineCount(text);
    t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1));
    const line = modal.lineSlice(text, @intCast(t.cur_row));
    t.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, t.cur_col))))));
    t.cur_pinned = true;
    t.vsel.active = false;
    t.msel.active = false;
    t.nsel = 0;
    t.select = false;
    t.restore_cursor = false;
    t.mode = .normal;
    t.normal.clear();
}

/// A dump's tag tail, what was typed after the name: a dump from 0.0.4 on
/// writes it, and an older one's saved tag is not read.
pub fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void {
    const tail = src.tag_tail orelse return;
    pane.tag.own = p.gpa.dupe(u8, tail) catch return;
}

/// The span a header's selection covers on line `row` of its text, in
/// that line's bytes: up to the caret while typing, and over the
/// cursor's own cell too in normal mode, where a cursor is a cell.
pub fn headerSelection(t: *const Text, text: []const u8, row: usize) ?struct { lo: usize, hi: usize } {
    if (!t.vsel.active) return null;
    const anchor = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.vsel.row)), .col = @intCast(@max(0, t.vsel.col)) });
    const cursor = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) });
    const lo = @min(anchor, cursor);
    const hi = if (t.mode == .insert) @max(anchor, cursor) else modal.nextGrapheme(text, @max(anchor, cursor));
    const start = modal.lineStartOffset(text, row);
    const end = modal.lineEndOffset(text, row);
    if (hi <= start or lo >= end) return null;
    return .{ .lo = @max(lo, start) - start, .hi = @min(hi, end) - start };
}

/// Column and workspace tags are one line, as acme's: a newline typed,
/// pasted or restored from a dump into one becomes a space, the caret and
/// the selection kept on the same characters.
pub fn flattenHeaders(p: *Pardes) void {
    for (p.col_tags[0..p.ncol]) |*t| flatten(t);
    flatten(&p.global_tag);
}

fn flatten(t: *Text) void {
    const own = t.own orelse return;
    if (std.mem.indexOfScalar(u8, own, '\n') == null) return;
    const cur = modal.offsetAt(own, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) });
    const anchor = modal.offsetAt(own, .{ .row = @intCast(@max(0, t.vsel.row)), .col = @intCast(@max(0, t.vsel.col)) });
    std.mem.replaceScalar(u8, own, '\n', ' ');
    t.cur_row = 0;
    t.cur_col = @intCast(cur);
    t.vsel.row = 0;
    t.vsel.col = @intCast(anchor);
    t.nsel = 0;
}

test "a column or workspace tag is one line: a newline in it is a space" {
    const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 12, .tty_only = true });
    defer p.deinit();
    p.col_tags[0].own = try p.gpa.dupe(u8, "New\nSnarf");
    p.col_tags[0].cur_row = 1;
    p.col_tags[0].cur_col = 2;
    p.global_tag.own = try p.gpa.dupe(u8, "Newcol\nExit");
    p.sync();
    try std.testing.expectEqualStrings("New Snarf", columnTag(p, 0));
    try std.testing.expectEqualStrings("Newcol Exit", p.global_tag.own.?);
    try std.testing.expectEqual(@as(i32, 0), p.col_tags[0].cur_row);
    try std.testing.expectEqual(@as(i32, 6), p.col_tags[0].cur_col);
    try std.testing.expectEqual(pardes.COLBAR_H, p.columnBarHeight());
    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));
}