summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/File.zig14
-rw-r--r--src/Messages.zig8
-rw-r--r--src/Terminal.zig12
-rw-r--r--src/body_layer.zig69
-rw-r--r--src/edit.zig34
-rw-r--r--src/exec.zig14
-rw-r--r--src/gui/gui.zig34
-rw-r--r--src/look.zig2
-rw-r--r--src/mouse.zig127
-rw-r--r--src/panes.zig6
-rw-r--r--src/pardes.zig237
-rw-r--r--src/pdf_view.zig23
-rw-r--r--src/tag_layer.zig6
13 files changed, 334 insertions, 252 deletions
diff --git a/src/File.zig b/src/File.zig
index 22e3c434..262f3ebb 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -378,7 +378,7 @@ pub fn gutterWidth(pane: *const Pane) u16 {
/// Convert between rendered cells and UTF-8 byte columns. Tag rows always
/// need grapheme conversion; file body rows additionally skip the gutter.
pub fn renderedLineByteCol(pane: *const Pane, row: i32, line_text: []const u8, display_col: usize) usize {
- if (row < pardes.BOX_H) return rawAtDisplay(line_text, display_col);
+ if (row < pane.tag_rows) return rawAtDisplay(line_text, display_col);
if (pane.file == null) return rawAtDisplay(line_text, display_col);
const prefix = @min(@as(usize, gutterWidth(pane)), line_text.len);
if (display_col <= prefix) return display_col;
@@ -386,7 +386,7 @@ pub fn renderedLineByteCol(pane: *const Pane, row: i32, line_text: []const u8, d
}
pub fn renderedLineDisplayCol(pane: *const Pane, row: i32, line_text: []const u8, byte_col: usize) usize {
- if (row < pardes.BOX_H) return rawDisplayCol(line_text, byte_col);
+ if (row < pane.tag_rows) return rawDisplayCol(line_text, byte_col);
if (pane.file == null) return rawDisplayCol(line_text, byte_col);
const prefix = @min(@as(usize, gutterWidth(pane)), line_text.len);
if (byte_col <= prefix) return byte_col;
@@ -1133,7 +1133,7 @@ pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bod
const typing_body = pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side);
const cur_line: i32 = if (active and typing_body) gcrow else std.math.minInt(i32);
// the body's first row, the way renderPane derives it (Tagbottom)
- const body_y = p.bodyTop(r);
+ const body_y = p.bodyTop(pane, r);
const context_bg = p.theme().tag_bg;
for (0..@min(pane.context_rows, body_h)) |context_row| {
for (0..tw) |col| {
@@ -1187,7 +1187,7 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16
// indexed start, same as bodyText — an empty tail simply paints nothing
var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n');
const total = nlines(p.gpa, f);
- const body_y = p.bodyTop(r);
+ const body_y = p.bodyTop(pane, r);
var vr: u16 = 0;
while (vr < body_h) : (vr += 1) {
var base: usize = undefined;
@@ -1263,7 +1263,7 @@ pub fn drawWrapMarkers(
pane_bg: pardes.Color,
) void {
if (tw <= gutterWidth(pane) + 1) return;
- const body_y = p.bodyTop(r);
+ const body_y = p.bodyTop(pane, r);
const marker_fg = p.chromeTheme().lineno;
var row: u16 = 0;
while (row + 1 < pane.wrap_n and row + 1 < body_h) : (row += 1) {
@@ -1287,9 +1287,9 @@ pub fn paintWordSelection(
const tx = r.x + config.GUTTER;
const tw = r.w - config.GUTTER;
const prefix_width = gutterWidth(pane);
- const body_y = p.bodyTop(r);
+ const body_y = p.bodyTop(pane, r);
var vr: i32 = 0;
- while (vr + @as(i32, pardes.BOX_H) < @as(i32, r.h)) : (vr += 1) {
+ while (vr + @as(i32, pane.tag_rows) < @as(i32, r.h)) : (vr += 1) {
const here = pane.wrapAt(vr);
if (here.line != row) continue;
var hi = word_hi;
diff --git a/src/Messages.zig b/src/Messages.zig
index 053950da..c7ff09be 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -397,7 +397,7 @@ pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror)
/// them instead of letting the last one win the row.
pub fn collectNotices(p: *Pardes, pane: *Pane, r: Rect, id: usize) void {
pane.notices.len = 0;
- if (r.w <= config.GUTTER or r.h <= BOX_H) return;
+ if (r.w <= config.GUTTER or r.h <= pane.tag_rows) return;
// Oldest message line on top, so lines already up do not move when a
// new one lands under them; when rows run out the oldest go first.
for (pane.msg_older[0..pane.msg_older_len], 0..) |line, i| pane.notices.pushAt(.older, @intCast(i), line.slot);
@@ -409,7 +409,7 @@ pub fn collectNotices(p: *Pardes, pane: *Pane, r: Rect, id: usize) void {
// carrying the cursor: a prompt you cannot see is a prompt you type
// into blind. A two-row pane spends its only body row on one notice,
// which is what the single shared row used to do anyway.
- const room: u8 = @intCast(@min(@as(u16, Pane.Notices.max), r.h - BOX_H));
+ const room: u8 = @intCast(@min(@as(u16, Pane.Notices.max), r.h - pane.tag_rows));
const n = &pane.notices;
if (n.span() <= room) return;
// Too short for the gaps: close them up in screen order, and if even
@@ -743,7 +743,7 @@ test "notices stack over the body's top rows without moving them" {
defer frame.deinit();
const r = p.rects[id];
- const top = p.bodyTop(r);
+ const top = p.bodyTop(pane, r);
// A message and a leader prefix at once: two chips, in the order
// collectNotices chose, each one row tall and each one row further down.
@@ -754,7 +754,7 @@ test "notices stack over the body's top rows without moving them" {
_ = try p.render(frame.allocator());
try std.testing.expectEqual(@as(u8, 2), pane.notices.len);
// An overlay, not a reservation: the body still starts where it did.
- try std.testing.expectEqual(top, p.bodyTop(r));
+ try std.testing.expectEqual(top, p.bodyTop(pane, r));
var seen: usize = 0;
for (p.surface.tagLayers()) |*layer| {
diff --git a/src/Terminal.zig b/src/Terminal.zig
index 7b5af696..16d5af26 100644
--- a/src/Terminal.zig
+++ b/src/Terminal.zig
@@ -1723,7 +1723,7 @@ pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bo
if (comptime !enabled) return;
const s = &p.surface;
if (pane.terminal == null) return;
- const body_y = p.bodyTop(r);
+ const body_y = p.bodyTop(pane, r);
var filtered_storage: FilteredColors = undefined;
const filtered: ?*FilteredColors = if (pane.tty_filter) blk: {
const tz_filter = tracy.zone(@src(), "filterInit");
@@ -2081,7 +2081,7 @@ test "terminal Filter keys indexed truecolor OSC and background-only cells throu
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 = p.bodyTop(pane, r);
const raw = try p.render(frame.allocator());
try testing.expectEqual(pardes.Color{ .index = 1 }, raw.at(tx, body_y).style.fg);
@@ -2231,7 +2231,7 @@ test "terminal Filter preserves exact palette-null light theme default roles" {
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 = p.bodyTop(pane, r);
const ordinary = try p.render(frame.allocator());
try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, ordinary.at(tx, body_y).style.fg);
try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, ordinary.at(tx, body_y).style.bg);
@@ -2290,7 +2290,7 @@ test "terminal Filter refuses a foreground that would collapse onto the page" {
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 = p.bodyTop(pane, r);
for (0..9) |t| {
p.settings.theme = @intCast(t);
@@ -2382,7 +2382,7 @@ test "tty ansi colors follow the prompt hug into normal mode" {
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 = p.bodyTop(pane, r);
const red: pardes.Color = .{ .rgb = p.theme().palette.?[1] };
const blue: pardes.Color = .{ .rgb = p.theme().palette.?[4] };
@@ -2417,7 +2417,7 @@ test "a prompt row hidden end to end paints nothing at all" {
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 = p.bodyTop(pane, r);
p.shell_rows.stale = true;
const s = try p.render(frame.allocator());
diff --git a/src/body_layer.zig b/src/body_layer.zig
index e85e10f1..2b2c8a74 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -135,11 +135,11 @@ fn paintTerminalSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.P
const raw = pane.body.mode == .tty;
const tx = r.x + config.GUTTER;
const width = r.w -| config.GUTTER;
- const body_y = p.bodyTop(r);
+ const body_y = p.bodyTop(pane, r);
var lines = std.mem.splitScalar(u8, body, '\n');
var visible: i32 = 0;
while (lines.next()) |line| : (visible += 1) {
- if (visible >= r.h -| BOX_H) break;
+ if (visible >= r.h -| pane.tag_rows) break;
const source_row = if (raw) panes.Terminal.gridOffset(pane) + visible else pane.wrapAt(visible).line;
for (rows) |row| {
const target = if (row.raw_terminal)
@@ -177,10 +177,10 @@ fn paintSourceSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.Poi
const terminal_lines = if (pane.file == null) panes.Terminal.cursorLines(p, pane) catch return else &.{};
const tx = r.x + config.GUTTER;
const width = r.w -| config.GUTTER;
- const body_y = p.bodyTop(r);
+ const body_y = p.bodyTop(pane, r);
const prefix: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0;
var visible: i32 = 0;
- while (visible < @as(i32, r.h -| BOX_H)) : (visible += 1) {
+ while (visible < @as(i32, r.h -| pane.tag_rows)) : (visible += 1) {
const source = pane.wrapAt(visible);
const line = edit.pointerSourceLine(pane, terminal_lines, source.line);
const start = @min(line.len, @as(usize, @intCast(@max(0, source.at))));
@@ -224,13 +224,13 @@ pub fn paintPointerSelection(
var row: u16 = 0;
while (row < r.h) : (row += 1) {
if (@as(i32, row) < r0 or @as(i32, row) > r1) continue;
- const sy = if (row < BOX_H) tag_y else body_y + row - BOX_H;
+ const sy = if (row < pane.tag_rows) tag_y + row else body_y + row - pane.tag_rows;
// File line numbers occupy a gutter only in the body. The tag is
// row zero in Sel space and starts at its real first text cell.
- const scroll: i32 = if (row < BOX_H) pane.tag_scroll else 0;
- var col: i32 = if (pane.file != null and row >= BOX_H) @max(c0, @as(i32, panes.File.gutterWidth(pane))) else @max(0, c0 - scroll);
- const row_x = if (row < BOX_H) r.x + @min(TAG_TEXT_INSET, r.w) else tx;
- const row_width = if (row < BOX_H) r.w -| TAG_TEXT_INSET else tw;
+ const scroll: i32 = if (row < pane.tag_rows) pane.tag_scroll else 0;
+ var col: i32 = if (pane.file != null and row >= pane.tag_rows) @max(c0, @as(i32, panes.File.gutterWidth(pane))) else @max(0, c0 - scroll);
+ const row_x = if (row < pane.tag_rows) r.x + @min(TAG_TEXT_INSET, r.w) else tx;
+ const row_width = if (row < pane.tag_rows) r.w -| TAG_TEXT_INSET else tw;
while (col <= c1 - scroll and col < row_width) : (col += 1) {
const cell = s.at(row_x + @as(u16, @intCast(col)), sy);
cell.default = false;
@@ -242,8 +242,8 @@ pub fn paintPointerSelection(
pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize) !void {
if (!p.settings.tree_context_tag_style or pane.context_rows == 0 or pane.file == null or pane.collapsed) return;
- if (r.w <= config.GUTTER or r.h <= BOX_H) return;
- const viewport_rows = r.h - BOX_H;
+ if (r.w <= config.GUTTER or r.h <= pane.tag_rows) return;
+ const viewport_rows = r.h - pane.tag_rows;
if (viewport_rows == 0) return;
// The canonical fixed grid remains available to terminal clients. The
// separate body layer reserves enough rows for any compact font size.
@@ -260,24 +260,24 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec
const layer = &p.surface.body_layers[id];
const count = logical_rows * width;
if (layer.cells.len != count) layer.cells = try p.gpa.realloc(layer.cells, count);
- const temporary_cells = try arena.alloc(Cell, @as(usize, r.w) * (logical_rows + BOX_H));
+ const temporary_cells = try arena.alloc(Cell, @as(usize, r.w) * (logical_rows + pane.tag_rows));
@memset(temporary_cells, .{});
- var temporary: Surface = .{ .cols = r.w, .rows = @intCast(logical_rows + BOX_H), .cells = temporary_cells };
+ var temporary: Surface = .{ .cols = r.w, .rows = @intCast(logical_rows + pane.tag_rows), .cells = temporary_cells };
std.mem.swap(Surface, &p.surface, &temporary);
- const synthetic: Rect = .{ .x = 0, .y = 0, .w = r.w, .h = @intCast(logical_rows + BOX_H) };
+ const synthetic: Rect = .{ .x = 0, .y = 0, .w = r.w, .h = @intCast(logical_rows + pane.tag_rows) };
renderBody(p, arena, pane, synthetic, id, id == p.active, false) catch |err| {
std.mem.swap(Surface, &p.surface, &temporary);
return err;
};
std.mem.swap(Surface, &p.surface, &temporary);
- const first_row: usize = if (p.settings.tag_bottom) 0 else BOX_H;
+ const first_row: usize = if (p.settings.tag_bottom) 0 else pane.tag_rows;
for (0..logical_rows) |row| {
const start = (row + first_row) * r.w + config.GUTTER;
@memcpy(layer.cells[row * width ..][0..width], temporary_cells[start..][0..width]);
}
layer.pane = @intCast(id);
layer.serial = pane.serial;
- layer.viewport = .{ .x = r.x + config.GUTTER, .y = p.bodyTop(r), .w = width, .h = viewport_rows };
+ layer.viewport = .{ .x = r.x + config.GUTTER, .y = p.bodyTop(pane, r), .w = width, .h = viewport_rows };
layer.cols = width;
layer.rows = @intCast(logical_rows);
layer.context_rows = pane.context_rows;
@@ -295,15 +295,15 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
const chrome = p.chromeTheme();
const tx = r.x + config.GUTTER;
const tw = r.w - config.GUTTER;
- const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ const tag_y = p.tagTop(pane, r);
// Below the notice bands: they are stacked at the top of the body the
// way tree-sitter context rows are, so the body starts under them
// rather than being painted over by them.
- const body_y = p.bodyTop(r);
+ const body_y = p.bodyTop(pane, r);
const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default;
const page_rgb = th.bg orelse th.tag_bg;
// body: emulator rows, prompt rows hidden outside tty mode
- const body_h = r.h -| BOX_H;
+ const body_h = r.h -| pane.tag_rows;
const body_bottom = r.h;
const body_style: CellStyle = .{
.fg = if (th.fg) |c| .{ .rgb = c } else .default,
@@ -365,7 +365,7 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
if (sl.state == .done and b != 0) continue;
// A select sweep over the tag is the tag's selection, painted there
// in the tag's own selection colours.
- if (b == pardes.sel_slot and @min(sl.r0, sl.r1) < BOX_H) continue;
+ if (b == pardes.sel_slot and @min(sl.r0, sl.r1) < pane.tag_rows) continue;
paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg);
}
// modal line selection (helix `x`): whole body rows, normal mode only
@@ -377,13 +377,13 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
th.sel_bg;
const lo = @min(pane.body.msel.r0, pane.body.msel.r1);
const hi = @max(pane.body.msel.r0, pane.body.msel.r1);
- var row: u16 = BOX_H; // never paint the tag row
+ var row: u16 = pane.tag_rows; // never paint the tag row
while (row < body_bottom) : (row += 1) {
- const ar = pane.wrapAt(@as(i32, row) - @as(i32, BOX_H)).line;
+ const ar = pane.wrapAt(@as(i32, row) - @as(i32, pane.tag_rows)).line;
if (ar < lo or ar > hi) continue;
var col: u16 = if (pane.file != null) panes.File.gutterWidth(pane) else 0;
while (col < tw) : (col += 1) {
- const cell = s.at(tx + col, body_y + row - BOX_H);
+ const cell = s.at(tx + col, body_y + row - pane.tag_rows);
cell.default = false;
cell.style.bg = .{ .rgb = modal_bg };
if (!hover_only) cell.style.fg = .{ .rgb = th.sel_fg };
@@ -415,7 +415,7 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
else
colors.mix(th.sel_bg, page_rgb);
var vr: i32 = 0;
- while (vr + @as(i32, BOX_H) < @as(i32, body_bottom)) : (vr += 1) {
+ while (vr + @as(i32, pane.tag_rows) < @as(i32, body_bottom)) : (vr += 1) {
const w = pane.wrapAt(vr);
if (w.line < bnd.lo_row or w.line > bnd.hi_row) continue;
const visible_line = modal.lineSlice(body, @intCast(vr));
@@ -444,7 +444,7 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
if (primary and !preview and !hover_only) continue; // the hardware cursor IS the primary's
if (hover_only) continue; // quiet preview preserves the source ink
const cw = pane.wrapRow(sr.row, sr.col);
- const crow = cw.row + @as(i32, BOX_H);
+ const crow = cw.row + @as(i32, pane.tag_rows);
const ccol = (if (pane.file != null)
panes.File.displayOffset(pane, sr.row, cw.at, sr.col)
else
@@ -453,8 +453,8 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
@intCast(@max(0, cw.at)),
@intCast(@max(0, sr.col)),
)) + vpfx;
- if (crow >= BOX_H and crow < @as(i32, body_bottom) and ccol >= vpfx and ccol < tw) {
- const cell = s.at(tx + @as(u16, @intCast(ccol)), body_y + @as(u16, @intCast(crow - BOX_H)));
+ if (crow >= pane.tag_rows and crow < @as(i32, body_bottom) and ccol >= vpfx and ccol < tw) {
+ const cell = s.at(tx + @as(u16, @intCast(ccol)), body_y + @as(u16, @intCast(crow - pane.tag_rows)));
cell.default = false;
cell.style.bg = .{ .rgb = th.sel_fg };
cell.style.fg = .{ .rgb = th.sel_bg };
@@ -471,7 +471,7 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
const crow = if (pane.body.cur_pinned) pane.body.cur_row else pane.surfRow(@as(i32, @intCast(cur.y)) + goff);
const ccol = if (pane.body.cur_pinned) pane.body.cur_col else @as(i32, @intCast(cur.x));
const cwp = pane.wrapRow(crow, ccol);
- const prow = cwp.row + @as(i32, BOX_H);
+ const prow = cwp.row + @as(i32, pane.tag_rows);
// Files store source-byte columns; the Surface stores display
// cells, so account for every expanded tab before the cursor.
const cx = if (pane.file != null)
@@ -484,10 +484,10 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
)
else
ccol;
- if (prow >= BOX_H and cx >= 0 and prow < body_bottom and cx < tw)
- s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = body_y + @as(u16, @intCast(prow - BOX_H)), .bar = pane.body.mode == .insert };
+ if (prow >= pane.tag_rows and cx >= 0 and prow < body_bottom and cx < tw)
+ s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = body_y + @as(u16, @intCast(prow - pane.tag_rows)), .bar = pane.body.mode == .insert };
} else if (panes.Terminal.visibleCursor(pane)) |cur| {
- if (cur.y + BOX_H < body_bottom and cur.x < tw)
+ if (cur.y + pane.tag_rows < body_bottom and cur.x < tw)
s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y };
}
}
@@ -496,10 +496,9 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
// The rail covers the notice bands too: nothing else paints those
// two gutter columns, so a gap there keeps the frame-wide border
// fill and reads as a notch cut out of the scrollbar.
- const rail_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H;
- const rail_h = r.h -| BOX_H;
- s.fill(r.x, rail_y, 1, rail_h, .{ .bg = .{ .rgb = chrome.scroll_track } });
- s.fill(r.x + 1, rail_y, 1, rail_h, .{ .bg = pane_bg });
+ const rail_h = r.h -| pane.tag_rows;
+ s.fill(r.x, body_y, 1, rail_h, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ s.fill(r.x + 1, body_y, 1, rail_h, .{ .bg = pane_bg });
const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{
.total = panes.File.nlines(p.gpa, f),
.offset = f.scroll,
diff --git a/src/edit.zig b/src/edit.zig
index 1822db31..36544d6b 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -128,10 +128,14 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void {
p.emit(.{ .set_clipboard = {} });
}
-/// tag + '\n' + body: the full selectable pane text (row 0 = the tag)
+/// tag + '\n' + body: the full selectable pane text, a row to a screen row
+/// (the tag's lines first, as many as it shows)
pub fn paneText(p: *Pardes, pane: *Pane) ![]u8 {
const arena = p.scratch.allocator();
- return std.fmt.allocPrint(arena, "{s}\n{s}", .{ try p.tagText(arena, pane), try body_layer.bodyText(p, arena, pane) });
+ const tag = try p.tagText(arena, pane);
+ const cut = modal.lineStartOffset(tag, pane.tag_rows);
+ const shown = if (cut < tag.len) tag[0 .. cut - 1] else tag;
+ return std.fmt.allocPrint(arena, "{s}\n{s}", .{ shown, try body_layer.bodyText(p, arena, pane) });
}
pub fn pointerSourceLine(pane: *Pane, terminal_lines: []const []const u8, row: i32) []const u8 {
@@ -144,16 +148,16 @@ pub fn pointerSourceLine(pane: *Pane, terminal_lines: []const []const u8, row: i
pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void {
var gesture = pane.sel[slot];
if (pane.hasPdf()) return;
- if (gesture.state != .done or @min(gesture.r0, gesture.r1) < BOX_H) return;
+ if (gesture.state != .done or @min(gesture.r0, gesture.r1) < pane.tag_rows) return;
if (pane.pointerSelection(gesture) != null) return;
pane.clearPointerSelection(slot);
// Refresh the source map before translating the rendered rectangle.
const raw = pane.isTerminal() and pane.body.mode == .tty;
const body = try body_layer.bodyText(p, p.scratch.allocator(), pane);
const terminal_lines = if (pane.file == null and !raw) try panes.Terminal.cursorLines(p, pane) else &.{};
- const first = @max(0, @min(gesture.r0, gesture.r1) - BOX_H);
+ const first = @max(0, @min(gesture.r0, gesture.r1) - pane.tag_rows);
const logical_rows = if (pane.body_rows > 0) pane.body_rows else pane.rows;
- const last = @min(@as(i32, logical_rows) - 1, @max(gesture.r0, gesture.r1) - BOX_H);
+ const last = @min(@as(i32, logical_rows) - 1, @max(gesture.r0, gesture.r1) - pane.tag_rows);
if (first > last) return;
const rows = try p.gpa.alloc(Pane.PointerRow, @intCast(last - first + 1));
errdefer p.gpa.free(rows);
@@ -1595,28 +1599,28 @@ const PointerTextSelection = struct {
};
pub fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_hit: ?TagHit) ?PointerTextSelection {
- if (p.panes[id] == null) return null;
+ const pane = p.panes[id] orelse return null;
const r = p.rects[id];
if (col < r.x + config.GUTTER or col >= r.x + r.w or row < r.y or row >= r.y + r.h) return null;
- const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
- const body_y = p.bodyTop(r);
- const on_tag = row >= tag_y and row < tag_y + BOX_H;
+ const tag_y = p.tagTop(pane, r);
+ const body_y = p.bodyTop(pane, r);
+ const on_tag = row >= tag_y and row < tag_y + pane.tag_rows;
if (on_tag and col < r.x + TAG_TEXT_INSET) return null;
// A notice chip is chrome: it sits OVER the body's top rows, so a
// click inside one belongs to nobody. The rest of those rows is
// ordinary body text and still answers normally.
- if (!on_tag and row >= body_y and row - body_y < p.panes[id].?.notices.span() and
- p.panes[id].?.notices.covers(row - body_y, col)) return null;
+ if (!on_tag and row >= body_y and row - body_y < pane.notices.span() and
+ pane.notices.covers(row - body_y, col)) return null;
if (!on_tag) if (body_hit) |supplied| {
const mapped_hit = mouse.bodyHitForPane(p, id, supplied, false) orelse return null;
const c = @as(i32, mapped_hit.col) - config.GUTTER;
- const v = @as(i32, mapped_hit.row) + BOX_H;
+ const v = @as(i32, mapped_hit.row) + pane.tag_rows;
return .{ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }, .on_tag = false };
};
- const tag_col = if (on_tag) mouse.tagColumn(p, .pane, id, tag_hit, false) else null;
+ const tag_col = if (on_tag) mouse.tagColumn(p, .pane, id, tag_hit, false, row - tag_y) else null;
const c: i32 = (if (tag_col) |value| @as(i32, value) else @as(i32, col) - @as(i32, r.x + (if (on_tag) TAG_TEXT_INSET else config.GUTTER))) +
- (if (on_tag) @as(i32, p.panes[id].?.tag_scroll) else 0);
- const v: i32 = if (on_tag) 0 else @as(i32, row) - @as(i32, body_y) + @as(i32, BOX_H);
+ (if (on_tag) @as(i32, pane.tag_scroll) else 0);
+ const v: i32 = if (on_tag) @as(i32, row) - @as(i32, tag_y) else @as(i32, row) - @as(i32, body_y) + @as(i32, pane.tag_rows);
return .{
.sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v },
.on_tag = on_tag,
diff --git a/src/exec.zig b/src/exec.zig
index 82b3da4a..a673e9e7 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -94,14 +94,14 @@ pub const PointerOperand = struct {
};
pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand {
- const visible = clicked.r0 - @as(i32, BOX_H);
+ const visible = clicked.r0 - @as(i32, pane.tag_rows);
const wrapped = pane.wrapAt(visible);
- const raw = pane.isTerminal() and pane.body.mode == .tty and clicked.r0 >= BOX_H;
+ const raw = pane.isTerminal() and pane.body.mode == .tty and clicked.r0 >= pane.tag_rows;
const row = if (raw) panes.Terminal.gridOffset(pane) + visible else wrapped.line;
const raw_line = if (raw) modal.lineSlice(body_layer.bodyText(p, p.scratch.allocator(), pane) catch "", @intCast(@max(0, visible))) else "";
const col = if (raw)
@as(i32, @intCast(panes.File.rawAtDisplay(raw_line, @intCast(@max(0, clicked.c0)))))
- else if (clicked.r0 >= BOX_H)
+ else if (clicked.r0 >= pane.tag_rows)
edit.paneByteAtDisplay(
p,
&pane.body,
@@ -115,7 +115,7 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand
const kept = pane.sel[sel_slot];
const inside_kept = if (pane.pointerSelection(kept)) |rows| inside: {
- if (clicked.r0 < BOX_H) break :inside false;
+ if (clicked.r0 < pane.tag_rows) break :inside false;
if (pane.file != null and clicked.c0 < panes.File.gutterWidth(pane)) break :inside false;
const source_col: usize = @intCast(@max(0, col));
for (rows) |span| {
@@ -155,7 +155,7 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand
result.text = edit.currentSelText(p, pane);
return result;
}
- if (pane.file != null and clicked.r0 >= BOX_H and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) {
+ if (pane.file != null and clicked.r0 >= pane.tag_rows and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) {
const line = panes.File.sourceLine(pane, row);
const source_col: usize = @intCast(@max(0, col));
const b = look.expandedWord(line, source_col) orelse return result;
@@ -168,9 +168,9 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand
const first = pane.wrapRow(row, lo);
const last = pane.wrapRow(row, hi - 1);
if (first.row >= 0 and last.row >= 0) {
- expanded.r0 = first.row + @as(i32, BOX_H);
+ expanded.r0 = first.row + @as(i32, pane.tag_rows);
expanded.c0 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, first.at, lo);
- expanded.r1 = last.row + @as(i32, BOX_H);
+ expanded.r1 = last.row + @as(i32, pane.tag_rows);
expanded.c1 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayEndOffset(pane, row, last.at, hi - 1);
}
result.preview = expanded;
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index dfd528a3..140ca1db 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -1842,6 +1842,7 @@ const Gui = struct {
scroll_pane: ?usize = null,
scroll_rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 },
scroll_body_y: u16 = 0, // that rect's first BODY row (Tagbottom moves it)
+ scroll_body_h: u16 = 0, // and its BODY rows, which a taller tag leaves fewer of
scroll_col: u16 = 0, // where the wheel turned: crossed rows are delivered
scroll_row: u16 = 0, // THERE, not wherever the pointer has drifted to since
scroll_delta: f32 = 0, // finite raw distance waiting for the render batch
@@ -4460,11 +4461,11 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
return;
}
const r = core.rects[id];
- if (r.h <= pardes.BOX_H) {
+ if (r.h <= pane.tag_rows) {
resetScroll(g);
return;
}
- const delta = boundScrollDelta(g.scroll_lag, queued_delta, r.h - pardes.BOX_H);
+ const delta = boundScrollDelta(g.scroll_lag, queued_delta, r.h - pane.tag_rows);
const st = applyScrollDelta(g.scroll_lag, delta);
var left = st.rows;
while (left != 0) {
@@ -4484,14 +4485,15 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
}
g.scroll_lag = st.lag;
g.scroll_rect = r;
- const body_y = core.bodyTop(r);
+ const body_y = core.bodyTop(pane, r);
g.scroll_body_y = body_y;
+ g.scroll_body_h = r.h -| pane.tag_rows;
if (st.rows != 0) {
const s = &core.surface;
g.scroll_edge_len = 0;
- if (r.w > config.GUTTER and r.h > pardes.BOX_H and r.x + r.w <= s.cols) {
+ if (r.w > config.GUTTER and r.h > pane.tag_rows and r.x + r.w <= s.cols) {
const bw = r.w - config.GUTTER;
- const bh = r.h - pardes.BOX_H;
+ const bh = g.scroll_body_h;
const k: u16 = @intCast(@min(@abs(st.rows), @as(i32, bh)));
const srow = body_y + (if (st.rows > 0) k - 1 else bh - k);
if (srow < s.rows) {
@@ -4515,11 +4517,11 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard
for (surface.panelTracks()) |track|
if (track.active() and track.pane == scroll_pane) return 0;
const r = g.scroll_rect;
- if (r.w <= config.GUTTER or r.h <= pardes.BOX_H) return 0;
+ const bh = g.scroll_body_h;
+ if (r.w <= config.GUTTER or bh == 0) return 0;
const x0 = r.x + config.GUTTER;
const y0 = g.scroll_body_y;
const bw = r.w - config.GUTTER;
- const bh = r.h - pardes.BOX_H;
if (x0 + bw > surface.cols or y0 + bh > surface.rows) return 0; // resized under us
var shifted = layout;
shifted.y_off -= g.scroll_lag * layout.h;
@@ -4553,7 +4555,7 @@ fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect
const px = layout.x_off + @as(f32, @floatFromInt(r.x + config.GUTTER)) * layout.w;
const py = layout.y_off + @as(f32, @floatFromInt(g.scroll_body_y)) * layout.h;
const pw = @as(f32, @floatFromInt(r.w - config.GUTTER)) * layout.w;
- const ph = @as(f32, @floatFromInt(r.h - pardes.BOX_H)) * layout.h;
+ const ph = @as(f32, @floatFromInt(g.scroll_body_h)) * layout.h;
const x0 = std.math.clamp(@as(i32, @intFromFloat(@floor(px))), 0, @as(i32, @intCast(sw)));
const y0 = std.math.clamp(@as(i32, @intFromFloat(@floor(py))), 0, @as(i32, @intCast(sh)));
const x1 = std.math.clamp(@as(i32, @intFromFloat(@ceil(px + pw))), x0, @as(i32, @intCast(sw)));
@@ -5617,8 +5619,9 @@ fn taglineBaseRgb(core: *const pardes.Pardes, col: u16, row: u16) [3]u8 {
};
}
const r = core.rects[core.active];
- const tag_y = if (core.settings.tag_bottom) r.y + r.h -| pardes.BOX_H else r.y;
- return if (r.h > 0 and row == tag_y and col >= r.x and col - r.x < r.w) chrome.tag_active_bg else chrome.tag_bg;
+ const pane = core.panes[core.active] orelse return chrome.tag_bg;
+ const tag_y = core.tagTop(pane, r);
+ return if (r.h > 0 and row >= tag_y and row < tag_y + pane.tag_rows and col >= r.x and col - r.x < r.w) chrome.tag_active_bg else chrome.tag_bg;
}
test "compact column and pane underlays share focus tint without tinting the global tag" {
@@ -7474,11 +7477,12 @@ fn appendPaneChrome(
const chrome = core.chromeTheme();
const page = overlayRgb(core.theme().bg orelse bg_default);
for (core.panes, core.rects) |slot, r| {
- if (slot == null or r.w <= config.GUTTER or r.h <= pardes.BOX_H) continue;
+ const pane = slot orelse continue;
+ if (r.w <= config.GUTTER or r.h <= pane.tag_rows) continue;
if (@as(u32, r.x) + r.w > surface.cols or @as(u32, r.y) + r.h > surface.rows) continue;
- const tag_y = if (core.settings.tag_bottom) r.y + r.h - pardes.BOX_H else r.y;
- const body_y = core.bodyTop(r);
- const body_end = body_y + (r.h -| pardes.BOX_H);
+ const tag_y = core.tagTop(pane, r);
+ const body_y = core.bodyTop(pane, r);
+ const body_end = body_y + (r.h -| pane.tag_rows);
const x0 = @as(f32, @floatFromInt(r.x)) * layout.w;
const x1 = x0 + @as(f32, @floatFromInt(r.w)) * layout.w;
const tag_cells = surface.cells[@as(usize, tag_y) * surface.cols + r.x + pardes.TAG_TEXT_INSET ..][0 .. r.w - pardes.TAG_TEXT_INSET];
@@ -7486,7 +7490,7 @@ fn appendPaneChrome(
if (cellFontRole(cell) != .tagline) break false;
} else true;
if (tag_visible) {
- const rule_y = @as(f32, @floatFromInt(tag_y + @as(u16, if (core.settings.tag_bottom) 0 else 1))) * layout.h;
+ const rule_y = @as(f32, @floatFromInt(tag_y + (if (core.settings.tag_bottom) 0 else pane.tag_rows))) * layout.h;
// Like the band above it, the rule of a pane at the right edge
// runs on through the pixels past the last whole cell.
const rule_x1 = if (builder.win_w - x1 < layout.w) @max(x1, builder.win_w) else x1;
diff --git a/src/look.zig b/src/look.zig
index 6f9f8c6a..8aa2c0a1 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -483,7 +483,7 @@ pub fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel {
/// coords: row 0 is the tag; file panes carry the line-number prefix)
pub fn cursorWordSel(p: *Pardes, pane: *Pane) Pane.Sel {
const w = pane.wrapRow(pane.body.cur_row, pane.body.cur_col);
- const vrow = w.row + @as(i32, BOX_H);
+ const vrow = w.row + @as(i32, pane.tag_rows);
const vcol = if (pane.file != null)
panes.File.displayOffset(pane, pane.body.cur_row, w.at, pane.body.cur_col) + @as(i32, panes.File.gutterWidth(pane))
else blk: {
diff --git a/src/mouse.zig b/src/mouse.zig
index 343051b0..33646090 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -103,11 +103,11 @@ pub const ChromeTarget = struct { col: u16, row: u16 };
pub fn chromeTarget(p: *const Pardes, col: u16, row: u16) ?ChromeTarget {
if (row < p.topBarHeight() + p.columnBarHeight()) return .{ .col = col, .row = row };
for (p.panes, 0..) |slot, id| {
- if (slot == null) continue;
+ const pane = slot orelse continue;
const rect = p.rects[id];
- const tag = if (p.settings.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
- if (row == tag and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER)
- return .{ .col = col, .row = tag };
+ const tag = p.tagTop(pane, rect);
+ if (row >= tag and row < tag + pane.tag_rows and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER)
+ return .{ .col = col, .row = row };
}
for (0..p.ncol -| 1) |column| {
const handle = p.col_x[column] + p.col_w[column] -| 1;
@@ -122,11 +122,11 @@ pub fn chromeTarget(p: *const Pardes, col: u16, row: u16) ?ChromeTarget {
}
}
for (p.panes, 0..) |slot, id| {
- if (slot == null) continue;
+ const pane = slot orelse continue;
const rect = p.rects[id];
- const tag = if (p.settings.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
- if (row == tag and col >= rect.x and col < rect.x + rect.w)
- return .{ .col = col, .row = tag };
+ const tag = p.tagTop(pane, rect);
+ if (row >= tag and row < tag + pane.tag_rows and col >= rect.x and col < rect.x + rect.w)
+ return .{ .col = col, .row = row };
}
for (0..p.ncol -| 1) |column| {
const handle = p.col_x[column] + p.col_w[column] -| 1;
@@ -182,11 +182,13 @@ pub fn sameTagCell(a: ?TagHit, b: ?TagHit) bool {
return first.kind == second.kind and first.id == second.id and first.serial == second.serial and first.col == second.col;
}
-pub fn tagColumn(p: *const Pardes, kind: TagKind, id: usize, supplied: ?TagHit, clamp: bool) ?u16 {
+/// The tag column a host's pointer is at. Clamped, a point off the tag is
+/// brought back onto line `line` of it, for a drag that leaves it.
+pub fn tagColumn(p: *const Pardes, kind: TagKind, id: usize, supplied: ?TagHit, clamp: bool, line: u16) ?u16 {
var point = supplied orelse return null;
if (point.kind != kind or point.id != id) return null;
if (clamp) for (p.surface.tagLayers()) |*layer| {
- if (layer.cols == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial) continue;
+ if (layer.cols == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial or layer.line != line) continue;
const bw: f32 = @floatFromInt(point.metrics.body_w);
const bh: f32 = @floatFromInt(point.metrics.body_h);
const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw;
@@ -298,7 +300,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
};
const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0;
const bar = modal.lineSlice(edit.flatSurface(p, t) catch return, 0);
- const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tagColumn(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + p.header_scroll));
+ const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tagColumn(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag, 0) orelse (mcol -| x)) + p.header_scroll));
// the anchor stays where the press put it
t.cur_row = 0;
t.cur_col = @intCast(at);
@@ -330,7 +332,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
const focused = p.header_focus and p.header_column == column;
const bar = modal.lineSlice(edit.flatSurface(p, t) catch return, 0);
const scroll = if (focused) p.header_scroll else 0;
- const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tagColumn(p, if (column != null) .column else .workspace, column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + scroll));
+ const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tagColumn(p, if (column != null) .column else .workspace, column orelse 0, p.pointer_tag_hit, p.header_drag, 0) orelse (mcol -| x)) + scroll));
if (m.button == config.select_button) {
// A clicked header is typed straight into, as a clicked tag is.
if (!focused) p.enterHeader(column);
@@ -417,8 +419,9 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
}
if (hovered) |mid| {
const mr = p.rects[mid];
- const mtag = if (p.settings.tag_bottom) mr.y + mr.h -| BOX_H else mr.y;
- if (mcol < mr.x + config.GUTTER and mrow >= mtag and mrow < mtag + BOX_H) {
+ const mpane = p.panes[mid].?;
+ const mtag = p.tagTop(mpane, mr);
+ if (mcol < mr.x + config.GUTTER and mrow >= mtag and mrow < mtag + mpane.tag_rows) {
p.drag = .{ .move = .{ .id = mid, .cur_x = mcol, .cur_y = mrow } };
return;
}
@@ -449,14 +452,14 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
previous.focus = .body;
previous.closePrompt();
};
- const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
- const body_y = p.bodyTop(r);
+ const tag_y = p.tagTop(pane, r);
+ const body_y = p.bodyTop(pane, r);
if (mcol < r.x + config.GUTTER) {
// gutter scrollbar: a left click scrolls UP to that row
p.active = id;
pane.scrollBy(-(@as(i32, mrow) - @as(i32, body_y)));
} else {
- const on_tag = mrow >= tag_y and mrow < tag_y + BOX_H;
+ const on_tag = mrow >= tag_y and mrow < tag_y + pane.tag_rows;
if (on_tag and mcol < r.x + TAG_TEXT_INSET) return;
p.active = id;
pane.closePrompt();
@@ -475,8 +478,9 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
pane.tag.msel.active = false;
pane.tag.nsel = 0;
pane.tag.cur_pinned = true;
- pane.tag.cur_row = 0;
- pane.tag.cur_col = @intCast(p.tagOffsetAt(pane, @intCast(@max(0, pointed.sel.c0))) orelse 0);
+ const at = p.tagOffsetAt(pane, @intCast(@max(0, pointed.sel.r0)), @intCast(@max(0, pointed.sel.c0))) orelse modal.Cursor{ .row = 0, .col = 0 };
+ pane.tag.cur_row = @intCast(at.row);
+ pane.tag.cur_col = @intCast(at.col);
}
p.drag = .{ .select = .{
.id = id,
@@ -524,9 +528,9 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
const pane = p.panes[id] orelse return;
// the tag row and the body's first, exactly as the select
// arm above derives them
- const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
- const body_y = p.bodyTop(r);
- const on_tag = mrow >= tag_y and mrow < tag_y + BOX_H;
+ const tag_y = p.tagTop(pane, r);
+ const body_y = p.bodyTop(pane, r);
+ const on_tag = mrow >= tag_y and mrow < tag_y + pane.tag_rows;
if (mcol < r.x + config.GUTTER and !on_tag) {
const local = @as(i32, mrow) - @as(i32, body_y);
if (m.button == config.look_button) p.active = id;
@@ -605,30 +609,37 @@ pub fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) vo
if (p.panes[s.id]) |pane| {
const r = p.rects[s.id];
const b = @intFromEnum(s.button);
- if (pane.body_rows > 0 and pane.sel[b].r0 >= BOX_H) if (body_hit) |supplied| {
+ const tag_rows: i32 = pane.tag_rows;
+ if (pane.body_rows > 0 and pane.sel[b].r0 >= tag_rows) if (body_hit) |supplied| {
const mapped_hit = bodyHitForPane(p, s.id, supplied, true) orelse return;
pane.sel[b].c1 = @as(i32, mapped_hit.col) - config.GUTTER;
- pane.sel[b].r1 = @as(i32, mapped_hit.row) + BOX_H;
+ pane.sel[b].r1 = @as(i32, mapped_hit.row) + tag_rows;
return;
};
- if (pane.sel[b].r0 < BOX_H) if (tagColumn(p, .pane, s.id, p.pointer_tag_hit, true)) |value| {
+ // A sweep that began on the tag stays on it, on the tag line
+ // nearest the pointer.
+ const tag_y = p.tagTop(pane, r);
+ const tag_line: u16 = @intCast(std.math.clamp(@as(i32, mrow) - @as(i32, tag_y), 0, tag_rows - 1));
+ if (pane.sel[b].r0 < tag_rows) if (tagColumn(p, .pane, s.id, p.pointer_tag_hit, true, tag_line)) |value| {
pane.sel[b].c1 = @as(i32, value) + pane.tag_scroll;
- pane.sel[b].r1 = 0;
+ pane.sel[b].r1 = tag_line;
return;
};
pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
- // Sel row BOX_H is the body's FIRST row, the same thing
+ // Sel row tag_rows is the body's FIRST row, the same thing
// pointerTextSelection means by it -- so the drag has to
// measure from the body's top, notice bands included, or a
// selection made while a message is up lands rows away
// from the pointer.
- const body_y = p.bodyTop(r);
- const body_h = r.h -| BOX_H;
- pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0)
+ const body_y = p.bodyTop(pane, r);
+ const body_h = r.h -| pane.tag_rows;
+ pane.sel[b].r1 = if (pane.sel[b].r0 < tag_rows)
+ tag_line
+ else if (body_h == 0)
0
else
- @as(i32, @min(@max(mrow, body_y), body_y + body_h - 1)) - @as(i32, body_y) + @as(i32, BOX_H);
- if (pane.sel[b].r1 < BOX_H) pane.sel[b].c1 += @as(i32, pane.tag_scroll) - TAG_GAP;
+ @as(i32, @min(@max(mrow, body_y), body_y + body_h - 1)) - @as(i32, body_y) + tag_rows;
+ if (pane.sel[b].r1 < tag_rows) pane.sel[b].c1 += @as(i32, pane.tag_scroll) - TAG_GAP;
if (comptime pdf_enabled)
panes.Pdf.pointerUpdate(&s.pdf, p, pane, mcol, mrow);
}
@@ -668,8 +679,10 @@ fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand:
if (on_tag) {
const tag = p.tagText(p.scratch.allocator(), pane) catch return .{};
const sel = operand.expanded orelse operand.preview orelse return .{};
- const lead = @min(sel.c0, sel.c1);
- const at = panes.File.rawAtDisplay(tag, @intCast(@max(0, lead)));
+ 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 = @intCast(@max(0, lead.r0));
+ const start = modal.lineStartOffset(tag, row);
+ const at = start + panes.File.rawAtDisplay(modal.lineSlice(tag, 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)) };
}
@@ -692,7 +705,7 @@ fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand:
else if (operand.expanded orelse operand.preview) |sel| lead: {
// A selection's leading corner in reading order, converted the way
// `pointerOperand` converts the click itself.
- const top = @min(sel.r0, sel.r1) - @as(i32, BOX_H);
+ const top = @min(sel.r0, sel.r1) - @as(i32, pane.tag_rows);
if (top < 0) break :lead null;
const w = pane.wrapAt(top);
const col = edit.paneByteAtDisplay(p, &pane.body, w.line, w.at, @min(sel.c0, sel.c1) - panes.File.gutterWidth(pane));
@@ -825,19 +838,19 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
const sl = pane.sel[sel_slot];
const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1;
if (!dragged) pane.sel[sel_slot].state = .none;
- if (sl.r0 < BOX_H) {
+ if (sl.r0 < pane.tag_rows) {
// On the tag the caret goes where the button came up. A
// click on the path, if it can be renamed, drafts a name.
- const at = p.tagOffsetAt(pane, @intCast(@max(0, sl.c1)));
- if (at) |col| {
- pane.tag.cur_row = 0;
- pane.tag.cur_col = @intCast(col);
+ const at = p.tagOffsetAt(pane, @intCast(@max(0, sl.r1)), @intCast(@max(0, sl.c1)));
+ if (at) |cursor| {
+ pane.tag.cur_row = @intCast(cursor.row);
+ pane.tag.cur_col = @intCast(cursor.col);
} else if (!dragged) p.draftName(pane, @intCast(@max(0, sl.c1)));
return;
}
if (dragged) edit.capturePointerSelection(p, pane, sel_slot) catch {};
if (dragged and pane.body.mode == .tty) mirrorTtySelection(p, pane);
- const body_vis = sl.r1 - @as(i32, BOX_H);
+ const body_vis = sl.r1 - @as(i32, pane.tag_rows);
if (body_vis >= 0 and pane.body.mode != .tty) {
const w = pane.wrapAt(body_vis);
pane.body.cur_row = w.line;
@@ -864,10 +877,10 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
// A right-click on a line NUMBER looks at that line of
// this file -- a sticky context header's number included,
// since the row map holds each header's own source line.
- if (s.button == config.look_button and clk.r0 >= BOX_H and clk.r0 == clk.r1 and
+ if (s.button == config.look_button and clk.r0 >= pane.tag_rows and clk.r0 == clk.r1 and
clk.c0 == clk.c1 and clk.c0 >= 0 and pane.body.mode != .tty)
if (pane.file) |f| if (f.output == null and clk.c0 < panes.File.gutterWidth(pane)) {
- const line = pane.wrapAt(clk.r0 - BOX_H).line;
+ const line = pane.wrapAt(clk.r0 - pane.tag_rows).line;
if (line >= 0) {
pane.sel[b].state = .none;
var buf: [48]u8 = undefined;
@@ -881,14 +894,14 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
pane.sel[b] = expanded
else if (operand.text == null)
pane.sel[b].state = .none;
- if (s.button == config.look_button and clk.r0 >= BOX_H and pane.body.mode != .tty) {
+ if (s.button == config.look_button and clk.r0 >= pane.tag_rows and pane.body.mode != .tty) {
pane.body.cur_row = operand.row;
pane.body.cur_col = operand.col;
if (edit.clampFileCursor(&pane.body)) pane.ensureCursorVisible();
}
const txt = operand.text;
const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd;
- if (clk.r0 < BOX_H and s.button == config.exec_button and pane.prompt == .name) {
+ if (clk.r0 == 0 and s.button == config.exec_button and pane.prompt == .name) {
const prefix_width = panes.File.displayWidth(p.tagPrefix(pane) catch return);
const on_name = @as(usize, @intCast(@max(0, clk.c0))) < prefix_width;
if (!p.commitTagName(pane)) return;
@@ -909,7 +922,7 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
pane.sel[b].c1 += shift;
}
dispatchPointerBuiltin(p, s.id, cmd, txt, .{
- .on_tag = clk.r0 < BOX_H,
+ .on_tag = clk.r0 < pane.tag_rows,
.operand = operand,
});
}
@@ -931,7 +944,7 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
if (cut and pv.selection_text.len > 0) edit.setYank(p, pv.selection_text);
return;
};
- if (@min(pane.sel[sel_slot].r0, pane.sel[sel_slot].r1) < BOX_H) {
+ if (@min(pane.sel[sel_slot].r0, pane.sel[sel_slot].r1) < pane.tag_rows) {
// A sweep over the tag cuts from and pastes into the tag's own text;
// the part of it over the prefix is not the tag's to cut.
const t = &pane.tag;
@@ -939,12 +952,16 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
s.chorded = true;
const sl = pane.sel[sel_slot];
pane.sel[sel_slot].state = .none;
- const from = p.tagOffsetAt(pane, @intCast(@max(0, @min(sl.c0, sl.c1)))) orelse 0;
- const to = p.tagOffsetAt(pane, @intCast(@max(0, @max(sl.c0, sl.c1)))) orelse 0;
- t.cur_row = 0;
- t.cur_col = @intCast(to);
+ const forward = sl.r0 < sl.r1 or (sl.r0 == sl.r1 and sl.c0 <= sl.c1);
+ const start = if (forward) .{ sl.r0, sl.c0 } else .{ sl.r1, sl.c1 };
+ const end = if (forward) .{ sl.r1, sl.c1 } else .{ sl.r0, sl.c0 };
+ const origin: modal.Cursor = .{ .row = 0, .col = 0 };
+ const from = p.tagOffsetAt(pane, @intCast(@max(0, start[0])), @intCast(@max(0, start[1]))) orelse origin;
+ const to = p.tagOffsetAt(pane, @intCast(@max(0, end[0])), @intCast(@max(0, end[1]))) orelse origin;
+ t.cur_row = @intCast(to.row);
+ t.cur_col = @intCast(to.col);
t.cur_pinned = true;
- t.vsel = .{ .active = from != to, .row = 0, .col = @intCast(from), .explicit = false };
+ t.vsel = .{ .active = from.row != to.row or from.col != to.col, .row = @intCast(from.row), .col = @intCast(from.col), .explicit = false };
t.msel.active = false;
t.nsel = 0;
}
@@ -965,7 +982,7 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
if (y.len == 0) return;
if (panes.Terminal.reportsMouse(pane)) {
const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999));
- const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, BOX_H) + 1, 1, 9999));
+ const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, pane.tag_rows) + 1, 1, 9999));
var mb: [32]u8 = undefined;
if (panes.Terminal.mouseFormatSgr(pane)) {
p.emitWrite(s.id, std.fmt.bufPrint(&mb, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ col, row, col, row }) catch return);
@@ -986,8 +1003,8 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
const pfx: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0;
// both ends of the sweep through the same last-frame map, so a
// chord over wrapped rows cuts the text that was under it
- const w0 = pane.wrapAt(@max(0, sl.r0 - @as(i32, BOX_H)));
- const w1 = pane.wrapAt(@max(0, sl.r1 - @as(i32, BOX_H)));
+ const w0 = pane.wrapAt(@max(0, sl.r0 - @as(i32, pane.tag_rows)));
+ const w1 = pane.wrapAt(@max(0, sl.r1 - @as(i32, pane.tag_rows)));
const row0 = w0.line;
const row1 = w1.line;
const col0 = edit.paneByteAtDisplay(p, &pane.body, w0.line, w0.at, sl.c0 - pfx);
diff --git a/src/panes.zig b/src/panes.zig
index 720f655f..6887008c 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -25,7 +25,7 @@ pub const EditText = struct { text: []u8, row0: i32 };
pub const Pane = struct {
/// One mouse selection (block-shaped), per button. c/r are text-area relative;
- /// r counts from the tag row (body starts at BOX_H).
+ /// r counts from the tag's first row (the body starts at Pane.tag_rows).
pub const Sel = struct {
state: enum { none, dragging, done } = .none,
source_id: u64 = 0,
@@ -121,6 +121,10 @@ pub const Pane = struct {
focus: Focus = .body,
/// Display columns hidden to reveal the tag caret; tag addresses stay whole.
tag_scroll: u16 = 0,
+ /// Rows the tag takes on screen: one per line of it, as many as the pane
+ /// has room for with a body row to spare. Set with the geometry in sync.
+ /// Pointer rows count from the tag's first, so a body starts at this one.
+ tag_rows: u16 = 1,
cwd: Cwd = .none,
hscroll: i32 = 0,
// Visible rows map to source lines and raw/display column origins.
diff --git a/src/pardes.zig b/src/pardes.zig
index 60c41f50..72a282c2 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -249,8 +249,13 @@ pub const TagLayer = @import("tag_layer.zig").Layer;
pub const TagHit = @import("tag_layer.zig").Hit;
pub const TagKind = @import("tag_layer.zig").Kind;
/// Panes, then columns, then the workspace bar, then each pane's notice bands.
-pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max;
+/// A tag shows at most this many lines; past them it is cut, like a line
+/// too long for its width.
+pub const MAX_TAG_ROWS = 8;
+pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max + MAX_PANES * (MAX_TAG_ROWS - 1);
pub const NOTICE_LAYER_BASE = MAX_PANES + MAX_COLS + 1;
+/// Where the layers for a pane tag's second and later lines start.
+pub const TAG_LINE_LAYER_BASE = NOTICE_LAYER_BASE + MAX_PANES * Pane.Notices.max;
pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px;
pub const MAX_COLS = 6;
const column_weight_unit = layout.column_weight_unit;
@@ -285,16 +290,17 @@ pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?layout.Tra
}
if (track) |active| {
const box = active.contentBox();
- const tag_y = if (p.settings.tag_bottom) box.y + box.h - @as(f32, @floatFromInt(BOX_H)) else box.y;
+ const rows: f32 = @floatFromInt(if (active.pane < MAX_PANES) if (p.panes[active.pane]) |pane| pane.tag_rows else BOX_H else BOX_H);
+ const tag_y = if (p.settings.tag_bottom) box.y + box.h - rows else box.y;
if (@as(f32, @floatFromInt(row)) >= tag_y and
- @as(f32, @floatFromInt(row)) < tag_y + @as(f32, @floatFromInt(BOX_H)))
+ @as(f32, @floatFromInt(row)) < tag_y + rows)
return box.x;
}
for (p.panes, 0..) |slot, id| {
- if (slot == null) continue;
+ const pane = slot orelse continue;
const r = p.rects[id];
- const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
- if (row == tag_y and col >= r.x and col < r.x + r.w) return @floatFromInt(r.x);
+ const tag_y = p.tagTop(pane, r);
+ if (row >= tag_y and row < tag_y + pane.tag_rows and col >= r.x and col < r.x + r.w) return @floatFromInt(r.x);
}
return @floatFromInt(col);
}
@@ -352,10 +358,10 @@ pub fn gridColAt(p: ?*const Pardes, x: f32, row: u16, body_w: f32, tagline_w: f3
}
};
if (p) |core| for (core.panes, 0..) |slot, id| {
- if (slot == null) continue;
+ const pane = slot orelse continue;
const r = core.rects[id];
- const tag_y = if (core.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
- if (row != tag_y or r.w == 0) continue;
+ const tag_y = core.tagTop(pane, r);
+ if (row < tag_y or row >= tag_y + pane.tag_rows or r.w == 0) continue;
const left = @as(f32, @floatFromInt(r.x)) * body;
const right = @as(f32, @floatFromInt(r.x + r.w)) * body;
if (x < left or x >= right) continue;
@@ -813,7 +819,7 @@ test "TreeContext tag style preserves message rows and its opt-out survives rest
const band = for (p.surface.tagLayers()) |*layer| {
if (layer.cols != 0 and layer.kind == .notice) break layer;
} else return error.NoNoticeBand;
- try std.testing.expectEqual(p.bodyTop(r), band.viewport.y);
+ try std.testing.expectEqual(p.bodyTop(pane, r), band.viewport.y);
try std.testing.expectEqual(p.surface.body_layers[0].viewport.y, band.viewport.y);
// Sized to its message, flush with the pane's right edge, and the text
// right aligned a cell short of that edge.
@@ -829,7 +835,7 @@ test "TreeContext tag style preserves message rows and its opt-out survives rest
_ = try p.render(p.scratch.allocator());
try std.testing.expectEqual(@as(u16, 0), p.surface.body_layers[0].rows);
try std.testing.expectEqual(@as(u16, 0), pane.body_rows);
- try std.testing.expectEqual(FontRole.body, p.surface.at(r.x + config.GUTTER, p.bodyTop(r)).style.font_role);
+ try std.testing.expectEqual(FontRole.body, p.surface.at(r.x + config.GUTTER, p.bodyTop(pane, r)).style.font_role);
try dump.dumpState(p);
const restored = try dump.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?);
defer restored.deinit();
@@ -4915,15 +4921,14 @@ pub const Pardes = struct {
pane.focus = .tag;
}
- /// Where display column `col` of the tagline (tag scroll included) lands
- /// in the tag's own text, or null when it is on the prefix.
- pub fn tagOffsetAt(p: *Pardes, pane: *Pane, col: usize) ?usize {
- const prefix = p.tagPrefix(pane) catch return null;
- const width = panes.File.displayWidth(prefix);
+ /// Where display column `col` of tag line `row` (tag scroll included)
+ /// lands in the tag's own text, or null when it is on the prefix, which
+ /// only the first line has.
+ pub fn tagOffsetAt(p: *Pardes, pane: *Pane, row: usize, col: usize) ?modal.Cursor {
+ const width = if (row == 0) panes.File.displayWidth(p.tagPrefix(pane) catch return null) else 0;
if (col < width) return null;
- const tail = curTail(pane);
- const line = tail[0 .. std.mem.indexOfScalar(u8, tail, '\n') orelse tail.len];
- return modal.graphemeStart(line, panes.File.rawAtDisplay(line, col - width));
+ const line = modal.lineSlice(curTail(pane), row);
+ return .{ .row = row, .col = modal.graphemeStart(line, panes.File.rawAtDisplay(line, col - width)) };
}
/// Start a name draft for a file that can take one, its caret at display
@@ -5822,7 +5827,7 @@ pub const Pardes = struct {
}
// A sweep over the tag is the tag's while it has the keyboard.
const swept = pane.sel[sel_slot];
- if (pane.focus == .body and swept.state == .done and @min(swept.r0, swept.r1) < BOX_H) {
+ if (pane.focus == .body and swept.state == .done and @min(swept.r0, swept.r1) < pane.tag_rows) {
pane.clearPointerSelection(sel_slot);
pane.sel[sel_slot].state = .none;
}
@@ -5836,9 +5841,14 @@ pub const Pardes = struct {
state.reply_len = 0;
};
const r = p.rects[id];
+ // The tag takes a row per line, leaving the body one row if it
+ // had any; a folded pane shows its first line only.
+ const lines = std.mem.count(u8, curTail(pane), "\n") + 1;
+ pane.tag_rows = @intCast(@min(lines, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1)));
+ if (pane.collapsed) pane.tag_rows = BOX_H;
const cols = @max(1, r.w -| config.GUTTER);
- const rows = @max(1, r.h -| BOX_H); // the tag steals the top row
- if ((cols != pane.cols or rows != pane.rows) and r.h > BOX_H) {
+ const rows = @max(1, r.h -| pane.tag_rows); // the tag steals the top rows
+ if ((cols != pane.cols or rows != pane.rows) and r.h > pane.tag_rows) {
// doc panes have no pty/emulator grid to reflow; just record
// the size so bodyText renders the right number of rows
if (pane.isTerminal()) {
@@ -5905,7 +5915,8 @@ pub const Pardes = struct {
if (pane.serial != layer.serial) return null;
}
if (layer.kind == .column and (layer.id >= p.ncol or p.col_serial[layer.id] != layer.serial)) return null;
- return layer.hitAt(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h));
+ // A taller tag has a layer per line; the point is on one of them.
+ return layer.hitAt(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)) orelse continue;
}
return null;
}
@@ -5913,8 +5924,13 @@ pub const Pardes = struct {
/// The pane's first body row: under the tag when it is on top, over it
/// when it is at the bottom. Notice bands do NOT move it -- they are an
/// overlay, drawn over the top rows rather than taking them.
- pub fn bodyTop(p: *const Pardes, r: Rect) u16 {
- return if (p.settings.tag_bottom) r.y else r.y + BOX_H;
+ pub fn bodyTop(p: *const Pardes, pane: *const Pane, r: Rect) u16 {
+ return if (p.settings.tag_bottom) r.y else r.y + pane.tag_rows;
+ }
+
+ /// The tag's first row: the pane's top, or its last rows with Tagbottom.
+ pub fn tagTop(p: *const Pardes, pane: *const Pane, r: Rect) u16 {
+ return if (p.settings.tag_bottom) r.y + r.h -| pane.tag_rows else r.y;
}
fn tagCapacity(p: *const Pardes, physical_width: u16) u16 {
@@ -6005,7 +6021,7 @@ pub const Pardes = struct {
// taken out of the body the way a tree-sitter context header is.
// The chip is as wide as its own message and no wider, so the text
// beneath the rest of the row keeps reading as the file it is.
- const first = p.bodyTop(r);
+ const first = p.bodyTop(pane, r);
var leader_buf: [16]u8 = undefined;
for (pane.notices.slice(), 0..) |kind, i| {
const row = first + @as(u16, pane.notices.row[i]);
@@ -6385,26 +6401,41 @@ pub const Pardes = struct {
const r = p.rects[id];
if (r.w <= TAG_TEXT_INSET or r.h == 0) continue;
const tag = try p.tagText(arena, pane);
- const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), panes.File.displayWidth(tag) + 1)));
- const layer = &p.surface.tag_layers[id];
- if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns);
- const storage = try arena.alloc(Cell, @as(usize, columns) + TAG_TEXT_INSET);
+ const rows = pane.tag_rows;
+ // As wide as the widest line it shows, and never narrower than
+ // its viewport.
+ var widest: usize = 0;
+ var split = std.mem.splitScalar(u8, tag, '\n');
+ for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse ""));
+ const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), widest + 1)));
+ const pitch = @as(usize, columns) + TAG_TEXT_INSET;
+ const storage = try arena.alloc(Cell, pitch * rows);
@memset(storage, .{});
- var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = 1, .cells = storage };
+ var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage };
std.mem.swap(Surface, &p.surface, &temporary);
- p.paintPaneTag(arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = 1 }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| {
+ p.paintPaneTag(arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| {
std.mem.swap(Surface, &p.surface, &temporary);
return err;
};
std.mem.swap(Surface, &p.surface, &temporary);
- @memcpy(layer.cells, storage[TAG_TEXT_INSET..]);
- layer.kind = .pane;
- layer.id = @intCast(id);
- layer.serial = pane.serial;
- layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = if (p.settings.tag_bottom) r.y + r.h - BOX_H else r.y, .w = r.w - TAG_TEXT_INSET, .h = BOX_H };
- layer.cols = columns;
- layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
- layer.cursor = if (!p.header_focus and temporary.cursor != null) .{ .col = temporary.cursor.?.x - TAG_TEXT_INSET, .bar = temporary.cursor.?.bar } else null;
+ // A layer to a line: the first in the pane's own slot, the rest
+ // in the slots kept for the lines of taller tags.
+ for (0..rows) |line| {
+ const layer = &p.surface.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1];
+ if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns);
+ @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]);
+ layer.kind = .pane;
+ layer.id = @intCast(id);
+ layer.serial = pane.serial;
+ layer.line = @intCast(line);
+ layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H };
+ layer.cols = columns;
+ layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
+ layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor|
+ (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null)
+ else
+ null;
+ }
}
// With the workspace tag row handed to native chrome there is no
// workspace layer to render; the menu bar carries its commands.
@@ -6422,8 +6453,8 @@ pub const Pardes = struct {
for (p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
const r = p.rects[id];
- if (r.w <= TAG_TEXT_INSET or r.h <= BOX_H) continue;
- const first = p.bodyTop(r);
+ if (r.w <= TAG_TEXT_INSET or r.h <= pane.tag_rows) continue;
+ const first = p.bodyTop(pane, r);
var leader_buf: [16]u8 = undefined;
for (pane.notices.slice(), 0..) |kind, i| {
const text = Messages.noticeText(p, pane, i, &leader_buf);
@@ -6556,8 +6587,8 @@ pub const Pardes = struct {
const chrome = p.chromeTheme();
const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter
const tw = r.w - config.GUTTER;
- const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
- const body_y = p.bodyTop(r);
+ const tag_y = p.tagTop(pane, r);
+ const body_y = p.bodyTop(pane, r);
const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default;
// text area resets to terminal-default cells (vaxis clear semantics);
@@ -6579,7 +6610,7 @@ pub const Pardes = struct {
// Grips keep physical grid dimensions, independently of tag text.
.font_role = .tagline,
};
- s.fill(r.x, tag_y, config.GUTTER, BOX_H, box_style);
+ s.fill(r.x, tag_y, config.GUTTER, pane.tag_rows, box_style);
s.set(r.x, tag_y, switch (pane.body.mode) {
.normal => config.tag_normal,
.insert => config.tag_insert,
@@ -6600,14 +6631,13 @@ pub const Pardes = struct {
// GUI's image pass, kitty's z-order), so a chip over it would be
// painted out. Text gets the overlay; a picture gives up the rows.
const shown = pane.notices.span();
- const image_h = (r.h -| BOX_H) -| shown;
+ const image_h = (r.h -| pane.tag_rows) -| shown;
panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h);
// thumbless, but the same one column as the real scrollbar below —
// that is the whole point of drawing it, and like that one it runs
// past the notice bands so the gutter has no notch in it
- const rail_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H;
- s.fill(r.x, rail_y, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
- s.fill(r.x + 1, rail_y, 1, r.h -| BOX_H, .{ .bg = pane_bg });
+ s.fill(r.x, body_y, 1, r.h -| pane.tag_rows, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ s.fill(r.x + 1, body_y, 1, r.h -| pane.tag_rows, .{ .bg = pane_bg });
return;
}
@@ -6620,8 +6650,9 @@ pub const Pardes = struct {
const chrome = p.chromeTheme();
const tx = r.x + @min(TAG_TEXT_INSET, r.w);
const tw = r.w -| TAG_TEXT_INSET;
- const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
- const body_y = p.bodyTop(r);
+ const tag_y = p.tagTop(pane, r);
+ const body_y = p.bodyTop(pane, r);
+ const rows = pane.tag_rows;
const page_rgb = th.bg orelse th.tag_bg;
// Focus is visible along the tag without changing its live text grid.
const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
@@ -6629,44 +6660,53 @@ pub const Pardes = struct {
const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline };
// Padding belongs to the same font row but keeps its default ink: a
// role must not manufacture a foreground color for blank cells.
- s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), BOX_H, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline });
- s.fill(tx, tag_y, tw, BOX_H, .{
+ s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline });
+ s.fill(tx, tag_y, tw, rows, .{
.bg = .{ .rgb = tag_bg },
.font_role = .tagline,
});
const whole = try p.tagText(arena, pane);
const tail = curTail(pane);
const prefix_len = whole.len - tail.len;
- // The tagline shows the tag's first line: its prefix and the first
- // line of its own text.
- const tag = whole[0 .. std.mem.indexOfScalar(u8, whole, '\n') orelse whole.len];
- // The keyboard's caret on it, as a byte offset: in its own text when
- // the tag has the keyboard, in the path while a name is drafted.
- const caret_at: ?usize = if (!active)
+ // A row per line: the first carries the prefix, the rest are the
+ // tag's own text alone.
+ var lines: [MAX_TAG_ROWS][]const u8 = @splat("");
+ var split = std.mem.splitScalar(u8, whole, '\n');
+ for (lines[0..rows]) |*line| line.* = split.next() orelse "";
+ // The keyboard's caret, as a row and a byte offset in that row's
+ // line: in the tag's own text when the tag has the keyboard, in the
+ // path while a name is drafted.
+ const Caret = struct { row: usize, at: usize };
+ const caret: ?Caret = if (!active)
null
else if (pane.prompt == .name)
- @min(tag.len, @as(usize, @intCast(@max(0, pane.input.cur_col))))
- else if (pane.focus == .tag and pane.tag.cur_row == 0)
- @min(tag.len, prefix_len + @as(usize, @intCast(@max(0, pane.tag.cur_col))))
- else
- null;
- if (caret_at) |at| {
- const caret = panes.File.rawDisplayCol(tag, at);
+ .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) }
+ else if (pane.focus == .tag and pane.tag.cur_row < rows) caret: {
+ const row: usize = @intCast(@max(0, pane.tag.cur_row));
+ const lead: usize = if (row == 0) prefix_len else 0;
+ break :caret .{ .row = row, .at = @min(lines[row].len, lead + @as(usize, @intCast(@max(0, pane.tag.cur_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 (caret < scroll) scroll = caret;
- if (caret >= scroll + capacity) scroll = caret - capacity + 1;
- var raw = panes.File.rawAtDisplay(tag, scroll);
- scroll = panes.File.rawDisplayCol(tag, raw);
- if (caret >= scroll + capacity) {
- raw = modal.nextGrapheme(tag, raw);
- scroll = panes.File.rawDisplayCol(tag, raw);
+ 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);
}
pane.tag_scroll = @intCast(scroll);
} else if (pane.focus != .tag) pane.tag_scroll = 0;
- const tag_start = panes.File.rawAtDisplay(tag, pane.tag_scroll);
- _ = s.print(tx, tag_y, tw, tag[tag_start..], tag_style);
+ for (lines[0..rows], 0..) |line, row| {
+ const start = 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 (tagTintRange(pane, tag, prefix_len)) |name| {
- const lo = @max(name.lo, tag_start);
+ 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;
@@ -6677,41 +6717,50 @@ pub const Pardes = struct {
}
}
if (p.look_hover_preview) |preview| {
- if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < BOX_H) {
+ if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) {
const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg));
body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null);
};
}
- // The tag's own selections, while it has the keyboard. A range of one
- // grapheme is the block cursor, not a selection.
+ // The tag's own selections, while it has the keyboard, a line at a
+ // time. A range of one grapheme is the block cursor, not a selection.
if (pane.focus == .tag) {
var ranges: [Text.max_selections]modal.Selection = undefined;
const got = pane.tag.ranges(tail, 0, &ranges);
- const shown = tag.len - prefix_len;
for (ranges[0..got.n]) |range| {
const lo = @min(range.anchor, range.head);
const hi = @max(range.anchor, range.head);
- if (modal.nextGrapheme(tail, lo) >= hi or lo >= shown) continue;
- var col = panes.File.rawDisplayCol(tag, prefix_len + lo) -| pane.tag_scroll;
- const end = panes.File.rawDisplayCol(tag, prefix_len + @min(hi, shown));
- while (col + pane.tag_scroll < end and col < tw) : (col += 1) {
- const cell = s.at(tx + @as(u16, @intCast(col)), tag_y);
- cell.default = false;
- cell.style.bg = .{ .rgb = th.sel_bg };
- cell.style.fg = .{ .rgb = th.sel_fg };
+ if (modal.nextGrapheme(tail, lo) >= hi) continue;
+ const first = modal.positionAt(tail, lo);
+ const last = modal.positionAt(tail, hi);
+ var row = first.row;
+ while (row <= last.row and row < rows) : (row += 1) {
+ const line = lines[row];
+ const lead: usize = if (row == 0) prefix_len else 0;
+ const from = lead + if (row == first.row) first.col else 0;
+ const to = @min(line.len, lead + if (row == last.row) last.col else modal.lineSlice(tail, row).len);
+ var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll;
+ const end = panes.File.rawDisplayCol(line, to);
+ const y = tag_y + @as(u16, @intCast(row));
+ while (col + pane.tag_scroll < end and col < tw) : (col += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(col)), y);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = th.sel_bg };
+ cell.style.fg = .{ .rgb = th.sel_fg };
+ }
}
}
}
// the caret: bar while typing, block in normal mode (same rule as a body)
- if (caret_at) |at| {
- const col = panes.File.rawDisplayCol(tag, at) -| pane.tag_scroll;
+ if (caret) |at| {
+ const col = panes.File.rawDisplayCol(lines[at.row], at.at) -| pane.tag_scroll;
const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode;
- if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y, .bar = mode == .insert };
+ if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y + @as(u16, @intCast(at.row)), .bar = mode == .insert };
}
for (pane.sel, 0..) |selection, button| {
if (selection.state == .none or (selection.state == .done and button != 0)) continue;
- if (@min(selection.r0, selection.r1) >= BOX_H) continue;
+ if (@min(selection.r0, selection.r1) >= rows) continue;
body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg);
}
}
@@ -6945,7 +6994,7 @@ test "a prompt chip is a band with its caret, still on the grid, and only the ch
_ = try p.render(frame.allocator());
const r = p.rects[id];
const cursor = p.surface.cursor orelse return error.MissingPromptCursor;
- const row = p.bodyTop(r);
+ const row = p.bodyTop(pane, r);
try std.testing.expectEqual(@as(u8, 1), pane.notices.len);
try std.testing.expectEqual(row, cursor.y);
try std.testing.expect(cursor.x < r.x + r.w);
@@ -7010,7 +7059,7 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro
try std.testing.expectEqualStrings("t", band.cells[caret.col - 1].grapheme());
try std.testing.expectEqualStrings("w", band.cells[caret.col].grapheme());
const grid = p.surface.cursor orelse return error.MissingPromptCursor;
- try std.testing.expectEqual(p.bodyTop(p.rects[0]), grid.y);
+ try std.testing.expectEqual(p.bodyTop(pane, p.rects[0]), grid.y);
// Submitting searches for what was typed and gives the prompt its line back.
p.update(.{ .key = .{ .cp = Key.enter } });
diff --git a/src/pdf_view.zig b/src/pdf_view.zig
index 92a05618..b2fd5866 100644
--- a/src/pdf_view.zig
+++ b/src/pdf_view.zig
@@ -2170,7 +2170,7 @@ pub fn paneViewport(core: *const pardes.Pardes, pane: *const pardes.Pane) ?Viewp
if (slot == pane) break core.rects[id];
} else return null;
const cols = rect.w -| config.GUTTER;
- const rows = rect.h -| pardes.BOX_H;
+ const rows = rect.h -| pane.tag_rows;
if (cols == 0 or rows == 0) return null;
return .{
.pixel_w = @as(u32, cols) * @as(u32, core.cell_pixels.w),
@@ -2208,8 +2208,8 @@ pub fn pageAtGridRow(core: *const pardes.Pardes, pane: *const pardes.Pane, row:
const rect = for (core.panes, 0..) |slot, id| {
if (slot == pane) break core.rects[id];
} else return null;
- const body_y = core.bodyTop(rect);
- if (row < body_y or row >= body_y + (rect.h -| pardes.BOX_H)) return null;
+ const body_y = core.bodyTop(pane, rect);
+ if (row < body_y or row >= body_y + (rect.h -| pane.tag_rows)) return null;
const local_y = @as(f64, @floatFromInt(
@as(u32, row - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2,
));
@@ -2265,7 +2265,7 @@ pub fn panePointAtPage(
if (slot == pane) break core.rects[id];
} else return null;
const body_x = @as(i64, rect.x + config.GUTTER);
- const body_y = @as(i64, core.bodyTop(rect));
+ const body_y = @as(i64, core.bodyTop(pane, rect));
const px = (@as(i64, col) - body_x) * core.cell_pixels.w + core.cell_pixels.w / 2;
const py = (@as(i64, row) - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2;
return pointAtPage(state, view, page, px, py, clamp_to_page);
@@ -2549,7 +2549,7 @@ pub fn draw(
text_width: u16,
) bool {
if (comptime !enabled) return false;
- if (!core.native_images or rect.h <= pardes.BOX_H) return false;
+ if (!core.native_images or rect.h <= pane.tag_rows) return false;
const state = &(pane.pdf orelse return false);
const view = paneViewport(core, pane) orelse return false;
const key = TintKey{ .mode = state.tint, .colors = tintColors(core) };
@@ -2598,9 +2598,9 @@ pub fn draw(
.x = text_x,
// Below the notice chips, like an image: a placed page is
// drawn after the cells and would paint a chip out.
- .y = core.bodyTop(rect) + pane.notices.span(),
+ .y = core.bodyTop(pane, rect) + pane.notices.span(),
.w = text_width,
- .h = (rect.h -| pardes.BOX_H) -| pane.notices.span(),
+ .h = (rect.h -| pane.tag_rows) -| pane.notices.span(),
.rgba = placed.rgba,
.iw = placed.width,
// The texture contains the retained band, not the full page.
@@ -2616,15 +2616,14 @@ pub fn draw(
// This frame has spent the motion used to choose its raster band.
state.scroll_travel = 0;
- const body_y = core.bodyTop(rect);
- const body_h = rect.h -| pardes.BOX_H;
+ const body_y = core.bodyTop(pane, rect);
+ const body_h = rect.h -| pane.tag_rows;
const chrome = core.chromeTheme();
const theme = core.theme();
const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default;
// The rail runs past the notice bands; only the thumb tracks the text.
- const rail_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H;
- core.surface.fill(rect.x, rail_y, 1, rect.h -| pardes.BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
- core.surface.fill(rect.x + 1, rail_y, 1, rect.h -| pardes.BOX_H, .{ .bg = pane_bg });
+ core.surface.fill(rect.x, body_y, 1, body_h, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ core.surface.fill(rect.x + 1, body_y, 1, body_h, .{ .bg = pane_bg });
const track_h: usize = body_h;
const total = @max(@as(u64, 1), state.document_height);
diff --git a/src/tag_layer.zig b/src/tag_layer.zig
index 52da297d..ac7c4ac2 100644
--- a/src/tag_layer.zig
+++ b/src/tag_layer.zig
@@ -13,6 +13,8 @@ pub const Hit = struct {
id: u16,
serial: u32,
col: u16,
+ /// Which line of a taller tag the layer draws.
+ line: u16 = 0,
pixel_x: f32,
pixel_y: f32,
metrics: pardes.RowMetrics,
@@ -21,6 +23,9 @@ pub const Layer = struct {
kind: Kind = .workspace,
id: u16 = 0,
serial: u32 = 0,
+ /// A pane's tag taller than a line is drawn a layer to a line; this is
+ /// which one, counted from its first.
+ line: u16 = 0,
viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 },
cols: u16 = 0,
bg: [3]u8 = .{ 0, 0, 0 },
@@ -54,6 +59,7 @@ pub const Layer = struct {
.kind = layer.kind,
.id = layer.id,
.serial = layer.serial,
+ .line = layer.line,
.col = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols - 1)), @floor(dx / tw))),
.pixel_x = x,
.pixel_y = y,