summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/gui/gui.zig123
-rw-r--r--src/layout.zig92
-rw-r--r--src/macos.zig5
-rw-r--r--src/macos/Sources/PardesView.swift68
-rw-r--r--src/macos/pardes.h1
-rw-r--r--src/pardes.zig128
-rw-r--r--src/themes/atelier.zig2
-rw-r--r--src/themes/clay.zig2
-rw-r--r--src/themes/daybreak.zig2
-rw-r--r--src/themes/dusk.zig2
-rw-r--r--src/themes/forge.zig2
-rw-r--r--src/themes/forge_black.zig2
-rw-r--r--src/themes/forge_soft.zig2
-rw-r--r--src/themes/harvest.zig2
-rw-r--r--src/themes/ink.zig2
-rw-r--r--src/themes/lagoon.zig2
-rw-r--r--src/themes/orchard.zig2
-rw-r--r--src/themes/orchard_black.zig2
-rw-r--r--src/themes/paper.zig2
-rw-r--r--src/themes/solarium.zig2
-rw-r--r--src/themes/spectrum.zig2
-rw-r--r--src/web.zig4
-rw-r--r--src/web/app.mjs37
23 files changed, 375 insertions, 113 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index f4c87ed1..10ac839e 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -4936,9 +4936,9 @@ fn renderFrame(
const data_diff = data_effect and surface.panelCellChanged(col, row);
const logical_idx: u32 = @as(u32, row) * surface.cols + col;
const instance_count = if (data_diff)
- cellInstanceCount(core, &surface.previous_cells[logical_idx], row) + cellInstanceCount(core, surface.at(col, row), row)
+ cellInstanceCount(&surface.previous_cells[logical_idx]) + cellInstanceCount(surface.at(col, row))
else
- cellInstanceCount(core, surface.at(col, row), row);
+ cellInstanceCount(surface.at(col, row));
paint_plan.batches[batch_index].cell_count = std.math.add(
u32,
paint_plan.batches[batch_index].cell_count,
@@ -4961,7 +4961,7 @@ fn renderFrame(
destination.cell_count = std.math.add(
u32,
destination.cell_count,
- cellInstanceCount(core, &surface.previous_cells[logical_idx], row),
+ cellInstanceCount(&surface.previous_cells[logical_idx]),
) catch return error.GpuCapacity;
}
}
@@ -5359,14 +5359,8 @@ test "concealed terminal text emits no ink on opaque or transparent ground" {
}
}
-fn drawnFontRole(core: ?*const pardes.Pardes, cell: *const pardes.Cell, row: u16) pardes.FontRole {
- const role = cellFontRole(cell);
- if (role != .tagline or core != null or row < pardes.TOPBAR_H) return role;
- return .body;
-}
-
-fn cellInstanceCount(core: ?*const pardes.Pardes, cell: *const pardes.Cell, row: u16) u32 {
- return if (drawnFontRole(core, cell, row) == .tagline) 2 else 1;
+fn cellInstanceCount(cell: *const pardes.Cell) u32 {
+ return if (cellFontRole(cell) == .tagline) 2 else 1;
}
fn taglineBaseRgb(core: *const pardes.Pardes, col: u16, row: u16) [3]u8 {
@@ -5427,13 +5421,7 @@ fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const p
for (0..tagLayerCellCount(g, layer)) |col| {
const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.col == col else false;
emitInstance(g, instances, next.*, @intCast(col), layer.viewport.y, .{ .w = tw, .h = bh, .x_off = left, .y_off = 0 }, win_w, win_h, track, .tagline, &layer.cells[col], old_layer, cursor, page);
- const instance = &instances[next.*];
- const edge = right / win_w * 2 - 1;
- if (instance.x1 > edge) {
- const fraction = (edge - instance.x0) / (instance.x1 - instance.x0);
- instance.u1 = instance.u0 + (instance.u1 - instance.u0) * fraction;
- instance.x1 = edge;
- }
+ clipInstanceRight(&instances[next.*], right, win_w);
next.* += 1;
}
}
@@ -5450,6 +5438,14 @@ fn bodyLayerIn(layers: []const pardes.BodyLayer, col: u16, row: u16) ?*const par
return null;
}
+fn clipInstanceRight(instance: *CellInstance, right: f32, win_w: f32) void {
+ const edge = right / win_w * 2 - 1;
+ if (instance.x1 <= edge) return;
+ const fraction = std.math.clamp((edge - instance.x0) / (instance.x1 - instance.x0), 0, 1);
+ instance.u1 = instance.u0 + (instance.u1 - instance.u0) * fraction;
+ instance.x1 = @max(instance.x0, edge);
+}
+
fn clipBodyInstance(instance: *CellInstance, bottom: f32, win_h: f32) void {
const limit = 1 - bottom / win_h * 2;
if (instance.y1 >= limit) return;
@@ -5464,6 +5460,7 @@ fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const
const tw: f32 = @floatFromInt(g.tagline_width);
const th: f32 = @floatFromInt(g.tagline_height);
const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw;
+ const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * bw;
const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * bh;
for (0..layer.rows) |index| {
const row: u16 = @intCast(index);
@@ -5478,14 +5475,17 @@ fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const
background.style.font_role = .body;
emitInstance(g, instances, next.*, 0, 0, .{ .w = @as(f32, @floatFromInt(layer.viewport.w)) * bw, .h = height, .x_off = left, .y_off = top }, win_w, win_h, track, .body, &background, old_layer, false, page);
clipBodyInstance(&instances[next.*], bottom, win_h);
+ clipInstanceRight(&instances[next.*], right, win_w);
next.* += 1;
}
for (0..layer.cols) |col| {
+ if (left + @as(f32, @floatFromInt(col)) * width >= right) break;
const cell = &layer.cells[index * layer.cols + col];
const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.x == col and cu.y == row else false;
const glyph_top = top - (if (header) @as(f32, @floatFromInt(taglineBandOffset(0, win_h, g.cell_h, g.tagline_height))) else 0);
emitInstance(g, instances, next.*, 0, 0, .{ .w = width, .h = height, .x_off = left + @as(f32, @floatFromInt(col)) * width, .y_off = glyph_top }, win_w, win_h, track, role, cell, old_layer, cursor, page);
clipBodyInstance(&instances[next.*], bottom, win_h);
+ clipInstanceRight(&instances[next.*], right, win_w);
next.* += 1;
}
if (layer.hasContextBorderAfter(row)) {
@@ -5497,6 +5497,7 @@ fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const
.y_off = top + height - 1,
}, win_w, win_h, track, .body, &border, old_layer, false, page);
clipBodyInstance(&instances[next.*], bottom, win_h);
+ clipInstanceRight(&instances[next.*], right, win_w);
next.* += 1;
}
}
@@ -5519,7 +5520,7 @@ fn emitSurfaceCell(
is_cursor: bool,
page: Ground,
) void {
- const role = drawnFontRole(core, cell, row);
+ const role = cellFontRole(cell);
if (role == .tagline) {
var band_base = tagline_base.*;
if (core) |p| band_base.style.bg = .{ .rgb = taglineBaseRgb(p, col, row) };
@@ -5528,8 +5529,9 @@ fn emitSurfaceCell(
// adjacent workspace, column and pane tags at TaglineSize < 100.
emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, &band_base, old_layer, false, page);
next.* += 1;
- const tag_layout = taglineLayoutForCell(g, col);
- emitInstance(g, instances, next.*, col, row, tag_layout, win_w, win_h, track, .tagline, cell, old_layer, is_cursor, page);
+ // Tag glyphs keep their font size inside a full physical-width grip.
+ emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, cell, old_layer, is_cursor, page);
+ instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w;
next.* += 1;
return;
}
@@ -5556,8 +5558,14 @@ test "small tagline fonts leave no background gutters between adjacent tags" {
try std.testing.expectApproxEqAbs(@as(f32, 20.0 / 480.0 * 2), base.y0 - base.y1, 0.0001);
try std.testing.expectApproxEqAbs(@as(f32, 16.0 / 480.0 * 2), ink.y0 - ink.y1, 0.0001);
try std.testing.expect(ink.y0 <= base.y0 and ink.y1 >= base.y1);
+ try std.testing.expectApproxEqAbs(@as(f32, 10.0 / 800.0 * 2), ink.x1 - ink.x0, 0.0001);
+ try std.testing.expectApproxEqAbs(@as(f32, 10) / atlas_w, ink.u1 - ink.u0, 0.0001);
if (row > 0) try std.testing.expectApproxEqAbs(instances[(row - 1) * 2].y1, base.y0, 0.0001);
}
+ next = 0;
+ emitSurfaceCell(&g, null, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, &cell, &cell, true, false, Ground.opaqueRgb(bg_default));
+ try std.testing.expectEqual(@as(u32, 2), next);
+ try std.testing.expectApproxEqAbs(@as(f32, 16.0 / 480.0 * 2), instances[1].y0 - instances[1].y1, 0.0001);
}
fn emitInstance(
@@ -6335,11 +6343,12 @@ fn buildOverlay(
});
}
if (transient_on) {
- if (core) |p| appendPaneChrome(g, p, surface, layout, &builder);
+ if (core) |p| appendPaneChrome(p, surface, layout, &builder);
+ appendTagGrips(g, surface, layout, &builder);
if (surface.cursor) |cursor| {
if (cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) {
const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x];
- const role = drawnFontRole(core, &cell, cursor.y);
+ const role = cellFontRole(&cell);
const visual_height: f32 = if (role == .tagline)
@floatFromInt(g.tagline_height)
else
@@ -6504,7 +6513,6 @@ fn cellBackgroundIs(cell: *const pardes.Cell, rgb: [3]u8) bool {
// The canonical surface still owns scrollbar positions and hit targets. Only
// their pixel ink is inset here; no layout, selection or mouse coordinates move.
fn appendPaneChrome(
- g: *const Gui,
core: *const pardes.Pardes,
surface: *const pardes.Surface,
layout: CellLayout,
@@ -6520,31 +6528,13 @@ fn appendPaneChrome(
const body_end = body_y + r.h - pardes.BOX_H;
const x0 = @as(f32, @floatFromInt(r.x)) * layout.w;
const x1 = x0 + @as(f32, @floatFromInt(r.w)) * layout.w;
- const tag_cell = &surface.cells[@as(usize, tag_y) * surface.cols + r.x];
- const tag_cells = surface.cells[@as(usize, tag_y) * surface.cols + r.x ..][0..r.w];
+ const tag_cells = surface.cells[@as(usize, tag_y) * surface.cols + r.x + config.GUTTER ..][0 .. r.w - config.GUTTER];
const tag_visible = for (tag_cells) |*cell| {
if (cellFontRole(cell) != .tagline) break false;
} else true;
if (tag_visible) {
const rule_y = @as(f32, @floatFromInt(tag_y + @as(u16, if (core.settings.tag_bottom) 0 else 1))) * layout.h;
builder.addRect(x0, rule_y - @as(f32, if (core.settings.tag_bottom) 0 else 1), x1, rule_y + @as(f32, if (core.settings.tag_bottom) 1 else 0), overlayRgb(chrome.border));
-
- // A quiet grip gives the normal-mode move box a purpose at rest.
- // Insert and terminal badges retain their existing visible glyphs.
- if (tag_cell.printableAscii() == ' ' and g.tagline_height >= 6 and g.tagline_width >= 3) {
- const tag_layout = fixedCellLayout(g);
- const center_x = tag_layout.x_off + (@as(f32, @floatFromInt(r.x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * tag_layout.w;
- const center_y = @as(f32, @floatFromInt(tag_y)) * layout.h +
- @as(f32, @floatFromInt(taglineBandOffset(tag_y, builder.win_h, g.cell_h, g.tagline_height))) +
- @as(f32, @floatFromInt(g.tagline_height)) / 2;
- const half_w = @max(1, @min(3, tag_layout.w - 2));
- const ink = overlayRgb(switch (tag_cell.style.fg) {
- .rgb => |rgb| rgb,
- else => chrome.tag_fg,
- });
- builder.addRect(@floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), ink);
- builder.addRect(@floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), ink);
- }
}
// Do not paint over resize previews or debug text occupying the rail.
@@ -6573,6 +6563,46 @@ fn appendPaneChrome(
appendColumnSpines(core, surface.rows, layout, builder);
}
+// Tag handles use the canonical physical cells, including detached frames.
+// Their tag text begins at the layer viewport and may use a narrower font.
+fn appendTagGrips(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout, builder: *OverlayBuilder) void {
+ if (g.tagline_height < 6 or g.tagline_width < 3) return;
+ for (surface.tagLayers()) |layer| {
+ if (layer.cols == 0 or layer.kind == .workspace or layer.viewport.x < config.GUTTER) continue;
+ const x = layer.viewport.x - config.GUTTER;
+ const y = layer.viewport.y;
+ if (x >= surface.cols or y >= surface.rows) continue;
+ const cell = &surface.cells[@as(usize, y) * surface.cols + x];
+ if (cell.default or cell.printableAscii() != ' ') continue;
+ const rgb = switch (cell.style.fg) {
+ .rgb => |rgb| rgb,
+ else => continue,
+ };
+ const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w;
+ const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2;
+ const half_w = @max(1, @min(3, layout.w - 2));
+ builder.addRect(@floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), overlayRgb(rgb));
+ builder.addRect(@floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), overlayRgb(rgb));
+ }
+}
+
+test "column and pane grip marks share physical centering" {
+ var g: Gui = undefined;
+ g.tagline_height = 12;
+ g.tagline_width = 6;
+ g.cell_h = 20;
+ var cells: [80]pardes.Cell = @splat(.{ .default = false, .style = .{ .fg = .{ .rgb = .{ 100, 120, 140 } } } });
+ var surface: pardes.Surface = .{ .cols = 20, .rows = 4, .cells = &cells };
+ surface.tag_layers[0] = .{ .kind = .column, .viewport = .{ .x = 7, .y = 1, .w = 13, .h = 1 }, .cols = 20 };
+ surface.tag_layers[1] = .{ .kind = .pane, .viewport = .{ .x = 7, .y = 3, .w = 13, .h = 1 }, .cols = 20 };
+ var vertices: [24]OverlayVertex = undefined;
+ var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 200, .win_h = 80 };
+ appendTagGrips(&g, &surface, .{ .w = 10, .h = 20, .x_off = 0, .y_off = 0 }, &builder);
+ try std.testing.expectEqual(@as(usize, 24), builder.len);
+ for (0..12) |i| try std.testing.expectEqual(vertices[i].x, vertices[i + 12].x);
+ try std.testing.expectApproxEqAbs(@as(f32, 57.0 / 200.0 * 2.0 - 1.0), vertices[0].x, 0.0001);
+}
+
fn appendSquareRail(builder: *OverlayBuilder, x: f32, y0: f32, y1: f32, cell_w: f32, thumb: bool, color: OverlayColor) void {
const width = @min(cell_w, if (thumb) @max(2, @floor(cell_w * 0.5)) else 2);
builder.addRect(x, y0, x + width, y1, color);
@@ -6955,6 +6985,13 @@ test "compact body layer rendering fills recovered rows and clips the final glyp
try std.testing.expectApproxEqAbs(@as(f32, 0.12), instances[14].y0, 0.0001);
try std.testing.expectApproxEqAbs(@as(f32, -0.4), instances[19].y1, 0.0001);
try std.testing.expectApproxEqAbs(instances[19].v0 + @as(f32, 12) / atlas_h, instances[19].v1, 0.0001);
+ // A drag preview reserves the last physical column without changing cells.
+ layer.viewport.w = 1;
+ for ([_]bool{ false, true }) |old| {
+ next = 0;
+ emitBodyLayer(&g, &instances, &next, &layer, 200, 200, null, Ground.opaqueRgb(bg_default), false, old, .{ 80, 90, 100 });
+ for (instances[0..next]) |instance| try std.testing.expect(instance.x1 <= -0.6 + 0.0001);
+ }
}
test "compact tag layers fill available width and clip at their physical edge" {
diff --git a/src/layout.zig b/src/layout.zig
index f789ba02..47b2c1a4 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -707,6 +707,55 @@ pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) vo
}
}
+pub fn columnBySerial(p: *const Pardes, serial: u32) ?usize {
+ if (serial == 0) return null;
+ for (0..p.ncol) |column| if (p.col_serial[column] == serial) return column;
+ return null;
+}
+
+pub fn columnMoveTarget(p: *const Pardes, source: usize, x: u16) usize {
+ if (source >= p.ncol) return source;
+ var insertion: usize = 0;
+ for (0..p.ncol) |column| {
+ if (column == source) continue;
+ if (x >= p.col_x[column] + p.col_w[column] / 2) insertion += 1;
+ }
+ return insertion;
+}
+
+pub fn reorderColumn(p: *Pardes, from: usize, to: usize) void {
+ if (from >= p.ncol or to >= p.ncol or from == to) return;
+ const ids = p.col_panes[from];
+ const count = p.col_n[from];
+ const weight = p.col_weight[from];
+ const tag = p.col_tags[from];
+ const serial = p.col_serial[from];
+ var at = from;
+ while (at != to) {
+ const next = if (at < to) at + 1 else at - 1;
+ p.col_panes[at] = p.col_panes[next];
+ p.col_n[at] = p.col_n[next];
+ p.col_weight[at] = p.col_weight[next];
+ p.col_tags[at] = p.col_tags[next];
+ p.col_serial[at] = p.col_serial[next];
+ at = next;
+ }
+ p.col_panes[to] = ids;
+ p.col_n[to] = count;
+ p.col_weight[to] = weight;
+ p.col_tags[to] = tag;
+ p.col_serial[to] = serial;
+ if (p.header_column) |column| {
+ if (column == from) p.header_column = to else if (from < to and column > from and column <= to) {
+ p.header_column = column - 1;
+ } else if (from > to and column >= to and column < from) p.header_column = column + 1;
+ }
+ p.pointer_tag_hit = null;
+ p.look_hover_wait = null;
+ p.look_hover_preview = null;
+ compute(p);
+}
+
pub fn targetColumn(p: *Pardes, cur_x: u16) usize {
var tc: usize = if (p.ncol > 0) p.ncol - 1 else 0;
for (0..p.ncol) |c| {
@@ -862,9 +911,11 @@ pub fn removePane(p: *Pardes, id: usize) void {
p.col_n[j] = p.col_n[j + 1];
p.col_weight[j] = p.col_weight[j + 1];
p.col_tags[j] = p.col_tags[j + 1];
+ p.col_serial[j] = p.col_serial[j + 1];
}
p.ncol -= 1;
p.col_tags[p.ncol] = .{};
+ p.col_serial[p.ncol] = 0;
}
}
@@ -883,9 +934,11 @@ pub fn joinCol(p: *Pardes) void {
p.col_n[j] = p.col_n[j + 1];
p.col_weight[j] = p.col_weight[j + 1];
p.col_tags[j] = p.col_tags[j + 1];
+ p.col_serial[j] = p.col_serial[j + 1];
}
p.ncol -= 1;
p.col_tags[p.ncol] = .{};
+ p.col_serial[p.ncol] = 0;
}
pub fn canSplitColumn(p: *Pardes, source_id: usize) bool {
@@ -931,9 +984,11 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool {
p.col_n[j] = p.col_n[j - 1];
p.col_weight[j] = p.col_weight[j - 1];
p.col_tags[j] = p.col_tags[j - 1];
+ p.col_serial[j] = p.col_serial[j - 1];
}
p.col_weight[c] = new_weight;
p.col_tags[c] = .{};
+ p.col_serial[c] = 0;
p.col_panes[c][0] = id;
p.col_n[c] = 1;
p.ncol += 1;
@@ -1103,6 +1158,11 @@ fn computeCollapsedColumn(p: *Pardes, c: usize, x: u16, width: u16, folded: usiz
pub fn compute(p: *Pardes) void {
p.rects = @splat(.{});
if (p.ncol == 0) return;
+ for (0..p.ncol) |column| if (p.col_serial[column] == 0) {
+ p.col_serial[column] = p.next_column_serial;
+ p.next_column_serial +%= 1;
+ if (p.next_column_serial == 0) p.next_column_serial = 1;
+ };
var wsum: u128 = 0;
for (0..p.ncol) |c| wsum += p.col_weight[c];
if (wsum == 0) wsum = 1;
@@ -2140,3 +2200,35 @@ test "presentation snapshots own compact body and tag rows across later edits" {
try std.testing.expectEqual(@as(u8, 'B'), state.previous_body_layers[0].cells[0].text[0]);
try std.testing.expectEqual(@as(u8, 'U'), state.previous_tag_layers[0].cells[0].text[0]);
}
+
+test "column reorder moves only crossed state and preserves weights" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 173, .rows = 20 });
+ defer p.deinit();
+ p.ncol = 4;
+ p.col_n = @splat(0);
+ for (0..4) |c| {
+ p.col_weight[c] = c * 13 + 7;
+ p.col_serial[c] = @intCast(c + 10);
+ p.col_panes[c][0] = c;
+ p.col_tags[c].saved_col = @intCast(c + 20);
+ }
+ compute(p);
+ const original_weights = p.col_weight;
+ const outer_x = p.col_x[0];
+ const outer_w = p.col_w[0];
+ p.header_column = 1;
+ p.topbar_col = 23;
+ p.header_scroll = 9;
+ reorderColumn(p, 1, 3);
+ try std.testing.expectEqual(original_weights[1], p.col_weight[3]);
+ 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(?u16, 23), p.topbar_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]);
+ reorderColumn(p, 3, 1);
+ try std.testing.expectEqualSlices(u64, original_weights[0..4], p.col_weight[0..4]);
+ try std.testing.expectEqual(@as(?u16, 21), p.col_tags[1].saved_col);
+}
diff --git a/src/macos.zig b/src/macos.zig
index 5406fcb4..febb7f88 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -1876,6 +1876,10 @@ export fn pardes_cursor_x() i32 {
return if (st.core.surface.cursor) |c| c.x else -1;
}
+export fn pardes_grip_columns() u32 {
+ return @import("config.zig").GUTTER;
+}
+
export fn pardes_tag_layer_limit() u32 {
return pardes.MAX_TAG_LAYERS;
}
@@ -2414,6 +2418,7 @@ test "pardes.h declares every export the way it is defined" {
try expectSameAbi(@TypeOf(c.pardes_pointer_shape), @TypeOf(pardes_pointer_shape));
try expectSameAbi(@TypeOf(c.pardes_mouse_pixel), @TypeOf(pardes_mouse_pixel));
try expectSameAbi(@TypeOf(c.pardes_row_metrics), @TypeOf(pardes_row_metrics));
+ try expectSameAbi(@TypeOf(c.pardes_grip_columns), @TypeOf(pardes_grip_columns));
try expectSameAbi(@TypeOf(c.pardes_tag_layer_limit), @TypeOf(pardes_tag_layer_limit));
try expectSameAbi(@TypeOf(c.pardes_tag_layer_value), @TypeOf(pardes_tag_layer_value));
try expectSameAbi(@TypeOf(c.pardes_tag_layer_cells), @TypeOf(pardes_tag_layer_cells));
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift
index 0b16458e..4c122a13 100644
--- a/src/macos/Sources/PardesView.swift
+++ b/src/macos/Sources/PardesView.swift
@@ -512,18 +512,10 @@ private struct TaglineMetrics {
UInt16(clamping: row), Float(canvasHeight * scale), cellPixels, bandPixels)) / scale
}
- /// Where a tagline cell's GLYPH sits, on the compact grid.
- ///
- /// Only the text compacts. The colour band behind it was already painted
- /// pane-wide on the body grid, which is what keeps a tag bar the width of
- /// its pane; this steps the characters on top of it at the tagline face's
- /// own pitch, anchored so the compact grid's column zero is the pane's
- /// physical left edge. The anchor is the core's — the SDL shell reaches the
- /// identical rule through `pardes.taglineOriginCol`, and pane rects are not
- /// otherwise on this shell's ABI at all.
+ /// Canonical tag cells are physical grip slots; TagLayer positions text.
func glyphX(col: Int, row: Int, bodyCellWidth: CGFloat) -> CGFloat {
- let origin = CGFloat(pardes_tagline_origin_col(UInt16(clamping: col), UInt16(clamping: row)))
- return origin * bodyCellWidth + (CGFloat(col) - origin) * width
+ // Canonical tag cells are physical grips; TagLayer owns compact text.
+ CGFloat(col) * bodyCellWidth
}
/// Thickness of the rule joining the topbar band to the first pane-tag band,
@@ -992,14 +984,6 @@ final class PardesView: NSView {
// Band geometry is stated against the whole canvas: the last row's rule
// depends on where the window edge is, not just on the row index.
let canvasHeight = bounds.height
- // The tag band's own colour, fetched once per frame. A compact tag row
- // is painted in TWO passes — pane-wide band on the body grid, then each
- // cell's own background on the narrower grid its glyph uses — which is
- // what the SDL shell spends its second quad per tagline cell on. Before
- // there is a session to ask there is no base and the single body-grid
- // fill below is all there is.
- let taglineBaseBG = pardes_tagline_bg()
- let taglineTwoPass = taglineBaseBG != UInt32(PARDES_COLOR_DEFAULT)
for row in 0..<rows {
let base = row * cols
let y = CGFloat(row) * cellHeight
@@ -1025,29 +1009,8 @@ final class PardesView: NSView {
if color != bgClear {
let bandY = tagline ? y + bandTop : y
let bandHeight = tagline ? taglines.height : cellHeight
- if tagline && taglineTwoPass {
- // The band keeps the BODY grid so it spans its pane
- // exactly. Anything that is NOT the band — a hovered
- // word, a selection, the block cursor — belongs to the
- // cell and has to land under the glyph, which moved to
- // the narrower grid. Runs are painted left to right and
- // a compact overlay always sits left of its own run's
- // right edge, so no later band can cover one.
- fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY,
- width: CGFloat(col - start) * cellWidth, height: bandHeight),
- taglineBaseBG, 1)
- if color != taglineBaseBG {
- fill(ctx, CGRect(
- x: taglines.glyphX(col: start, row: row, bodyCellWidth: cellWidth),
- y: bandY,
- width: CGFloat(col - start) * taglines.width,
- height: bandHeight), color, 1)
- }
- } else {
- fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY,
- width: CGFloat(col - start) * cellWidth, height: bandHeight),
- color, 1)
- }
+ fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY,
+ width: CGFloat(col - start) * cellWidth, height: bandHeight), color, 1)
}
start = col
color = next
@@ -1168,9 +1131,10 @@ final class PardesView: NSView {
}
if frozenImages == nil {
drawBodyLayers(ctx, hideCursor:hideCursor)
- drawTagLayers(ctx, hideCursor:hideCursor)
+ drawTagLayers(ctx, hideCursor:hideCursor, canonical:cells, cols:cols, rows:rows)
} else {
for layer in presentedTagLayers {
+ drawTagGrip(ctx, values:layer.values, canonical:cells, cols:cols, rows:rows)
layer.cells.withUnsafeBufferPointer { cells in
if let base = cells.baseAddress { drawTagLayer(ctx, values:layer.values, cells:base, hideCursor:true) }
}
@@ -1197,14 +1161,30 @@ final class PardesView: NSView {
return result
}
- private func drawTagLayers(_ ctx: CGContext, hideCursor: Bool) {
+ private func drawTagLayers(_ ctx: CGContext, hideCursor: Bool, canonical: UnsafePointer<pardes_cell_s>, cols: Int, rows: Int) {
for index in 0..<pardes_tag_layer_limit() {
let values = (0...10).map { Int(pardes_tag_layer_value(index, UInt32($0))) }
if values[0] == 0 { continue }
+ drawTagGrip(ctx, values:values, canonical:canonical, cols:cols, rows:rows)
if let cells = pardes_tag_layer_cells(index) { drawTagLayer(ctx, values: values, cells: cells, hideCursor: hideCursor) }
}
}
+ private func drawTagGrip(_ ctx: CGContext, values: [Int], canonical: UnsafePointer<pardes_cell_s>, cols: Int, rows: Int) {
+ let width = Int(pardes_grip_columns())
+ let x = values[4] - width, y = values[5]
+ if values[1] == 0 || x < 0 || x >= cols || y >= rows || taglines.height < 6 || taglines.width < 3 { return }
+ var cell = canonical[y * cols + x]
+ let text = withUnsafeBytes(of: &cell.text) { String(decoding: $0.prefix(Int(cell.len)), as: UTF8.self) }
+ if text != " " { return }
+ let style = resolve(cell, block:false, ground:themeBG ?? pardesDefaultBG, clearGround:false)
+ let centerX = (CGFloat(x) + CGFloat(width) / 2) * cellWidth
+ let centerY = CGFloat(y) * cellHeight + taglines.top(row:y, canvasHeight:bounds.height) + taglines.height / 2
+ let halfWidth = max(1, min(3, cellWidth - 2))
+ fill(ctx, CGRect(x:floor(centerX-halfWidth), y:floor(centerY-2), width:2*halfWidth, height:1), style.fg, 1)
+ fill(ctx, CGRect(x:floor(centerX-halfWidth), y:floor(centerY+1), width:2*halfWidth, height:1), style.fg, 1)
+ }
+
private func drawTagLayer(_ ctx: CGContext, values: [Int], cells: UnsafePointer<pardes_cell_s>, hideCursor: Bool) {
let left = CGFloat(values[4]) * cellWidth
let y = CGFloat(values[5]) * cellHeight + taglines.top(row: values[5], canvasHeight: bounds.height)
diff --git a/src/macos/pardes.h b/src/macos/pardes.h
index e72f134a..15a76d04 100644
--- a/src/macos/pardes.h
+++ b/src/macos/pardes.h
@@ -341,6 +341,7 @@ void pardes_mouse_pixel(pardes_mouse_button_e button, pardes_mouse_kind_e kind,
uint16_t col, uint16_t row, uint32_t mods, float x, float y,
float body_w, float body_h, float tagline_w, float tagline_h);
void pardes_row_metrics(uint16_t body_w, uint16_t body_h, uint16_t tagline_w, uint16_t tagline_h);
+uint32_t pardes_grip_columns(void);
uint32_t pardes_tag_layer_limit(void);
uint32_t pardes_tag_layer_value(uint32_t index, uint32_t field);
const pardes_cell_s *pardes_tag_layer_cells(uint32_t index);
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);
diff --git a/src/themes/atelier.zig b/src/themes/atelier.zig
index bd0e9639..0193e2e9 100644
--- a/src/themes/atelier.zig
+++ b/src/themes/atelier.zig
@@ -13,6 +13,8 @@ pub const theme = .{
.tag_active_fg = .{ 0x35, 0x58, 0x4d },
.box = .{ 0x3e, 0x7a, 0x68 },
.box_dim = .{ 0x99, 0xb6, 0xa3 },
+ .column_box = .{ 0x88, 0x57, 0x22 },
+ .column_box_dim = .{ 0xb6, 0xa3, 0x87 },
.kw = .{ 0x76, 0x4d, 0x80 },
.str = .{ 0x3c, 0x6c, 0x43 },
.num = .{ 0x88, 0x57, 0x22 },
diff --git a/src/themes/clay.zig b/src/themes/clay.zig
index 3c729dd8..af824764 100644
--- a/src/themes/clay.zig
+++ b/src/themes/clay.zig
@@ -14,6 +14,8 @@ pub const theme = .{
.tag_active_fg = .{ 0xe3, 0xe2, 0xc2 },
.box = .{ 0x8e, 0xc0, 0x7c },
.box_dim = .{ 0x67, 0x77, 0x52 },
+ .column_box = .{ 0xd3, 0x86, 0x9b },
+ .column_box_dim = .{ 0x83, 0x62, 0x68 },
.kw = .{ 0xfb, 0x8a, 0x69 },
.str = .{ 0xb8, 0xbb, 0x26 },
.num = .{ 0xd3, 0x86, 0x9b },
diff --git a/src/themes/daybreak.zig b/src/themes/daybreak.zig
index eca2e7fc..c55069a9 100644
--- a/src/themes/daybreak.zig
+++ b/src/themes/daybreak.zig
@@ -13,6 +13,8 @@ pub const theme = .{
.tag_active_fg = .{ 0x21, 0x3e, 0x56 },
.box = .{ 0x07, 0x5f, 0x91 },
.box_dim = .{ 0x81, 0x9d, 0xb1 },
+ .column_box = .{ 0x76, 0x42, 0x09 },
+ .column_box_dim = .{ 0xb1, 0x9a, 0x80 },
.kw = .{ 0x62, 0x2a, 0x88 },
.str = .{ 0x24, 0x5b, 0x30 },
.num = .{ 0x76, 0x42, 0x09 },
diff --git a/src/themes/dusk.zig b/src/themes/dusk.zig
index ac3e4301..32948d02 100644
--- a/src/themes/dusk.zig
+++ b/src/themes/dusk.zig
@@ -13,6 +13,8 @@ pub const theme = .{
.tag_active_fg = .{ 0xd2, 0xc2, 0xd1 },
.box = .{ 0xe4, 0xb3, 0x9b },
.box_dim = .{ 0x71, 0x57, 0x68 },
+ .column_box = .{ 0xba, 0xc8, 0x94 },
+ .column_box_dim = .{ 0x73, 0x77, 0x62 },
.kw = .{ 0xcf, 0xb1, 0xe8 },
.str = .{ 0xba, 0xc8, 0x94 },
.num = .{ 0xe8, 0xb5, 0x92 },
diff --git a/src/themes/forge.zig b/src/themes/forge.zig
index 83273329..7f78f2ca 100644
--- a/src/themes/forge.zig
+++ b/src/themes/forge.zig
@@ -14,6 +14,8 @@ pub const theme = .{
.tag_active_fg = .{ 0xd1, 0xd5, 0xde },
.box = .{ 0xa5, 0xc4, 0xee },
.box_dim = .{ 0x59, 0x65, 0x77 },
+ .column_box = .{ 0xb5, 0xce, 0xa8 },
+ .column_box_dim = .{ 0x6b, 0x79, 0x69 },
.kw = .{ 0x75, 0xb5, 0xe8 },
.str = .{ 0xce, 0x91, 0x78 },
.num = .{ 0xb5, 0xce, 0xa8 },
diff --git a/src/themes/forge_black.zig b/src/themes/forge_black.zig
index b9847472..d9835110 100644
--- a/src/themes/forge_black.zig
+++ b/src/themes/forge_black.zig
@@ -14,6 +14,8 @@ pub const theme = .{
.tag_active_fg = .{ 0xd1, 0xd5, 0xde },
.box = .{ 0xa5, 0xc4, 0xee },
.box_dim = .{ 0x59, 0x65, 0x77 },
+ .column_box = .{ 0xb5, 0xce, 0xa8 },
+ .column_box_dim = .{ 0x6b, 0x79, 0x69 },
.kw = .{ 0x75, 0xb5, 0xe8 },
.str = .{ 0xce, 0x91, 0x78 },
.num = .{ 0xb5, 0xce, 0xa8 },
diff --git a/src/themes/forge_soft.zig b/src/themes/forge_soft.zig
index b003e559..896bd9e8 100644
--- a/src/themes/forge_soft.zig
+++ b/src/themes/forge_soft.zig
@@ -14,6 +14,8 @@ pub const theme = .{
.tag_active_fg = .{ 0xd1, 0xd5, 0xde },
.box = .{ 0xa5, 0xc4, 0xee },
.box_dim = .{ 0x59, 0x65, 0x77 },
+ .column_box = .{ 0xb5, 0xce, 0xa8 },
+ .column_box_dim = .{ 0x6f, 0x7d, 0x6e },
.kw = .{ 0x75, 0xb5, 0xe8 },
.str = .{ 0xda, 0xa5, 0x8e },
.num = .{ 0xb5, 0xce, 0xa8 },
diff --git a/src/themes/harvest.zig b/src/themes/harvest.zig
index a2b4d619..bf367a24 100644
--- a/src/themes/harvest.zig
+++ b/src/themes/harvest.zig
@@ -14,6 +14,8 @@ pub const theme = .{
.tag_active_fg = .{ 0xe1, 0xe4, 0xc4 },
.box = .{ 0xcf, 0xba, 0x8b },
.box_dim = .{ 0x77, 0x79, 0x53 },
+ .column_box = .{ 0xf0, 0x7d, 0x63 },
+ .column_box_dim = .{ 0x92, 0x5c, 0x48 },
.kw = .{ 0xf0, 0x7d, 0x63 },
.str = .{ 0x99, 0xbe, 0x70 },
.num = .{ 0xfa, 0xd5, 0x66 },
diff --git a/src/themes/ink.zig b/src/themes/ink.zig
index 352fd095..3b92a635 100644
--- a/src/themes/ink.zig
+++ b/src/themes/ink.zig
@@ -13,6 +13,8 @@ pub const theme = .{
.tag_active_fg = .{ 0xe1, 0xee, 0xf9 },
.box = .{ 0x86, 0xd8, 0xff },
.box_dim = .{ 0x64, 0x79, 0x8b },
+ .column_box = .{ 0xff, 0xd6, 0x94 },
+ .column_box_dim = .{ 0x8a, 0x7b, 0x60 },
.kw = .{ 0xd5, 0xb8, 0xff },
.str = .{ 0xb5, 0xee, 0xae },
.num = .{ 0xff, 0xd6, 0x94 },
diff --git a/src/themes/lagoon.zig b/src/themes/lagoon.zig
index b19597e8..516639b0 100644
--- a/src/themes/lagoon.zig
+++ b/src/themes/lagoon.zig
@@ -14,6 +14,8 @@ pub const theme = .{
.tag_active_fg = .{ 0xc7, 0xe3, 0xd7 },
.box = .{ 0x89, 0xc7, 0xb4 },
.box_dim = .{ 0x59, 0x7e, 0x71 },
+ .column_box = .{ 0xff, 0xad, 0x91 },
+ .column_box_dim = .{ 0x94, 0x78, 0x68 },
.kw = .{ 0xd5, 0xa8, 0xed },
.str = .{ 0xc3, 0xe8, 0x8d },
.num = .{ 0xff, 0xad, 0x91 },
diff --git a/src/themes/orchard.zig b/src/themes/orchard.zig
index 3e87948c..888a5b27 100644
--- a/src/themes/orchard.zig
+++ b/src/themes/orchard.zig
@@ -13,6 +13,8 @@ pub const theme = .{
.tag_active_fg = .{ 0xbe, 0xcf, 0xc4 },
.box = .{ 0xad, 0xcc, 0x91 },
.box_dim = .{ 0x4c, 0x63, 0x56 },
+ .column_box = .{ 0xe9, 0xbf, 0x91 },
+ .column_box_dim = .{ 0x83, 0x73, 0x5a },
.kw = .{ 0xc9, 0xb0, 0xe4 },
.str = .{ 0xb6, 0xce, 0x94 },
.num = .{ 0xe9, 0xbf, 0x91 },
diff --git a/src/themes/orchard_black.zig b/src/themes/orchard_black.zig
index 8440d9ac..be960b93 100644
--- a/src/themes/orchard_black.zig
+++ b/src/themes/orchard_black.zig
@@ -13,6 +13,8 @@ pub const theme = .{
.tag_active_fg = .{ 0xbe, 0xcf, 0xc4 },
.box = .{ 0xad, 0xcc, 0x91 },
.box_dim = .{ 0x4c, 0x63, 0x56 },
+ .column_box = .{ 0xe9, 0xbf, 0x91 },
+ .column_box_dim = .{ 0x83, 0x73, 0x5a },
.kw = .{ 0xc9, 0xb0, 0xe4 },
.str = .{ 0xb6, 0xce, 0x94 },
.num = .{ 0xe9, 0xbf, 0x91 },
diff --git a/src/themes/paper.zig b/src/themes/paper.zig
index 732d3d0a..b5d24835 100644
--- a/src/themes/paper.zig
+++ b/src/themes/paper.zig
@@ -13,6 +13,8 @@ pub const theme = .{
.tag_active_fg = .{ 0x4a, 0x5a, 0x52 },
.box = .{ 0x3c, 0x6b, 0x56 },
.box_dim = .{ 0x91, 0xa3, 0x95 },
+ .column_box = .{ 0x87, 0x58, 0x26 },
+ .column_box_dim = .{ 0xb7, 0xa1, 0x84 },
.kw = .{ 0x73, 0x51, 0x90 },
.str = .{ 0x41, 0x68, 0x48 },
.num = .{ 0x87, 0x58, 0x26 },
diff --git a/src/themes/solarium.zig b/src/themes/solarium.zig
index c7ee2bdb..fe2525a6 100644
--- a/src/themes/solarium.zig
+++ b/src/themes/solarium.zig
@@ -14,6 +14,8 @@ pub const theme = .{
.tag_active_fg = .{ 0xd3, 0xe0, 0xc9 },
.box = .{ 0xd5, 0xbc, 0x72 },
.box_dim = .{ 0x4c, 0x79, 0x72 },
+ .column_box = .{ 0x6a, 0xae, 0xdd },
+ .column_box_dim = .{ 0x3e, 0x76, 0x90 },
.kw = .{ 0x6a, 0xae, 0xdd },
.str = .{ 0xaf, 0xbd, 0x52 },
.num = .{ 0xd9, 0x9a, 0x5e },
diff --git a/src/themes/spectrum.zig b/src/themes/spectrum.zig
index 664f4d0f..5a1cecc6 100644
--- a/src/themes/spectrum.zig
+++ b/src/themes/spectrum.zig
@@ -14,6 +14,8 @@ pub const theme = .{
.tag_active_fg = .{ 0xe9, 0xe4, 0xc7 },
.box = .{ 0xff, 0xd8, 0x66 },
.box_dim = .{ 0x8b, 0x80, 0x56 },
+ .column_box = .{ 0xab, 0x9d, 0xf2 },
+ .column_box_dim = .{ 0x73, 0x6a, 0x91 },
.kw = .{ 0xff, 0x61, 0x88 },
.str = .{ 0xa9, 0xdc, 0x76 },
.num = .{ 0xab, 0x9d, 0xf2 },
diff --git a/src/web.zig b/src/web.zig
index a4e46276..72a7bb05 100644
--- a/src/web.zig
+++ b/src/web.zig
@@ -332,6 +332,10 @@ export fn pardes_cursor_x() i32 {
return if (s.core.surface.cursor) |cursor| cursor.x else -1;
}
+export fn pardes_grip_columns() u32 {
+ return @import("config.zig").GUTTER;
+}
+
export fn pardes_tag_layer_limit() u32 {
return pardes.MAX_TAG_LAYERS;
}
diff --git a/src/web/app.mjs b/src/web/app.mjs
index 2a4f8a65..3dcf6ab5 100644
--- a/src/web/app.mjs
+++ b/src/web/app.mjs
@@ -55,10 +55,16 @@ export class DomRenderer {
const cursorBar = this.wasm.pardes_cursor_bar() !== 0;
const raw = new Array(count);
const tagCovered = new Uint8Array(count);
+ const tagGrips = new Map();
if (this.wasm.pardes_tag_layer_limit) {
for (let i = 0; i < this.wasm.pardes_tag_layer_limit(); i++) {
const val = field => this.wasm.pardes_tag_layer_value(i, field);
if (!val(0)) continue;
+ const gripCols = this.wasm.pardes_grip_columns?.() ?? 2;
+ if (val(1) !== 0 && val(4) >= gripCols && this.cellHeight) {
+ const offset = this.wasm.pardes_tagline_band_offset(val(5),rows*this.cellHeight*256,Math.round(this.cellHeight*256),Math.round(this.taglineHeight*256))/256;
+ for (let x = val(4)-gripCols; x < val(4); x++) tagGrips.set(val(5)*cols+x,offset);
+ }
for (let x = val(4); x < Math.min(cols, val(4) + val(6)); x++) {
if (val(5) < rows) tagCovered[val(5) * cols + x] = 1;
}
@@ -77,6 +83,10 @@ export class DomRenderer {
const cursor = x === cursorX && y === cursorY;
const cell = this.cells[index];
cell.style.visibility = tagCovered[index] ? "hidden" : "";
+ const gripOffset = tagGrips.get(index);
+ cell.style.height = gripOffset === undefined ? "" : `${this.taglineHeight}px`;
+ cell.style.lineHeight = gripOffset === undefined ? "" : `${this.taglineHeight}px`;
+ cell.style.marginTop = gripOffset === undefined ? "" : `${gripOffset}px`;
if (cell.textContent !== text) cell.textContent = text;
const styleKey = `${fg}:${bg}:${attrs}:${cursor ? 1 : 0}:${cursorBar ? 1 : 0}`;
if (cell.pardesStyle !== styleKey) {
@@ -86,12 +96,12 @@ export class DomRenderer {
raw[index] = { text, fg, bg, attrs };
}
this.renderBodyLayers();
- this.renderTagLayers();
+ this.renderTagLayers(raw);
this.snapshot = { cols, rows, cells: raw, cursorX, cursorY, cursorBar };
return this.snapshot;
}
- renderTagLayers() {
+ renderTagLayers(canonical) {
if (!this.wasm.pardes_tag_layer_limit || !this.cellWidth) return;
if (!this.tagRoot?.isConnected) {
this.tagRoot = this.root.ownerDocument.createElement("div");
@@ -110,10 +120,15 @@ export class DomRenderer {
if (!ptr) continue;
const bytes = new Uint8Array(this.wasm.memory.buffer,ptr,cols*CELL_SIZE);
const offset = this.wasm.pardes_tagline_band_offset(values[5],this.rowCount*bh*256,Math.round(bh*256),Math.round(th*256))/256;
- const geometry = [bw,bh,tw,th,offset,...values].join(":");
+ const gripCols = this.wasm.pardes_grip_columns?.() ?? 2;
+ const gripX = values[4] - gripCols;
+ const gripCell = canonical[values[5]*this.cols+gripX];
+ const grip = values[1] !== 0 && gripX >= 0 && gripCell?.text === " " && th >= 6 && tw >= 3;
+ const geometry = [bw,bh,tw,th,offset,...values,grip,gripCell?.fg].join(":");
const cached = this.tagNodes.get(i);
if (cached && cached.geometry === geometry && cached.bytes.length === bytes.length && cached.bytes.every((byte,index)=>byte===bytes[index])) {
nodes.push(cached.node);
+ if (cached.grip) nodes.push(cached.grip);
continue;
}
const view = new DataView(this.wasm.memory.buffer,ptr,cols*CELL_SIZE);
@@ -132,8 +147,22 @@ export class DomRenderer {
cell.style.lineHeight = `${th}px`;
layer.append(cell);
}
- this.tagNodes.set(i,{geometry,bytes:bytes.slice(),node:layer});
+ let gripNode = null;
+ if (grip) {
+ gripNode = this.root.ownerDocument.createElement("div");
+ gripNode.dataset.tagGrip = String(i);
+ const centerX = (gripX+gripCols/2)*bw, centerY = values[5]*bh+offset+th/2;
+ const half = Math.max(1,Math.min(3,bw-2));
+ gripNode.style.cssText = "position:absolute;inset:0;pointer-events:none";
+ for (const dy of [-2,1]) {
+ const mark = this.root.ownerDocument.createElement("div");
+ mark.style.cssText = `position:absolute;left:${Math.floor(centerX-half)}px;top:${Math.floor(centerY+dy)}px;width:${2*half}px;height:1px;background:${color(gripCell.fg,DEFAULT_FG)}`;
+ gripNode.append(mark);
+ }
+ }
+ this.tagNodes.set(i,{geometry,bytes:bytes.slice(),node:layer,grip:gripNode});
nodes.push(layer);
+ if (gripNode) nodes.push(gripNode);
}
if (nodes.length !== this.tagRoot.childNodes.length || nodes.some((node,index)=>node!==this.tagRoot.childNodes[index])) this.tagRoot.replaceChildren(...nodes);
}