diff --git a/README.md b/README.md index 07db0f0..811d189 100644 --- a/README.md +++ b/README.md @@ -1063,7 +1063,16 @@ closer count as one. `clipboard` says what programs may do with the clipboard, and which pastes harrier asks about first. Asking is a confirmation over the pane: Enter gives the highlighted answer, which starts on the one that does -nothing, Escape is that one, and the arrows and Tab move between them. +nothing, Escape is that one, and Left, Right and Tab move between them. +The text that would be pasted or written is shown as it is, in a box of +the pane's own colors under a line saying how many lines and bytes it +is: each line on a row of its own, its line break marked ⏎ — `\n`, +`\r\n`, or a lone `\r`, any of which reaches the program as Enter — +a long line wrapped with ↪ before each row after its first, tabs at +stops of eight, and control characters shown dim as their pictures (␛, +␇, ␡), or as � with what is not UTF-8. The confirmation grows with the +text until it fills the pane, and then the text scrolls: with the wheel, +Up and Down, and PageUp and PageDown. - **`read`**: a program reading the clipboard or the primary selection, with OSC 52 or with [kitty's clipboard protocol][kitty-clipboard] (OSC 5522). @@ -1087,15 +1096,14 @@ nothing, Escape is that one, and the arrows and Tab move between them. OSC 52 or kitty's protocol, in place of what is there: text, or with kitty's protocol any types, an image beside its text say. `.allow`, the default, lets it; `.ask` asks first, showing the text, a PNG - image itself, and each type with its size; `.deny` refuses it. A program writing with + image itself above it, and each type with its size; `.deny` refuses it. A program writing with kitty's protocol is told how it went once it has: `DONE` when the write is on the clipboard, `EPERM` when it was refused. On Windows an image is offered as a bitmap as well as a PNG, for the programs that take nothing else, and a type Windows has no format for is put there under its own name. - **`confirm_unsafe_paste`**: asks before a paste that would run as it - arrives, showing the text with its line breaks marked ⏎: one with a - line break in it, into a program that has not asked for bracketed + arrives, showing the text: one with a line break in it, into a program that has not asked for bracketed paste. **Paste** or **Cancel**. Every way of pasting to a program is asked about: the paste key, the selection's, the middle button, and dropping text or files. On by default. A paste into a program that diff --git a/locales/en-US.ftl b/locales/en-US.ftl index 7831a41..e2bf047 100644 --- a/locales/en-US.ftl +++ b/locales/en-US.ftl @@ -136,6 +136,9 @@ confirm-write-question-remote = # question, separated by " · ". # $type: its MIME type, such as image/png; $size: such as 1.2 MiB confirm-type-size = { $type } ({ $size }) +# Over the text that would be pasted or written: how much there is. +# $lines: as toast-lines, such as 3 lines; $size: such as 42 B +confirm-text-size = { $lines } · { $size } confirm-paste = Paste confirm-cancel = Cancel confirm-allow = Allow diff --git a/src/Confirmation.zig b/src/Confirmation.zig index 280283b..933ec34 100644 --- a/src/Confirmation.zig +++ b/src/Confirmation.zig @@ -27,6 +27,15 @@ hover: ?usize = null, /// in it: what the pointer is over. Empty until drawn. box: Rect = .{}, buttons: [max_answers]Rect = @splat(.{}), +/// The text that would be pasted or written, in rows, and where it was +/// last drawn: the first row shown, how many are, and the well they are +/// shown in. Empty for a question with no text. +layout: Layout = .{}, +scroll: usize = 0, +shown_rows: usize = 0, +well: Rect = .{}, +/// What the wheel has turned that is not yet a whole row. +scroll_rest: f32 = 0, pub const Rect = struct { x: u32 = 0, @@ -87,6 +96,27 @@ pub fn init(q: Question) Confirmation { pub fn deinit(c: *Confirmation, gpa: Allocator) void { c.question.deinit(gpa); + c.layout.deinit(gpa); +} + +/// The text the question shows, which would be pasted or written: none +/// for a read, or a write of something other than text. +pub fn shownText(c: *const Confirmation) []const u8 { + return switch (c.question) { + .paste => |x| x.text, + .write => |x| for (x.contents) |y| { + if (vt.clipboard.isTextMime(y.mime)) break y.data; + } else "", + .read => "", + }; +} + +/// Scrolls the text `by` rows, down when positive, no further than its +/// ends. +pub fn scrollBy(c: *Confirmation, by: isize) void { + const last = c.layout.len() -| c.shown_rows; + const to = @as(isize, @intCast(c.scroll)) + by; + c.scroll = @intCast(std.math.clamp(to, 0, @as(isize, @intCast(last)))); } /// The answers on offer, in the order shown. @@ -227,40 +257,184 @@ pub fn unsafePaste(text: []const u8, bracketed: bool) bool { return std.mem.findAny(u8, text, "\r\n") != null; } -/// `text` as a confirmation shows it, in `buf`: each line break as ⏎, so -/// that what would be run is plain, and other control characters dropped. -/// At most `buf.len` bytes, ending in … when cut short. -pub fn preview(text: []const u8, buf: []u8) []const u8 { - const ellipsis = "…"; - const enter = "⏎"; +/// The most cells wide a confirmation's text is laid out. +pub const max_text_cells = 120; + +/// One row of a confirmation's text, as it is shown: its code points, and +/// for each whether it is one of harrier's marks rather than the text — +/// ⏎ at a line break, ↪ before a wrapped line's next row, a control +/// character's picture, or � — which are dim; and the cell each is in. +pub const Row = struct { + cps: []const u21, + marks: []const bool, + cols: []const u32, + /// Where in the text the next row starts. + end: usize, + /// Whether the next row goes on with this one's line. + wraps: bool, + /// The cells it takes. + width: u32, +}; + +/// The most code points one row holds, however many a grapheme has. +pub const max_row_cps = 4 * max_text_cells; + +pub const RowBuffers = struct { + cps: [max_row_cps]u21 = undefined, + marks: [max_row_cps]bool = undefined, + cols: [max_row_cps]u32 = undefined, +}; + +/// The row of `text` that starts at `start`, at most `cells` wide, in +/// `bufs`; `continued` when it goes on with the row before's line. A line +/// break ends a row, as Enter: `\n`, `\r\n` as one, and a lone `\r`. The +/// last cell is kept for the ⏎ that marks one, so that a full line and +/// its break fit. A tab goes to the next stop of eight cells, a row +/// otherwise breaks between graphemes, and no row is empty unless the +/// text is. +pub fn row(text: []const u8, start: usize, continued: bool, cells: u32, bufs: *RowBuffers) Row { + const limit = @max(cells, 2) - 1; var n: usize = 0; - var i: usize = 0; - while (i < text.len) : (i += 1) { + var used: u32 = 0; + const B = struct { + fn put(b: *RowBuffers, at: *usize, cp: u21, mark: bool, col: u32) void { + b.cps[at.*] = cp; + b.marks[at.*] = mark; + b.cols[at.*] = col; + at.* += 1; + } + }; + if (continued) { + B.put(bufs, &n, 0x21aa, true, 0); + used = 1; + } + // A tab's stops are counted from the line's text, not from ↪. + const lead = used; + var i = start; + while (i < text.len) { const c = text[i]; - const piece: []const u8 = switch (c) { - '\r' => blk: { - // \r\n is one line break. - if (i + 1 < text.len and text[i + 1] == '\n') i += 1; - break :blk enter; + if (c == '\n' or c == '\r') { + B.put(bufs, &n, 0x23ce, true, used); + used += 1; + const len: usize = if (c == '\r' and i + 1 < text.len and text[i + 1] == '\n') 2 else 1; + return done(bufs, n, i + len, false, used); + } + // Something shown as one cell of its own: a control character's + // picture, or � for what is not to be shown. + const single: ?struct { cp: u21, len: usize } = switch (c) { + '\t' => null, + 0...8, 11, 12, 14...31 => .{ .cp = 0x2400 + @as(u21, c), .len = 1 }, + 127 => .{ .cp = 0x2421, .len = 1 }, + else => if (c < 0x80) null else decoded: { + const len = std.unicode.utf8ByteSequenceLength(c) catch break :decoded .{ .cp = 0xfffd, .len = 1 }; + if (i + len > text.len) break :decoded .{ .cp = 0xfffd, .len = 1 }; + const cp = std.unicode.utf8Decode(text[i..][0..len]) catch break :decoded .{ .cp = 0xfffd, .len = 1 }; + break :decoded if (cp >= 0x80 and cp <= 0x9f) .{ .cp = 0xfffd, .len = len } else null; }, - '\n' => enter, - '\t' => " ", - 0...8, 11...12, 14...31, 127 => "", - else => text[i .. i + 1], }; - if (n + piece.len > buf.len -| ellipsis.len) { - // Back to the start of a code point, so as not to cut one. - while (n > 0 and buf[n - 1] & 0xc0 == 0x80) n -= 1; - if (n > 0 and buf[n - 1] >= 0xc0) n -= 1; - @memcpy(buf[n..][0..ellipsis.len], ellipsis); - return buf[0 .. n + ellipsis.len]; + if (single) |s| { + if (used + 1 > limit and n > lead) return done(bufs, n, i, true, used); + B.put(bufs, &n, s.cp, true, used); + used += 1; + i += s.len; + continue; + } + if (c == '\t') { + const to = 8 - (used - lead) % 8; + if (used + to > limit and n > lead) return done(bufs, n, i, true, used); + for (0..to) |k| { + if (n == bufs.cps.len) break; + B.put(bufs, &n, ' ', false, used + @as(u32, @intCast(k))); + } + used += to; + i += 1; + continue; + } + // A grapheme: decoded far enough to see where it ends, up to + // something that cannot be part of one. + var cps: [32]u21 = undefined; + var ends: [32]usize = undefined; + var k: usize = 0; + var j = i; + while (k < cps.len and j < text.len) { + const b = text[j]; + if (b < 0x20 or b == 0x7f) break; + const len = std.unicode.utf8ByteSequenceLength(b) catch break; + if (j + len > text.len) break; + const cp = std.unicode.utf8Decode(text[j..][0..len]) catch break; + if (k > 0 and cp >= 0x80 and cp <= 0x9f) break; + cps[k] = cp; + j += len; + ends[k] = j; + k += 1; + // Plain ASCII followed by more of it ends where it is. + if (b < 0x80 and (j == text.len or text[j] < 0x80)) break; } - @memcpy(buf[n..][0..piece.len], piece); - n += piece.len; + const g = vt.unicode.graphemeWidth(u21, cps[0..k]); + const glen = @max(g.len, 1); + const width: u32 = if (g.width == 2) 2 else 1; + if ((used + width > limit or n + glen > bufs.cps.len) and n > lead) return done(bufs, n, i, true, used); + for (cps[0..glen]) |cp| B.put(bufs, &n, cp, false, used); + used += width; + i = ends[glen - 1]; } - return buf[0..n]; + return done(bufs, n, text.len, false, used); } +fn done(bufs: *RowBuffers, n: usize, end: usize, wraps: bool, width: u32) Row { + return .{ .cps = bufs.cps[0..n], .marks = bufs.marks[0..n], .cols = bufs.cols[0..n], .end = end, .wraps = wraps, .width = width }; +} + +/// A confirmation's text laid out in rows: where each starts, for drawing +/// those shown and scrolling through all of them. +pub const Layout = struct { + /// The width it was laid out at, and the text: laid out again when + /// either changes. + cells: u32 = 0, + text: []const u8 = &.{}, + starts: std.ArrayList(Start) = .empty, + /// The widest row. + widest: u32 = 0, + + pub const Start = struct { at: usize, continued: bool }; + + pub fn deinit(l: *Layout, gpa: Allocator) void { + l.starts.deinit(gpa); + l.* = .{}; + } + + /// `text` in rows `cells` wide, unless it is already. + pub fn update(l: *Layout, gpa: Allocator, text: []const u8, cells: u32) Allocator.Error!void { + if (l.cells == cells and l.text.ptr == text.ptr and l.text.len == text.len) return; + l.starts.clearRetainingCapacity(); + l.widest = 0; + l.cells = 0; + var bufs: RowBuffers = .{}; + var at: usize = 0; + var continued = false; + while (at < text.len) { + try l.starts.append(gpa, .{ .at = at, .continued = continued }); + const r = row(text, at, continued, cells, &bufs); + l.widest = @max(l.widest, r.width); + // Nothing is left behind, whatever the text. + at = @max(r.end, at + 1); + continued = r.wraps; + } + l.cells = cells; + l.text = text; + } + + /// Row `i`, in `bufs`. + pub fn get(l: *const Layout, i: usize, bufs: *RowBuffers) Row { + const s = l.starts.items[i]; + return row(l.text, s.at, s.continued, l.cells, bufs); + } + + pub fn len(l: *const Layout) usize { + return l.starts.items.len; + } +}; + /// Whether a confirmation names the Clipboard types `mimes`: only when one /// is not text, since text alone is shown as text. pub fn namesTypes(mimes: anytype) bool { @@ -332,14 +506,74 @@ test "the answer that does nothing is selected to begin with" { try testing.expectEqual(@as(usize, 2), p.answers().len); } -test "a preview shows line breaks, and is cut short whole" { - var buf: [64]u8 = undefined; - try testing.expectEqualStrings("ls⏎rm -rf ~⏎", preview("ls\r\nrm\t-rf ~\n", &buf)); - try testing.expectEqualStrings("a⏎b", preview("a\rb\x07", &buf)); - var small: [8]u8 = undefined; - // 8 bytes: room for 5 and the ellipsis, but ⏎ is 3 and is not cut. - try testing.expectEqualStrings("abc…", preview("abc\ndefgh", &small)); - try testing.expectEqualStrings("abcde…", preview("abcdefghij", &small)); +/// Row `i` of `text` laid out `cells` wide, as text: each mark in +/// brackets, so that what is harrier's and what is the text's can be told +/// apart. +fn shown(text: []const u8, cells: u32) ![]const u8 { + const S = struct { + var out: [4096]u8 = undefined; + }; + var layout: Layout = .{}; + defer layout.deinit(testing.allocator); + try layout.update(testing.allocator, text, cells); + var w: std.Io.Writer = .fixed(&S.out); + var bufs: RowBuffers = .{}; + for (0..layout.len()) |i| { + if (i > 0) try w.writeAll("|"); + const r = layout.get(i, &bufs); + for (r.cps, r.marks) |cp, mark| { + if (mark) try w.writeAll("["); + try w.print("{u}", .{cp}); + if (mark) try w.writeAll("]"); + } + } + return w.buffered(); +} + +test "each line is a row, its break marked, and a lone CR is one" { + try testing.expectEqualStrings("ls[⏎]|rm -rf ~[⏎]", try shown("ls\r\nrm -rf ~\n", 40)); + try testing.expectEqualStrings("a[⏎]|b", try shown("a\rb", 40)); + try testing.expectEqualStrings("[⏎]|[⏎]|x", try shown("\n\nx", 40)); + try testing.expectEqualStrings("", try shown("", 40)); +} + +test "a long line wraps, each row after the first marked" { + // Ten cells: nine of text, and one kept for a break. + try testing.expectEqualStrings("abcdefghi|[↪]jklmnopq|[↪]rs[⏎]|t", try shown("abcdefghijklmnopqrs\nt", 10)); + // A line that fills its row has its break there too. + try testing.expectEqualStrings("abcdefghi[⏎]|j", try shown("abcdefghi\nj", 10)); +} + +test "no grapheme is cut, and a wide one takes two cells" { + try testing.expectEqualStrings("e\u{301}e\u{301}|[↪]e\u{301}", try shown("e\u{301}e\u{301}e\u{301}", 3)); + var bufs: RowBuffers = .{}; + const r = row("\u{4e2d}\u{6587}x", 0, false, 10, &bufs); + try testing.expectEqual(@as(u32, 5), r.width); + try testing.expectEqualSlices(u32, &.{ 0, 2, 4 }, r.cols); +} + +test "tabs go to stops of eight, counted from the line's text" { + var bufs: RowBuffers = .{}; + try testing.expectEqual(@as(u32, 9), row("ab\tc", 0, false, 40, &bufs).width); + try testing.expectEqual(@as(u32, 10), row("ab\tc", 0, true, 40, &bufs).width); +} + +test "control characters are shown as their pictures, and what is not UTF-8 as one" { + try testing.expectEqualStrings("[␛][A[␇][␀][␡]", try shown("\x1b[A\x07\x00\x7f", 40)); + try testing.expectEqualStrings("a[\u{fffd}]b[\u{fffd}]c", try shown("a\xffb\u{9b}c", 40)); + try testing.expectEqualStrings("[\u{fffd}][\u{fffd}]", try shown("\xe2\x82", 40)); +} + +test "a layout is made again only for other text or another width" { + var layout: Layout = .{}; + defer layout.deinit(testing.allocator); + const text = "abcdefghijklmnopqrstuvwxyz"; + try layout.update(testing.allocator, text, 10); + try testing.expectEqual(@as(usize, 4), layout.len()); + try testing.expectEqual(@as(u32, 9), layout.widest); + try layout.update(testing.allocator, text, 40); + try testing.expectEqual(@as(usize, 1), layout.len()); + try testing.expectEqual(@as(u32, 26), layout.widest); } test fit { diff --git a/src/Window.zig b/src/Window.zig index 5662d66..8e6afc1 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -1451,6 +1451,7 @@ fn ask(w: *Window, p: *Pane, q: Confirmation.Question) void { if (p.confirmation) |*old| { if (!Confirmation.replaces(old.question, q)) return w.answerWith(p, q, .no); const was = old.question; + old.layout.deinit(w.gpa); p.confirmation = null; w.answerWith(p, was, .no); } @@ -1476,7 +1477,8 @@ fn unask(w: *Window, p: *Pane) void { /// Answers `p`'s waiting question with `a`. fn answer(w: *Window, p: *Pane, a: Confirmation.Answer) void { - const c = p.confirmation orelse return; + var c = p.confirmation orelse return; + c.layout.deinit(w.gpa); p.confirmation = null; w.unask(p); w.dirty = true; @@ -1533,8 +1535,8 @@ fn platformClipboard(c: Confirmation.Clipboard) ev.Clipboard { } /// A key typed while the focused pane is asking: Enter answers with the -/// selected answer and Escape with the one that does nothing, the arrows -/// and Tab select. Nothing reaches the program meanwhile, a paste +/// selected answer and Escape with the one that does nothing, Left, Right +/// and Tab select, and Up, Down, PageUp and PageDown scroll the text. Nothing reaches the program meanwhile, a paste /// included, but any other binding still works, so that the user can go /// elsewhere and come back. fn confirmationKey(w: *Window, k: ev.Key) void { @@ -1545,10 +1547,15 @@ fn confirmationKey(w: *Window, k: ev.Key) void { 0xff1b => return w.answer(p, .no), // Return and keypad Enter. 0xff0d, 0xff8d => return w.answer(p, c.current()), - // Left, Up, and shift+Tab. - 0xff51, 0xff52, 0xfe20 => c.move(-1), - // Right, Down, Tab. - 0xff53, 0xff54, 0xff09 => c.move(if (k.mods.shift) -1 else 1), + // Left, and shift+Tab. + 0xff51, 0xfe20 => c.move(-1), + // Right, Tab. + 0xff53, 0xff09 => c.move(if (k.mods.shift) -1 else 1), + // Up, Down, PageUp, PageDown. + 0xff52 => c.scrollBy(-1), + 0xff54 => c.scrollBy(1), + 0xff55 => c.scrollBy(-@as(isize, @intCast(@max(c.shown_rows, 1)))), + 0xff56 => c.scrollBy(@intCast(@max(c.shown_rows, 1))), else => { if (bindings.find(w.app.bindingsFor(p), k)) |action| switch (action) { // Pasting is the program's; it waits. @@ -1586,6 +1593,20 @@ fn confirmationMotion(w: *Window, x: f32, y: f32) bool { return true; } +/// The wheel over a confirmation scrolls its text, three rows a notch as a +/// pane's scrollback does; whether it was over one. +fn confirmationScroll(w: *Window, dy: f32) bool { + const p = w.confirmationAt(w.pointer_x, w.pointer_y) orelse return false; + const c = &p.confirmation.?; + c.scroll_rest += dy; + const rows: i32 = @intFromFloat(c.scroll_rest); + if (rows == 0) return true; + c.scroll_rest -= @floatFromInt(rows); + c.scrollBy(rows * 3); + w.dirty = true; + return true; +} + /// A press over a confirmation: a left press on an answer gives it, and /// anything else over it is ignored. fn confirmationButton(w: *Window, b: ev.MouseButton, pressed: bool) bool { @@ -1649,9 +1670,19 @@ fn buildConfirmation(w: *Window, p: *Pane) !void { const max_cells = (r.w -| 4 * pad) / @max(cw, 1); if (max_cells < 20) return; - // Typed: @min with 64 would narrow it to a u7, which `cells * max_rows` - // overflows in a pane 64 cells wide. - const cells: u32 = @min(64, max_cells); + // The text, laid out as wide as the box may be, less a cell for its + // scrollbar; the box is then as wide as the widest row needs, and + // never narrower than 64 cells, which the question and the answers + // have always had. Typed: @min with 64 would narrow it to a u7, which + // arithmetic with it overflows in a pane 64 cells wide. + const text = c.shownText(); + const text_cells: u32 = @min(Confirmation.max_text_cells, max_cells) - 1; + if (text.len > 0) c.layout.update(w.gpa, text, text_cells) catch { + c.layout.deinit(w.gpa); + }; + const has_text = c.layout.len() > 0; + const narrowest: u32 = @min(64, max_cells); + const cells: u32 = if (has_text) @min(text_cells + 1, @max(narrowest, c.layout.widest + 1)) else narrowest; // The question. var head_buf: [512]u8 = undefined; @@ -1671,8 +1702,7 @@ fn buildConfirmation(w: *Window, p: *Pane) !void { var head_cps: [256]u21 = undefined; const head = decor.fitTitle(head_w.buffered(), cells, &head_cps); - // Why, for a paste; and what would be pasted or written, a few rows of - // it. + // Why, for a paste. var why_cps: [256]u21 = undefined; const why: []const u21 = if (c.question == .paste) decor.fitTitle(l10n.text("confirm-paste-why"), cells, &why_cps) else &.{}; // Which Clipboard types, when any is not text: a write's, with their @@ -1702,54 +1732,50 @@ fn buildConfirmation(w: *Window, p: *Pane) !void { } var types_cps: [512]u21 = undefined; const types = decor.fitTitle(types_w.buffered(), cells, &types_cps); - const text: []const u8 = switch (c.question) { - .paste => |x| x.text, - .write => |x| for (x.contents) |y| { - if (vt.clipboard.isTextMime(y.mime)) break y.data; - } else "", - .read => "", - }; - var preview_buf: [1024]u8 = undefined; - var preview_cps: [1024]u21 = undefined; - const max_rows = 3; - const preview = decor.fitTitle(Confirmation.preview(text, &preview_buf), cells * max_rows, &preview_cps); - var rows: [max_rows][]const u21 = undefined; - var n_rows: usize = 0; - { - var at: usize = 0; - while (at < preview.len and n_rows < max_rows) { - const start = at; - var used: usize = 0; - while (at < preview.len) { - const g = frame.graphemeAt(preview[at..], true); - if (used + g.width > cells) break; - used += g.width; - at += g.len; - } - if (at == start) break; - rows[n_rows] = preview[start..at]; - n_rows += 1; - } - } + // How much text there is, over the well it is shown in. + var size_label_buf: [128]u8 = undefined; + var size_label_w: std.Io.Writer = .fixed(&size_label_buf); + if (has_text) { + var lines_buf: [64]u8 = undefined; + var lines_w: std.Io.Writer = .fixed(&lines_buf); + l10n.write(&lines_w, "toast-lines", &.{l10n.num("count", Toast.count(text).lines)}) catch {}; + var size_buf: [32]u8 = undefined; + l10n.write(&size_label_w, "confirm-text-size", &.{ l10n.str("lines", lines_w.buffered()), l10n.str("size", Confirmation.formatSize(text.len, &size_buf)) }) catch {}; + } + var size_label_cps: [128]u21 = undefined; + const size_label = decor.fitTitle(size_label_w.buffered(), cells, &size_label_cps); const row_h = ch + ch / 3; const button_h = ch + ch / 2; - const lines: u32 = 1 + @as(u32, if (why.len > 0) 1 else 0) + @as(u32, if (types.len > 0) 1 else 0) + @as(u32, @intCast(n_rows)); + const lines: u32 = 1 + @as(u32, if (why.len > 0) 1 else 0) + @as(u32, if (types.len > 0) 1 else 0) + @as(u32, if (has_text) 1 else 0); const box_w = cells * cw + 2 * pad; const text_h = pad / 2 + lines * row_h + pad / 2 + button_h + pad / 2; - if (r.h < text_h + ch) return; - // The image, as large as fits, in what is left of the pane: at most - // eight rows, and none when that would be less than two. + // The well's own room around its rows. + const well_pad = pad / 2; + const well_min = if (has_text) ch + 2 * well_pad + pad / 2 else 0; + const y = r.y + @min(r.h / 8, 3 * ch); + // Down to a cell above the pane's bottom. + const room = (r.y + r.h) -| y -| ch; + if (room < text_h + well_min) return; + // The image, as large as fits, in what is left of the pane once the + // well has a row: at most eight rows, and none when that would be less + // than two. const pv = previewOf(p); const shown: Confirmation.Rect = if (pv) |v| shown: { - const max_h = @min(max_preview_rows * ch, r.h -| (text_h + ch + pad / 2)); + const max_h = @min(max_preview_rows * ch, (room - text_h - well_min) -| pad / 2); if (max_h < 2 * ch) break :shown .{}; const size = Confirmation.fit(v.width, v.height, cells * cw, max_h); break :shown .{ .w = size.w, .h = size.h }; } else .{}; - const box_h = text_h + if (shown.h > 0) shown.h + pad / 2 else 0; + const image_h = if (shown.h > 0) shown.h + pad / 2 else 0; + // The well: as many rows as there are, or as fit. + const fit_rows = if (has_text) (room - text_h - image_h - (well_min - ch)) / ch else 0; + const n_rows: usize = @min(c.layout.len(), fit_rows); + c.shown_rows = n_rows; + c.scrollBy(0); + const well_h: u32 = if (has_text) @as(u32, @intCast(n_rows)) * ch + 2 * well_pad else 0; + const box_h = text_h + image_h + if (has_text) well_h + pad / 2 else 0; const x = r.x + (r.w - box_w) / 2; - const y = r.y + @min(r.h / 8, 3 * ch); c.box = .{ .x = x, .y = y, .w = box_w, .h = box_h }; try w.builder.rect(@floatFromInt(x), @floatFromInt(y), @floatFromInt(box_w), @floatFromInt(box_h), .{ edge.r, edge.g, edge.b, 255 }); try w.builder.rect(@floatFromInt(x + one), @floatFromInt(y + one), @floatFromInt(box_w - 2 * one), @floatFromInt(box_h - 2 * one), .{ fill.r, fill.g, fill.b, 255 }); @@ -1781,11 +1807,48 @@ fn buildConfirmation(w: *Window, p: *Pane) !void { } } try w.builder.picture(previewKey(p), @floatFromInt(px), @floatFromInt(py), @floatFromInt(shown.w), @floatFromInt(shown.h), v.rgba_width, v.rgba_height); - ly += shown.h + pad / 2; + ly += image_h; } - for (rows[0..n_rows]) |row| { - try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(x + pad), @floatFromInt(ly + (row_h - ch) / 2), row, .{ dim.r, dim.g, dim.b, 255 }); + c.well = .{}; + if (has_text) { + try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(x + pad), @floatFromInt(ly + (row_h - ch) / 2), size_label, .{ dim.r, dim.g, dim.b, 255 }); ly += row_h; + // The text in a well of the pane's own colors, so that it is + // plainly not harrier's: a box of its background, edged, and the + // text in its foreground, with harrier's marks dim. + const wx = x + pad / 2; + const ww = box_w - pad; + c.well = .{ .x = wx, .y = ly, .w = ww, .h = well_h }; + try w.builder.rect(@floatFromInt(wx), @floatFromInt(ly), @floatFromInt(ww), @floatFromInt(well_h), .{ edge.r, edge.g, edge.b, 255 }); + try w.builder.rect(@floatFromInt(wx + one), @floatFromInt(ly + one), @floatFromInt(ww - 2 * one), @floatFromInt(well_h - 2 * one), .{ bg.r, bg.g, bg.b, 255 }); + const mark = decor.mix(bg, fg, 0.45); + var bufs: Confirmation.RowBuffers = .{}; + var ry = ly + well_pad; + for (c.scroll..c.scroll + n_rows) |i| { + const row = c.layout.get(i, &bufs); + // A run at a time, of the text or of marks. + var at: usize = 0; + while (at < row.cps.len) { + var end = at + 1; + while (end < row.cps.len and row.marks[end] == row.marks[at]) end += 1; + const color = if (row.marks[at]) mark else fg; + try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(x + pad + row.cols[at] * cw), @floatFromInt(ry), row.cps[at..end], .{ color.r, color.g, color.b, 255 }); + at = end; + } + ry += ch; + } + // Where the rows shown are among them all, when they are not all. + const sb: Scrollbar = .{ + .rect = .{ .x = wx, .y = ly + one, .w = ww - one, .h = well_h - 2 * one }, + .scale = w.scale, + .bar = .{ .total = c.layout.len(), .offset = c.scroll, .len = n_rows }, + }; + if (sb.scrollable()) { + const th = sb.thumb(false); + const tc = decor.mix(bg, fg, 0.4); + try w.builder.rect(@floatFromInt(th.x), @floatFromInt(th.y), @floatFromInt(th.w), @floatFromInt(th.h), .{ tc.r, tc.g, tc.b, 255 }); + } + ly += well_h + pad / 2; } // The answers, right to left from the box's right edge. @@ -2792,6 +2855,7 @@ fn button(w: *Window, b: ev.MouseButton, pressed: bool) void { /// The wheel scrolls the pane under the pointer. fn scroll(w: *Window, dy: f32) void { if (w.profileSelectorScroll(dy)) return; + if (w.confirmationScroll(dy)) return; const tb = w.tab().paneAt(w.pointer_x, w.pointer_y) orelse w.pane(); tb.scroll_rest += dy; const lines: i32 = @intFromFloat(tb.scroll_rest);