diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-23 02:00:00 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | f6738c7edfff2249e0b13f2a76962f45d02c86bb (patch) | |
| tree | 3f255da736fb0a6f4b926c72054d83091f941d21 | |
| parent | 9695cb098c784792af79c22d46a17f7bc7247d33 (diff) | |
| download | pardes-f6738c7edfff2249e0b13f2a76962f45d02c86bb.tar.gz pardes-f6738c7edfff2249e0b13f2a76962f45d02c86bb.zip | |
Center tagline bands, frame anchors evenly, fill to the window edge, and resize columns from their grip
Every tag band (workspace, column, pane) is centred in its row, so text sits
at one baseline offset. Anchors are inset from the column rule by that same
margin, so they are square and the band frames them on the left, top and bottom;
the pane mark moves to stay centred. When the window is not a whole number of
cells, the bands, rules, spines and scroll thumbs at the right and bottom edges
run on through the leftover pixels.
A column grip dropped short of another column's place now moves the column's
left edge, with a dashed rail preview, sharing setColumnPairWidth with the
border drag. A release still on the grip changes nothing, and a pair too
narrow for two MINW columns is left alone.
PARDES_TEST_PAD adds leftover pixels to a test-mode window and capture.
| -rw-r--r-- | docs/config.md | 16 | ||||
| -rw-r--r-- | features.txt | 2 | ||||
| -rw-r--r-- | src/gui/gui.zig | 88 | ||||
| -rw-r--r-- | src/layout.zig | 17 | ||||
| -rw-r--r-- | src/macos.zig | 2 | ||||
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 5 | ||||
| -rw-r--r-- | src/pardes.zig | 60 | ||||
| -rw-r--r-- | src/web.zig | 2 | ||||
| -rw-r--r-- | test/column_drag.zig | 40 |
9 files changed, 161 insertions, 71 deletions
diff --git a/docs/config.md b/docs/config.md index 27818b5b..ab5e5617 100644 --- a/docs/config.md +++ b/docs/config.md @@ -398,16 +398,18 @@ band would overlap its pane body or a neighbour instead of leaving the body grid stable. `Config` reports the active percentage. The browser applies the same compiled percentage to its DOM glyphs but has no runtime setter. -Both native GUIs join the reduced-height global and pane tagline bands with -`gui_topbar_pane_border_px` physical pixels. Set it to zero for a direct join. +Both native GUIs separate the reduced-height global and pane tagline bands with +a `gui_topbar_pane_border_px` physical-pixel rule. Set it to zero to leave it out. `gui_topbar_pane_border_rgb` can pin an RGB color; its default `null` follows the theme's `border` role in SDL (falling back to `scroll_track` for older -themes), and `scroll_track` in the macOS shell. With `Tagbottom` enabled, a tagline -on the final grid row is bottom-aligned so the same unused half-band does not -show beneath it. The rule itself lives in the core +themes), and `scroll_track` in the macOS shell. Every band is centred in its +row, so the workspace, column and pane tag text share one baseline offset and +the anchors get the same margin above, below and to the left. With `Tagbottom` +enabled, a tagline on the final grid row is bottom-aligned so the unused +half-band does not show beneath it. The rule lives in the core (`pardes.taglineBandOffset`), and the macOS shell reaches it over the C ABI -rather than keeping its own copy — it had one, it only ever centred, and the -seam that leaves between the two bands is what this setting exists to close. +rather than keeping its own copy. When the window is not a whole number of cells, +bands and rules at the right and bottom edges run on through the leftover pixels. What happens to a codepoint the chosen face has no glyph for differs by shell. The SDL GUI falls back through a chain it builds itself: embedded Adwaita diff --git a/features.txt b/features.txt index 0db7c497..86b2a834 100644 --- a/features.txt +++ b/features.txt @@ -162,7 +162,7 @@ Found by adversarial review and NOT fixed, because they are upstream or macOS-on contributes 0, and PardesView's liquid-glass affordance never draws. The tests pass only because they set the flag by hand. The comment in the look_hover_preview paint block claims it carries those cells out to the hosts; it does not. - -Dworkspace-tag is read only in src/macos.zig, and core.settings.workspace_tag is assigned only there, so the option builds cleanly for gui/tty/web and is - silently ignored. gui.zig hard-codes `true` at six taglineBandOffset call sites and still uses raw TOPBAR_H at five more. + silently ignored. (taglineBandOffset no longer takes it: every band is centred since 2026-09-23.) gui.zig still uses raw TOPBAR_H at five sites. - The detached wire encodes the pointer shape in the frame header variant (full_link/diff_link), so the newer `.target` shape collapses to `.arrow` for an attached frontend. Fixing it means a new header variant, i.e. a protocol change. - A shell that outlives its editor keeps PARDES_PID; if that pid is reused, `pardes <file>` now exits 1 instead of falling back to starting an editor. Exiting 1 diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 7f28124c..48087d0b 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1809,6 +1809,8 @@ const Gui = struct { pointer_cell: ?MouseCell = null, capture: bool = false, + /// Pixels the capture holds past the grid (PARDES_TEST_PAD). + capture_pad: u32 = 0, capture_dir: []const u8 = "", capture_tex: ?*c.SDL_GPUTexture = null, capture_tex_w: u32 = 0, @@ -2196,7 +2198,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u if (test_mode) { const cols = envU16(env, "PARDES_TEST_COLS") orelse 80; const rows = envU16(env, "PARDES_TEST_ROWS") orelse 24; - _ = c.SDL_SetWindowSize(window, @intCast(cols * cell_w), @intCast(rows * cell_h)); + // PARDES_TEST_PAD: extra pixels past the grid, as a real window has. + const pad = envU16(env, "PARDES_TEST_PAD") orelse 0; + _ = c.SDL_SetWindowSize(window, @intCast(cols * cell_w + pad), @intCast(rows * cell_h + pad)); _ = c.SDL_SyncWindow(window); } @@ -2284,6 +2288,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u .atlas_stage = atlas_stage, .glyphs = std.AutoHashMap(GlyphKey, Slot).init(gpa), .capture = test_mode, + .capture_pad = if (test_mode) envU16(env, "PARDES_TEST_PAD") orelse 0 else 0, .capture_dir = capture_dir orelse "", .soft_present = soft_present, .transparent = config.gui_transparent, @@ -4909,8 +4914,8 @@ fn renderFrame( var sh: u32 = 0; var target: *c.SDL_GPUTexture = undefined; if (g.capture) { - sw = @as(u32, surface.cols) * g.cell_w; - sh = @as(u32, surface.rows) * g.cell_h; + sw = @as(u32, surface.cols) * g.cell_w + g.capture_pad; + sh = @as(u32, surface.rows) * g.cell_h + g.capture_pad; if (sw == 0 or sh == 0) { command_consumed = true; _ = c.SDL_SubmitGPUCommandBuffer(cmd); @@ -5360,18 +5365,14 @@ fn bottomTaglinePresent(surface: *const pardes.Surface) bool { return false; } -test "tagline bands face the topbar rule and Tagbottom faces the window edge" { +test "tagline bands center in their cells and Tagbottom faces the window edge" { const cell_h: u32 = 20; const tagline_h: u32 = 16; const canvas_h: f32 = 200; - const border = topbarPaneBorderPixels(cell_h, tagline_h); - try std.testing.expectEqual(@as(u32, 1), border); - try std.testing.expectEqual(@as(u32, 4), taglineBandOffset(0, canvas_h, cell_h, tagline_h, true)); - try std.testing.expectEqual(@as(u32, 2), taglineBandOffset(0, canvas_h, cell_h, tagline_h, false)); - try std.testing.expectEqual(@as(u32, 1), taglineBandOffset(1, canvas_h, cell_h, tagline_h, true)); - try std.testing.expectEqual(@as(u32, 2), taglineBandOffset(1, canvas_h, cell_h, tagline_h, false)); - try std.testing.expectEqual(@as(u32, 2), taglineBandOffset(5, canvas_h, cell_h, tagline_h, true)); - try std.testing.expectEqual(@as(u32, 4), taglineBandOffset(9, canvas_h, cell_h, tagline_h, true)); + // Workspace, column and pane rows alike: text lines up across bands. + for ([_]u16{ 0, 1, 2, 5, 8 }) |row| + try std.testing.expectEqual(@as(u32, 2), taglineBandOffset(row, canvas_h, cell_h, tagline_h)); + try std.testing.expectEqual(@as(u32, 4), taglineBandOffset(9, canvas_h, cell_h, tagline_h)); } fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_cursor: bool, page: Ground) ResolvedCell { @@ -5502,7 +5503,10 @@ fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const p // it comes out from under whatever is above it. const slide = layer.slide * bh; const top = @as(f32, @floatFromInt(layer.viewport.y)) * bh; - emitInstance(g, instances, next.*, 0, layer.viewport.y, .{ .w = right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, &background, old_layer, false, page); + // A window is rarely a whole number of cells wide: a band that ends at + // the last column runs on through the leftover pixels to the edge. + const band_right = if (win_w - right < bw) @max(right, win_w) else right; + emitInstance(g, instances, next.*, 0, layer.viewport.y, .{ .w = band_right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, &background, old_layer, false, page); if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); next.* += 1; for (0..tagLayerCellCount(g, layer)) |col| { @@ -5578,7 +5582,7 @@ fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const 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, true))) else 0); + 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); @@ -5628,6 +5632,10 @@ fn paneGripCell(layers: []const pardes.TagLayer, cells: []const pardes.Cell, col return null; } +fn anchorInset(g: *const Gui, row: u16, win_h: f32) u32 { + return taglineBandOffset(row, win_h, g.cell_h, g.tagline_height); +} + fn emitSurfaceCell( g: *Gui, core: ?*const pardes.Pardes, @@ -5657,19 +5665,27 @@ fn emitSurfaceCell( next.* += 1; // 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); + // An anchor starts at its column's rule: its left edge is inset by + // its top margin so the band frames it on the left as above and + // below, and its mark moves right by half that to stay centered. + const inset: u32 = if (core) |p| if (std.mem.indexOfScalar(u16, p.col_x[0..p.ncol], col -| (if (grip) |indicator| indicator.offset else 0)) != null) anchorInset(g, row, win_h) else 0 else 0; if (grip) |indicator| { const cached = cachedGlyph(g, cellCodepoint(indicator.cell), .tagline, GlyphDecoration.fromCell(indicator.cell), true); const slot = cached orelse g.space_slot; - const offset: u32 = if (cached != null) @as(u32, indicator.offset) * g.cell_w else 0; + const offset: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, indicator.offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0; const resolved = resolveCell(g, indicator.cell, .tagline, false, page); instances[next.*].fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255; instances[next.*].fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255; instances[next.*].fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255; - instances[next.*].u0 = @as(f32, @floatFromInt(slot.u + offset)) / atlas_w; + instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + offset) / atlas_w; instances[next.*].v0 = @as(f32, @floatFromInt(slot.v)) / atlas_h; instances[next.*].v1 = @as(f32, @floatFromInt(slot.v + g.tagline_height)) / atlas_h; } instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w; + if (inset > 0 and (grip == null or grip.?.offset == 0)) { + instances[next.*].x0 += @as(f32, @floatFromInt(inset)) / win_w * 2; + instances[next.*].u0 += @as(f32, @floatFromInt(inset)) / atlas_w; + } next.* += 1; return; } @@ -5690,7 +5706,8 @@ test "small tagline fonts leave no background gutters between adjacent tags" { var instances: [6]CellInstance = undefined; var next: u32 = 0; for (0..3) |row| { - emitSurfaceCell(&g, core, &instances, &next, 0, @intCast(row), fixedCellLayout(&g), 800, 480, null, &cell, &cell, null, false, false, Ground.opaqueRgb(bg_default)); + // Column 1: past the anchor's inset edge at the column's left. + emitSurfaceCell(&g, core, &instances, &next, 1, @intCast(row), fixedCellLayout(&g), 800, 480, null, &cell, &cell, null, false, false, Ground.opaqueRgb(bg_default)); const base = instances[row * 2]; const ink = instances[row * 2 + 1]; try std.testing.expectApproxEqAbs(@as(f32, 20.0 / 480.0 * 2), base.y0 - base.y1, 0.0001); @@ -5700,6 +5717,13 @@ test "small tagline fonts leave no background gutters between adjacent tags" { 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); } + // Column 0 is an anchor's edge: its ink is inset by the band's margin + // (2 px here) and its underlay still covers the whole cell. + next = 0; + emitSurfaceCell(&g, core, &instances, &next, 0, 1, fixedCellLayout(&g), 800, 480, null, &cell, &cell, null, false, false, Ground.opaqueRgb(bg_default)); + try std.testing.expectApproxEqAbs(@as(f32, 10.0 / 800.0 * 2), instances[0].x1 - instances[0].x0, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 8.0 / 800.0 * 2), instances[1].x1 - instances[1].x0, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 8) / atlas_w, instances[1].u1 - instances[1].u0, 0.0001); next = 0; emitSurfaceCell(&g, null, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, &cell, &cell, null, true, false, Ground.opaqueRgb(bg_default)); try std.testing.expectEqual(@as(u32, 2), next); @@ -5730,7 +5754,7 @@ fn emitInstance( else layout.h; const band_offset: f32 = if (resolved.role == .tagline) - @floatFromInt(taglineBandOffset(row, win_h, g.cell_h, g.tagline_height, true)) + @floatFromInt(taglineBandOffset(row, win_h, g.cell_h, g.tagline_height)) else 0; const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h + band_offset; @@ -6562,7 +6586,7 @@ fn addCursorBar( const x0 = layout.x_off + @as(f32, @floatFromInt(x)) * layout.w; const height = std.math.clamp(visual_height, 1.0, layout.h); const y0 = layout.y_off + @as(f32, @floatFromInt(y)) * layout.h + - @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, @intFromFloat(layout.h), @intFromFloat(height), true))); + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, @intFromFloat(layout.h), @intFromFloat(height)))); builder.addRect( x0, y0, @@ -6662,7 +6686,7 @@ fn buildOverlay( const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w; const x = left + @as(f32, @floatFromInt(cursor.col)) * width; if (x >= right) continue; - const top = @as(f32, @floatFromInt(layer.viewport.y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(layer.viewport.y, builder.win_h, g.cell_h, g.tagline_height, true))); + const top = @as(f32, @floatFromInt(layer.viewport.y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(layer.viewport.y, builder.win_h, g.cell_h, g.tagline_height))); builder.addRect(x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), overlayRgb(cursorColor(core, &layer.cells[cursor.col], .tagline))); }; if (transient_on) appendTouchOverlay(g, &builder, debug_on); @@ -6683,7 +6707,7 @@ fn appendWorkspacePet(g: *Gui, core: ?*const pardes.Pardes, surface: *const pard if (cellCodepoint(cell) != ' ') occupied = i + 1; } const editing = p.topbar_col != null and p.header_column == null; - const band_top: f32 = @floatFromInt(taglineBandOffset(0, builder.win_h, g.cell_h, g.tagline_height, true)); + const band_top: f32 = @floatFromInt(taglineBandOffset(0, builder.win_h, g.cell_h, g.tagline_height)); const region = if (occupied >= surface.cols) null else pet.lane( occupied, @floatFromInt(g.tagline_width), @@ -6810,7 +6834,10 @@ fn appendPaneChrome( } 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)); + // Like the band above it, the rule of a pane at the right edge + // runs on through the pixels past the last whole cell. + const rule_x1 = if (builder.win_w - x1 < layout.w) @max(x1, builder.win_w) else x1; + builder.addRect(x0, rule_y - @as(f32, if (core.settings.tag_bottom) 0 else 1), rule_x1, rule_y + @as(f32, if (core.settings.tag_bottom) 1 else 0), overlayRgb(chrome.border)); } // Do not paint over resize previews or debug text occupying the rail. @@ -6827,12 +6854,13 @@ fn appendPaneChrome( } if (row != body_end) continue; const y0 = @as(f32, @floatFromInt(body_y)) * layout.h; - const y1 = @as(f32, @floatFromInt(body_end)) * layout.h; + const body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h; + const y1 = if (body_end == surface.rows and builder.win_h - body_y1 < layout.h) @max(body_y1, builder.win_h) else body_y1; builder.addRect(x0, y0, x0 + layout.w, y1, page); appendSquareRail(builder, x0, y0, y1, layout.w, false, overlayRgb(chrome.scroll_track)); if (thumb_start) |first| { const thumb_y0 = @as(f32, @floatFromInt(first)) * layout.h; - const thumb_y1 = @as(f32, @floatFromInt(thumb_end)) * layout.h; + const thumb_y1 = if (thumb_end == body_end) y1 else @as(f32, @floatFromInt(thumb_end)) * layout.h; appendSquareRail(builder, x0, thumb_y0, thumb_y1, layout.w, true, overlayRgb(chrome.scroll_thumb)); } } @@ -6854,8 +6882,8 @@ fn appendTagGrips(g: *const Gui, surface: *const pardes.Surface, layout: CellLay .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, true))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; + const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, builder.win_h) / 2)); + 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)); @@ -6875,7 +6903,9 @@ test "column grips stay unmarked while pane marks retain physical centering" { 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, 12), builder.len); - try std.testing.expectApproxEqAbs(@as(f32, 57.0 / 200.0 * 2.0 - 1.0), vertices[0].x, 0.0001); + // Centered in the anchor, whose left edge is inset by the band's offset + // (8 px on this last row): the mark moves right by half of it. + try std.testing.expectApproxEqAbs(@as(f32, 61.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 { @@ -6885,7 +6915,9 @@ fn appendSquareRail(builder: *OverlayBuilder, x: f32, y0: f32, y1: f32, cell_w: fn appendColumnSpines(core: *const pardes.Pardes, rows: u16, layout: CellLayout, builder: *OverlayBuilder) void { const y0 = @as(f32, @floatFromInt(pardes.TOPBAR_H)) * layout.h; - const y1 = @as(f32, @floatFromInt(rows)) * layout.h; + const grid_y1 = @as(f32, @floatFromInt(rows)) * layout.h; + // Down through the pixels past the last whole row, as tags run right. + const y1 = if (builder.win_h - grid_y1 < layout.h) @max(grid_y1, builder.win_h) else grid_y1; if (y1 <= y0) return; // A single boundary spans all panes, including their tags. Its left edge // agrees with the square scroll markers and existing move-box hit area. diff --git a/src/layout.zig b/src/layout.zig index c6cb7503..8db05a07 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -723,6 +723,23 @@ pub fn columnMoveTarget(p: *const Pardes, source: usize, x: u16) usize { return insertion; } +/// Share out the width of columns `left` and `left + 1` so the left one is +/// `left_width` cells, neither below MINW. A border drag and a column grip +/// dropped inside its own place both end here. +pub fn setColumnPairWidth(p: *Pardes, left: usize, left_width: u64) void { + if (left + 1 >= p.ncol) return; + const combined: u64 = p.col_w[left] + p.col_w[left + 1]; + // Too narrow for both to keep MINW: any split would squeeze one away. + if (combined < 2 * @as(u64, config.MINW)) return; + const width = std.math.clamp(left_width, config.MINW, @max(@as(u64, config.MINW), combined -| config.MINW)); + const pair = p.col_weight[left] + p.col_weight[left + 1]; + if (pair <= 1 or combined == 0) return; + const numerator = @as(u128, pair) * width + combined / 2; + const share = std.math.clamp(@as(u64, @intCast(numerator / combined)), 1, pair - 1); + p.col_weight[left] = share; + p.col_weight[left + 1] = pair - share; +} + 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]; diff --git a/src/macos.zig b/src/macos.zig index c359eb3f..3ff8bdfa 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -951,7 +951,7 @@ export fn pardes_gui_tagline_font_percent() u8 { export fn pardes_tagline_band_offset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { pardes.turn.wake(); defer pardes.turn.rest(); - return pardes.taglineBandOffset(row, canvas_h, cell_h, tagline_h, if (state) |*st| st.core.settings.workspace_tag else true); + return pardes.taglineBandOffset(row, canvas_h, cell_h, tagline_h); } export fn pardes_topbar_pane_border_px(cell_h: u32, tagline_h: u32) u32 { diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index 5ad09c38..70f467e3 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -523,9 +523,8 @@ private struct TaglineMetrics { /// Where this band sits inside row `row`, measured down from the row's top. /// The rule belongs to the core (`pardes_tagline_band_offset`) so that this - /// shell and the SDL one cannot disagree about it: row zero and the first - /// pane-tag row meet at a shared rule, a Tagbottom band on the last row - /// faces the window edge, and everything between is centred. + /// shell and the SDL one cannot disagree about it: every band is centred, + /// except a Tagbottom band on the last row, which faces the window edge. func top(row: Int, canvasHeight: CGFloat) -> CGFloat { CGFloat(pardes_tagline_band_offset( UInt16(clamping: row), Float(canvasHeight * scale), cellPixels, bandPixels)) / scale diff --git a/src/pardes.zig b/src/pardes.zig index fae14f47..ca2c34a4 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -232,19 +232,12 @@ pub const BOX_H: u16 = 1; pub const TAG_GAP: u16 = 1; pub const TAG_TEXT_INSET: u16 = config.GUTTER + TAG_GAP; -pub fn taglineBandOffset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32, workspace: bool) u32 { +pub fn taglineBandOffset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { const spare = cell_h -| tagline_h; - const border = topbarPaneBorderPixels(cell_h, tagline_h); const rows: u32 = @max(1, @as(u32, @intFromFloat(@floor(canvas_h / @as(f32, @floatFromInt(cell_h)))))); - if (!workspace) { - // No workspace row: nothing above row 0 to share a rule with, so - // every band centers in its cell the way the middle rows always - // have. The anchor boxes this lets the hosts draw stay symmetric. - if (@as(u32, row) + 1 >= rows) return spare; - return spare / 2; - } - if (row == 0) return spare - border / 2; - if (row == TOPBAR_H) return border - border / 2; + // Every band centers in its cell, so text and anchors sit at the same + // height in the workspace, column and pane tags alike. Only the last row + // faces the window edge (Tagbottom). if (@as(u32, row) + 1 >= rows) return spare; return spare / 2; } @@ -5160,7 +5153,9 @@ 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 }, + /// `grab`: how far into the grip it was pressed, so the column's left + /// edge lands at `cur_x - grab`. A release still on the grip moves nothing. + column_move: struct { column: usize, serial: u32, cur_x: u16, grab: u16 = 0 }, 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 @@ -12022,7 +12017,7 @@ pub const Pardes = struct { 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 } }; + p.drag = .{ .column_move = .{ .column = column, .serial = p.col_serial[column], .cur_x = mcol, .grab = mcol - p.col_x[column] } }; } return; } @@ -12482,20 +12477,10 @@ pub const Pardes = struct { .border_v => |d| { const c = d.left_col; const old_seam = p.col_x[c] + p.col_w[c] -| 1; - if (c + 1 < p.ncol and d.cur_x != old_seam) { - const combined: u64 = p.col_w[c] + p.col_w[c + 1]; - // the handle is the left pane's LAST column (inclusive), so - // the new width includes it — a no-drag click changes nothing - var nl: u64 = (d.cur_x + 1) -| p.col_x[c]; - nl = std.math.clamp(nl, config.MINW, @max(@as(u64, config.MINW), combined -| config.MINW)); - const pair = p.col_weight[c] + p.col_weight[c + 1]; - if (pair > 1 and combined > 0) { - const numerator = @as(u128, pair) * nl + combined / 2; - const left = std.math.clamp(@as(u64, @intCast(numerator / combined)), 1, pair - 1); - p.col_weight[c] = left; - p.col_weight[c + 1] = pair - left; - } - } + // the handle is the left pane's LAST column (inclusive), so + // the new width includes it — a no-drag click changes nothing + if (c + 1 < p.ncol and d.cur_x != old_seam) + layout.setColumnPairWidth(p, c, (d.cur_x + 1) -| p.col_x[c]); if (d.corner) |k| { if (d.cur_y != seamRowOf(p, k.col, k.idx)) layout.applyRowSplit(p, k.col, k.idx, d.cur_y); @@ -12503,9 +12488,15 @@ 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)); + .column_move => |d| if (layout.columnBySerial(p, d.serial)) |column| { + const target = layout.columnMoveTarget(p, column, d.cur_x); + if (target != column) { + layout.reorderColumn(p, column, target); + } else if (column > 0 and (d.cur_x < p.col_x[column] or d.cur_x >= p.col_x[column] + config.GUTTER)) { + // Dropped short of another column's place: as in acme, + // the column's left edge moves to where it was dropped. + layout.setColumnPairWidth(p, column - 1, (d.cur_x -| d.grab) -| p.col_x[column - 1]); + } }, .move => |d| { layout.movePane(p, d.id, d.cur_x, d.cur_y); @@ -14181,6 +14172,15 @@ pub const Pardes = struct { } var row: u16 = p.topBarHeight(); while (row < s.rows) : (row += 1) s.overlayDash(x, row, "╎"); + } else if (source > 0 and source < p.ncol and (d.cur_x < p.col_x[source] or d.cur_x >= p.col_x[source] + config.GUTTER) and + p.col_w[source - 1] + p.col_w[source] >= 2 * config.MINW) + { + // Where the left edge would land: the border drag's rail. + const lo = p.col_x[source - 1] + config.MINW; + const hi = (p.col_x[source] + p.col_w[source]) -| config.MINW; + const x = @min(std.math.clamp(d.cur_x -| d.grab, lo, @max(lo, hi)), s.cols -| 1); + var row: u16 = p.topBarHeight(); + while (row < s.rows) : (row += 1) s.overlayDash(x, row, "╎"); } }, .move => |d| if (d.cur_x < s.cols) { diff --git a/src/web.zig b/src/web.zig index 7325af54..b5a992ae 100644 --- a/src/web.zig +++ b/src/web.zig @@ -376,7 +376,7 @@ export fn pardes_tag_layer_cells(index: u32) ?[*]const WebCell { } export fn pardes_tagline_band_offset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { - return pardes.taglineBandOffset(row, canvas_h, cell_h, tagline_h, true); + return pardes.taglineBandOffset(row, canvas_h, cell_h, tagline_h); } export fn pardes_body_layer_value(index: u32, field: u32) u32 { diff --git a/test/column_drag.zig b/test/column_drag.zig index be4e33a3..85dca66b 100644 --- a/test/column_drag.zig +++ b/test/column_drag.zig @@ -212,3 +212,43 @@ test "column grips stay blank and muted across focus hover and drag" { } } } + +test "column grip dropped in its own place moves the column's left edge" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("body\n"); + p.presentation.enabled = false; + p.settings.column_tags = true; + try std.testing.expect(p.executeBuiltinLine(0, "New")); + try std.testing.expect(layout.splitColumn(p, p.active, p.active, false)); + p.update(.tick); + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const serials = .{ p.col_serial[0], p.col_serial[1] }; + const edge = p.col_x[1]; + const total = p.col_w[0] + p.col_w[1]; + // A click in place, from either grip cell, changes nothing. + for (0..config.GUTTER) |offset| { + const x = edge + @as(u16, @intCast(offset)); + mouse(p, .press, x); + mouse(p, .release, x); + _ = try p.render(frame.allocator()); + try std.testing.expectEqual(edge, p.col_x[1]); + } + // Grabbed by its second cell and dropped 7 cells left: the edge follows. + mouse(p, .press, edge + 1); + mouse(p, .drag, edge + 1 - 7); + _ = try p.render(frame.allocator()); + mouse(p, .release, edge + 1 - 7); + _ = try p.render(frame.allocator()); + try std.testing.expectEqual(edge - 7, p.col_x[1]); + try std.testing.expectEqual(total, p.col_w[0] + p.col_w[1]); + try std.testing.expectEqual(serials[0], p.col_serial[0]); + try std.testing.expectEqual(serials[1], p.col_serial[1]); + // Past the neighbour's middle it is still a reorder, not a resize. + mouse(p, .press, p.col_x[1]); + mouse(p, .release, p.col_x[0] + 1); + try std.testing.expectEqual(serials[1], p.col_serial[0]); + try std.testing.expectEqual(serials[0], p.col_serial[1]); +} |
