bed879b5
fix: directional focus finds a pane when the midpoint sits on a rail
a73x 2026-09-01 04:58
Commit message
src/client/layout.zig
| Old | New | ||
|---|---|---|---|
| @@ -55,7 +55,13 @@ const Container = struct { | |||
| 55 | /// Geometric adjacency over a FLAT result, not the tree. From the midpoint of | 55 | /// Geometric adjacency over a FLAT result, not the tree. From the midpoint of |
| 56 | /// the focused rect's `dir` edge, candidates are panes whose opposite edge abuts | 56 | /// the focused rect's `dir` edge, candidates are panes whose opposite edge abuts |
| 57 | /// it (gap <= 1, since a rail sits between beside panes) and whose perpendicular | 57 | /// it (gap <= 1, since a rail sits between beside panes) and whose perpendicular |
| 58 | /// span contains the midpoint; nearest edge wins ties. No focus history. | 58 | /// span contains the midpoint or ends within one cell of it. The tolerance on |
| 59 | /// the span is not optional: a pane stacked over two side-by-side panes has | ||
| 60 | /// its midpoint column exactly on the rail between them whenever the widths | ||
| 61 | /// divide evenly (81 cols → mid 40 → rail 40), and a rail is in no pane's | ||
| 62 | /// span, so an exact-containment test found nothing and `j` did nothing. | ||
| 63 | /// Nearest edge wins; among equal edges the span nearest the midpoint, then | ||
| 64 | /// the first placed. No focus history. | ||
| 59 | pub fn neighbor(flat: Flat, focus: u8, dir: Dir) ?u8 { | 65 | pub fn neighbor(flat: Flat, focus: u8, dir: Dir) ?u8 { |
| 60 | const fr = flat.rectOf(focus) orelse return null; | 66 | const fr = flat.rectOf(focus) orelse return null; |
| 61 | 67 | ||
| @@ -64,36 +70,41 @@ pub fn neighbor(flat: Flat, focus: u8, dir: Dir) ?u8 { | |||
| 64 | const mid_col = fr.left + (fr.cols -| 1) / 2; | 70 | const mid_col = fr.left + (fr.cols -| 1) / 2; |
| 65 | 71 | ||
| 66 | var best: ?u8 = null; | 72 | var best: ?u8 = null; |
| 67 | var best_dist: u16 = std.math.maxInt(u16); | 73 | var best_gap: u16 = std.math.maxInt(u16); |
| 74 | var best_perp: u16 = std.math.maxInt(u16); | ||
| 68 | 75 | ||
| 69 | for (flat.placed) |p| { | 76 | for (flat.placed) |p| { |
| 70 | if (p.tile == focus) continue; | 77 | if (p.tile == focus) continue; |
| 71 | const r = p.rect; | 78 | const r = p.rect; |
| 72 | 79 | ||
| 80 | // How far the midpoint sits outside the candidate's perpendicular | ||
| 81 | // span: 0 inside it, 1 when only a rail separates them. | ||
| 82 | const perp: u16 = switch (dir) { | ||
| 83 | .left, .right => spanDistance(mid_row, r.top, r.rows), | ||
| 84 | .up, .down => spanDistance(mid_col, r.left, r.cols), | ||
| 85 | }; | ||
| 86 | if (perp > 1) continue; | ||
| 87 | |||
| 73 | // Compute the gap between the focus's `dir` edge and the candidate's | 88 | // Compute the gap between the focus's `dir` edge and the candidate's |
| 74 | // opposite edge. A candidate on the wrong side (its opposite edge is | 89 | // opposite edge. A candidate on the wrong side (its opposite edge is |
| 75 | // past the focus, not before it) is rejected by saturating to max. | 90 | // past the focus, not before it) is rejected by saturating to max. |
| 76 | const gap: u16 = switch (dir) { | 91 | const gap: u16 = switch (dir) { |
| 77 | .left => blk: { | 92 | .left => blk: { |
| 78 | if (mid_row < r.top or mid_row >= r.top + r.rows) break :blk std.math.maxInt(u16); | ||
| 79 | const cand_right = r.left +% r.cols; | 93 | const cand_right = r.left +% r.cols; |
| 80 | if (cand_right > fr.left) break :blk std.math.maxInt(u16); | 94 | if (cand_right > fr.left) break :blk std.math.maxInt(u16); |
| 81 | break :blk fr.left - cand_right; | 95 | break :blk fr.left - cand_right; |
| 82 | }, | 96 | }, |
| 83 | .right => blk: { | 97 | .right => blk: { |
| 84 | if (mid_row < r.top or mid_row >= r.top + r.rows) break :blk std.math.maxInt(u16); | ||
| 85 | const focus_right = fr.left +% fr.cols; | 98 | const focus_right = fr.left +% fr.cols; |
| 86 | if (r.left < focus_right) break :blk std.math.maxInt(u16); | 99 | if (r.left < focus_right) break :blk std.math.maxInt(u16); |
| 87 | break :blk r.left - focus_right; | 100 | break :blk r.left - focus_right; |
| 88 | }, | 101 | }, |
| 89 | .up => blk: { | 102 | .up => blk: { |
| 90 | if (mid_col < r.left or mid_col >= r.left + r.cols) break :blk std.math.maxInt(u16); | ||
| 91 | const cand_bottom = r.top +% r.rows; | 103 | const cand_bottom = r.top +% r.rows; |
| 92 | if (cand_bottom > fr.top) break :blk std.math.maxInt(u16); | 104 | if (cand_bottom > fr.top) break :blk std.math.maxInt(u16); |
| 93 | break :blk fr.top - cand_bottom; | 105 | break :blk fr.top - cand_bottom; |
| 94 | }, | 106 | }, |
| 95 | .down => blk: { | 107 | .down => blk: { |
| 96 | if (mid_col < r.left or mid_col >= r.left + r.cols) break :blk std.math.maxInt(u16); | ||
| 97 | const focus_bottom = fr.top +% fr.rows; | 108 | const focus_bottom = fr.top +% fr.rows; |
| 98 | if (r.top < focus_bottom) break :blk std.math.maxInt(u16); | 109 | if (r.top < focus_bottom) break :blk std.math.maxInt(u16); |
| 99 | break :blk r.top - focus_bottom; | 110 | break :blk r.top - focus_bottom; |
| @@ -101,8 +112,9 @@ pub fn neighbor(flat: Flat, focus: u8, dir: Dir) ?u8 { | |||
| 101 | }; | 112 | }; |
| 102 | if (gap > 1) continue; | 113 | if (gap > 1) continue; |
| 103 | 114 | ||
| 104 | if (gap < best_dist) { | 115 | if (gap < best_gap or (gap == best_gap and perp < best_perp)) { |
| 105 | best_dist = gap; | 116 | best_gap = gap; |
| 117 | best_perp = perp; | ||
| 106 | best = p.tile; | 118 | best = p.tile; |
| 107 | } | 119 | } |
| 108 | } | 120 | } |
| @@ -110,6 +122,15 @@ pub fn neighbor(flat: Flat, focus: u8, dir: Dir) ?u8 { | |||
| 110 | return best; | 122 | return best; |
| 111 | } | 123 | } |
| 112 | 124 | ||
| 125 | /// Cells between `point` and the half-open span `[start, start + len)`; 0 | ||
| 126 | /// when the point is inside it. | ||
| 127 | fn spanDistance(point: u16, start: u16, len: u16) u16 { | ||
| 128 | if (point < start) return start - point; | ||
| 129 | const end = start +% len; // exclusive | ||
| 130 | if (point >= end) return point - (end -| 1); | ||
| 131 | return 0; | ||
| 132 | } | ||
| 133 | |||
| 113 | pub const Tree = struct { | 134 | pub const Tree = struct { |
| 114 | alloc: std.mem.Allocator, | 135 | alloc: std.mem.Allocator, |
| 115 | root: ?*Node = null, | 136 | root: ?*Node = null, |
| @@ -1287,3 +1308,41 @@ test "flatten: a container of zero total weight is TooSmall, not a divide by zer | |||
| 1287 | ); | 1308 | ); |
| 1288 | } | 1309 | } |
| 1289 | } | 1310 | } |
| 1311 | |||
| 1312 | test "neighbor down from a pane stacked on two side-by-side panes" { | ||
| 1313 | var t = Tree.init(std.testing.allocator); | ||
| 1314 | defer t.deinit(); | ||
| 1315 | try t.addFirst(0); | ||
| 1316 | try t.splitBelow(0, 1); | ||
| 1317 | try t.splitRight(1, 2); | ||
| 1318 | // 81 cols: the top pane's midpoint is col 40, which is the rail between | ||
| 1319 | // the two bottom panes (cols 0-39 and 41-80). Neither contains it. | ||
| 1320 | var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null); | ||
| 1321 | defer f.deinit(std.testing.allocator); | ||
| 1322 | try std.testing.expectEqual(@as(u16, 40), f.rails[0].col); | ||
| 1323 | // Both bottom panes abut the rail equally; the first placed wins, and | ||
| 1324 | // the answer must be one of the two rather than nothing. | ||
| 1325 | try std.testing.expectEqual(@as(?u8, 1), neighbor(f, 0, .down)); | ||
| 1326 | try std.testing.expectEqual(@as(?u8, 0), neighbor(f, 1, .up)); | ||
| 1327 | try std.testing.expectEqual(@as(?u8, 0), neighbor(f, 2, .up)); | ||
| 1328 | try std.testing.expectEqual(@as(?u8, 2), neighbor(f, 1, .right)); | ||
| 1329 | try std.testing.expectEqual(@as(?u8, 1), neighbor(f, 2, .left)); | ||
| 1330 | } | ||
| 1331 | |||
| 1332 | test "neighbor up from a pane below two side-by-side panes" { | ||
| 1333 | // The mirror of the test above: the rail is between the two TOP panes, | ||
| 1334 | // and the wide bottom pane's midpoint column lands on it. | ||
| 1335 | var t = Tree.init(std.testing.allocator); | ||
| 1336 | defer t.deinit(); | ||
| 1337 | try t.addFirst(0); | ||
| 1338 | try t.splitBelow(0, 1); | ||
| 1339 | try t.splitRight(0, 2); | ||
| 1340 | var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null); | ||
| 1341 | defer f.deinit(std.testing.allocator); | ||
| 1342 | try std.testing.expectEqual(@as(u16, 40), f.rails[0].col); | ||
| 1343 | try std.testing.expectEqual(@as(?u8, 0), neighbor(f, 1, .up)); | ||
| 1344 | try std.testing.expectEqual(@as(?u8, 1), neighbor(f, 0, .down)); | ||
| 1345 | try std.testing.expectEqual(@as(?u8, 1), neighbor(f, 2, .down)); | ||
| 1346 | try std.testing.expectEqual(@as(?u8, 2), neighbor(f, 0, .right)); | ||
| 1347 | try std.testing.expectEqual(@as(?u8, 0), neighbor(f, 2, .left)); | ||
| 1348 | } | ||