diff options
| -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]); +} |
