a73x

bed879b5

fix: directional focus finds a pane when the midpoint sits on a rail

a73x   2026-09-01 04:58

Commit message
fix: directional focus finds a pane when the midpoint sits on a rail

layout.neighbor required a candidate's perpendicular span to contain the
focused pane's edge midpoint. A pane stacked over two side-by-side panes
on an odd width puts that midpoint on the rail between them (81 cols: mid
40, rail 40), which no span contains, so Ctrl-\ j did nothing while k
from either bottom pane worked. Tolerate a midpoint within one cell of a
span, the rail width the gap check already allows, and rank by gap, then
perpendicular distance, then placement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPn8iyEdvA1g8Sb5HKyYce

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 }