diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/gui/gui.zig | 123 | ||||
| -rw-r--r-- | src/layout.zig | 92 | ||||
| -rw-r--r-- | src/macos.zig | 5 | ||||
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 68 | ||||
| -rw-r--r-- | src/macos/pardes.h | 1 | ||||
| -rw-r--r-- | src/pardes.zig | 128 | ||||
| -rw-r--r-- | src/themes/atelier.zig | 2 | ||||
| -rw-r--r-- | src/themes/clay.zig | 2 | ||||
| -rw-r--r-- | src/themes/daybreak.zig | 2 | ||||
| -rw-r--r-- | src/themes/dusk.zig | 2 | ||||
| -rw-r--r-- | src/themes/forge.zig | 2 | ||||
| -rw-r--r-- | src/themes/forge_black.zig | 2 | ||||
| -rw-r--r-- | src/themes/forge_soft.zig | 2 | ||||
| -rw-r--r-- | src/themes/harvest.zig | 2 | ||||
| -rw-r--r-- | src/themes/ink.zig | 2 | ||||
| -rw-r--r-- | src/themes/lagoon.zig | 2 | ||||
| -rw-r--r-- | src/themes/orchard.zig | 2 | ||||
| -rw-r--r-- | src/themes/orchard_black.zig | 2 | ||||
| -rw-r--r-- | src/themes/paper.zig | 2 | ||||
| -rw-r--r-- | src/themes/solarium.zig | 2 | ||||
| -rw-r--r-- | src/themes/spectrum.zig | 2 | ||||
| -rw-r--r-- | src/web.zig | 4 | ||||
| -rw-r--r-- | src/web/app.mjs | 37 |
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); } |
