diff options
Diffstat (limited to 'test/panes.zig')
| -rw-r--r-- | test/panes.zig | 385 |
1 files changed, 291 insertions, 94 deletions
diff --git a/test/panes.zig b/test/panes.zig index 40b4b6ba..f00b35c4 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -86,6 +86,8 @@ const TagLayerTests = struct { const source_y = @as(f32, @floatFromInt(layer.viewport.y)) + 0.5; const painted_x = shown.x + (x / 10 - track.to.x) / track.to.w * shown.w; const painted_y = shown.y + (source_y - track.to.y) / track.to.h * shown.h; + // Each is a first click, whatever cell the one before hit. + p.last_click = null; for ([_]pardes.Mouse.Kind{ .press, .release }) |kind| { var event = mouse(layer, x, kind); // Hosts inverse the exact painted pixel into the tag envelope; @@ -103,7 +105,6 @@ const TagLayerTests = struct { defer p.deinit(); _ = try p.setTestFile("body\n"); p.presentation.enabled = false; - p.settings.column_tags = true; p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 5, .tagline_h = 10 }; try std.testing.expect(p.executeBuiltinLine(0, "New")); const second = p.active; @@ -163,6 +164,23 @@ const TagLayerTests = struct { } }; + +// Rows relative to where the pane's body and tag are, whatever height its +// tag has (a tag wraps onto more rows when it is long). +fn paneAt(p: *Pardes, r: anytype) *pardes.Pane { + for (p.panes, 0..) |slot, id| if (slot) |pane| if (std.meta.eql(p.rects[id], r)) return pane; + unreachable; +} +fn bodyY(p: *Pardes, r: anytype) u16 { + return p.bodyTop(paneAt(p, r), r); +} +fn bodyH(p: *Pardes, r: anytype) u16 { + return r.h -| paneAt(p, r).tag_rows; +} +fn tagY(p: *Pardes, r: anytype) u16 { + return p.tagTop(paneAt(p, r), r); +} + test "a tag with a newline in it is as tall as its lines, and each line is typed into and pointed at" { for ([_]bool{ false, true }) |bottom| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 14 }); @@ -267,22 +285,14 @@ test "a tag with more lines than rows scrolls to keep its caret in view" { try std.testing.expectEqual(tag_y, top.cursor.?.y); try std.testing.expectEqualStrings("/", top.at(x, tag_y).grapheme()); - // A column tag does the same in its rows. + // A column tag is one row, as acme's: its lines are joined. many.clearRetainingCapacity(); try many.appendSlice(std.testing.allocator, "New"); for (1..21) |n| try many.print(std.testing.allocator, "\nC{d}", .{n}); p.col_tags[0].own = try p.gpa.dupe(u8, many.items); p.update(.tick); - const rows = p.columnBarHeight(); - try std.testing.expectEqual(@as(u16, pardes.MAX_TAG_ROWS), rows); - pardes.tagline.enterHeader(p, 0); - p.update(.{ .key = .{ .cp = 'g' } }); - p.update(.{ .key = .{ .cp = 'e' } }); - const header = try p.render(frame.allocator()); - const header_row = p.topBarHeight() + rows - 1; - try std.testing.expectEqual(header_row, header.cursor.?.y); - try std.testing.expectEqualStrings("C", header.at(p.col_x[0] + pardes.TAG_TEXT_INSET, header_row).grapheme()); - try std.testing.expectEqualStrings("2", header.at(p.col_x[0] + pardes.TAG_TEXT_INSET + 1, header_row).grapheme()); + try std.testing.expectEqual(pardes.COLBAR_H, p.columnBarHeight()); + try std.testing.expect(std.mem.indexOfScalar(u8, p.col_tags[0].own.?, '\n') == null); } const TagNavigationTests = struct { @@ -413,7 +423,7 @@ const TagNavigationTests = struct { defer frame.deinit(); _ = try p.render(frame.allocator()); const rect = p.rects[0]; - const y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; + const y = tagY(p, rect); const prefix: u16 = @intCast(panes.File.displayWidth(try pardes.tagline.tagPrefix(p, first))); ClickCursorTests.click(p, .left, rect.x + pardes.TAG_TEXT_INSET + prefix + 6, y); try std.testing.expectEqual(@as(usize, 0), p.active); @@ -595,7 +605,8 @@ const BodyHitTests = struct { p.update(.{ .mouse = mouse(layer, last - 1, prefix + 11, .press) }); // Release at a different source row without an intermediate drag event. p.update(.{ .mouse = mouse(layer, last, prefix + 14, .release) }); - try std.testing.expectEqualStrings("body\nbody", pardes.test_api.heldSelection(p, 0).?); + // A stream: the last row from its start, indentation and all. + try std.testing.expect(std.mem.endsWith(u8, pardes.test_api.heldSelection(p, 0).?, "// body") and std.mem.startsWith(u8, pardes.test_api.heldSelection(p, 0).?, "body\n")); p.update(.{ .mouse = mouse(layer, last, 10, .press) }); p.update(.{ .mouse = mouse(layer, last, 10, .release) }); try std.testing.expectEqual(expected, pane.body.cur_row); @@ -779,6 +790,8 @@ const TreeContextTests = struct { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 48, .rows = 14 }); defer p.deinit(); const pane = try setZigFile(p, "pub fn @\"界name\"(\n\tvalue: usize,\n) void {\n" ++ " _ = value;\n" ** 12 ++ "}\n"); + pane.tag_expanded = false; // a one-row tag keeps this small screen's rows + p.sync(); p.settings.wrap = false; p.settings.colors = false; try std.testing.expect(p.executeBuiltinLine(0, "TreeContext")); @@ -816,6 +829,8 @@ const TreeContextTests = struct { defer p.deinit(); const body = " // 012345678901234567890123456789界tail"; const pane = try setZigFile(p, "pub fn aLongDeclarationNameThatCannotFit() void {\n // before\n" ++ body ++ "\n}\n"); + pane.tag_expanded = false; // a one-row tag keeps this small screen's rows + p.sync(); p.settings.wrap = true; try std.testing.expect(p.executeBuiltinLine(0, "TreeContext")); pane.scrollBy(2); @@ -873,7 +888,7 @@ const TtySelectionTests = struct { try noChildInput(p); } - test "tty selection preserves exact rectangle through yank clipboard and mode changes" { + test "tty selection preserves its exact stream through yank clipboard and mode changes" { if (comptime !panes.Terminal.enabled) return error.SkipZigTest; for ([_]bool{ false, true }) |prompt| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); @@ -894,7 +909,7 @@ const TtySelectionTests = struct { const x = rect.x + config.GUTTER; const y = p.bodyTop(pane, rect); JumpSelectionTests.drag(p, x + @as(u16, if (prompt) 0 else 1), y, x + 3, y + @as(u16, if (prompt) 0 else 1)); - const expected = if (prompt) "$ ab" else "lph\nrav"; + const expected = if (prompt) "$ ab" else "lpha\nbrav"; try std.testing.expectEqual(panes.Text.Mode.tty, pane.body.mode); try std.testing.expectEqualStrings(expected, pardes.test_api.heldSelection(p, 0).?); try std.testing.expectEqualStrings(expected, p.registers.text(p.gpa, '"') orelse return error.MissingAutomaticTtyYank); @@ -907,9 +922,13 @@ const TtySelectionTests = struct { try std.testing.expectEqual(panes.Text.Mode.normal, pane.body.mode); const normal = try p.render(frame.allocator()); if (!prompt) { - const selection_bg: pardes.Color = .{ .rgb = p.theme().sel_bg }; - try std.testing.expect(!std.meta.eql(selection_bg, normal.at(x + 4, y).style.bg)); - try std.testing.expect(!std.meta.eql(selection_bg, normal.at(x, y + 1).style.bg)); + // A stream: the first row to its edge, the last from its + // start; (x + 1, y) is surely in it, whatever its tint. + const selected = normal.at(x + 1, y).style.bg; + try std.testing.expect(std.meta.eql(selected, normal.at(x + 4, y).style.bg)); + try std.testing.expect(std.meta.eql(selected, normal.at(x, y + 1).style.bg)); + try std.testing.expect(!std.meta.eql(selected, normal.at(x + 4, y + 1).style.bg)); + try std.testing.expect(!std.meta.eql(selected, normal.at(x, y).style.bg)); } try std.testing.expectEqualStrings(expected, pardes.test_api.heldSelection(p, 0).?); p.update(.{ .key = .{ .cp = 'y' } }); @@ -954,14 +973,14 @@ const TtySelectionTests = struct { _ = try p.render(frame.allocator()); const source = p.rects[0]; const sx = source.x + config.GUTTER; - const sy = if (p.settings.tag_bottom) source.y else source.y + pardes.BOX_H; + const sy = bodyY(p, source); JumpSelectionTests.drag(p, sx + 1, sy, sx + 3, sy + 1); - try std.testing.expectEqualStrings("lph\nrav", p.registers.text(p.gpa, '"') orelse return error.MissingAutomaticTtyYank); + try std.testing.expectEqualStrings("lpha\nbrav", p.registers.text(p.gpa, '"') orelse return error.MissingAutomaticTtyYank); try noChildInput(p); const target = p.rects[1]; const tx = target.x + config.GUTTER + 2; - const ty = if (p.settings.tag_bottom) target.y else target.y + pardes.BOX_H; + const ty = bodyY(p, target); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = tx, .row = ty } }); p.update(.{ .mouse = .{ .button = .right, .kind = .press, .col = tx, .row = ty } }); p.update(.{ .mouse = .{ .button = .right, .kind = .release, .col = tx, .row = ty } }); @@ -976,13 +995,13 @@ const TtySelectionTests = struct { } const expected = try std.fmt.allocPrint(p.gpa, "{s}{s}", .{ if (reports_mouse) "\x1b[<0;3;1M\x1b[<0;3;1m" else "", - if (bracketed) "\x1b[200~lph\nrav\x1b[201~" else "lph\rrav", + if (bracketed) "\x1b[200~lpha\nbrav\x1b[201~" else "lpha\rbrav", }); defer p.gpa.free(expected); try std.testing.expectEqualStrings(expected, written.written()); try std.testing.expectEqual(panes.Text.Mode.tty, p.panes[0].?.body.mode); try std.testing.expectEqual(panes.Text.Mode.tty, p.panes[1].?.body.mode); - try std.testing.expectEqualStrings("lph\nrav", pardes.test_api.heldSelection(p, 0).?); + try std.testing.expectEqualStrings("lpha\nbrav", pardes.test_api.heldSelection(p, 0).?); } } } @@ -1060,6 +1079,107 @@ const ClickCursorTests = struct { p.update(.{ .mouse = .{ .button = button, .kind = .release, .col = x, .row = y } }); } + test "a B1 sweep held past a pane's bottom scrolls the body and keeps its start" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + var text: std.ArrayList(u8) = .empty; + defer text.deinit(std.testing.allocator); + for (0..60) |i| try text.print(std.testing.allocator, "line{d}\n", .{i}); + const pane = try p.setTestFile(text.items); + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const rect = p.rects[0]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane); + const y = p.bodyTop(pane, rect); + // The pane reaches the screen's bottom: its last row is the edge. + const below = rect.y + rect.h - 1; + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = x, .row = y + 1 } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = x + 2, .row = below } }); + for (0..20) |_| p.update(.tick); + try std.testing.expect(pane.scroll() >= 20); + _ = try p.render(frame.allocator()); // a frame between the ticks and the release + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = x + 2, .row = below } }); + const got = pardes.edit.currentSelText(p, pane).?; + try std.testing.expect(std.mem.startsWith(u8, got, "line1\nline2\n")); + try std.testing.expect(std.mem.count(u8, got, "\n") > 20); + } + + test "everything a B1 chord does while B1 is down is one undo step" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("one two three\n"); + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const rect = p.rects[0]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane); + const y = p.bodyTop(pane, rect); + const M = struct { + fn ev(pp: *Pardes, b: pardes.Mouse.Button, k: pardes.Mouse.Kind, cx: u16, cy: u16) void { + pp.update(.{ .mouse = .{ .button = b, .kind = k, .col = cx, .row = cy } }); + } + }; + // B1-B2 then B1-B3: cut, then paste back: a copy, one step. + const before = pane.file.?.history.undo_len; + M.ev(p, .left, .press, x + 4, y); + M.ev(p, .left, .drag, x + 6, y); + M.ev(p, .middle, .press, x + 6, y); + M.ev(p, .middle, .release, x + 6, y); + M.ev(p, .right, .press, x + 6, y); + M.ev(p, .right, .release, x + 6, y); + M.ev(p, .left, .release, x + 6, y); + try std.testing.expectEqualStrings("one two three\n", pane.file.?.content); + try std.testing.expectEqualStrings("two", p.registers.text(p.gpa, '"').?); + try std.testing.expect(pane.file.?.history.undo_len <= before + 1); + // A cut alone comes back with one Undo. + M.ev(p, .left, .press, x + 4, y); + M.ev(p, .left, .drag, x + 6, y); + M.ev(p, .middle, .press, x + 6, y); + M.ev(p, .middle, .release, x + 6, y); + M.ev(p, .left, .release, x + 6, y); + try std.testing.expectEqualStrings("one three\n", pane.file.?.content); + try std.testing.expect(p.executeBuiltinLine(0, "Undo")); + try std.testing.expectEqualStrings("one two three\n", pane.file.?.content); + } + + test "a double-click selects a word, a line, a bracketed or quoted text, as acme's" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("call foo_bar(a, b)\nsay \"hi there\"\n"); + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const rect = p.rects[0]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane); + const y = p.bodyTop(pane, rect); + const Want = struct { col: u16, row: u16, text: []const u8 }; + for ([_]Want{ + .{ .col = 6, .row = 0, .text = "foo_bar" }, + .{ .col = 13, .row = 0, .text = "a, b" }, + .{ .col = 0, .row = 0, .text = "call foo_bar(a, b)\n" }, + .{ .col = 5, .row = 1, .text = "hi there" }, + }) |w| { + click(p, .left, x + w.col, y + w.row); + click(p, .left, x + w.col, y + w.row); + try std.testing.expectEqualStrings(w.text, pardes.edit.currentSelText(p, pane).?); + // A third click is a plain click again: the caret, no selection. + click(p, .left, x + w.col, y + w.row); + try std.testing.expect(!pane.body.vsel.active); + } + // In the tag too. + const tag_text = try pardes.tagline.tagText(p, frame.allocator(), pane); + const del: u16 = @intCast(std.mem.indexOf(u8, tag_text, "Del").? + 1); + const tag_x = rect.x + pardes.TAG_TEXT_INSET + del; + click(p, .left, tag_x, p.tagTop(pane, rect)); + click(p, .left, tag_x, p.tagTop(pane, rect)); + try std.testing.expect(pane.tag.vsel.active); + const tag_now = try pardes.tagline.tagText(p, frame.allocator(), pane); + const a = modal.offsetAt(tag_now, .{ .row = @intCast(pane.tag.vsel.row), .col = @intCast(pane.tag.vsel.col) }); + const h = modal.offsetAt(tag_now, .{ .row = @intCast(pane.tag.cur_row), .col = @intCast(pane.tag.cur_col) }); + try std.testing.expectEqualStrings("Del", tag_now[a .. h + 1]); + } + test "click cursor and insertion agree beyond line and file ends" { for ([_]bool{ false, true }) |already_insert| { for ([_]bool{ false, true }) |bottom_tag| { @@ -1080,7 +1200,7 @@ const ClickCursorTests = struct { _ = try p.render(frame.allocator()); const rect = p.rects[0]; const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane); - const y = if (bottom_tag) rect.y else rect.y + pardes.BOX_H; + const y = bodyY(p, rect); click(p, .left, x + 15, y + case.click_row); try std.testing.expectEqual(case.row, pane.body.cur_row); try std.testing.expectEqual(case.col, pane.body.cur_col); @@ -1149,7 +1269,7 @@ const ClickCursorTests = struct { _ = try p.render(frame.allocator()); const rect = p.rects[0]; const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane); - const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + const y = bodyY(p, rect); JumpSelectionTests.drag(p, x + 1, y + 4, x + 3, y + 4); try std.testing.expectEqualStrings("lph", pardes.test_api.heldSelection(p, 0).?); for (0..2) |_| p.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = x + 2, .row = y + 4 } }); @@ -1253,9 +1373,42 @@ const JumpSelectionTests = struct { try std.testing.expectEqual(@as(i32, 0), pane.body.cur_col); } - test "jump selection clears copied rectangles in both history directions" { + test "a B1 sweep over several lines is a stream, as acme's, in a body and a tag" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("alpha\nbravo\ncharlie\n"); + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const rect = p.rects[0]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane); + const y = p.bodyTop(pane, rect); + // From the press to the end of its line, the line between whole, the + // last from its start: no line numbers, no rectangle. + drag(p, x + 2, y, x + 1, y + 2); + try std.testing.expectEqualStrings("pha\nbravo\nch", pardes.test_api.heldSelection(p, 0).?); + // Upward, the same text. + drag(p, x + 1, y + 2, x + 2, y); + try std.testing.expectEqualStrings("pha\nbravo\nch", pardes.test_api.heldSelection(p, 0).?); + // A tag of several rows sweeps the same way. + if (pane.tag.own) |own| p.gpa.free(own); + pane.tag.own = try p.gpa.dupe(u8, "Save Del\nLook Edit"); + p.sync(); + _ = try p.render(frame.allocator()); + p.sync(); + try std.testing.expectEqual(@as(u16, 2), pane.tag_rows); + const sl: panes.Pane.Sel = .{ .state = .done, .r0 = 0, .c0 = 3, .r1 = 1, .c1 = 1 }; + const text = try pardes.edit.paneText(p, pane); + const swept = try pardes.edit.selectionText(p, pane, sl); + const first_line = text[0 .. std.mem.indexOfScalar(u8, text, '\n') orelse text.len]; + const second = std.mem.sliceTo(text[first_line.len + 1 ..], '\n'); + const want = try std.fmt.allocPrint(frame.allocator(), "{s}\n{s}", .{ first_line[3..], second[0..@min(2, second.len)] }); + try std.testing.expectEqualStrings(want, swept); + } + + test "jump selection clears copied sweeps in both history directions" { for ([_]bool{ false, true }) |reverse| - try exercise("alpha\nbravo\n", false, reverse, 1, 3, "lph\nrav"); + try exercise("alpha\nbravo\n", false, reverse, 1, 3, "lpha\nbrav"); } test "jump selection clears wrapped Unicode and tab rectangles" { @@ -1325,22 +1478,29 @@ const TagNameTintTests = struct { var arena = std.heap.ArenaAllocator.init(p.gpa); defer arena.deinit(); const surface = try p.render(arena.allocator()); - const text = try pardes.tagline.tagText(p, arena.allocator(), pane); + // The tag as shown: its rows wrapped, each offset mapped back to the + // tag's own to say whether it is in the name. + const shown = try pardes.tagline.shownTag(p, arena.allocator(), pane); const base = std.mem.lastIndexOfScalar(u8, name, '/') orelse return error.MissingDirectory; const rect = p.rects[id]; - const y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; const left = rect.x + pardes.TAG_TEXT_INSET; var visible_name: usize = 0; - var at: usize = 0; - while (at < text.len) : (at = modal.nextGrapheme(text, at)) { - const start: i32 = @as(i32, @intCast(panes.File.rawDisplayCol(text, at))) - pane.tag_scroll; - const end = start + @as(i32, @intCast(panes.File.displayWidth(text[at..modal.nextGrapheme(text, at)]))); - var col = @max(0, start); - while (col < end and col < rect.w - pardes.TAG_TEXT_INSET) : (col += 1) { - const in_name = at > base and at < name.len; - const fg = if (in_name) name_fg else tag_fg; - try std.testing.expectEqual(pardes.Color{ .rgb = fg }, surface.at(left + @as(u16, @intCast(col)), y).style.fg); - if (in_name) visible_name += 1; + for (0..pane.tag_rows) |row| { + const line_start = modal.lineStartOffset(shown.text, row + pane.tag_top); + const line = modal.lineSlice(shown.text, row + pane.tag_top); + const y = tagY(p, rect) + @as(u16, @intCast(row)); + var at: usize = 0; + while (at < line.len) : (at = modal.nextGrapheme(line, at)) { + const real = shown.toReal(line_start + at); + const start: i32 = @intCast(panes.File.rawDisplayCol(line, at)); + const end = start + @as(i32, @intCast(panes.File.displayWidth(line[at..modal.nextGrapheme(line, at)]))); + var col = start; + while (col < end and col < rect.w - pardes.TAG_TEXT_INSET) : (col += 1) { + const in_name = real > base and real < name.len; + const fg = if (in_name) name_fg else tag_fg; + try std.testing.expectEqual(pardes.Color{ .rgb = fg }, surface.at(left + @as(u16, @intCast(col)), y).style.fg); + if (in_name) visible_name += 1; + } } } return visible_name; @@ -1366,7 +1526,7 @@ const TagNameTintTests = struct { } } - test "tag filename tint follows scrolled Unicode drafted names and yields to selection" { + test "tag filename tint follows wrapped Unicode drafted names and yields to selection" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 38, .rows = 14 }); defer p.deinit(); const pane = try p.setTestFile("body\n"); @@ -1376,17 +1536,20 @@ const TagNameTintTests = struct { pardes.tagline.draftName(p, pane, 0); redraft(pane, name); pane.input.cur_col = @intCast(std.mem.indexOf(u8, name, "界").?); + p.sync(); // the drafted name's rows try std.testing.expect((try check(p, 0, name)) > 0); - try std.testing.expect(pane.tag_scroll > 0); - // A sweep over the wide glyph wears the selection over the tint. - const col: i32 = @intCast(panes.File.rawDisplayCol(name, @intCast(pane.input.cur_col))); - pane.sel[0] = .{ .state = .done, .c0 = col, .c1 = col + 1, .r0 = 0, .r1 = 0 }; - pane.tag_scroll += 4; // bring both cells of the selected wide glyph into view + try std.testing.expect(pane.tag_rows > 1); // the long name wraps + // A sweep over the wide glyph, on the row it wrapped to, wears the + // selection over the tint. var arena = std.heap.ArenaAllocator.init(p.gpa); defer arena.deinit(); + const shown = try pardes.tagline.shownTag(p, arena.allocator(), pane); + const pos = modal.positionAt(shown.text, shown.fromReal(@intCast(pane.input.cur_col))); + const col: i32 = @intCast(panes.File.rawDisplayCol(modal.lineSlice(shown.text, pos.row), pos.col)); + pane.sel[0] = .{ .state = .done, .c0 = col, .c1 = col + 1, .r0 = @intCast(pos.row), .r1 = @intCast(pos.row) }; const surface = try p.render(arena.allocator()); - const x = p.rects[0].x + pardes.TAG_TEXT_INSET + @as(u16, @intCast(col - pane.tag_scroll)); - const y = if (p.settings.tag_bottom) p.rects[0].y + p.rects[0].h - pardes.BOX_H else p.rects[0].y; + const x = p.rects[0].x + pardes.TAG_TEXT_INSET + @as(u16, @intCast(col)); + const y = tagY(p, p.rects[0]) + @as(u16, @intCast(pos.row)); try std.testing.expectEqual(pardes.Color{ .rgb = p.theme().sel_fg }, surface.at(x, y).style.fg); try std.testing.expectEqual(pardes.Color{ .rgb = p.theme().sel_fg }, surface.at(x + 1, y).style.fg); } @@ -1409,7 +1572,7 @@ const TagNameTintTests = struct { p.settings.focus_tint = active; const surface = try p.render(arena.allocator()); const x = p.rects[0].x + pardes.TAG_TEXT_INSET; - const y = if (p.settings.tag_bottom) p.rects[0].y + p.rects[0].h - pardes.BOX_H else p.rects[0].y; + const y = tagY(p, p.rects[0]); const fg = if (active) active_fg else tag_fg; try std.testing.expectEqual(pardes.Color{ .rgb = fg }, surface.at(x + 1, y).style.fg); try std.testing.expectEqual(pardes.Color{ .rgb = fg }, surface.at(x + 5, y).style.fg); @@ -1428,7 +1591,7 @@ fn redraft(pane: *Pane, name: []const u8) void { /// Click the tagline at the end of the path: a name draft, caret there. fn clickPathEnd(p: *Pardes, pane: *Pane) void { const rect = p.rects[p.active]; - const y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; + const y = tagY(p, rect); const x = rect.x + pardes.TAG_TEXT_INSET + @as(u16, @intCast(panes.File.displayWidth(pane.file.?.path) - 1)); ClickCursorTests.click(p, .left, x, y); std.debug.assert(pane.prompt == .name); @@ -1570,7 +1733,7 @@ test "paste into pane commands and prompts never inserts into the body" { try std.testing.expect(std.mem.indexOf(u8, pardes.tagline.curTail(pane), " Custom\nCommand") != null); } -test "long pane tags reveal commands and map visible clicks to whole tag addresses" { +test "a long pane tag wraps onto rows, and a click on a wrapped row maps to the whole tag's address" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 50, .rows = 12 }); defer p.deinit(); const pane = try p.setTestFile("body\n"); @@ -1582,13 +1745,15 @@ test "long pane tags reveal commands and map visible clicks to whole tag address var arena = std.heap.ArenaAllocator.init(p.gpa); defer arena.deinit(); const surface = try p.render(arena.allocator()); - try std.testing.expect(pane.tag_scroll > 0); + try std.testing.expect(pane.tag_rows > 1); try std.testing.expect(surface.cursor.?.x < p.rects[0].x + p.rects[0].w); const text = try pardes.tagline.tagText(p, p.gpa, pane); defer p.gpa.free(text); - const offset = std.mem.indexOf(u8, text, "SyntaxBold").?; - const x: u16 = @intCast(p.rects[0].x + pardes.TAG_TEXT_INSET + panes.File.rawDisplayCol(text, offset) - pane.tag_scroll); - const y = p.rects[0].y; + const shown = try pardes.tagline.shownTag(p, arena.allocator(), pane); + const at = shown.fromReal(std.mem.indexOf(u8, text, "SyntaxBold").?); + const pos = modal.positionAt(shown.text, at); + const x: u16 = @intCast(p.rects[0].x + pardes.TAG_TEXT_INSET + panes.File.rawDisplayCol(modal.lineSlice(shown.text, pos.row), pos.col)); + const y = p.tagTop(pane, p.rects[0]) + @as(u16, @intCast(pos.row)); const before = p.settings.syntax_bold; p.update(.{ .mouse = .{ .col = x, .row = y, .button = config.exec_button, .kind = .press } }); p.update(.{ .mouse = .{ .col = x, .row = y, .button = config.exec_button, .kind = .release } }); @@ -1596,6 +1761,32 @@ test "long pane tags reveal commands and map visible clicks to whole tag address try std.testing.expectEqualStrings(text, try pardes.tagline.tagText(p, p.scratch.allocator(), pane)); } +test "a word the tag's wrapping breaks across rows is still one word to a click" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 50, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("body\n"); + try pane.appendTag(" SyntaxBold"); + var arena = std.heap.ArenaAllocator.init(p.gpa); + defer arena.deinit(); + // Lengthen the path until the wrap splits the word after "Synt". + var pad: usize = 1; + const shown = while (pad < 80) : (pad += 1) { + p.gpa.free(pane.file.?.path); + const path = try std.fmt.allocPrint(arena.allocator(), "/{s}/f.zig", .{("a" ** 80)[0..pad]}); + pane.file.?.path = try p.gpa.dupe(u8, path); + p.sync(); + const s = try pardes.tagline.shownTag(p, arena.allocator(), pane); + if (std.mem.indexOf(u8, s.text, "Synt\n") != null) break s; + } else return error.NoSplit; + const pos = modal.positionAt(shown.text, std.mem.indexOf(u8, shown.text, "Synt").?); + const x: u16 = @intCast(p.rects[0].x + pardes.TAG_TEXT_INSET + pos.col + 1); + const y = p.tagTop(pane, p.rects[0]) + @as(u16, @intCast(pos.row)); + const before = p.settings.syntax_bold; + p.update(.{ .mouse = .{ .col = x, .row = y, .button = config.exec_button, .kind = .press } }); + p.update(.{ .mouse = .{ .col = x, .row = y, .button = config.exec_button, .kind = .release } }); + try std.testing.expectEqual(!before, p.settings.syntax_bold); +} + test "terminal overlay recoloring never materializes unrelated history" { const testing = std.testing; const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 32, .rows = 8 }); @@ -1618,7 +1809,7 @@ test "terminal overlay recoloring never materializes unrelated history" { const surface = try p.render(frame.allocator()); try testing.expect(p.shell_rows.pane == null); const rect = p.rects[0]; - const body_y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + const body_y = bodyY(p, rect); const cell = surface.at(rect.x + config.GUTTER, body_y); try testing.expectEqualStrings("c", cell.grapheme()); try testing.expectEqual(pardes.Color{ .rgb = p.theme().palette.?[2] }, cell.style.fg); @@ -1666,7 +1857,7 @@ test "terminal output keeps following new rows after scrollback eviction" { const surface = try p.render(frame.allocator()); const rect = p.rects[0]; const x = rect.x + config.GUTTER; - const y = (if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H) + pane.rows - 2; + const y = (bodyY(p, rect)) + pane.rows - 2; for (last, 0..) |byte, col| { const cell = surface.at(x + @as(u16, @intCast(col)), y); try std.testing.expectEqualStrings(&.{byte}, cell.grapheme()); @@ -2319,7 +2510,7 @@ test "unplaced terminal panes keep their allocated grid until placement" { p.update(.tick); try std.testing.expect(p.rects[id].w > 0 and p.rects[id].h > pardes.BOX_H); try std.testing.expectEqual(p.rects[id].w - config.GUTTER, pane.cols); - try std.testing.expectEqual(p.rects[id].h - pardes.BOX_H, pane.rows); + try std.testing.expectEqual(bodyH(p, p.rects[id]), pane.rows); } test "document panes ignore terminal output without allocating terminal state" { @@ -2672,7 +2863,7 @@ const OutputTests = struct { layout.compute(p); const surface = try p.render(frame.allocator()); const rect = p.rects[pane.search_pane.?]; - const body_y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + const body_y = bodyY(p, rect); for (file.location_rows, 0..) |row, index| { if (!row.declaration) continue; const line = modal.lineSlice(file.content, index); @@ -2720,7 +2911,7 @@ const OutputTests = struct { _ = try p.render(frame.allocator()); const rect = p.rects[id]; const x = rect.x + config.GUTTER + panes.File.gutterWidth(help) + @as(u16, @intCast(at - line_start + 2)); - const y = (if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H) + @as(u16, @intCast(row)); + const y = (bodyY(p, rect)) + @as(u16, @intCast(row)); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = x, .row = y } }); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = x, .row = y } }); try std.testing.expect(p.active != id); @@ -2790,7 +2981,7 @@ const OutputTests = struct { p.acknowledgePanelPresentation(&.{}); const rect = p.rects[id]; const x = rect.x + config.GUTTER + panes.File.gutterWidth(result); - const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + const y = bodyY(p, rect); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = x, .row = y } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = x + 2, .row = y + 1 } }); try std.testing.expect(p.drag == .select); @@ -3105,8 +3296,8 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const rows: usize = r.h - pardes.BOX_H; + const body_y = bodyY(p, r); + const rows: usize = bodyH(p, r); const cols: usize = r.w -| config.GUTTER; const Snap = struct { text: [][7]u8, len: []u8, style: []pardes.CellStyle }; @@ -3346,7 +3537,7 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = bodyY(p, r); const red: pardes.Color = .{ .index = 1 }; const green: pardes.Color = .{ .index = 2 }; const blue: pardes.Color = .{ .index = 4 }; @@ -3401,7 +3592,7 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = bodyY(p, r); p.shell_rows.stale = true; const s = try p.render(frame.allocator()); @@ -3449,8 +3640,8 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const body_h = r.h - pardes.BOX_H; + const body_y = bodyY(p, r); + const body_h = bodyH(p, r); p.shell_rows.stale = true; const s = try p.render(frame.allocator()); @@ -3488,7 +3679,7 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = bodyY(p, r); const red: pardes.Color = .{ .index = 1 }; const green: pardes.Color = .{ .index = 2 }; const blue: pardes.Color = .{ .index = 4 }; @@ -3525,7 +3716,7 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = bodyY(p, r); const red: pardes.Color = .{ .index = 1 }; // Covers far more rows than the grid was ever dumped for, so the anchor @@ -3607,7 +3798,7 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = bodyY(p, r); p.shell_rows.stale = true; const s = try p.render(frame.allocator()); @@ -3685,8 +3876,8 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const rows: usize = r.h - pardes.BOX_H; + const body_y = bodyY(p, r); + const rows: usize = bodyH(p, r); const cols: usize = r.w -| config.GUTTER; // What tty mode shows with nothing left behind: the reference. @@ -3737,6 +3928,8 @@ const TerminalTests = struct { 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(); pane.tty_filter = filter; pane.body.mode = mode; p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[32m\u{754C}\x1b[41m\x1b[K\x1b[7G\x1b[44m\x1b[K\r\n" } }); @@ -3745,7 +3938,7 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = bodyY(p, r); p.shell_rows.stale = true; const s = try p.render(frame.allocator()); @@ -3781,7 +3974,7 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = bodyY(p, r); p.shell_rows.stale = true; const s = try p.render(frame.allocator()); @@ -3814,10 +4007,10 @@ const TerminalTests = struct { const s = try p.render(frame.allocator()); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = bodyY(p, r); var bad: usize = 0; - for (0..r.h -| pardes.BOX_H) |vr| { + for (0..bodyH(p, r)) |vr| { var buf: [16]u8 = undefined; var n: usize = 0; for (0..10) |c| { @@ -3857,7 +4050,7 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = bodyY(p, r); p.update(.{ .output = .{ .pane = 0, .bytes = "plain text\r\n" } }); p.shell_rows.stale = true; @@ -3895,7 +4088,7 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = bodyY(p, r); // The state two ordinary edits reach: one at the bottom, one that split a // line further up. The buffer now spans rows 2..6 and diverges at BOTH @@ -3933,7 +4126,7 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = bodyY(p, r); // The state three keystrokes reach on any blank shell row: type a character // and delete it, and the buffer holds NO text while still standing in for @@ -3958,6 +4151,8 @@ const TerminalTests = struct { 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(); pane.tty_filter = false; pane.body.mode = .tty; @@ -3967,7 +4162,7 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = bodyY(p, r); const blue: pardes.Color = .{ .index = 4 }; p.shell_rows.stale = true; @@ -4010,12 +4205,12 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = bodyY(p, r); const s = try p.render(frame.allocator()); const want = pardes.Color{ .index = 33 }; var seen = false; - for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| { + for (0..@as(usize, bodyH(p, r))) |vr| { var buf: [15]u8 = undefined; for (0..15) |c| { const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); @@ -4063,11 +4258,11 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = bodyY(p, r); const s = try p.render(frame.allocator()); var seen = false; - for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| { + for (0..@as(usize, bodyH(p, r))) |vr| { var buf: [12]u8 = undefined; for (0..12) |c| { const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); @@ -4117,8 +4312,8 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const body_h = r.h -| pardes.BOX_H; + const body_y = bodyY(p, r); + const body_h = bodyH(p, r); var step: usize = 0; while (step < 12) : (step += 1) { @@ -4212,11 +4407,11 @@ const TerminalTests = struct { defer frame.deinit(); const r = p.rects[0]; const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_y = bodyY(p, r); const s = try p.render(frame.allocator()); var seen: usize = 0; - for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| { + for (0..@as(usize, bodyH(p, r))) |vr| { var buf: [12]u8 = undefined; for (0..12) |c| { const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); @@ -4294,7 +4489,7 @@ test "cancelled normal gestures leave no count or subprefix after mouse and focu switch (gesture) { .body, .tag => { const rect = p.rects[0]; - const tag_y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; + const tag_y = tagY(p, rect); const body_y = p.bodyTop(pane, rect); const x = if (gesture == .tag) rect.x + pardes.TAG_TEXT_INSET + @as(u16, @intCast(panes.File.displayWidth(try pardes.tagline.tagPrefix(p, pane)))) + 3 @@ -4424,9 +4619,9 @@ test "growing a file pane colors every newly visible Zig keyword" { _ = frame.reset(.retain_capacity); const surface = try p.render(frame.allocator()); const rect = p.rects[0]; - const body_y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + const body_y = bodyY(p, rect); var seen: usize = 0; - for (body_y..body_y + rect.h - pardes.BOX_H) |y| { + for (body_y..body_y + bodyH(p, rect)) |y| { for (rect.x + config.GUTTER..rect.x + rect.w - 1) |x| { const first = surface.at(@intCast(x), @intCast(y)); const second = surface.at(@intCast(x + 1), @intCast(y)); @@ -4467,6 +4662,8 @@ test "Zig keywords keep every rendered byte colored across line-number widths" { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = view.cols, .rows = 10 }); defer p.deinit(); const pane = try p.setTestFile(source); + pane.tag_expanded = false; // a one-row tag keeps this small screen's rows + p.sync(); const file = &pane.file.?; gpa.free(file.path); file.path = try gpa.dupe(u8, "/highlight.zig"); @@ -4483,8 +4680,8 @@ test "Zig keywords keep every rendered byte colored across line-number widths" { var seen: usize = 0; var if_position: ?struct { x: u16, y: u16 } = null; const rect = p.rects[0]; - const body_y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; - for (body_y..body_y + rect.h - pardes.BOX_H) |y| { + const body_y = bodyY(p, rect); + for (body_y..body_y + bodyH(p, rect)) |y| { for (rect.x + config.GUTTER..rect.x + rect.w - 1) |x| { const first = surface.at(@intCast(x), @intCast(y)); const second = surface.at(@intCast(x + 1), @intCast(y)); @@ -4533,7 +4730,7 @@ test "terminal overlays preserve trailing blank styles without coloring inserted defer frame.deinit(); const rect = p.rects[0]; const x = rect.x + config.GUTTER; - const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + const y = bodyY(p, rect); const palette = p.theme().palette.?; const colors = [_]pardes.Color{ .{ .rgb = palette[1] }, .{ .rgb = palette[2] }, .{ .rgb = palette[4] } }; const original = try p.render(frame.allocator()); |
