diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 128 |
1 files changed, 106 insertions, 22 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 25f4e18e..90658589 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -2948,6 +2948,9 @@ pub const Theme = struct { tag_name_fg: ?[3]u8 = null, /// Active tint; absence preserves the shared name tint or active tag ink. tag_active_name_fg: ?[3]u8 = null, + /// Column grips stay distinct from pane grips in both focus states. + column_box: ?[3]u8 = null, + column_box_dim: ?[3]u8 = null, border: ?[3]u8 = null, search_bg: ?[3]u8 = null, search_fg: ?[3]u8 = null, @@ -3175,15 +3178,46 @@ test "legacy ThemeFile documents inherit Pardes UI roles without new fields" { try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_active_bg); try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_name_fg); try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_active_name_fg); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.column_box); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.column_box_dim); try std.testing.expectEqual(@as(?[3]u8, null), parsed.search_bg); const chrome = ChromeTheme.fromTheme(&parsed); try std.testing.expectEqual(parsed.tag_bg, chrome.tag_active_bg); try std.testing.expectEqual(parsed.tag_fg, chrome.tag_name_fg); try std.testing.expectEqual(chrome.tag_active_fg, chrome.tag_active_name_fg); + try std.testing.expectEqual(parsed.num, chrome.column_box); + try std.testing.expectEqual(mix(parsed.num, parsed.tag_bg), chrome.column_box_dim); try std.testing.expectEqual(parsed.sel_bg, chrome.search_bg); try std.testing.expectEqual(parsed.fg.?, chrome.diagnostic_error); } +test "column grip colors remain distinct from pane grips across native themes" { + for (themes[0..native_theme_count]) |th| { + try std.testing.expect(th.column_box != null and th.column_box_dim != null); + const chrome = ChromeTheme.fromTheme(&th); + for ([_][2][3]u8{ + .{ chrome.box, chrome.column_box }, + .{ chrome.box_dim, chrome.column_box_dim }, + }) |pair| { + var distance: u32 = 0; + for (pair[0], pair[1]) |a, b| { + const delta = @as(i32, a) - b; + distance += @intCast(delta * delta); + } + try std.testing.expect(distance >= 30 * 30); + } + } + var custom = themes[0]; + custom.column_box = .{ 90, 130, 170 }; + custom.column_box_dim = null; + var chrome = ChromeTheme.fromTheme(&custom); + try std.testing.expectEqual(custom.column_box.?, chrome.column_box); + try std.testing.expectEqual(mix(custom.column_box.?, custom.tag_bg), chrome.column_box_dim); + custom.column_box_dim = .{ 40, 60, 80 }; + chrome = ChromeTheme.fromTheme(&custom); + try std.testing.expectEqual(custom.column_box_dim.?, chrome.column_box_dim); +} + test "custom filename tints preserve optional active fallback precedence" { var theme = themes[0]; theme.tag_name_fg = null; @@ -3272,6 +3306,8 @@ pub const ChromeTheme = struct { diagnostic_hint: [3]u8, box: [3]u8, box_dim: [3]u8, + column_box: [3]u8, + column_box_dim: [3]u8, lineno: [3]u8, scroll_track: [3]u8, scroll_thumb: [3]u8, @@ -3293,6 +3329,8 @@ pub const ChromeTheme = struct { .diagnostic_hint = th.diagnostic_hint orelse (th.fg orelse th.tag_fg), .box = th.box, .box_dim = th.box_dim, + .column_box = th.column_box orelse th.num, + .column_box_dim = th.column_box_dim orelse mix(th.column_box orelse th.num, th.tag_bg), .lineno = th.lineno, .scroll_track = th.scroll_track, .scroll_thumb = th.scroll_thumb, @@ -4327,8 +4365,8 @@ test "editable workspace and column tags replace selections and persist" { p.update(.{ .key = .{ .cp = 'H', .text = "Help" } }); try std.testing.expect(std.mem.startsWith(u8, p.global_tag.slice(""), "Help Joincol")); p.update(.{ .key = .{ .cp = Key.escape } }); - p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = 0, .row = TOPBAR_H } }); - p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 0, .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = config.GUTTER, .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = config.GUTTER, .row = TOPBAR_H } }); p.update(.{ .key = .{ .cp = 'é', .text = "é" } }); try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "éNew")); p.update(.{ .key = .{ .cp = Key.backspace } }); @@ -4373,8 +4411,8 @@ test "column tags render edit hover and scroll from the column edge" { var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); for (0..p.ncol) |column| { - const x = p.col_x[column]; - const width = p.col_w[column]; + const x = p.col_x[column] + config.GUTTER; + const width = p.col_w[column] - config.GUTTER; const initial = try p.render(frame.allocator()); for ("New", 0..) |byte, offset| try std.testing.expectEqual(byte, initial.at(x + @as(u16, @intCast(offset)), TOPBAR_H).text[0]); @@ -4395,7 +4433,7 @@ test "column tags render edit hover and scroll from the column edge" { try std.testing.expect(std.mem.startsWith(u8, p.columnTag(column), "Help Tty")); const edited = try p.render(frame.allocator()); try std.testing.expectEqual(x + 4, edited.cursor.?.x); - // The two reclaimed cells belong to text and caret reveal, too. + // Only the text viewport participates in caret reveal. const long: [80]u8 = @splat('a'); try std.testing.expect(p.col_tags[column].set(long[0 .. width - 1])); p.update(.{ .key = .{ .cp = Key.end } }); @@ -4427,8 +4465,8 @@ test "column tag first-cell execution keeps New local to the clicked column" { p.active = p.col_panes[1 - column][0]; p.acknowledgePanelPresentation(&.{}); const before = p.col_n; - p.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = p.col_x[column], .row = TOPBAR_H } }); - p.update(.{ .mouse = .{ .button = .middle, .kind = .release, .col = p.col_x[column], .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = p.col_x[column] + config.GUTTER, .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .middle, .kind = .release, .col = p.col_x[column] + config.GUTTER, .row = TOPBAR_H } }); try std.testing.expectEqual(before[column] + 1, p.col_n[column]); try std.testing.expectEqual(before[1 - column], p.col_n[1 - column]); try std.testing.expectEqual(column, layout.findPane(p, p.active).?.col); @@ -4758,6 +4796,7 @@ const Drag = union(enum) { border_v: struct { left_col: usize, cur_x: u16, corner: ?struct { col: usize, idx: usize } = null, cur_y: u16 = 0 }, border_h: struct { col: usize, top_idx: usize, cur_y: u16 }, move: struct { id: usize, cur_x: u16, cur_y: u16 }, + column_move: struct { column: usize, serial: u32, cur_x: u16 }, tag: struct { id: usize }, /// chorded: a 1-2/1-3 cut/paste chord fired during this left drag — /// the drag's own release is then inert @@ -4774,7 +4813,7 @@ const Drag = union(enum) { fn button(drag: Drag) ?Mouse.Button { return switch (drag) { .none => null, - .border_v, .border_h, .move, .tag => config.select_button, + .border_v, .border_h, .move, .column_move, .tag => config.select_button, .select => |selection| selection.button, }; } @@ -5891,6 +5930,8 @@ pub const Pardes = struct { col_panes: [MAX_COLS][MAX_PANES]usize = undefined, col_n: [MAX_COLS]usize = @splat(0), col_tags: [MAX_COLS]TagLine = @splat(.{}), + col_serial: [MAX_COLS]u32 = @splat(0), + next_column_serial: u32 = 1, global_tag: TagLine = .{}, // derived each sync rects: [MAX_PANES]Rect = @splat(.{}), @@ -6217,7 +6258,7 @@ pub const Pardes = struct { return; }; const text = p.headerText(); - const width = p.tagCapacity(if (p.header_column) |c| p.col_w[c] else p.screen_w); + const width = p.tagCapacity(if (p.header_column) |c| p.col_w[c] -| config.GUTTER else p.screen_w); const col = panes.File.rawDisplayCol(text, @min(cur, text.len)); if (col < p.header_scroll) p.header_scroll = col; if (col >= p.header_scroll + width) p.header_scroll = col -| (width -| 1); @@ -11231,8 +11272,18 @@ pub const Pardes = struct { p.chord_arg = null; } } + if (mrow >= TOPBAR_H and mrow < TOPBAR_H + p.columnBarHeight() and m.kind == .press) { + for (0..p.ncol) |column| { + if (mcol < p.col_x[column] or mcol >= p.col_x[column] + @min(config.GUTTER, p.col_w[column])) continue; + if (m.button == config.select_button) { + p.header_drag = false; + p.drag = .{ .column_move = .{ .column = column, .serial = p.col_serial[column], .cur_x = mcol } }; + } + return; + } + } if (p.header_drag and m.button == config.select_button and (m.kind == .drag or m.kind == .release)) { - const x = if (p.header_column) |c| p.col_x[c] else 0; + const x = if (p.header_column) |c| p.col_x[c] + config.GUTTER else 0; const bar = p.headerText(); p.topbar_col = @intCast(@min(bar.len, panes.File.rawAtDisplay(bar, (p.tagColumn(if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + p.header_scroll))); if (m.kind == .release) p.header_drag = false; @@ -11249,7 +11300,7 @@ pub const Pardes = struct { if (mrow >= TOPBAR_H) { for (0..p.ncol) |c| if (mcol >= p.col_x[c] and mcol < p.col_x[c] + p.col_w[c]) { column = c; - x = p.col_x[c]; + x = p.col_x[c] + config.GUTTER; if (layout.findPane(p, p.active)) |active_col| { if (active_col.col != c and p.col_n[c] > 0) p.active = p.col_panes[c][0]; } @@ -11413,7 +11464,7 @@ pub const Pardes = struct { }, .drag => p.dragUpdate(mcol, mrow, p.pointer_body_hit), .release => { - if ((p.pointer_body_hit != null or p.pointer_tag_hit != null) and p.drag.button() == m.button) p.dragUpdate(mcol, mrow, p.pointer_body_hit); + if ((p.pointer_body_hit != null or p.pointer_tag_hit != null or p.drag == .column_move) and p.drag.button() == m.button) p.dragUpdate(mcol, mrow, p.pointer_body_hit); p.dragRelease(config.select_button); }, .motion => {}, @@ -11473,7 +11524,7 @@ pub const Pardes = struct { }, .drag => p.dragUpdate(mcol, mrow, p.pointer_body_hit), .release => { - if ((p.pointer_body_hit != null or p.pointer_tag_hit != null) and p.drag.button() == m.button) p.dragUpdate(mcol, mrow, p.pointer_body_hit); + if ((p.pointer_body_hit != null or p.pointer_tag_hit != null or p.drag == .column_move) and p.drag.button() == m.button) p.dragUpdate(mcol, mrow, p.pointer_body_hit); p.dragRelease(m.button); }, .motion => {}, @@ -11514,6 +11565,7 @@ pub const Pardes = struct { d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom); } else d.cur_y = mrow; }, + .column_move => |*d| d.cur_x = mcol, .move => |*d| { d.cur_x = mcol; d.cur_y = mrow; @@ -11701,6 +11753,10 @@ pub const Pardes = struct { }, .border_h => |d| if (d.cur_y != seamRowOf(p, d.col, d.top_idx)) layout.applyRowSplit(p, d.col, d.top_idx, d.cur_y), + .column_move => |d| { + if (layout.columnBySerial(p, d.serial)) |column| + layout.reorderColumn(p, column, layout.columnMoveTarget(p, column, d.cur_x)); + }, .move => |d| { layout.movePane(p, d.id, d.cur_x, d.cur_y); // a file moved into the left column evicts a lone unused shell @@ -12661,6 +12717,7 @@ pub const Pardes = struct { pane.collapsed = src.collapsed; } p.ncol = @min(st.columns.len, MAX_COLS); + p.col_serial = @splat(0); if (st.topbar_custom) |bar| _ = p.global_tag.set(bar); for (st.columns[0..p.ncol], 0..) |col, c| { if (col.tag) |bar| _ = p.col_tags[c].set(bar); @@ -13017,6 +13074,7 @@ pub const Pardes = struct { const pane = p.panes[layer.id] orelse return null; if (pane.serial != layer.serial) return null; } + if (layer.kind == .column and (layer.id >= p.ncol or p.col_serial[layer.id] != layer.serial)) return null; return layer.hitAt(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)); } return null; @@ -13156,12 +13214,19 @@ pub const Pardes = struct { if (col < s.cols) s.cursor = .{ .x = @intCast(col), .y = 0, .bar = p.header_insert }; }; if (p.columnBarHeight() > 0) for (0..p.ncol) |c| { - const x = p.col_x[c]; - const w = p.col_w[c]; - if (w == 0 or x >= s.cols) continue; + const grip = @min(config.GUTTER, p.col_w[c]); + const x = p.col_x[c] + grip; + const w = p.col_w[c] - grip; + 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; + const grip_bg = if (active_col) 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], TOPBAR_H, grip, 1, grip_style); + s.fill(x, TOPBAR_H, w, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); const bar_scroll = if (p.topbar_col != null and p.header_column == c) p.header_scroll else 0; const tag = p.columnTag(c); @@ -13185,8 +13250,8 @@ pub const Pardes = struct { }; if (p.topbar_col) |cur| { const bar = p.headerText(); - const x = if (p.header_column) |c| p.col_x[c] else 0; - const w = if (p.header_column) |c| p.col_w[c] else s.cols; + const x = if (p.header_column) |c| p.col_x[c] + config.GUTTER else 0; + const w = if (p.header_column) |c| p.col_w[c] -| config.GUTTER else s.cols; const y: u16 = if (p.header_column != null) TOPBAR_H else 0; const anchor = p.header_anchor orelse cur; var col = panes.File.rawDisplayCol(bar, @min(cur, anchor)) -| p.header_scroll; @@ -13218,6 +13283,26 @@ pub const Pardes = struct { var col = p.col_x[d.col]; while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); }, + .column_move => |d| { + const source = layout.columnBySerial(p, d.serial) orelse d.column; + const target = layout.columnMoveTarget(p, source, d.cur_x); + if (source < p.ncol and target != source) { + const boundary = if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target]; + const x = @min(boundary, s.cols -| 1); + // Leave the outside insertion rail visible above compact + // text layers. This clips presentation only, never reflows. + if (boundary == s.cols) { + for (&s.body_layers) |*layer| if (layer.rows > 0 and layer.viewport.x + layer.viewport.w == s.cols) { + layer.viewport.w -|= 1; + }; + for (&s.tag_layers) |*layer| if (layer.cols > 0 and layer.viewport.x + layer.viewport.w == s.cols and layer.viewport.y >= TOPBAR_H) { + layer.viewport.w -|= 1; + }; + } + var row: u16 = TOPBAR_H; + while (row < s.rows) : (row += 1) s.overlayDash(x, row, "╎"); + } + }, .move => |d| if (d.cur_x < s.cols) { if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| { var col: u16 = p.col_x[placement.preview_col]; @@ -13547,7 +13632,7 @@ pub const Pardes = struct { 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; - try p.renderHeaderLayer(arena, MAX_PANES + column, .column, @intCast(column), .{ .x = p.col_x[column], .y = TOPBAR_H, .w = p.col_w[column], .h = COLBAR_H }, p.columnTag(column), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); + try p.renderHeaderLayer(arena, MAX_PANES + column, .column, @intCast(column), .{ .x = p.col_x[column] + @min(config.GUTTER, p.col_w[column]), .y = TOPBAR_H, .w = p.col_w[column] -| config.GUTTER, .h = COLBAR_H }, p.columnTag(column), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); }; } @@ -13558,7 +13643,7 @@ pub const Pardes = struct { if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); layer.kind = kind; layer.id = id; - layer.serial = 0; + layer.serial = if (kind == .column) p.col_serial[id] else 0; layer.viewport = viewport; layer.cols = columns; layer.bg = bg; @@ -13678,8 +13763,7 @@ pub const Pardes = struct { const box_style: CellStyle = .{ .fg = .{ .rgb = box_ink }, .bg = .{ .rgb = box_bg }, - // The mode badge is the left end of the tagline, not body text. - // Carry the same role so every GUI backend scales the whole row. + // Grips keep physical grid dimensions, independently of tag text. .font_role = .tagline, }; s.fill(r.x, tag_y, config.GUTTER, BOX_H, box_style); |
