diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/body_layer.zig | 457 | ||||
| -rw-r--r-- | src/builtins.zig | 15 | ||||
| -rw-r--r-- | src/colors.zig | 3 | ||||
| -rw-r--r-- | src/dump.zig | 5 | ||||
| -rw-r--r-- | src/edit.zig | 14 | ||||
| -rw-r--r-- | src/exec.zig | 5 | ||||
| -rw-r--r-- | src/fs-help.txt | 2 | ||||
| -rw-r--r-- | src/fs.zig | 2 | ||||
| -rw-r--r-- | src/gui/gui.zig | 2 | ||||
| -rw-r--r-- | src/layout.zig | 9 | ||||
| -rw-r--r-- | src/look.zig | 7 | ||||
| -rw-r--r-- | src/mouse.zig | 62 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 11 | ||||
| -rw-r--r-- | src/normal.zig | 7 | ||||
| -rw-r--r-- | src/panes.zig | 2 | ||||
| -rw-r--r-- | src/pardes.zig | 1025 | ||||
| -rw-r--r-- | src/tag_layer.zig | 25 | ||||
| -rw-r--r-- | src/tagline.zig | 360 |
18 files changed, 1032 insertions, 981 deletions
diff --git a/src/body_layer.zig b/src/body_layer.zig index 2b2c8a74..ab000795 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -2,7 +2,8 @@ //! outnumber its physical grid rows: hosts place the compact declaration rows //! first and clip the remaining body rows. After the layer types come the //! functions that draw a body into the surface: its text, colours, selections -//! and cursor. +//! and cursor; then the tags drawn beside it, a pane's and the column and +//! workspace ones, each line a tag layer of its own. const std = @import("std"); const pardes = @import("pardes.zig"); const normal = @import("normal.zig"); @@ -24,6 +25,20 @@ const BOX_H = pardes.BOX_H; const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; const Rect = layout.Rect; const Pardes = pardes.Pardes; +const tagline = @import("tagline.zig"); +const Messages = @import("Messages.zig"); +const Text = panes.Text; +const MAX_PANES = pardes.MAX_PANES; +const TagKind = @import("tag_layer.zig").Kind; +const MAX_TAG_ROWS = pardes.MAX_TAG_ROWS; +const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE; +const TAG_LINE_LAYER_BASE = pardes.TAG_LINE_LAYER_BASE; +const HEADER_LINE_LAYER_BASE = pardes.HEADER_LINE_LAYER_BASE; +const MAX_COLS = pardes.MAX_COLS; +const TOPBAR_H = pardes.TOPBAR_H; +const COLBAR_H = pardes.COLBAR_H; +const TAG_GAP = pardes.TAG_GAP; +const wordBoundsAtCol = pardes.wordBoundsAtCol; pub const Metrics = struct { body_w: u16 = 1, @@ -540,3 +555,443 @@ pub fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 { if (pane.file) |*f| return panes.File.bodyText(arena, pane, f, p.settings.wrap); return panes.Terminal.bodyText(arena, pane); } + +/// The workspace and column tags as the grid shows them: their text, the +/// word under the pointer and, in the one being edited, its selection and +/// caret. Their tag layers are drawn by renderTagLayers. +pub fn paintHeaders(p: *Pardes) !void { + const s = &p.surface; + const chrome = p.chromeTheme(); + const th = p.theme(); + // global tagbar: full width, top rows — unless a native shell has + // taken its commands over, in which case the rows are not ours to draw + tagline.revealHeader(p); + const global = tagline.topbar(p); + const global_rows = p.topBarHeight(); + const global_scroll = if (p.header_focus and p.header_column == null) p.header_scroll else 0; + if (p.settings.workspace_tag) { + s.fill(0, 0, s.cols, global_rows, .{ + .bg = .{ .rgb = chrome.tag_bg }, + .font_role = .tagline, + }); + for (0..global_rows) |row| { + const line = modal.lineSlice(global, row); + _ = s.print(0, @intCast(row), s.cols, line[panes.File.rawAtDisplay(line, global_scroll)..], .{ + .fg = .{ .rgb = chrome.tag_fg }, + .bg = .{ .rgb = chrome.tag_bg }, + .font_role = .tagline, + }); + } + } + if (p.pointer_inside and p.hover_row < global_rows) { + const bar = modal.lineSlice(global, p.hover_row); + if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| { + var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll; + const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll; + while (col < hi and col < s.cols) : (col += 1) { + const cell = s.at(@intCast(col), p.hover_row); + cell.default = false; + cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.fg = .{ .rgb = th.sel_fg }; + } + } + } + const column_rows = p.columnBarHeight(); + if (column_rows > 0) for (0..p.ncol) |c| { + const grip = @min(config.GUTTER, p.col_w[c]); + const inset = @min(TAG_TEXT_INSET, p.col_w[c]); + const x = p.col_x[c] + inset; + const w = p.col_w[c] - inset; + const y0 = global_rows; + if (p.col_w[c] == 0 or p.col_x[c] >= s.cols) continue; + const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false; + const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; + // A grip is muted until it is grabbed; then it lights, as a pane's + // box does for the active pane, so the drag shows what it holds. + const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c; + const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim; + const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10; + const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 }; + const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline }; + s.fill(p.col_x[c], y0, grip, column_rows, grip_style); + s.fill(p.col_x[c] + grip, y0, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + + s.fill(x, y0, w, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + const bar_scroll = if (p.header_focus and p.header_column == c) p.header_scroll else 0; + const tag = tagline.columnTag(p, c); + for (0..column_rows) |row| { + const line = modal.lineSlice(tag, row); + _ = s.print(x, y0 + @as(u16, @intCast(row)), w, line[panes.File.rawAtDisplay(line, bar_scroll)..], .{ + .fg = .{ .rgb = fg }, + .bg = .{ .rgb = bg }, + .font_role = .tagline, + }); + } + // A carried column's pointer is aiming at a place, not a word. + if (p.pointer_inside and p.drag != .column_move and p.hover_row >= y0 and p.hover_row < y0 + column_rows and p.hover_col >= x and p.hover_col < x + w) { + const bar = modal.lineSlice(tag, p.hover_row - y0); + if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| { + var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll; + const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll; + while (col < hi and col < w) : (col += 1) { + const cell = s.at(x + @as(u16, @intCast(col)), p.hover_row); + cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.fg = .{ .rgb = th.sel_fg }; + } + } + } + }; + if (tagline.focusedHeader(p)) |t| { + const text = try edit.flatSurface(p, t); + const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; + const w = if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else s.cols; + const y: u16 = if (p.header_column != null) global_rows else 0; + const rows = if (p.header_column != null) column_rows else global_rows; + for (0..rows) |row| { + const bar = modal.lineSlice(text, row); + const span = tagline.headerSelection(t, text, row) orelse continue; + var col = panes.File.rawDisplayCol(bar, span.lo) -| p.header_scroll; + const hi = panes.File.rawDisplayCol(bar, span.hi) -| p.header_scroll; + while (col < hi and col < w) : (col += 1) { + const cell = s.at(x + @as(u16, @intCast(col)), y + @as(u16, @intCast(row))); + cell.style.fg = .{ .rgb = th.sel_fg }; + cell.style.bg = .{ .rgb = th.sel_bg }; + } + } + if (t.cur_row >= 0 and t.cur_row < rows) { + const bar = modal.lineSlice(text, @intCast(t.cur_row)); + const caret = panes.File.rawDisplayCol(bar, @min(@as(usize, @intCast(@max(0, t.cur_col))), bar.len)) -| p.header_scroll; + if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y + @as(u16, @intCast(t.cur_row)), .bar = t.mode == .insert }; + } + } +} + +fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } { + if (pane.isTerminal()) { + var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n"); + while (words.next()) |word| { + if (!std.mem.eql(u8, word, "Tty")) continue; + const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr); + return .{ .lo = lo, .hi = lo + word.len }; + } + return null; + } + var start: usize = 0; + const name = if (pane.file) |file| + if (pane.prompt == .name) pane.input.own orelse file.path else file.path + else if (pane.image) |state| blk: { + // Image tags put renderer controls before the path. + start = prefix_len -| state.path.len; + break :blk state.path; + } else if (comptime pdf_enabled) blk: { + break :blk if (pane.pdf) |state| state.path else return null; + } else return null; + const basename = if (std.mem.lastIndexOfScalar(u8, name, '/')) |slash| slash + 1 else 0; + if (basename == name.len) return null; + return .{ .lo = start + basename, .hi = start + name.len }; +} + +pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { + const chrome = p.chromeTheme(); + 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 == 0) continue; + const tag = try tagline.tagText(p, arena, pane); + 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 = rows, .cells = storage }; + std.mem.swap(Surface, &p.surface, &temporary); + paintPaneTag(p, 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); + // 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. + if (p.settings.workspace_tag) { + for (0..p.topBarHeight()) |line| { + const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; + try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = 0, .y = @intCast(line), .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line), chrome.tag_bg, chrome.tag_fg); + } + } + // Each notice is its own tagline-shaped chip over the top of the body. + // Going through the same layer machinery the pane and column tags use + // is what gives it the tagline's height, its small-font metrics, the + // band offset and the border -- none of which a body-grid row can have + // just by wearing a tagline font. The geometry is the one the grid + // pass already chose and recorded, so the two cannot disagree and the + // GUI's "skip the cells a tag layer covers" leaves no stub behind. + // A prompt is one of them too: left on the grid, a pixel shell drew + // its tagline glyphs one to a BODY cell, spaced out like a banner. + 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 <= 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); + if (text.len == 0) continue; + const cx = pane.notices.left[i]; + if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue; + const chip = r.x + r.w - cx; + // Right aligned inside the chip, a blank cell short of its + // edge: the same one the grid pass leaves for a prompt caret. + // Wider than the chip, the text loses its head, as on the grid. + const room = p.tagCapacity(chip) -| 1; + const shown = panes.File.displayWidth(text); + var kept = text; + if (shown > room) { + var start = panes.File.rawAtDisplay(text, shown - room); + if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1); + kept = text[start..]; + } + const pad = room -| panes.File.displayWidth(kept); + const line = try arena.alloc(u8, pad + kept.len); + @memset(line[0..pad], ' '); + @memcpy(line[pad..], kept); + // A pixel shell slides the message in as it fades up, and + // fades it on the way out. + const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{}; + const fade = motion.alpha; + const page = p.theme().bg orelse chrome.tag_bg; + const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; + try renderHeaderLayer(p, arena, layer_index, .notice, @intCast(id), 0, .{ + .x = cx, + .y = first + @as(u16, pane.notices.row[i]), + .w = chip, + .h = BOX_H, + }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade)); + p.surface.tag_layers[layer_index].slide = motion.slide; + p.surface.tag_layers[layer_index].fade = fade; + // The caret rides the band, where the prompt's glyphs are, + // at the same place in the text the grid pass puts it. + if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; + const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); + const dropped = shown - panes.File.displayWidth(kept); + if (typed < dropped) continue; + p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert }; + } + } + if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { + if (p.col_w[column] == 0) continue; + const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false; + for (0..p.columnBarHeight()) |line| { + const index = if (line == 0) MAX_PANES + column else HEADER_LINE_LAYER_BASE + column * (MAX_TAG_ROWS - 1) + line - 1; + try renderHeaderLayer(p, arena, index, .column, @intCast(column), @intCast(line), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight() + @as(u16, @intCast(line)), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, modal.lineSlice(tagline.columnTag(p, column), line), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); + } + }; +} + +/// One row of a header or a notice band as a tag layer: `text` is line +/// `line` of what it shows. +fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { + _ = arena; + const layer = &p.surface.tag_layers[index]; + if (viewport.w == 0) { + layer.cols = 0; + return; + } + const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1))); + if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); + layer.kind = kind; + layer.id = id; + layer.line = line; + layer.serial = if (kind == .column) p.col_serial[id] else 0; + layer.viewport = viewport; + layer.cols = columns; + layer.bg = bg; + layer.cursor = null; + layer.slide = 0; + layer.fade = 1; + var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; + // A notice band carries a PANE id, not a column index, so it must not + // answer to the column being edited -- that would scroll and select + // its text with the column tag's offsets and give it a second caret. + const editing: ?*Text = if (p.header_focus and switch (kind) { + .workspace => p.header_column == null, + .column => p.header_column == id, + .pane, .notice => false, + }) tagline.focusedHeader(p) else null; + const scroll = if (editing != null) p.header_scroll else 0; + surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); + if (editing) |t| { + if (tagline.headerSelection(t, try edit.flatSurface(p, t), line)) |span| { + var col = panes.File.rawDisplayCol(text, span.lo) -| scroll; + const end = panes.File.rawDisplayCol(text, span.hi) -| scroll; + while (col < end and col < columns) : (col += 1) { + surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; + } + } + const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; + if (t.cur_row == line and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; + } else if (p.pointer_inside) { + const tag_hit = p.pointer_tag_hit; + const col: ?usize = if (tag_hit) |pointer| + (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null) + else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) + p.hover_col - viewport.x + else + null; + if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| { + var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll; + const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll; + while (cell < end and cell < columns) : (cell += 1) { + surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; + } + }; + } +} + +pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { + const s = &p.surface; + const th = p.theme(); + const chrome = p.chromeTheme(); + const tx = r.x + @min(TAG_TEXT_INSET, r.w); + const tw = r.w -| TAG_TEXT_INSET; + 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; + const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; + 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), 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 tagline.tagText(p, arena, pane); + const tail = tagline.curTail(pane); + const prefix_len = whole.len - tail.len; + // 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) + .{ .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 (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; + 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, panes.File.rawAtDisplay(tag, pane.tag_scroll)); + const hi = @min(name.hi, tag.len); + if (lo < hi) { + var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll; + const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll; + const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg; + while (col < end and col < tw) : (col += 1) + s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg }; + } + } + 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) < rows) { + const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); + 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 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); + 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) 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| { + 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 + @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) >= rows) continue; + paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); + } +} diff --git a/src/builtins.zig b/src/builtins.zig index 74645376..e9f29102 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -1,6 +1,7 @@ //! Command structs and runtime settings form the builtin registry; bindings live in config.zig. const std = @import("std"); const pardes = @import("pardes.zig"); +const tagline = @import("tagline.zig"); const dump = @import("dump.zig"); const exec = @import("exec.zig"); const look = @import("look.zig"); @@ -742,7 +743,7 @@ pub const Grep = struct { pub const Left = struct { pub fn run(c: Ctx) void { if (c.p.header_focus) { - if (c.p.header_column) |column| if (column > 0) c.p.enterHeader(column - 1); + if (c.p.header_column) |column| if (column > 0) tagline.enterHeader(c.p, column - 1); return; } _ = layout.focusDir(c.p, c.id, .left); @@ -754,10 +755,10 @@ pub const Down = struct { if (c.p.header_focus) { const column = layout.findPane(c.p, c.id); if (c.p.header_column == null and c.p.columnBarHeight() > 0) - return c.p.enterHeader(if (column) |at| at.col else 0); + return tagline.enterHeader(c.p, if (column) |at| at.col else 0); // Onto the top pane of the column the header stands over. const col = c.p.header_column orelse if (column) |at| at.col else return; - c.p.exitHeader(); + tagline.exitHeader(c.p); if (col < c.p.ncol and c.p.col_n[col] > 0) c.p.active = c.p.col_panes[col][0]; return; } @@ -768,20 +769,20 @@ pub const Down = struct { pub const Up = struct { pub fn run(c: Ctx) void { if (c.p.header_focus) { - if (c.p.header_column != null and c.p.topBarHeight() > 0) c.p.enterHeader(null); + if (c.p.header_column != null and c.p.topBarHeight() > 0) tagline.enterHeader(c.p, null); return; } if (layout.focusDir(c.p, c.id, .up)) return; if (c.p.columnBarHeight() > 0) { - if (layout.findPane(c.p, c.id)) |at| c.p.enterHeader(at.col); - } else if (c.p.topBarHeight() > 0) c.p.enterHeader(null); + if (layout.findPane(c.p, c.id)) |at| tagline.enterHeader(c.p, at.col); + } else if (c.p.topBarHeight() > 0) tagline.enterHeader(c.p, null); } }; pub const Right = struct { pub fn run(c: Ctx) void { if (c.p.header_focus) { - if (c.p.header_column) |column| if (column + 1 < c.p.ncol) c.p.enterHeader(column + 1); + if (c.p.header_column) |column| if (column + 1 < c.p.ncol) tagline.enterHeader(c.p, column + 1); return; } _ = layout.focusDir(c.p, c.id, .right); diff --git a/src/colors.zig b/src/colors.zig index f92f67b1..bb366fc0 100644 --- a/src/colors.zig +++ b/src/colors.zig @@ -1,6 +1,7 @@ //! Colour themes: the Theme type, the curated and generated ring, chrome //! colours and their animation, theme files, and switching themes at runtime. const pardes = @import("pardes.zig"); +const tagline = @import("tagline.zig"); const panes = @import("panes.zig"); const std = @import("std"); const layout = @import("layout.zig"); @@ -602,7 +603,7 @@ test "line numbers stay quiet without a current-line tag background" { const moved = try p.render(p.scratch.allocator()); try std.testing.expect(!moved.at(x, y).style.bold); try std.testing.expect(moved.at(x, y + 1).style.bold); - p.enterTag(pane); + tagline.enterTag(p, pane); const tag_edit = try p.render(p.scratch.allocator()); try std.testing.expect(!tag_edit.at(x, y + 1).style.bold); } diff --git a/src/dump.zig b/src/dump.zig index aeb25a90..508e7300 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -7,6 +7,7 @@ const limits = @import("memory.zig").limits; // diagnostic is the only thing that says WHICH field went bad. const log = std.log.scoped(.dump); const pardes = @import("pardes.zig"); +const tagline = @import("tagline.zig"); const layout = @import("layout.zig"); const filesystem = @import("fs.zig"); const ctlfs = @import("ninep/tree.zig"); @@ -603,7 +604,7 @@ pub fn dumpState(p: *Pardes) !void { for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; slot_to_pane[id] = panes_len; - const tag = try p.tagText(arena, pane); + const tag = try tagline.tagText(p, arena, pane); const body = try body_layer.bodyText(p, arena, pane); const scroll: usize = @intCast(@max(0, pane.scroll())); var dp: Pane = if (pane.file) |*f| try pardes.panes.File.dumpPane( @@ -784,7 +785,7 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo break :restore_image try pardes.panes.Image.restore(p, i, src); }, }; - p.restoreDumpTail(pane, src); + tagline.restoreDumpTail(p, pane, src); pane.vweight = src.vweight; pane.collapsed = src.collapsed; } diff --git a/src/edit.zig b/src/edit.zig index 36544d6b..edc86dd2 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -3,6 +3,7 @@ //! operations, replace, case, join, indent, comments, numbers, textobjects //! and surround, undo, yank and paste, and the pointer selections as text. const pardes = @import("pardes.zig"); +const tagline = @import("tagline.zig"); const mouse = @import("mouse.zig"); const normal = @import("normal.zig"); const std = @import("std"); @@ -15,7 +16,8 @@ const lsp = @import("lsp/lsp.zig"); const platform = pardes.platform; const Pane = panes.Pane; const Text = panes.Text; -const TagHit = @import("tag_layer.zig").Hit; +const tag_layer = @import("tag_layer.zig"); +const TagHit = tag_layer.Hit; const BOX_H = pardes.BOX_H; const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; const Mouse = pardes.Mouse; @@ -72,7 +74,7 @@ pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) voi } pub fn applyPaste(p: *Pardes, bytes: []const u8) void { - if (p.focusedHeader()) |t| { + if (tagline.focusedHeader(p)) |t| { p.clip_pending = null; return pasteInto(p, t, bytes); } @@ -132,7 +134,7 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { /// (the tag's lines first, as many as it shows) pub fn paneText(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); - const tag = try p.tagText(arena, pane); + const tag = try tagline.tagText(p, 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) }); @@ -353,9 +355,9 @@ pub fn pinPaneCursor(p: *Pardes, t: *Text) void { pub fn flatSurface(p: *Pardes, t: *Text) ![]const u8 { switch (t.what) { .body => {}, - .tag => return Pardes.curTail(t.pane()), + .tag => return tagline.curTail(t.pane()), .input => return t.own orelse "", - .column, .workspace => return t.own orelse p.headerDefault(t.what), + .column, .workspace => return t.own orelse tagline.headerDefault(p, t.what), } const pane = t.pane(); if (pane.file) |f| return f.content; @@ -1617,7 +1619,7 @@ pub fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16, bod 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, row - tag_y) else null; + const tag_col = if (on_tag) tag_layer.columnAt(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, 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); diff --git a/src/exec.zig b/src/exec.zig index a673e9e7..a0e3d47f 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -3,6 +3,7 @@ //! chord hands over, the terminals commands run in, placing a document, and //! saving a pane. const pardes = @import("pardes.zig"); +const tagline = @import("tagline.zig"); const std = @import("std"); const layout = @import("layout.zig"); const modal = @import("modal.zig"); @@ -352,7 +353,7 @@ pub fn replaceStillborn(p: *Pardes) void { const out = file.output orelse return; if (out.from != .cmd or out.from.cmd != .New) return; if (file.revision != 0 or file.content.len != 0) return; - if (old.tag.own) |own| if (!std.mem.eql(u8, own, Pardes.defaultTail(old))) return; + if (old.tag.own) |own| if (!std.mem.eql(u8, own, tagline.defaultTail(old))) return; const was_active = p.active == old_id; layout.compute(p); // the joiner may have no rect yet; absorb snaps to rows p.removePane(old_id, new_id) catch |err| return p.reportError(old_id, "close", err); @@ -490,7 +491,7 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[] const tagline_metrics_changed = setting.action == .tagline_size and p.settings.font.tagline_percent != previous_tagline_percent; if (p.settings.column_tags != previous_column_tags) { - if (p.header_column != null) p.exitHeader(); + if (p.header_column != null) tagline.exitHeader(p); } if (transition_changed or tagline_metrics_changed or p.settings.column_tags != previous_column_tags) { p.abandonPanelAnimations(); diff --git a/src/fs-help.txt b/src/fs-help.txt index 4eecad41..efdecf46 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -39,7 +39,7 @@ Pitfalls, one each: addr, dot and limit read the same pair of offsets they take, so cp between them works. dirty, mark and scroll read "0" or "1" and take "0" or "1"; truncating limit lifts it. Writing body appends; open it with truncation to replace; writes are whole UTF-8 sequences. - Truncating tag clears the part you may edit; truncating dot or addr empties it. + tag reads path++text; writes append to the text (newlines too); truncating clears it, and dot or addr. A terminal's body is a history snapshot frozen per open; pty/data is the live stream. A failing command is reported in the editor and in log, not as a write error; a bad line fails the write. pane/<n>/ctl: acme's status line; takes get (reload), lock/unlock on a held fd (exec 3>ctl; echo lock >&3). @@ -36,10 +36,12 @@ else const source_files = [_]Source{ .{ .path = "src/pardes.zig", .contents = @embedFile("pardes.zig") }, .{ .path = "src/panes.zig", .contents = @embedFile("panes.zig") }, + .{ .path = "src/Text.zig", .contents = @embedFile("Text.zig") }, .{ .path = "src/surface.zig", .contents = @embedFile("surface.zig") }, .{ .path = "src/colors.zig", .contents = @embedFile("colors.zig") }, .{ .path = "src/Messages.zig", .contents = @embedFile("Messages.zig") }, .{ .path = "src/body_layer.zig", .contents = @embedFile("body_layer.zig") }, + .{ .path = "src/tagline.zig", .contents = @embedFile("tagline.zig") }, .{ .path = "src/edit.zig", .contents = @embedFile("edit.zig") }, .{ .path = "src/normal.zig", .contents = @embedFile("normal.zig") }, .{ .path = "src/mouse.zig", .contents = @embedFile("mouse.zig") }, diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 7bf45676..415b21e8 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -7417,7 +7417,7 @@ test "pet overlay stays beyond workspace text and vanishes while its tag is edit try std.testing.expect(x >= 20 * 8 - 0.01); try std.testing.expect(y >= 0 and y < 20); } - core.enterHeader(null); + pardes.tagline.enterHeader(core, null); builder.len = 0; appendWorkspacePet(&g, core, &surface, &builder); try std.testing.expectEqual(@as(usize, 0), builder.len); diff --git a/src/layout.zig b/src/layout.zig index 54b89081..8e44f1d9 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -1,5 +1,6 @@ const std = @import("std"); const pardes = @import("pardes.zig"); +const tagline = @import("tagline.zig"); const dump = @import("dump.zig"); const config = @import("config.zig"); const panes = @import("panes.zig"); @@ -934,7 +935,7 @@ pub fn removePane(p: *Pardes, id: usize) void { while (k + 1 < p.col_n[c]) : (k += 1) p.col_panes[c][k] = p.col_panes[c][k + 1]; p.col_n[c] -= 1; if (p.col_n[c] == 0) { - p.exitHeader(); + tagline.exitHeader(p); if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c]; p.col_tags[c].deinit(p.gpa); var j = c; @@ -955,7 +956,7 @@ pub fn joinCol(p: *Pardes) void { const f = findPane(p, p.active) orelse return; const c = f.col; if (c + 1 >= p.ncol) return; - p.exitHeader(); + tagline.exitHeader(p); const dst = c + 1; p.col_weight[dst] +|= p.col_weight[c]; for (0..p.col_n[c]) |k| p.col_panes[dst][p.col_n[dst] + k] = p.col_panes[c][k]; @@ -1010,7 +1011,7 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { const new_weight = old_weight - source_weight; p.col_weight[source_col] = source_weight; const c = source_col + @intFromBool(!before); - p.exitHeader(); + tagline.exitHeader(p); var j = p.ncol; while (j > c) : (j -= 1) { p.col_panes[j] = p.col_panes[j - 1]; @@ -2262,7 +2263,7 @@ test "column reorder moves only crossed state and preserves weights" { try std.testing.expectEqual(original_weights[2], p.col_weight[1]); try std.testing.expectEqual(@as(u32, 11), p.col_serial[3]); try std.testing.expectEqual(@as(?usize, 3), p.header_column); - try std.testing.expectEqual(@as(i32, 21), p.focusedHeader().?.cur_col); + try std.testing.expectEqual(@as(i32, 21), tagline.focusedHeader(p).?.cur_col); try std.testing.expectEqual(@as(usize, 9), p.header_scroll); try std.testing.expectEqual(outer_x, p.col_x[0]); try std.testing.expectEqual(outer_w, p.col_w[0]); diff --git a/src/look.zig b/src/look.zig index 8aa2c0a1..cbdf0bea 100644 --- a/src/look.zig +++ b/src/look.zig @@ -19,7 +19,8 @@ const Color = @import("surface.zig").Color; const pdf = panes.Pdf.pdf; const Pane = panes.Pane; const MAX_PANES = pardes.MAX_PANES; -const TagHit = @import("tag_layer.zig").Hit; +const tag_layer = @import("tag_layer.zig"); +const TagHit = tag_layer.Hit; const BOX_H = pardes.BOX_H; const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; const pathUnder = pardes.pathUnder; @@ -878,9 +879,9 @@ fn noteLookHover(p: *Pardes, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_h if (comptime pdf_enabled) if (p.pdf_hover_preview) |shown| if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return; if (p.look_hover_preview) |shown| - if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and mouse.sameBodyCell(shown.body_hit, body_hit) and mouse.sameTagCell(shown.tag_hit, tag_hit)) return; + if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and mouse.sameBodyCell(shown.body_hit, body_hit) and tag_layer.sameCell(shown.tag_hit, tag_hit)) return; if (p.look_hover_wait) |waiting| - if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and mouse.sameBodyCell(waiting.body_hit, body_hit) and mouse.sameTagCell(waiting.tag_hit, tag_hit)) return; + if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and mouse.sameBodyCell(waiting.body_hit, body_hit) and tag_layer.sameCell(waiting.tag_hit, tag_hit)) return; cancelLookHover(p); p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial, .body_hit = body_hit, .tag_hit = tag_hit }; diff --git a/src/mouse.zig b/src/mouse.zig index 4b624dca..df657ead 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -2,6 +2,7 @@ //! releases (acme's chords, border and seam drags, gestures), and the //! chrome targets a shell asks about. const pardes = @import("pardes.zig"); +const tagline = @import("tagline.zig"); const exec = @import("exec.zig"); const look = @import("look.zig"); const std = @import("std"); @@ -19,8 +20,7 @@ const pdf = panes.Pdf.pdf; const platform = pardes.platform; const Pane = panes.Pane; const MAX_PANES = pardes.MAX_PANES; -const TagHit = @import("tag_layer.zig").Hit; -const TagKind = @import("tag_layer.zig").Kind; +const tag_layer = @import("tag_layer.zig"); const TOPBAR_H = pardes.TOPBAR_H; const BOX_H = pardes.BOX_H; const TAG_GAP = pardes.TAG_GAP; @@ -176,30 +176,6 @@ pub fn bodyHitForPane(p: *const Pardes, id: usize, supplied: Mouse.BodyHit, clam return p.reprojectBodyHit(mapped_hit); } -pub fn sameTagCell(a: ?TagHit, b: ?TagHit) bool { - const first = a orelse return b == null; - const second = b orelse return false; - return first.kind == second.kind and first.id == second.id and first.serial == second.serial and first.col == second.col; -} - -/// 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 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; - const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw; - point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001)); - point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * bh; - break; - }; - return if (p.reprojectTagHit(point)) |mapped| mapped.col else null; -} - pub fn sameBodyCell(a: ?Mouse.BodyHit, b: ?Mouse.BodyHit) bool { const first = a orelse return b == null; const second = b orelse return false; @@ -294,7 +270,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { } } if (p.header_drag and m.button == config.select_button and (m.kind == .drag or m.kind == .release)) { - const t = p.focusedHeader() orelse { + const t = tagline.focusedHeader(p) orelse { p.header_drag = false; return; }; @@ -303,7 +279,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { const top = if (p.header_column != null) p.topBarHeight() else 0; const line: u16 = @intCast(@min(mrow -| top, modal.lineCount(text) -| 1, (if (p.header_column != null) p.columnBarHeight() else p.topBarHeight()) -| 1)); const bar = modal.lineSlice(text, line); - 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, line) orelse (mcol -| x)) + p.header_scroll)); + const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tag_layer.columnAt(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag, line) orelse (mcol -| x)) + p.header_scroll)); // the anchor stays where the press put it t.cur_row = line; t.cur_col = @intCast(at); @@ -338,23 +314,23 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { if (line > 0 and line >= modal.lineCount(text)) return; const bar = modal.lineSlice(text, line); 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, line) orelse (mcol -| x)) + scroll)); + const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tag_layer.columnAt(p, if (column != null) .column else .workspace, column orelse 0, p.pointer_tag_hit, p.header_drag, line) 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); + if (!focused) tagline.enterHeader(p, column); t.mode = .insert; t.cur_row = line; t.cur_col = @intCast(at); t.vsel = .{ .active = false, .row = line, .col = @intCast(at) }; p.header_drag = true; } else { - const span = if (focused) Pardes.headerSelection(t, text, line) else null; + const span = if (focused) tagline.headerSelection(t, text, line) else null; const selected = if (span) |sp| (if (at >= sp.lo and at < sp.hi) bar[sp.lo..sp.hi] else wordAtCol(bar, at)) else wordAtCol(bar, at); const word = p.scratch.allocator().dupe(u8, selected) catch return; - p.exitHeader(); + tagline.exitHeader(p); if (word.len > 0) { if (m.button == config.exec_button) { const named = std.meta.stringToEnum(Builtin, word); @@ -366,7 +342,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { return; } if (m.kind == .press) { - p.exitHeader(); + tagline.exitHeader(p); } switch (m.button) { @@ -484,7 +460,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { pane.tag.msel.active = false; pane.tag.nsel = 0; pane.tag.cur_pinned = true; - const at = p.tagOffsetAt(pane, @intCast(@max(0, pointed.sel.r0)), @intCast(@max(0, pointed.sel.c0))) orelse modal.Cursor{ .row = 0, .col = 0 }; + const at = tagline.tagOffsetAt(p, 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); } @@ -626,7 +602,7 @@ pub fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) vo // 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| { + if (pane.sel[b].r0 < tag_rows) if (tag_layer.columnAt(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 = tag_line; return; @@ -683,7 +659,7 @@ fn reportGesture( fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: exec.PointerOperand) ctlfs.pane.State.Range { const pane = p.panes[id] orelse return .{}; if (on_tag) { - const tag = p.tagText(p.scratch.allocator(), pane) catch return .{}; + const tag = tagline.tagText(p, p.scratch.allocator(), pane) catch return .{}; const sel = operand.expanded orelse operand.preview orelse return .{}; const lead = if (sel.r0 < sel.r1 or (sel.r0 == sel.r1 and sel.c0 <= sel.c1)) sel else Pane.Sel{ .r0 = sel.r1, .c0 = sel.c1 }; const row: usize = @intCast(@max(0, lead.r0)); @@ -847,11 +823,11 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { 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.r1)), @intCast(@max(0, sl.c1))); + const at = tagline.tagOffsetAt(p, 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))); + } else if (!dragged) tagline.draftName(p, pane, @intCast(@max(0, sl.c1))); return; } if (dragged) edit.capturePointerSelection(p, pane, sel_slot) catch {}; @@ -908,13 +884,13 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { const txt = operand.text; const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd; 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 prefix_width = panes.File.displayWidth(tagline.tagPrefix(p, pane) catch return); const on_name = @as(usize, @intCast(@max(0, clk.c0))) < prefix_width; - if (!p.commitTagName(pane)) return; + if (!tagline.commitTagName(p, pane)) return; // Executing a staged filename commits it; executing its // command tail uses the new identity (especially Save). if (on_name) return; - const new_width = panes.File.displayWidth(p.tagPrefix(pane) catch return); + const new_width = panes.File.displayWidth(tagline.tagPrefix(p, pane) catch return); const shift = @as(i32, @intCast(new_width)) - @as(i32, @intCast(prefix_width)); if (operand.expanded) |*sel| { sel.c0 += shift; @@ -962,8 +938,8 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { 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; + const from = tagline.tagOffsetAt(p, pane, @intCast(@max(0, start[0])), @intCast(@max(0, start[1]))) orelse origin; + const to = tagline.tagOffsetAt(p, 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; diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 282ae473..8e827fba 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -3,6 +3,7 @@ //! mark and scroll, errors, event, look and exec. Terminals add pty/ (pty.zig). const std = @import("std"); const pardes = @import("../pardes.zig"); +const tagline = @import("../tagline.zig"); const exec = @import("../exec.zig"); const look = @import("../look.zig"); const panes = @import("../panes.zig"); @@ -79,7 +80,7 @@ pub fn fileOf(pane: *Pane) ?*panes.File.State { } pub fn tagOf(p: *Pardes, pane: *Pane) []const u8 { - return p.tagText(p.scratch.allocator(), pane) catch ""; + return tagline.tagText(p, p.scratch.allocator(), pane) catch ""; } pub fn dirOf(pane: *Pane) []const u8 { @@ -422,7 +423,7 @@ fn writeTag(req: Req, pane: *Pane) Reply { if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; // The tag's own text grows by what is written, newlines and all: a tag // is a text like any other (acme's tag file appends the same way). - const had = Pardes.curTail(pane).len; + const had = tagline.curTail(pane).len; const room = limits.max_tag_tail -| had; if (room == 0) return Reply.fail(req.tag, E.NOSPC); const take = wholeUtf8(req.data[0..@min(req.data.len, room)]); @@ -746,7 +747,7 @@ test "tag reads the whole tag and writes append to the editable tail" { const before = rd(p, node, 0, 4096).bytes.len; const w = wr(p, node, " Mine"); try testing.expectEqual(@as(u32, 5), w.reply.written); - try testing.expect(std.mem.endsWith(u8, Pardes.curTail(pane), " Mine")); + try testing.expect(std.mem.endsWith(u8, tagline.curTail(pane), " Mine")); const after = rd(p, node, 0, 4096); try testing.expectEqual(before + 5, after.bytes.len); try testing.expect(std.mem.endsWith(u8, after.bytes, " Mine")); @@ -754,7 +755,7 @@ test "tag reads the whole tag and writes append to the editable tail" { // A tag is a text like the body: a newline written to it stays one, and // the tag reads back taller by it. try testing.expectEqual(@as(u32, 10), wr(p, node, "\nNext Line").reply.written); - try testing.expect(std.mem.endsWith(u8, Pardes.curTail(pane), " Mine\nNext Line")); + try testing.expect(std.mem.endsWith(u8, tagline.curTail(pane), " Mine\nNext Line")); try testing.expect(std.mem.endsWith(u8, rd(p, node, 0, 4096).bytes, " Mine\nNext Line")); if (pane.tag.own) |own| p.gpa.free(own); @@ -887,7 +888,7 @@ test "truncating the tag clears its editable tail" { const pane = p.panes[0].?; const tag = Node.of(pane.serial, .tag); _ = wr(p, tag, " Mine"); - try testing.expect(Pardes.curTail(pane).len > 0); + try testing.expect(tagline.curTail(pane).len > 0); const cleared = call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true }); try testing.expectEqual(Status.ok, cleared.reply.status); try testing.expectEqualStrings("", pane.tag.own.?); diff --git a/src/normal.zig b/src/normal.zig index 630f7689..29d6d620 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -2,6 +2,7 @@ //! modal actions, replayed across every selection, and each action is done //! to the pane; PDF panes get their own normal keys. const pardes = @import("pardes.zig"); +const tagline = @import("tagline.zig"); const exec = @import("exec.zig"); const look = @import("look.zig"); const std = @import("std"); @@ -556,8 +557,8 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void // keyboard between them. .command_line => switch (t.what) { .tag => pane.focus = .body, - .column, .workspace => p.exitHeader(), - .body, .input => p.enterTag(pane), + .column, .workspace => tagline.exitHeader(p), + .body, .input => tagline.enterTag(p, pane), }, .pipe_selection => |how| return p.startPrompt(pane, .{ .pipe = how }), .search => return p.startPrompt(pane, .{ .search = config.search_marker }), @@ -600,7 +601,7 @@ pub fn handlePdfNormal(p: *Pardes, pane: *Pane, key: Key) void { p.leader_on = true; p.leader_n = 0; }, - .command_line => p.enterTag(pane), + .command_line => tagline.enterTag(p, pane), .search => p.startPrompt(pane, .{ .search = config.search_marker }), .search_forward => look.lookWalk(p, 1), .search_backward => look.lookWalk(p, -1), diff --git a/src/panes.zig b/src/panes.zig index 6887008c..3cd66eb7 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -299,7 +299,7 @@ pub const Pane = struct { /// Add `text` to the end of the tag's own text, which starts out as the /// default a pane of its kind shows. pub fn appendTag(pane: *Pane, text: []const u8) !void { - const had = pane.tag.own orelse pardes.Pardes.defaultTail(pane); + const had = pane.tag.own orelse pardes.tagline.defaultTail(pane); const grown = try std.mem.concat(pane.gpa, u8, &.{ had, text }); if (pane.tag.own) |old| pane.gpa.free(old); pane.tag.own = grown; diff --git a/src/pardes.zig b/src/pardes.zig index d07539ba..c802f0ea 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -11,6 +11,7 @@ pub const syntax = @import("syntax.zig"); pub const locations_config = @import("locations_config.zig"); const tracy = @import("tracy.zig"); pub const panes = @import("panes.zig"); +pub const tagline = @import("tagline.zig"); pub const exec = @import("exec.zig"); pub const mouse = @import("mouse.zig"); pub const normal = @import("normal.zig"); @@ -445,6 +446,7 @@ pub const Loc = struct { }; test { + _ = @import("tagline.zig"); _ = @import("exec.zig"); _ = @import("look.zig"); _ = @import("mouse.zig"); @@ -459,48 +461,9 @@ test { _ = @import("tag_layer.zig"); } -const pane_tail = " " ++ config.pane_builtins_str; -const pdf_pane_tail = " " ++ config.pdf_pane_builtins_str; -const file_pane_tail = " " ++ config.file_pane_builtins_str; -const context_pane_tail = file_pane_tail ++ " TreeContext"; -const locations_pane_tail = file_pane_tail ++ " LocationsConfig"; -const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str; -const dirty_marker = " *"; -const legacy_pane_tail = " Del"; -const legacy_file_pane_tail = " Save Del"; -// The defaults from the release before Newtty joined every tagline. Recognized -// so a dump written then upgrades instead of keeping the old word as a tail. -const prev_pane_tail = " New Del"; -const prev_file_pane_tail = " Save New Del"; -const legacy_terminal_pane_tail = " New Del Filter"; -// ...and the terminal default from the release before Save reached it, when a -// scrollback was not yet something you could write to a path. -const prev_terminal_pane_tail = " New Newtty Del Filter"; -const pre_tty_terminal_pane_tail = " Save New Newtty Del Filter"; -const pre_column_pane_tail = " New Newtty Del"; -const pre_column_file_pane_tail = " Save New Newtty Del"; -const pre_column_pdf_pane_tail = " New Newtty Del PdfSections PdfTint"; -const pre_column_terminal_pane_tail = " Save New Newtty Del Tty Filter"; -const pre_collapse_pane_tail = " Tty Del"; -const pre_collapse_file_pane_tail = " Save Tty Del"; -const pre_collapse_pdf_pane_tail = " Tty Del PdfSections PdfTint"; -const pre_collapse_terminal_pane_tail = " Save Tty Del Togglettymode Filter"; -const pre_tty_first_terminal_pane_tail = " Save Tty Del Togglettymode Filter Collapse"; -const pre_mode_terminal_pane_tail = " Tty Save Del Togglettymode Filter Collapse"; -// ...and the defaults from before the closing word moved to the end of every -// tagline, where a click cannot land on it by overshooting the one before. -const pre_close_last_pane_tail = " Tty Del Collapse"; -const pre_close_last_file_pane_tail = " Save Tty Del Collapse"; -const pre_close_last_pdf_pane_tail = " Tty Del PdfSections PdfTint Collapse"; -const pre_close_last_terminal_pane_tail = " Tty Save Del Mode Filter Collapse"; - pub const Builtin = builtins.registry.Builtin(); pub const test_api = if (@import("builtin").is_test) struct { - pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { - return p.tagText(arena, pane); - } - pub fn runBuiltin( p: *Pardes, name: []const u8, @@ -669,11 +632,11 @@ test "pane-tag Exec prefers Tty and argument builtins before shell fallback" { p.acknowledgeShell(0, "/bin/sh", false); const pane = p.panes[0].?; - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(pane)); const tag_x = p.rects[0].x + TAG_TEXT_INSET; const tag_y = p.rects[0].y; - const rendered = try p.tagText(p.scratch.allocator(), pane); + const rendered = try tagline.tagText(p, p.scratch.allocator(), pane); const new_x = tag_x + @as(u16, @intCast(std.mem.indexOf(u8, rendered, "Tty").?)) + 1; var panes_before: usize = 0; for (p.panes) |s| if (s != null) { @@ -738,7 +701,7 @@ test "Collapse builtin keeps content and restores the pane from its default tag" p.sync(); const height = p.rects[0].h; const revision = pane.file.?.revision; - for ([_][]const u8{ pane_tail, file_pane_tail, pdf_pane_tail, terminal_pane_tail }) |tail| + for ([_][]const u8{ tagline.pane_tail, tagline.file_pane_tail, tagline.pdf_pane_tail, tagline.terminal_pane_tail }) |tail| try std.testing.expect(std.mem.endsWith(u8, tail, " Collapse Del")); try std.testing.expect(p.executeBuiltinLine(0, "Collapse")); @@ -749,7 +712,7 @@ test "Collapse builtin keeps content and restores the pane from its default tag" try std.testing.expectEqual(revision, pane.file.?.revision); try std.testing.expectEqualStrings("keep this text\n", pane.file.?.content); try std.testing.expect((try p.render(p.scratch.allocator())).cursor == null); - p.enterTag(pane); + tagline.enterTag(p, pane); const tag_cursor = (try p.render(p.scratch.allocator())).cursor orelse return error.MissingTagCursor; try std.testing.expectEqual(p.rects[0].y, tag_cursor.y); pane.focus = .body; @@ -761,9 +724,9 @@ test "Collapse builtin keeps content and restores the pane from its default tag" // Unedited defaults from the preceding version gain the new command. if (pane.tag.own) |own| p.gpa.free(own); pane.tag.own = null; - p.restoreTailAt(pane, pre_collapse_file_pane_tail, "", .file); + tagline.restoreTailAt(p, pane, tagline.pre_collapse_file_pane_tail, "", .file); try std.testing.expect(pane.tag.own == null); - try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(pane)); } test "LocationsConfig command reports partial updates and survives restore" { @@ -850,12 +813,12 @@ test "TreeContext command and custom tag survive a workspace round trip" { defer p.deinit(); const pane = try p.setTestFile("pub fn main() void {}\n"); try std.testing.expect(!panes.File.supportsContext(pane)); - try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(pane)); const path = try p.gpa.dupe(u8, "/test.zig"); p.gpa.free(pane.file.?.path); pane.file.?.path = path; if (!panes.File.supportsContext(pane)) return error.SkipZigTest; - try std.testing.expectEqualStrings(context_pane_tail, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(tagline.context_pane_tail, tagline.curTail(pane)); try std.testing.expect(!pane.file.?.tree_context); try std.testing.expect(p.executeBuiltinLine(0, "TreeContext on")); try std.testing.expect(pane.file.?.tree_context); @@ -872,14 +835,14 @@ test "TreeContext command and custom tag survive a workspace round trip" { defer restored.deinit(); const saved = restored.panes[0].?; try std.testing.expect(saved.file.?.tree_context); - try std.testing.expectEqualStrings(" Save Keep Del", Pardes.curTail(saved)); + try std.testing.expectEqualStrings(" Save Keep Del", tagline.curTail(saved)); } test "Togglettymode builtin and keyboard chord share mode transitions including edited tags" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; - try std.testing.expect(std.mem.indexOf(u8, Pardes.curTail(pane), " Mode ") != null); + try std.testing.expect(std.mem.indexOf(u8, tagline.curTail(pane), " Mode ") != null); try pane.appendTag(" Togglettymode"); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); @@ -887,19 +850,19 @@ test "Togglettymode builtin and keyboard chord share mode transitions including p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); // The tag has its own mode: the body keeps tty while the tag is typed into. - p.enterTag(pane); + tagline.enterTag(p, pane); try std.testing.expect(pane.focus == .tag); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); try std.testing.expectEqual(Text.Mode.normal, pane.body.mode); try std.testing.expect(pane.focus == .body); - p.enterTag(pane); + tagline.enterTag(p, pane); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); try std.testing.expect(pane.focus == .body); p.acknowledgePanelPresentation(&.{}); - const tag = try p.tagText(p.scratch.allocator(), pane); + const tag = try tagline.tagText(p, p.scratch.allocator(), pane); const x = p.rects[0].x + TAG_TEXT_INSET + @as(u16, @intCast(std.mem.indexOf(u8, tag, "Togglettymode").?)); const y = p.rects[0].y; p.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = x, .row = y } }); @@ -965,30 +928,30 @@ test "compact tag commands stay beside their own prefix across layout changes" { const above = p.panes[0].?; try std.testing.expectEqual(p.rects[0].x, p.rects[below_id].x); - const above_tag = try p.tagText(gpa, above); + const above_tag = try tagline.tagText(p, gpa, above); defer gpa.free(above_tag); - const below_tag = try p.tagText(p.scratch.allocator(), below); + const below_tag = try tagline.tagText(p, p.scratch.allocator(), below); try std.testing.expect(below_tag.len > above_tag.len); - const tail = terminal_pane_tail; + const tail = tagline.terminal_pane_tail; try std.testing.expectEqualStrings(tail, above_tag[above_tag.len - tail.len ..]); try std.testing.expectEqual(above.cwdSlice().len + tail.len, above_tag.len); p.update(.{ .resize = .{ .cols = 88, .rows = 30 } }); while (p.nextEffect()) |_| {} - try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); + try std.testing.expectEqualStrings(above_tag, try tagline.tagText(p, p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 87, .rows = 30 } }); while (p.nextEffect()) |_| {} - try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); + try std.testing.expectEqualStrings(above_tag, try tagline.tagText(p, p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); while (p.nextEffect()) |_| {} - try std.testing.expectEqualStrings(tail, Pardes.curTail(below)); + try std.testing.expectEqualStrings(tail, tagline.curTail(below)); try below.appendTag(" lots and lots of typing out here"); - try std.testing.expectEqual(above_tag.len, (try p.tagText(p.scratch.allocator(), above)).len); + try std.testing.expectEqual(above_tag.len, (try tagline.tagText(p, p.scratch.allocator(), above)).len); p.update(.{ .resize = .{ .cols = 100, .rows = 2 } }); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(u16, 0), p.rects[below_id].h); - try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); + try std.testing.expectEqualStrings(above_tag, try tagline.tagText(p, p.scratch.allocator(), above)); } test "legacy default tag tails upgrade while custom tails remain owned" { @@ -998,120 +961,120 @@ test "legacy default tag tails upgrade while custom tails remain owned" { const terminal = p.panes[0].?; const terminal_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try p.tagPrefix(terminal), - legacy_pane_tail, + try tagline.tagPrefix(p, terminal), + tagline.legacy_pane_tail, }); - p.restoreTail(terminal, terminal_old); + tagline.restoreTail(p, terminal, terminal_old); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); const terminal_previous = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ - try p.tagPrefix(terminal), + try tagline.tagPrefix(p, terminal), }); - p.restoreTail(terminal, terminal_previous); + tagline.restoreTail(p, terminal, terminal_previous); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); const terminal_prefilter = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try p.tagPrefix(terminal), - prev_terminal_pane_tail, + try tagline.tagPrefix(p, terminal), + tagline.prev_terminal_pane_tail, }); - p.restoreTail(terminal, terminal_prefilter); + tagline.restoreTail(p, terminal, terminal_prefilter); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); - for ([_][]const u8{ pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail }) |old_tail| { - const old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(terminal), old_tail }); - p.restoreTail(terminal, old); + for ([_][]const u8{ tagline.pre_mode_terminal_pane_tail, tagline.pre_tty_first_terminal_pane_tail, tagline.pre_collapse_terminal_pane_tail }) |old_tail| { + const old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try tagline.tagPrefix(p, terminal), old_tail }); + tagline.restoreTail(p, terminal, old); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); } - const custom_mode = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s} Keep", .{ try p.tagPrefix(terminal), pre_mode_terminal_pane_tail }); - p.restoreTail(terminal, custom_mode); + const custom_mode = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s} Keep", .{ try tagline.tagPrefix(p, terminal), tagline.pre_mode_terminal_pane_tail }); + tagline.restoreTail(p, terminal, custom_mode); try std.testing.expect(terminal.tag.own != null); - try std.testing.expectEqualStrings(pre_mode_terminal_pane_tail ++ " Keep", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.pre_mode_terminal_pane_tail ++ " Keep", tagline.curTail(terminal)); if (terminal.tag.own) |own| p.gpa.free(own); terminal.tag.own = null; const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{ - try p.tagPrefix(terminal), + try tagline.tagPrefix(p, terminal), }); - p.restoreTail(terminal, terminal_custom); + tagline.restoreTail(p, terminal, terminal_custom); try std.testing.expect(terminal.tag.own != null); - try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(terminal)); if (terminal.tag.own) |own| p.gpa.free(own); terminal.tag.own = null; - p.restoreDumpTail(terminal, .{ + tagline.restoreDumpTail(p, terminal, .{ .kind = .terminal, .tag = "TTY /historical/cwd Keep Del", .body = "", .terminal = .{ .cwd = "/historical/cwd" }, }); try std.testing.expect(terminal.tag.own != null); - try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(terminal)); if (terminal.tag.own) |own| p.gpa.free(own); terminal.tag.own = null; - p.restoreDumpTail(terminal, .{ + tagline.restoreDumpTail(p, terminal, .{ .kind = .terminal, .tag = "TTY /historical/cwd New Del", .body = "", .terminal = .{ .cwd = "/historical/cwd" }, }); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); // This happens to be the historical FILE default, but on a terminal it is // user-owned text and must not be swallowed by migration. if (terminal.tag.own) |own| p.gpa.free(own); terminal.tag.own = null; const terminal_fileish = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Del", .{ - try p.tagPrefix(terminal), + try tagline.tagPrefix(p, terminal), }); - p.restoreTail(terminal, terminal_fileish); + tagline.restoreTail(p, terminal, terminal_fileish); try std.testing.expect(terminal.tag.own != null); - try std.testing.expectEqualStrings(" Save Del", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Save Del", tagline.curTail(terminal)); // A savable file has a distinct old default. Save remains first after the // migration so the tag's established `:w<Tab>` route is unchanged. const file = try p.setTestFile(""); const file_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try p.tagPrefix(file), - legacy_file_pane_tail, + try tagline.tagPrefix(p, file), + tagline.legacy_file_pane_tail, }); - p.restoreTail(file, file_old); + tagline.restoreTail(p, file, file_old); try std.testing.expect(file.tag.own == null); - try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(file)); + try std.testing.expectEqualStrings(" Save Tty Collapse Del", tagline.curTail(file)); const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{ - try p.tagPrefix(file), + try tagline.tagPrefix(p, file), }); - p.restoreTail(file, file_custom); + tagline.restoreTail(p, file, file_custom); try std.testing.expect(file.tag.own != null); - try std.testing.expectEqualStrings(" Save Mine Del", Pardes.curTail(file)); + try std.testing.expectEqualStrings(" Save Mine Del", tagline.curTail(file)); // Conversely the generic pane's current default is a custom edit on a // savable file. Legacy recognition is scoped to the source pane class. if (file.tag.own) |own| p.gpa.free(own); file.tag.own = null; const file_generic = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ - try p.tagPrefix(file), + try tagline.tagPrefix(p, file), }); - p.restoreTail(file, file_generic); + tagline.restoreTail(p, file, file_generic); try std.testing.expect(file.tag.own != null); - try std.testing.expectEqualStrings(" New Del", Pardes.curTail(file)); + try std.testing.expectEqualStrings(" New Del", tagline.curTail(file)); const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{}); - try std.testing.expectEqualStrings(" Tty Collapse Del", Pardes.curTail(image_doc)); - p.restoreDumpTail(image_doc, .{ + try std.testing.expectEqualStrings(" Tty Collapse Del", tagline.curTail(image_doc)); + tagline.restoreDumpTail(p, image_doc, .{ .kind = .image, .tag = "img /tmp/legacy image.ppm Keep Del", .body = "", .image = .{ .path = "/tmp/legacy image.ppm" }, }); try std.testing.expect(image_doc.tag.own != null); - try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(image_doc)); + try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(image_doc)); } test "Joincol folds the active column into its right neighbor, keeping its panes" { @@ -1245,19 +1208,19 @@ test "first document keeps its originating shell and only opens a column when it test "pane defaults keep creation of scratch panes in column tags" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); - for ([_][]const u8{ pane_tail, file_pane_tail, pdf_pane_tail, terminal_pane_tail, config.topbar_str }) |tag| { + for ([_][]const u8{ tagline.pane_tail, tagline.file_pane_tail, tagline.pdf_pane_tail, tagline.terminal_pane_tail, config.topbar_str }) |tag| { var words = std.mem.tokenizeScalar(u8, tag, ' '); while (words.next()) |word| try std.testing.expect(!std.mem.eql(u8, word, "New")); } - try std.testing.expectEqualStrings("New Tty Find Grep Joincol Delcol", p.columnTag(0)); + try std.testing.expectEqualStrings("New Tty Find Grep Joincol Delcol", tagline.columnTag(p, 0)); const terminal = p.panes[0].?; - p.restoreTailAt(terminal, pre_column_terminal_pane_tail, "", .terminal); + tagline.restoreTailAt(p, terminal, tagline.pre_column_terminal_pane_tail, "", .terminal); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); const file = try p.setTestFile("unchanged\n"); - p.restoreTailAt(file, pre_column_file_pane_tail, "", .file); + tagline.restoreTailAt(p, file, tagline.pre_column_file_pane_tail, "", .file); try std.testing.expect(file.tag.own == null); - try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(file)); + try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(file)); } test "an unsaved file marker sits between its path and builtins until Save" { @@ -1267,29 +1230,29 @@ test "an unsaved file marker sits between its path and builtins until Save" { while (p.nextEffect()) |_| {} const pane = try p.setTestFile("before\n"); - const clean = try p.tagText(p.scratch.allocator(), pane); + const clean = try tagline.tagText(p, p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, clean, "/test.txt *") == null); panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); - const dirty = try p.tagText(p.scratch.allocator(), pane); + const dirty = try tagline.tagText(p, p.scratch.allocator(), pane); const marker_at = std.mem.indexOf(u8, dirty, "/test.txt *") orelse return error.MissingDirtyMarker; const save_at = std.mem.indexOf(u8, dirty, "Save") orelse return error.MissingSaveBuiltin; try std.testing.expect(marker_at < save_at); try std.testing.expect(p.executeBuiltinLine(0, "Save")); while (p.nextEffect()) |effect| p.perform(effect); - const saved = try p.tagText(p.scratch.allocator(), pane); + const saved = try tagline.tagText(p, p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, saved, "/test.txt *") == null); panes.File.changed(p, 0, "external\n"); - const reloaded = try p.tagText(p.scratch.allocator(), pane); + const reloaded = try tagline.tagText(p, p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, reloaded, "/test.txt *") == null); // A generated output is file-shaped and Save can write it to a path, but // there is no file of its own for it to be dirty against. pane.file.?.output = .{ .from = .search }; panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "result\n")); - const output = try p.tagText(p.scratch.allocator(), pane); + const output = try tagline.tagText(p, p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, output, "/test.txt *") == null); } @@ -1422,7 +1385,7 @@ test "image dump restores source bytes renderer choices and exact custom tail" { try std.testing.expectEqual(image.PaletteMode.terminal, restored_pane.image.?.pmode); try std.testing.expect(!restored_pane.image.?.ascii); try std.testing.expect(restored_pane.tag.own != null); - try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(restored_pane)); + try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(restored_pane)); try std.testing.expectEqualSlices(u8, source, restored_pane.image.?.raw); try dump.dumpState(restored); @@ -1903,7 +1866,7 @@ test "Save on an output buffer writes its rows out and leaves the buffer alone" defer gpa.free(rows); // Save leads its tagline now, and the path is REQUIRED: a bare Save asks - try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(out)); + try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(out)); try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(out.prompt == .save); edit.insertKey(p, &out.input, .{ .cp = 'h', .text = "help.txt" }); @@ -2091,17 +2054,17 @@ test "Save reaches every tagline with text behind it and no other" { const out = try p.setTestFile("build.zig:1:1 pub fn main\n"); out.file.?.output = .{ .from = .search }; - try std.testing.expectEqualStrings(locations_pane_tail, Pardes.curTail(out)); + try std.testing.expectEqualStrings(tagline.locations_pane_tail, tagline.curTail(out)); // A dump written before Save reached output buffers carries the generic // tail. That is a DEFAULT and not something typed, so it upgrades. const dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try p.tagPrefix(out), - pane_tail, + try tagline.tagPrefix(p, out), + tagline.pane_tail, }); - p.restoreTail(out, dumped); + tagline.restoreTail(p, out, dumped); try std.testing.expect(out.tag.own == null); - try std.testing.expectEqualStrings(locations_pane_tail, Pardes.curTail(out)); + try std.testing.expectEqualStrings(tagline.locations_pane_tail, tagline.curTail(out)); // A scratch is an output buffer that has worn the FILE tails all along, // so its own older default upgrades from that family too. @@ -2109,18 +2072,18 @@ test "Save reaches every tagline with text behind it and no other" { if (out.tag.own) |own| p.gpa.free(own); out.tag.own = null; const scratch_dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try p.tagPrefix(out), - prev_file_pane_tail, + try tagline.tagPrefix(p, out), + tagline.prev_file_pane_tail, }); - p.restoreTail(out, scratch_dumped); + tagline.restoreTail(p, out, scratch_dumped); try std.testing.expect(out.tag.own == null); - try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(out)); + try std.testing.expectEqualStrings(" Save Tty Collapse Del", tagline.curTail(out)); // An image's bytes on disk already are what they are: nothing of the // pane's own is unwritten, so the word is absent and inert. const img_id = p.freeSlot().?; const img = try panes.Image.create(p, img_id, "/tmp/pardes-tag.ppm", &.{}); - try std.testing.expectEqualStrings(" Tty Collapse Del", Pardes.curTail(img)); + try std.testing.expectEqualStrings(" Tty Collapse Del", tagline.curTail(img)); try std.testing.expect(p.executeBuiltinLine(img_id, "Save")); try std.testing.expect(img.prompt != .save); var buf: [256]u8 = undefined; @@ -2545,7 +2508,7 @@ comptime { const WordBounds = struct { lo: usize, hi: usize }; -fn wordBoundsAtCol(str: []const u8, col: usize) ?WordBounds { +pub fn wordBoundsAtCol(str: []const u8, col: usize) ?WordBounds { if (col >= str.len or str[col] == ' ') return null; var lo: usize = col; while (lo > 0 and str[lo - 1] != ' ') lo -= 1; @@ -2636,7 +2599,7 @@ test "Unicode display cells map back to body and tag byte cursors" { // A click in the wide path glyph drafts a name with its caret at the // grapheme start; arrow motion then advances by the full UTF-8 cluster. - p.draftName(pane, 1); + tagline.draftName(p, pane, 1); try std.testing.expectEqual(@as(i32, 0), pane.input.cur_col); edit.insertKey(p, &pane.input, .{ .cp = Key.right }); try std.testing.expectEqual(@as(i32, 3), pane.input.cur_col); @@ -2644,15 +2607,15 @@ test "Unicode display cells map back to body and tag byte cursors" { try std.testing.expectEqual(@as(i32, 6), pane.input.cur_col); pane.closePrompt(); - const before = try gpa.dupe(u8, Pardes.curTail(pane)); + const before = try gpa.dupe(u8, tagline.curTail(pane)); defer gpa.free(before); - p.enterTag(pane); + tagline.enterTag(p, pane); const insertion = pane.tag.cur_col; edit.insertKey(p, &pane.tag, .{ .cp = 'λ', .text = "λ" }); try std.testing.expectEqual(insertion + 2, pane.tag.cur_col); edit.insertKey(p, &pane.tag, .{ .cp = Key.backspace }); try std.testing.expectEqual(insertion, pane.tag.cur_col); - try std.testing.expectEqualStrings(before, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(before, tagline.curTail(pane)); } test "tabbed file aligns syntax cursor and mouse at the nearest insertion gap" { @@ -2726,23 +2689,23 @@ test "editable workspace and column tags are typed into and persist" { p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); p.update(.{ .key = .{ .cp = 'é', .text = "é" } }); - try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "éNew")); + try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, 0), "éNew")); p.update(.{ .key = .{ .cp = Key.backspace } }); - try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "New")); + try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, 0), "New")); p.update(.{ .paste = "Grep " }); - try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "Grep New")); + try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, 0), "Grep New")); try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); try dump.dumpState(p); const restored = try dump.restore(p, p.dump_out.?); defer restored.deinit(); try std.testing.expectEqualStrings(p.global_tag.own.?, restored.global_tag.own.?); - try std.testing.expectEqualStrings(p.columnTag(0), restored.columnTag(0)); + try std.testing.expectEqualStrings(tagline.columnTag(p, 0), tagline.columnTag(restored, 0)); p.gpa.free(p.col_tags[0].own.?); p.col_tags[0].own = try p.gpa.dupe(u8, ""); try dump.dumpState(p); const empty = try dump.restore(p, p.dump_out.?); defer empty.deinit(); - try std.testing.expectEqualStrings("", empty.columnTag(0)); + try std.testing.expectEqualStrings("", tagline.columnTag(empty, 0)); } test "column tags follow their column through split and close" { @@ -2752,12 +2715,12 @@ test "column tags follow their column through split and close" { p.col_tags[0].own = try p.gpa.dupe(u8, "Left Find"); _ = exec.execute(p, 0, "Newcol"); try std.testing.expectEqual(@as(usize, 2), p.ncol); - try std.testing.expectEqualStrings("Left Find", p.columnTag(0)); + try std.testing.expectEqualStrings("Left Find", tagline.columnTag(p, 0)); p.col_tags[1].own = try p.gpa.dupe(u8, "Right Grep"); p.active = p.col_panes[0][0]; layout.joinCol(p); try std.testing.expectEqual(@as(usize, 1), p.ncol); - try std.testing.expectEqualStrings("Right Grep", p.columnTag(0)); + try std.testing.expectEqualStrings("Right Grep", tagline.columnTag(p, 0)); } test "column tags render edit hover and scroll from the column edge" { @@ -2780,7 +2743,7 @@ test "column tags render edit hover and scroll from the column edge" { try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, hovered.at(x, TOPBAR_H).style.bg); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = x, .row = TOPBAR_H } }); try std.testing.expectEqual(column, p.header_column.?); - try std.testing.expectEqual(@as(i32, 0), p.focusedHeader().?.cur_col); + try std.testing.expectEqual(@as(i32, 0), tagline.focusedHeader(p).?.cur_col); const at_start = try p.render(frame.allocator()); try std.testing.expectEqual(x, at_start.cursor.?.x); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = x + 3, .row = TOPBAR_H } }); @@ -2790,7 +2753,7 @@ test "column tags render edit hover and scroll from the column edge" { try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, selected.at(x + @as(u16, @intCast(offset)), TOPBAR_H).style.bg); p.update(.{ .key = .{ .cp = Key.home } }); p.update(.{ .key = .{ .cp = 'H', .text = "Help " } }); - try std.testing.expect(std.mem.startsWith(u8, p.columnTag(column), "Help New Tty")); + try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, column), "Help New Tty")); const edited = try p.render(frame.allocator()); try std.testing.expectEqual(x + 5, edited.cursor.?.x); // Only the text viewport participates in caret reveal. @@ -2844,7 +2807,7 @@ test "header clicks beyond text append and long UTF-8 tags reveal their caret" { p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = 40, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 40, .row = TOPBAR_H } }); p.update(.{ .key = .{ .cp = 'x', .text = " extended" } }); - try std.testing.expectEqualStrings("New extended", p.columnTag(0)); + try std.testing.expectEqualStrings("New extended", tagline.columnTag(p, 0)); p.update(.{ .paste = " 界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界" }); const frame = try p.render(p.scratch.allocator()); try std.testing.expect(p.header_scroll > 0); @@ -2866,7 +2829,7 @@ test "a column tag with a newline in it takes a row per line and pushes its pane p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 40, .row = TOPBAR_H } }); p.update(.{ .key = .{ .cp = Key.enter } }); p.update(.{ .key = .{ .cp = 'S', .text = "Snarf" } }); - try std.testing.expectEqualStrings("New\nSnarf", p.columnTag(0)); + try std.testing.expectEqualStrings("New\nSnarf", tagline.columnTag(p, 0)); try std.testing.expectEqual(@as(u16, 2), p.columnBarHeight()); const frame = try p.render(p.scratch.allocator()); try std.testing.expectEqual(TOPBAR_H + 2, p.rects[0].y); @@ -2884,7 +2847,7 @@ test "a column tag with a newline in it takes a row per line and pushes its pane // Taking the newline back out gives the panes their row again. p.update(.{ .key = .{ .cp = Key.home } }); p.update(.{ .key = .{ .cp = Key.backspace } }); - try std.testing.expectEqualStrings("NewSnarf", p.columnTag(0)); + try std.testing.expectEqualStrings("NewSnarf", tagline.columnTag(p, 0)); _ = try p.render(p.scratch.allocator()); try std.testing.expectEqual(TOPBAR_H + COLBAR_H, p.rects[0].y); } @@ -2916,7 +2879,7 @@ test "ColumnTags reclaims the row and releases hidden header focus without losin try std.testing.expectEqual(shown.h + COLBAR_H, p.rects[0].h); try std.testing.expect(!p.header_focus and p.header_column == null); try std.testing.expect(!p.header_drag); - try std.testing.expectEqualStrings("New Help", p.columnTag(0)); + try std.testing.expectEqualStrings("New Help", tagline.columnTag(p, 0)); p.update(.{ .command = "ColumnTags" }); try std.testing.expect(p.settings.column_tags); @@ -2932,7 +2895,7 @@ test "ColumnTags reclaims the row and releases hidden header focus without losin try std.testing.expect(!p.header_focus and p.header_column == null); p.update(.{ .resize = .{ .cols = 60, .rows = 12 } }); try std.testing.expectEqual(shown, p.rects[0]); - try std.testing.expectEqualStrings("New Help", p.columnTag(0)); + try std.testing.expectEqualStrings("New Help", tagline.columnTag(p, 0)); try std.testing.expectEqualStrings("unchanged body\n", pane.file.?.content); } @@ -2940,7 +2903,7 @@ test "a header's execute key runs the word under its cursor and lets go of the k const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 12, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("body\n"); - p.enterHeader(null); + tagline.enterHeader(p, null); p.global_tag.cur_col = @intCast(std.mem.indexOf(u8, config.topbar_str, "Dump").?); p.update(.{ .key = .{ .cp = Key.tab } }); try std.testing.expect(!p.header_focus); @@ -4082,23 +4045,6 @@ pub const Pardes = struct { return a; } - /// What a column's tag says until someone edits it. - pub const column_tag_default = "New Tty Find Grep Joincol Delcol"; - - /// What a header says until someone edits it: for the workspace the - /// fixed builtins, plus `Restore <path>` once a dump exists. - pub fn headerDefault(p: *Pardes, what: Text.What) []const u8 { - if (what == .column) return column_tag_default; - const d = p.last_dump orelse return config.topbar_str; - return std.fmt.allocPrint(p.scratch.allocator(), config.topbar_str ++ " Restore {s}", .{d}) catch config.topbar_str; - } - - /// the topbar line -- render and click dispatch must agree on this exact - /// string - fn topbar(p: *Pardes) []const u8 { - return p.global_tag.own orelse p.headerDefault(.workspace); - } - /// The workspace tag's height: a row per line of it while the setting /// keeps it, zero once a native shell takes its commands over. Layout and /// hit testing read this rather than the constant, the way they read @@ -4119,36 +4065,6 @@ pub const Pardes = struct { return @intCast(@min(lines, MAX_TAG_ROWS, @max(COLBAR_H, p.screen_h / 3))); } - pub fn columnTag(p: *const Pardes, c: usize) []const u8 { - return p.col_tags[c].own orelse column_tag_default; - } - - /// The header with the keyboard, when one has it. - pub fn focusedHeader(p: *Pardes) ?*Text { - if (!p.header_focus) return null; - if (p.header_column) |c| return if (c < p.ncol) &p.col_tags[c] else null; - return &p.global_tag; - } - - fn revealHeader(p: *Pardes) void { - const t = p.focusedHeader() orelse { - p.header_scroll = 0; - return; - }; - const text = edit.flatSurface(p, t) catch return; - const line = modal.lineSlice(text, @intCast(@max(0, t.cur_row))); - const width = p.tagCapacity(if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else p.screen_w); - const col = panes.File.rawDisplayCol(line, @min(@as(usize, @intCast(@max(0, t.cur_col))), line.len)); - if (col < p.header_scroll) p.header_scroll = col; - if (col >= p.header_scroll + width) p.header_scroll = col -| (width -| 1); - var raw = panes.File.rawAtDisplay(line, p.header_scroll); - p.header_scroll = panes.File.rawDisplayCol(line, raw); - if (width > 0 and col >= p.header_scroll + width) { - raw = modal.nextGrapheme(line, raw); - p.header_scroll = panes.File.rawDisplayCol(line, raw); - } - } - fn detachCwds(p: *Pardes, parents: []const *Pane) !void { var copies: [MAX_PANES]?[]u8 = @splat(null); errdefer for (copies) |copy| if (copy) |bytes| p.gpa.free(bytes); @@ -4834,7 +4750,7 @@ pub const Pardes = struct { for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (pane.fs.readers == 0) continue; - const tag = p.tagText(p.scratch.allocator(), pane) catch continue; + const tag = tagline.tagText(p, p.scratch.allocator(), pane) catch continue; const snap = &pane.fs.tag_snap; if (std.mem.eql(u8, snap.items, tag)) continue; if (snap.capacity != 0 or snap.items.len != 0) @@ -4846,39 +4762,6 @@ pub const Pardes = struct { // ---- tag + selection text (chord sources) ---- - pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 { - const arena = p.scratch.allocator(); - if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint( - arena, - "{s} [{d}/{d}]", - .{ pv.path, pv.page + 1, pv.page_count }, - ); - if (pane.image) |*state| return panes.Image.tagPrefix(arena, state); - if (pane.file) |f| { - const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path; - if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision) - return std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker }); - return arena.dupe(u8, name); - } - return arena.dupe(u8, pane.cwdSlice()); - } - - /// The tag's own text: what the user made of it, or until then the - /// default a pane of its kind starts with. - pub fn curTail(pane: *Pane) []const u8 { - return pane.tag.own orelse defaultTail(pane); - } - - pub fn defaultTail(pane: *const Pane) []const u8 { - if (comptime pdf_enabled) if (pane.pdf != null) return pdf_pane_tail; - if (pane.file) |file| if (file.output) |out| - if (panes.Output.usesLocationsConfig(out.from)) return locations_pane_tail; - if (panes.File.supportsContext(pane)) return context_pane_tail; - if (pane.file != null) return file_pane_tail; - if (pane.isTerminal()) return terminal_pane_tail; - return pane_tail; - } - pub fn paneIdOf(p: *const Pardes, pane: *const Pane) ?usize { for (p.panes, 0..) |slot, id| if (slot) |sp| { if (sp == pane) return id; @@ -4886,168 +4769,13 @@ pub const Pardes = struct { return null; } - /// The tag as it is shown, read and pointed at: its computed prefix and - /// its own text after it. - pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { - const prefix = try p.tagPrefix(pane); - const tail = curTail(pane); - const out = try arena.alloc(u8, prefix.len + tail.len); - @memcpy(out[0..prefix.len], prefix); - @memcpy(out[prefix.len..], tail); - return out; - } - - fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } { - if (pane.isTerminal()) { - var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n"); - while (words.next()) |word| { - if (!std.mem.eql(u8, word, "Tty")) continue; - const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr); - return .{ .lo = lo, .hi = lo + word.len }; - } - return null; - } - var start: usize = 0; - const name = if (pane.file) |file| - if (pane.prompt == .name) pane.input.own orelse file.path else file.path - else if (pane.image) |state| blk: { - // Image tags put renderer controls before the path. - start = prefix_len -| state.path.len; - break :blk state.path; - } else if (comptime pdf_enabled) blk: { - break :blk if (pane.pdf) |state| state.path else return null; - } else return null; - const basename = if (std.mem.lastIndexOfScalar(u8, name, '/')) |slash| slash + 1 else 0; - if (basename == name.len) return null; - return .{ .lo = start + basename, .hi = start + name.len }; - } - - /// Give the keyboard to the tag in normal mode (`:`), its cursor where - /// the tag last had it. The first time there is nothing to remember, and - /// it starts on Save, so that `:` and the execute key write the file. - pub fn enterTag(p: *Pardes, pane: *Pane) void { - const t = &pane.tag; - const tail = curTail(pane); - if (!t.cur_pinned) { - var words = std.mem.tokenizeAny(u8, tail, " \t\n"); - const at = while (words.next()) |word| { - if (std.mem.eql(u8, word, "Save")) break @intFromPtr(word.ptr) - @intFromPtr(tail.ptr); - } else tail.len - std.mem.trimStart(u8, tail, " ").len; - const cursor = modal.positionAt(tail, @min(at, tail.len)); - t.cur_row = @intCast(cursor.row); - t.cur_col = @intCast(cursor.col); - t.cur_pinned = true; - // Reveal the suffix together with its commands, not merely the - // first command letter at the viewport's far right edge. - if (p.paneIdOf(pane)) |id| { - const tag = p.tagText(p.scratch.allocator(), pane) catch return; - const line = tag[0 .. std.mem.indexOfScalar(u8, tag, '\n') orelse tag.len]; - const width = p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET); - pane.tag_scroll = @intCast(panes.File.displayWidth(line) + 1 -| width); - } - } - // The text may have changed since (a 9P write, a rename), so the - // remembered cursor is pulled back inside it. It is the cursor that - // is remembered, not a selection. - const rows = modal.cursorLineCount(tail); - t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1)); - const line = modal.lineSlice(tail, @intCast(t.cur_row)); - t.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, t.cur_col)))))); - t.vsel.active = false; - t.msel.active = false; - t.nsel = 0; - t.select = false; - t.append_at = null; - t.sticky_col = -1; - t.mode = .normal; - t.normal.clear(); - pane.focus = .tag; - } - - /// 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 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 - /// column `col` of the path. Nothing changes until it is typed into. - pub fn draftName(p: *Pardes, pane: *Pane, col: usize) void { - const f = pane.file orelse return; - if (!panes.Output.fileTraits(f.output).saves) return; - const name = p.gpa.dupe(u8, f.path) catch return; - pane.closePrompt(); - pane.prompt = .name; - pane.input = .{ .what = .input, .own = name, .mode = .insert, .cur_pinned = true, .cur_col = @intCast(@min(name.len, panes.File.rawAtDisplay(name, col))) }; - } - - /// Commit a drafted buffer name, never a disk rename or write. Keep the - /// draft visible on failure so the user can correct it or cancel with Esc. - pub fn commitTagName(p: *Pardes, pane: *Pane) bool { - if (pane.prompt != .name) return true; - const id = p.paneIdOf(pane) orelse return false; - const name = pane.input.own orelse ""; - if (name.len == 0 or name[name.len - 1] == '/' or !std.unicode.utf8ValidateSlice(name)) { - p.reportError(id, "name", error.InvalidName); - return false; - } - for (name) |byte| if (byte < ' ') { - p.reportError(id, "name", error.InvalidName); - return false; - }; - const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ paneDir(pane), name }) catch |err| { - p.reportError(id, "name", err); - return false; - }; - ctlfs.pane.nameBuffer(p, id, full, true) catch |err| { - p.reportError(id, "name", err); - return false; - }; - pane.closePrompt(); - return true; - } - - pub fn exitHeader(p: *Pardes) void { - p.header_focus = false; - p.header_column = null; - p.header_drag = false; - p.header_scroll = 0; - } - - /// Give the keyboard to a column's tag, or the workspace's for null, in - /// normal mode, its cursor where that header last had it. - pub fn enterHeader(p: *Pardes, column: ?usize) void { - p.exitHeader(); - if (p.panes[p.active]) |pane| pane.focus = .body; - p.header_focus = true; - p.header_column = column; - const t = p.focusedHeader() orelse return p.exitHeader(); - const text = edit.flatSurface(p, t) catch ""; - const rows = modal.cursorLineCount(text); - t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1)); - const line = modal.lineSlice(text, @intCast(t.cur_row)); - t.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, t.cur_col)))))); - t.cur_pinned = true; - t.vsel.active = false; - t.msel.active = false; - t.nsel = 0; - t.select = false; - t.append_at = null; - t.mode = .normal; - t.normal.clear(); - } - /// Cycle only the body modes supported by this pane. pub fn cycleMode(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; if (pane.file == null and !pane.isTerminal()) return; const previous = pane.body.mode; p.active = id; - p.exitHeader(); + tagline.exitHeader(p); pane.focus = .body; pane.closePrompt(); switch (previous) { @@ -5068,7 +4796,7 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return; if (!pane.isTerminal()) return; p.active = id; - p.exitHeader(); + tagline.exitHeader(p); pane.focus = .body; pane.closePrompt(); if (pane.body.mode == .tty) { @@ -5089,7 +4817,7 @@ pub const Pardes = struct { return; } const pane = p.panes[p.active] orelse return; - const header = p.focusedHeader(); + const header = tagline.focusedHeader(p); if (header == null and pane.isTerminal() and pane.body.mode == .tty and pane.focus == .body and pane.prompt == .none) { p.leader_on = false; p.ctrl_w_pending = false; @@ -5136,7 +4864,7 @@ pub const Pardes = struct { // global window ops (any mode): Alt-n new terminal below, Alt-c move // the active terminal into a fresh column if (hit(key, config.new_shell_below)) { - p.exitHeader(); + tagline.exitHeader(p); const free = p.freeSlot() orelse return; const nt = p.newShell(free, "") catch return; nt.greet = true; @@ -5150,11 +4878,11 @@ pub const Pardes = struct { } for (config.jump_keys) |jk| { if (!hit(key, &.{jk.chord})) continue; - p.exitHeader(); + tagline.exitHeader(p); return exec.runBuiltin(p, jk.cmd, p.active, "", null); } if (hit(key, config.pane_to_new_column)) { - p.exitHeader(); + tagline.exitHeader(p); const f = layout.findPane(p, p.active).?; if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) { _ = layout.splitColumn(p, p.active, p.active, false); @@ -5170,7 +4898,7 @@ pub const Pardes = struct { // Enter or the execute key commits the draft and the keyboard // goes back to the body; Esc drops it. if (hit(key, config.look_key) or hit(key, config.exec_key)) { - if (p.commitTagName(pane)) pane.focus = .body; + if (tagline.commitTagName(p, pane)) pane.focus = .body; return; } if (hit(key, config.escape)) { @@ -5209,7 +4937,7 @@ pub const Pardes = struct { // text as it is shown: a tag's prefix is part of what a word is. const arena = p.scratch.allocator(); const own = arena.dupe(u8, edit.flatSurface(p, t) catch return) catch return; - const shown = if (t.what == .tag) p.tagText(arena, pane) catch return else own; + const shown = if (t.what == .tag) tagline.tagText(p, arena, pane) catch return else own; const range = t.primaryRange(own, 0); const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); @@ -5221,7 +4949,7 @@ pub const Pardes = struct { }; // Out of the tag or header FIRST: Del frees the pane, Kill // and Restore the whole session. - if (header != null) p.exitHeader() else pane.focus = .body; + if (header != null) tagline.exitHeader(p) else pane.focus = .body; if (txt.len > 0) exec.runBuiltin(p, cmd, p.active, "", txt); return; } @@ -5615,90 +5343,6 @@ pub const Pardes = struct { // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- - pub fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void { - if (src.tag_tail) |tail| { - pane.tag.own = p.gpa.dupe(u8, tail) catch return; - return; - } - const tail_class = tailClass(pane); - if (src.kind == .image and panes.Pdf.isPath(src.image.?.path)) { - if (panes.Pdf.legacySavedPrefix(src.image.?.path, src.tag)) |prefix| - return p.restoreTailAt(pane, src.tag, prefix, .generic); - } - if (src.kind == .terminal and std.mem.startsWith(u8, src.tag, "TTY ")) { - const legacy = std.fmt.allocPrint(p.scratch.allocator(), "TTY {s}", .{src.terminal.?.cwd}) catch return; - if (std.mem.startsWith(u8, src.tag, legacy)) - return p.restoreTailAt(pane, src.tag, legacy, .terminal); - } - const current = p.tagPrefix(pane) catch return; - if (std.mem.startsWith(u8, src.tag, current)) - return p.restoreTailAt(pane, src.tag, savedPrefix(src.tag, current, tail_class), tail_class); - if (src.kind != .image) return; - const saved = src.image.?; - if (pane.image) |*state| if (panes.Image.legacySavedPrefix(state, src.tag)) |legacy| - return p.restoreTailAt(pane, src.tag, legacy, .generic); - if (panes.Pdf.isPath(saved.path)) if (panes.Pdf.legacySavedPrefix(saved.path, src.tag)) |legacy| - p.restoreTailAt(pane, src.tag, legacy, .generic); - } - - fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void { - const pfx = p.tagPrefix(pane) catch return; - const class = tailClass(pane); - p.restoreTailAt(pane, saved_tag, savedPrefix(saved_tag, pfx, class), class); - } - - const TailClass = enum { generic, file, output, terminal }; - - fn tailClass(pane: *const Pane) TailClass { - if (pane.file) |f| return if (f.output == null) .file else .output; - if (pane.isTerminal()) return .terminal; - return .generic; - } - - fn savedPrefix(saved_tag: []const u8, live: []const u8, class: TailClass) []const u8 { - if (class == .generic or class == .terminal) return live; - if (!std.mem.startsWith(u8, saved_tag, live)) return live; - const marked_len = live.len + dirty_marker.len; - if (saved_tag.len >= marked_len and std.mem.eql(u8, saved_tag[live.len..marked_len], dirty_marker)) - return saved_tag[0..marked_len]; - return live; - } - - fn restoreTailAt( - p: *Pardes, - pane: *Pane, - saved_tag: []const u8, - pfx: []const u8, - class: TailClass, - ) void { - if (!std.mem.startsWith(u8, saved_tag, pfx)) return; - const rest = saved_tag[pfx.len..]; - const said = std.mem.trimStart(u8, rest, " "); - const defaults: []const []const u8 = switch (class) { - .generic => &.{ pdf_pane_tail, pane_tail, pre_close_last_pdf_pane_tail, pre_close_last_pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, - .file => &.{ context_pane_tail, file_pane_tail, pre_close_last_file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, - .output => &.{ - locations_pane_tail, - file_pane_tail, - pre_close_last_file_pane_tail, - pre_close_last_pane_tail, - prev_file_pane_tail, - legacy_file_pane_tail, - pane_tail, - prev_pane_tail, - legacy_pane_tail, - pre_column_file_pane_tail, - pre_column_pane_tail, - pre_collapse_file_pane_tail, - pre_collapse_pane_tail, - }, - .terminal => &.{ terminal_pane_tail, pre_close_last_terminal_pane_tail, pre_close_last_pane_tail, pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, - }; - for (defaults) |default| - if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return; - pane.tag.own = p.gpa.dupe(u8, rest) catch return; - } - /// Close a pane; its rows go to `to`, or where layout.absorbVWeight /// sends them when that is null. pub fn removePane(p: *Pardes, id: usize, to: ?usize) !void { @@ -5854,7 +5498,7 @@ pub const Pardes = struct { /// deferred greetings. The mirror of the prototype's loop epilogue. pub fn sync(p: *Pardes) void { if (p.header_column != null and p.columnBarHeight() == 0) { - p.exitHeader(); + tagline.exitHeader(p); } p.reapPanes(); layout.compute(p); @@ -5886,7 +5530,7 @@ pub const Pardes = struct { 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; + const lines = std.mem.count(u8, tagline.curTail(pane), "\n") + 1; pane.tag_rows = @intCast(@min(lines, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1))); if (pane.collapsed) pane.tag_rows = BOX_H; const cols = @max(1, r.w -| config.GUTTER); @@ -5976,7 +5620,7 @@ pub const Pardes = struct { 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 { + pub fn tagCapacity(p: *const Pardes, physical_width: u16) u16 { const layer: TagLayer = .{ .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } }; return layer.capacity(p.row_metrics); } @@ -6107,110 +5751,8 @@ pub const Pardes = struct { } } - // global tagbar: full width, top rows — unless a native shell has - // taken its commands over, in which case the rows are not ours to draw - p.revealHeader(); - const global = p.topbar(); - const global_rows = p.topBarHeight(); - const global_scroll = if (p.header_focus and p.header_column == null) p.header_scroll else 0; - if (p.settings.workspace_tag) { - s.fill(0, 0, s.cols, global_rows, .{ - .bg = .{ .rgb = chrome.tag_bg }, - .font_role = .tagline, - }); - for (0..global_rows) |row| { - const line = modal.lineSlice(global, row); - _ = s.print(0, @intCast(row), s.cols, line[panes.File.rawAtDisplay(line, global_scroll)..], .{ - .fg = .{ .rgb = chrome.tag_fg }, - .bg = .{ .rgb = chrome.tag_bg }, - .font_role = .tagline, - }); - } - } - if (p.pointer_inside and p.hover_row < global_rows) { - const bar = modal.lineSlice(global, p.hover_row); - if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| { - var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll; - const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll; - while (col < hi and col < s.cols) : (col += 1) { - const cell = s.at(@intCast(col), p.hover_row); - cell.default = false; - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; - } - } - } - const column_rows = p.columnBarHeight(); - if (column_rows > 0) for (0..p.ncol) |c| { - const grip = @min(config.GUTTER, p.col_w[c]); - const inset = @min(TAG_TEXT_INSET, p.col_w[c]); - const x = p.col_x[c] + inset; - const w = p.col_w[c] - inset; - const y0 = global_rows; - if (p.col_w[c] == 0 or p.col_x[c] >= s.cols) continue; - const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false; - const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; - const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; - // A grip is muted until it is grabbed; then it lights, as a pane's - // box does for the active pane, so the drag shows what it holds. - const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c; - const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim; - const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10; - const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 }; - const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline }; - s.fill(p.col_x[c], y0, grip, column_rows, grip_style); - s.fill(p.col_x[c] + grip, y0, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - - s.fill(x, y0, w, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - const bar_scroll = if (p.header_focus and p.header_column == c) p.header_scroll else 0; - const tag = p.columnTag(c); - for (0..column_rows) |row| { - const line = modal.lineSlice(tag, row); - _ = s.print(x, y0 + @as(u16, @intCast(row)), w, line[panes.File.rawAtDisplay(line, bar_scroll)..], .{ - .fg = .{ .rgb = fg }, - .bg = .{ .rgb = bg }, - .font_role = .tagline, - }); - } - // A carried column's pointer is aiming at a place, not a word. - if (p.pointer_inside and p.drag != .column_move and p.hover_row >= y0 and p.hover_row < y0 + column_rows and p.hover_col >= x and p.hover_col < x + w) { - const bar = modal.lineSlice(tag, p.hover_row - y0); - if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| { - var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll; - const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll; - while (col < hi and col < w) : (col += 1) { - const cell = s.at(x + @as(u16, @intCast(col)), p.hover_row); - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; - } - } - } - }; - if (p.focusedHeader()) |t| { - const text = try edit.flatSurface(p, t); - const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; - const w = if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else s.cols; - const y: u16 = if (p.header_column != null) global_rows else 0; - const rows = if (p.header_column != null) column_rows else global_rows; - for (0..rows) |row| { - const bar = modal.lineSlice(text, row); - const span = headerSelection(t, text, row) orelse continue; - var col = panes.File.rawDisplayCol(bar, span.lo) -| p.header_scroll; - const hi = panes.File.rawDisplayCol(bar, span.hi) -| p.header_scroll; - while (col < hi and col < w) : (col += 1) { - const cell = s.at(x + @as(u16, @intCast(col)), y + @as(u16, @intCast(row))); - cell.style.fg = .{ .rgb = th.sel_fg }; - cell.style.bg = .{ .rgb = th.sel_bg }; - } - } - if (t.cur_row >= 0 and t.cur_row < rows) { - const bar = modal.lineSlice(text, @intCast(t.cur_row)); - const caret = panes.File.rawDisplayCol(bar, @min(@as(usize, @intCast(@max(0, t.cur_col))), bar.len)) -| p.header_scroll; - if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y + @as(u16, @intCast(t.cur_row)), .bar = t.mode == .insert }; - } - } - - try p.renderTagLayers(arena); + try body_layer.paintHeaders(p); + try body_layer.renderTagLayers(p, arena); switch (p.drag) { .border_v => |d| { @@ -6450,204 +5992,6 @@ pub const Pardes = struct { return !cell.default and !(cell.len == 1 and cell.text[0] == ' '); } - fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { - const chrome = p.chromeTheme(); - 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 == 0) continue; - const tag = try p.tagText(arena, pane); - 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 = rows, .cells = storage }; - std.mem.swap(Surface, &p.surface, &temporary); - 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); - // 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. - if (p.settings.workspace_tag) { - for (0..p.topBarHeight()) |line| { - const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; - try p.renderHeaderLayer(arena, index, .workspace, 0, @intCast(line), .{ .x = 0, .y = @intCast(line), .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(p.topbar(), line), chrome.tag_bg, chrome.tag_fg); - } - } - // Each notice is its own tagline-shaped chip over the top of the body. - // Going through the same layer machinery the pane and column tags use - // is what gives it the tagline's height, its small-font metrics, the - // band offset and the border -- none of which a body-grid row can have - // just by wearing a tagline font. The geometry is the one the grid - // pass already chose and recorded, so the two cannot disagree and the - // GUI's "skip the cells a tag layer covers" leaves no stub behind. - // A prompt is one of them too: left on the grid, a pixel shell drew - // its tagline glyphs one to a BODY cell, spaced out like a banner. - 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 <= 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); - if (text.len == 0) continue; - const cx = pane.notices.left[i]; - if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue; - const chip = r.x + r.w - cx; - // Right aligned inside the chip, a blank cell short of its - // edge: the same one the grid pass leaves for a prompt caret. - // Wider than the chip, the text loses its head, as on the grid. - const room = p.tagCapacity(chip) -| 1; - const shown = panes.File.displayWidth(text); - var kept = text; - if (shown > room) { - var start = panes.File.rawAtDisplay(text, shown - room); - if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1); - kept = text[start..]; - } - const pad = room -| panes.File.displayWidth(kept); - const line = try arena.alloc(u8, pad + kept.len); - @memset(line[0..pad], ' '); - @memcpy(line[pad..], kept); - // A pixel shell slides the message in as it fades up, and - // fades it on the way out. - const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{}; - const fade = motion.alpha; - const page = p.theme().bg orelse chrome.tag_bg; - const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; - try p.renderHeaderLayer(arena, layer_index, .notice, @intCast(id), 0, .{ - .x = cx, - .y = first + @as(u16, pane.notices.row[i]), - .w = chip, - .h = BOX_H, - }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade)); - p.surface.tag_layers[layer_index].slide = motion.slide; - p.surface.tag_layers[layer_index].fade = fade; - // The caret rides the band, where the prompt's glyphs are, - // at the same place in the text the grid pass puts it. - if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; - const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); - const dropped = shown - panes.File.displayWidth(kept); - if (typed < dropped) continue; - p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert }; - } - } - if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { - if (p.col_w[column] == 0) continue; - const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false; - for (0..p.columnBarHeight()) |line| { - const index = if (line == 0) MAX_PANES + column else HEADER_LINE_LAYER_BASE + column * (MAX_TAG_ROWS - 1) + line - 1; - try p.renderHeaderLayer(arena, index, .column, @intCast(column), @intCast(line), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight() + @as(u16, @intCast(line)), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, modal.lineSlice(p.columnTag(column), line), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); - } - }; - } - - /// The span a header's selection covers on line `row` of its text, in - /// that line's bytes: up to the caret while typing, and over the - /// cursor's own cell too in normal mode, where a cursor is a cell. - pub fn headerSelection(t: *const Text, text: []const u8, row: usize) ?struct { lo: usize, hi: usize } { - if (!t.vsel.active) return null; - const anchor = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.vsel.row)), .col = @intCast(@max(0, t.vsel.col)) }); - const cursor = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }); - const lo = @min(anchor, cursor); - const hi = if (t.mode == .insert) @max(anchor, cursor) else modal.nextGrapheme(text, @max(anchor, cursor)); - const start = modal.lineStartOffset(text, row); - const end = modal.lineEndOffset(text, row); - if (hi <= start or lo >= end) return null; - return .{ .lo = @max(lo, start) - start, .hi = @min(hi, end) - start }; - } - - /// One row of a header or a notice band as a tag layer: `text` is line - /// `line` of what it shows. - fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { - _ = arena; - const layer = &p.surface.tag_layers[index]; - if (viewport.w == 0) { - layer.cols = 0; - return; - } - const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1))); - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); - layer.kind = kind; - layer.id = id; - layer.line = line; - layer.serial = if (kind == .column) p.col_serial[id] else 0; - layer.viewport = viewport; - layer.cols = columns; - layer.bg = bg; - layer.cursor = null; - layer.slide = 0; - layer.fade = 1; - var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; - // A notice band carries a PANE id, not a column index, so it must not - // answer to the column being edited -- that would scroll and select - // its text with the column tag's offsets and give it a second caret. - const editing: ?*Text = if (p.header_focus and switch (kind) { - .workspace => p.header_column == null, - .column => p.header_column == id, - .pane, .notice => false, - }) p.focusedHeader() else null; - const scroll = if (editing != null) p.header_scroll else 0; - surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); - if (editing) |t| { - if (headerSelection(t, try edit.flatSurface(p, t), line)) |span| { - var col = panes.File.rawDisplayCol(text, span.lo) -| scroll; - const end = panes.File.rawDisplayCol(text, span.hi) -| scroll; - while (col < end and col < columns) : (col += 1) { - surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; - surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; - } - } - const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; - if (t.cur_row == line and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; - } else if (p.pointer_inside) { - const tag_hit = p.pointer_tag_hit; - const col: ?usize = if (tag_hit) |pointer| - (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null) - else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) - p.hover_col - viewport.x - else - null; - if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| { - var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll; - const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll; - while (cell < end and cell < columns) : (cell += 1) { - surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; - surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; - } - }; - } - } - fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void { const tz = tracy.zone(@src(), "renderPane"); defer tz.end(); @@ -6687,7 +6031,7 @@ pub const Pardes = struct { .tty => config.tag_tty, }, box_style); - try p.paintPaneTag(arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET)); + try body_layer.paintPaneTag(p, arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET)); // Folded panes retain their body state, but neither body pixels nor // its cursor belong outside the one-row tag rectangle. @@ -6714,127 +6058,6 @@ pub const Pardes = struct { try body_layer.renderBody(p, arena, pane, r, id, active, true); } - fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { - const s = &p.surface; - const th = p.theme(); - const chrome = p.chromeTheme(); - const tx = r.x + @min(TAG_TEXT_INSET, r.w); - const tw = r.w -| TAG_TEXT_INSET; - 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; - const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; - 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), 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; - // 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) - .{ .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 (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; - 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, panes.File.rawAtDisplay(tag, pane.tag_scroll)); - const hi = @min(name.hi, tag.len); - if (lo < hi) { - var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll; - const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll; - const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg; - while (col < end and col < tw) : (col += 1) - s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg }; - } - } - 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) < 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 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); - 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) 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| { - 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 + @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) >= rows) continue; - body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); - } - } - pub fn appendImagePlace(p: *Pardes, place: ImagePlace) bool { const s = &p.surface; if (s.nimages == s.images.len) { @@ -6954,7 +6177,7 @@ test "raw terminal cursor obeys visibility without hiding modal and tag cursors" const modal_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(rect.x + config.GUTTER + 1, modal_cursor.x); try std.testing.expectEqual(body_y, modal_cursor.y); - p.enterTag(pane); + tagline.enterTag(p, pane); const tag_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y, tag_cursor.y); pane.focus = .body; diff --git a/src/tag_layer.zig b/src/tag_layer.zig index ac7c4ac2..7cfe5bbf 100644 --- a/src/tag_layer.zig +++ b/src/tag_layer.zig @@ -78,3 +78,28 @@ test "tag text capacity uses its own pitch and excludes the physical grip" { try std.testing.expect(layer.hitAt(120, 85, std.math.nan(f32), 20, 6, 12) == null); try std.testing.expect(layer.hitAt(120, 85, 10, 20, 0, 12) == null); } + +/// Whether two pointer hits name the same cell of the same tag line. +pub fn sameCell(a: ?Hit, b: ?Hit) bool { + const first = a orelse return b == null; + const second = b orelse return false; + return first.kind == second.kind and first.id == second.id and first.serial == second.serial and first.line == second.line and first.col == second.col; +} + +/// 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 columnAt(p: *const pardes.Pardes, kind: Kind, id: usize, supplied: ?Hit, 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 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; + const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw; + point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001)); + point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * bh; + break; + }; + return if (p.reprojectTagHit(point)) |mapped| mapped.col else null; +} diff --git a/src/tagline.zig b/src/tagline.zig new file mode 100644 index 00000000..1021e9db --- /dev/null +++ b/src/tagline.zig @@ -0,0 +1,360 @@ +//! The tags (acme's tag half of wind.c): a pane's computed prefix and its +//! default and saved tails, going into a tag and out again, the path drafted +//! as a new name, and the column and workspace tags above the panes. +const pardes = @import("pardes.zig"); +const std = @import("std"); +const modal = @import("modal.zig"); +const ctlfs = @import("ninep/tree.zig"); +const panes = @import("panes.zig"); +const edit = @import("edit.zig"); +const config = @import("config.zig"); +const pdf_enabled = panes.Pdf.enabled; +const dump = @import("dump.zig"); +const Pane = panes.Pane; +const Text = panes.Text; +const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; +const Pardes = pardes.Pardes; + +pub const pane_tail = " " ++ config.pane_builtins_str; +pub const pdf_pane_tail = " " ++ config.pdf_pane_builtins_str; +pub const file_pane_tail = " " ++ config.file_pane_builtins_str; +pub const context_pane_tail = file_pane_tail ++ " TreeContext"; +pub const locations_pane_tail = file_pane_tail ++ " LocationsConfig"; +pub const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str; +const dirty_marker = " *"; +pub const legacy_pane_tail = " Del"; +pub const legacy_file_pane_tail = " Save Del"; +// The defaults from the release before Newtty joined every tagline. Recognized +// so a dump written then upgrades instead of keeping the old word as a tail. +const prev_pane_tail = " New Del"; +pub const prev_file_pane_tail = " Save New Del"; +const legacy_terminal_pane_tail = " New Del Filter"; +// ...and the terminal default from the release before Save reached it, when a +// scrollback was not yet something you could write to a path. +pub const prev_terminal_pane_tail = " New Newtty Del Filter"; +const pre_tty_terminal_pane_tail = " Save New Newtty Del Filter"; +const pre_column_pane_tail = " New Newtty Del"; +pub const pre_column_file_pane_tail = " Save New Newtty Del"; +const pre_column_pdf_pane_tail = " New Newtty Del PdfSections PdfTint"; +pub const pre_column_terminal_pane_tail = " Save New Newtty Del Tty Filter"; +const pre_collapse_pane_tail = " Tty Del"; +pub const pre_collapse_file_pane_tail = " Save Tty Del"; +const pre_collapse_pdf_pane_tail = " Tty Del PdfSections PdfTint"; +pub const pre_collapse_terminal_pane_tail = " Save Tty Del Togglettymode Filter"; +pub const pre_tty_first_terminal_pane_tail = " Save Tty Del Togglettymode Filter Collapse"; +pub const pre_mode_terminal_pane_tail = " Tty Save Del Togglettymode Filter Collapse"; +// ...and the defaults from before the closing word moved to the end of every +// tagline, where a click cannot land on it by overshooting the one before. +const pre_close_last_pane_tail = " Tty Del Collapse"; +const pre_close_last_file_pane_tail = " Save Tty Del Collapse"; +const pre_close_last_pdf_pane_tail = " Tty Del PdfSections PdfTint Collapse"; +const pre_close_last_terminal_pane_tail = " Tty Save Del Mode Filter Collapse"; + +/// What a column's tag says until someone edits it. +pub const column_tag_default = "New Tty Find Grep Joincol Delcol"; + +/// What a header says until someone edits it: for the workspace the +/// fixed builtins, plus `Restore <path>` once a dump exists. +pub fn headerDefault(p: *Pardes, what: Text.What) []const u8 { + if (what == .column) return column_tag_default; + const d = p.last_dump orelse return config.topbar_str; + return std.fmt.allocPrint(p.scratch.allocator(), config.topbar_str ++ " Restore {s}", .{d}) catch config.topbar_str; +} + +/// the topbar line -- render and click dispatch must agree on this exact +/// string +pub fn topbar(p: *Pardes) []const u8 { + return p.global_tag.own orelse headerDefault(p, .workspace); +} + +pub fn columnTag(p: *const Pardes, c: usize) []const u8 { + return p.col_tags[c].own orelse column_tag_default; +} + +/// The header with the keyboard, when one has it. +pub fn focusedHeader(p: *Pardes) ?*Text { + if (!p.header_focus) return null; + if (p.header_column) |c| return if (c < p.ncol) &p.col_tags[c] else null; + return &p.global_tag; +} + +pub fn revealHeader(p: *Pardes) void { + const t = focusedHeader(p) orelse { + p.header_scroll = 0; + return; + }; + const text = edit.flatSurface(p, t) catch return; + const line = modal.lineSlice(text, @intCast(@max(0, t.cur_row))); + const width = p.tagCapacity(if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else p.screen_w); + const col = panes.File.rawDisplayCol(line, @min(@as(usize, @intCast(@max(0, t.cur_col))), line.len)); + if (col < p.header_scroll) p.header_scroll = col; + if (col >= p.header_scroll + width) p.header_scroll = col -| (width -| 1); + var raw = panes.File.rawAtDisplay(line, p.header_scroll); + p.header_scroll = panes.File.rawDisplayCol(line, raw); + if (width > 0 and col >= p.header_scroll + width) { + raw = modal.nextGrapheme(line, raw); + p.header_scroll = panes.File.rawDisplayCol(line, raw); + } +} + +pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 { + const arena = p.scratch.allocator(); + if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint( + arena, + "{s} [{d}/{d}]", + .{ pv.path, pv.page + 1, pv.page_count }, + ); + if (pane.image) |*state| return panes.Image.tagPrefix(arena, state); + if (pane.file) |f| { + const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path; + if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision) + return std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker }); + return arena.dupe(u8, name); + } + return arena.dupe(u8, pane.cwdSlice()); +} + +/// The tag's own text: what the user made of it, or until then the +/// default a pane of its kind starts with. +pub fn curTail(pane: *Pane) []const u8 { + return pane.tag.own orelse defaultTail(pane); +} + +pub fn defaultTail(pane: *const Pane) []const u8 { + if (comptime pdf_enabled) if (pane.pdf != null) return pdf_pane_tail; + if (pane.file) |file| if (file.output) |out| + if (panes.Output.usesLocationsConfig(out.from)) return locations_pane_tail; + if (panes.File.supportsContext(pane)) return context_pane_tail; + if (pane.file != null) return file_pane_tail; + if (pane.isTerminal()) return terminal_pane_tail; + return pane_tail; +} + +/// The tag as it is shown, read and pointed at: its computed prefix and +/// its own text after it. +pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { + const prefix = try tagPrefix(p, pane); + const tail = curTail(pane); + const out = try arena.alloc(u8, prefix.len + tail.len); + @memcpy(out[0..prefix.len], prefix); + @memcpy(out[prefix.len..], tail); + return out; +} + +/// Give the keyboard to the tag in normal mode (`:`), its cursor where +/// the tag last had it. The first time there is nothing to remember, and +/// it starts on Save, so that `:` and the execute key write the file. +pub fn enterTag(p: *Pardes, pane: *Pane) void { + const t = &pane.tag; + const tail = curTail(pane); + if (!t.cur_pinned) { + var words = std.mem.tokenizeAny(u8, tail, " \t\n"); + const at = while (words.next()) |word| { + if (std.mem.eql(u8, word, "Save")) break @intFromPtr(word.ptr) - @intFromPtr(tail.ptr); + } else tail.len - std.mem.trimStart(u8, tail, " ").len; + const cursor = modal.positionAt(tail, @min(at, tail.len)); + t.cur_row = @intCast(cursor.row); + t.cur_col = @intCast(cursor.col); + t.cur_pinned = true; + // Reveal the suffix together with its commands, not merely the + // first command letter at the viewport's far right edge. + if (p.paneIdOf(pane)) |id| { + const tag = tagText(p, p.scratch.allocator(), pane) catch return; + const line = tag[0 .. std.mem.indexOfScalar(u8, tag, '\n') orelse tag.len]; + const width = p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET); + pane.tag_scroll = @intCast(panes.File.displayWidth(line) + 1 -| width); + } + } + // The text may have changed since (a 9P write, a rename), so the + // remembered cursor is pulled back inside it. It is the cursor that + // is remembered, not a selection. + const rows = modal.cursorLineCount(tail); + t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1)); + const line = modal.lineSlice(tail, @intCast(t.cur_row)); + t.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, t.cur_col)))))); + t.vsel.active = false; + t.msel.active = false; + t.nsel = 0; + t.select = false; + t.append_at = null; + t.sticky_col = -1; + t.mode = .normal; + t.normal.clear(); + pane.focus = .tag; +} + +/// 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(tagPrefix(p, pane) catch return null) else 0; + if (col < width) return null; + 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 +/// column `col` of the path. Nothing changes until it is typed into. +pub fn draftName(p: *Pardes, pane: *Pane, col: usize) void { + const f = pane.file orelse return; + if (!panes.Output.fileTraits(f.output).saves) return; + const name = p.gpa.dupe(u8, f.path) catch return; + pane.closePrompt(); + pane.prompt = .name; + pane.input = .{ .what = .input, .own = name, .mode = .insert, .cur_pinned = true, .cur_col = @intCast(@min(name.len, panes.File.rawAtDisplay(name, col))) }; +} + +/// Commit a drafted buffer name, never a disk rename or write. Keep the +/// draft visible on failure so the user can correct it or cancel with Esc. +pub fn commitTagName(p: *Pardes, pane: *Pane) bool { + if (pane.prompt != .name) return true; + const id = p.paneIdOf(pane) orelse return false; + const name = pane.input.own orelse ""; + if (name.len == 0 or name[name.len - 1] == '/' or !std.unicode.utf8ValidateSlice(name)) { + p.reportError(id, "name", error.InvalidName); + return false; + } + for (name) |byte| if (byte < ' ') { + p.reportError(id, "name", error.InvalidName); + return false; + }; + const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ Pardes.paneDir(pane), name }) catch |err| { + p.reportError(id, "name", err); + return false; + }; + ctlfs.pane.nameBuffer(p, id, full, true) catch |err| { + p.reportError(id, "name", err); + return false; + }; + pane.closePrompt(); + return true; +} + +pub fn exitHeader(p: *Pardes) void { + p.header_focus = false; + p.header_column = null; + p.header_drag = false; + p.header_scroll = 0; +} + +/// Give the keyboard to a column's tag, or the workspace's for null, in +/// normal mode, its cursor where that header last had it. +pub fn enterHeader(p: *Pardes, column: ?usize) void { + exitHeader(p); + if (p.panes[p.active]) |pane| pane.focus = .body; + p.header_focus = true; + p.header_column = column; + const t = focusedHeader(p) orelse return exitHeader(p); + const text = edit.flatSurface(p, t) catch ""; + const rows = modal.cursorLineCount(text); + t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1)); + const line = modal.lineSlice(text, @intCast(t.cur_row)); + t.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, t.cur_col)))))); + t.cur_pinned = true; + t.vsel.active = false; + t.msel.active = false; + t.nsel = 0; + t.select = false; + t.append_at = null; + t.mode = .normal; + t.normal.clear(); +} + +pub fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void { + if (src.tag_tail) |tail| { + pane.tag.own = p.gpa.dupe(u8, tail) catch return; + return; + } + const tail_class = tailClass(pane); + if (src.kind == .image and panes.Pdf.isPath(src.image.?.path)) { + if (panes.Pdf.legacySavedPrefix(src.image.?.path, src.tag)) |prefix| + return restoreTailAt(p, pane, src.tag, prefix, .generic); + } + if (src.kind == .terminal and std.mem.startsWith(u8, src.tag, "TTY ")) { + const legacy = std.fmt.allocPrint(p.scratch.allocator(), "TTY {s}", .{src.terminal.?.cwd}) catch return; + if (std.mem.startsWith(u8, src.tag, legacy)) + return restoreTailAt(p, pane, src.tag, legacy, .terminal); + } + const current = tagPrefix(p, pane) catch return; + if (std.mem.startsWith(u8, src.tag, current)) + return restoreTailAt(p, pane, src.tag, savedPrefix(src.tag, current, tail_class), tail_class); + if (src.kind != .image) return; + const saved = src.image.?; + if (pane.image) |*state| if (panes.Image.legacySavedPrefix(state, src.tag)) |legacy| + return restoreTailAt(p, pane, src.tag, legacy, .generic); + if (panes.Pdf.isPath(saved.path)) if (panes.Pdf.legacySavedPrefix(saved.path, src.tag)) |legacy| + restoreTailAt(p, pane, src.tag, legacy, .generic); +} + +pub fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void { + const pfx = tagPrefix(p, pane) catch return; + const class = tailClass(pane); + restoreTailAt(p, pane, saved_tag, savedPrefix(saved_tag, pfx, class), class); +} + +const TailClass = enum { generic, file, output, terminal }; + +fn tailClass(pane: *const Pane) TailClass { + if (pane.file) |f| return if (f.output == null) .file else .output; + if (pane.isTerminal()) return .terminal; + return .generic; +} + +fn savedPrefix(saved_tag: []const u8, live: []const u8, class: TailClass) []const u8 { + if (class == .generic or class == .terminal) return live; + if (!std.mem.startsWith(u8, saved_tag, live)) return live; + const marked_len = live.len + dirty_marker.len; + if (saved_tag.len >= marked_len and std.mem.eql(u8, saved_tag[live.len..marked_len], dirty_marker)) + return saved_tag[0..marked_len]; + return live; +} + +pub fn restoreTailAt( + p: *Pardes, + pane: *Pane, + saved_tag: []const u8, + pfx: []const u8, + class: TailClass, +) void { + if (!std.mem.startsWith(u8, saved_tag, pfx)) return; + const rest = saved_tag[pfx.len..]; + const said = std.mem.trimStart(u8, rest, " "); + const defaults: []const []const u8 = switch (class) { + .generic => &.{ pdf_pane_tail, pane_tail, pre_close_last_pdf_pane_tail, pre_close_last_pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, + .file => &.{ context_pane_tail, file_pane_tail, pre_close_last_file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, + .output => &.{ + locations_pane_tail, + file_pane_tail, + pre_close_last_file_pane_tail, + pre_close_last_pane_tail, + prev_file_pane_tail, + legacy_file_pane_tail, + pane_tail, + prev_pane_tail, + legacy_pane_tail, + pre_column_file_pane_tail, + pre_column_pane_tail, + pre_collapse_file_pane_tail, + pre_collapse_pane_tail, + }, + .terminal => &.{ terminal_pane_tail, pre_close_last_terminal_pane_tail, pre_close_last_pane_tail, pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, + }; + for (defaults) |default| + if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return; + pane.tag.own = p.gpa.dupe(u8, rest) catch return; +} + +/// The span a header's selection covers on line `row` of its text, in +/// that line's bytes: up to the caret while typing, and over the +/// cursor's own cell too in normal mode, where a cursor is a cell. +pub fn headerSelection(t: *const Text, text: []const u8, row: usize) ?struct { lo: usize, hi: usize } { + if (!t.vsel.active) return null; + const anchor = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.vsel.row)), .col = @intCast(@max(0, t.vsel.col)) }); + const cursor = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }); + const lo = @min(anchor, cursor); + const hi = if (t.mode == .insert) @max(anchor, cursor) else modal.nextGrapheme(text, @max(anchor, cursor)); + const start = modal.lineStartOffset(text, row); + const end = modal.lineEndOffset(text, row); + if (hi <= start or lo >= end) return null; + return .{ .lo = @max(lo, start) - start, .hi = @min(hi, end) - start }; +} |
