a5fc3918
feat: align native split bindings with sway
a73x 2026-09-07 12:32
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -148,7 +148,7 @@ Open the saved workspace, or an explicit target in a temporary workspace: | |||
| 148 | ./zig-out/bin/muxg alpha --session work | 148 | ./zig-out/bin/muxg alpha --session work |
| 149 | ``` | 149 | ``` |
| 150 | 150 | ||
| 151 | Press `Ctrl+\`, then **v** for side by side or **b** for above/below. | 151 | Press `Ctrl+\`, then **v** for a pane below or **b** for a pane beside it. |
| 152 | This shows the intended split. Press the prefix again, then **Enter**, to open | 152 | This shows the intended split. Press the prefix again, then **Enter**, to open |
| 153 | the host picker. Arrows or **j/k** select a row; Enter chooses it. Select a host, | 153 | the host picker. Arrows or **j/k** select a row; Enter chooses it. Select a host, |
| 154 | then an existing session or **New session...**, which asks for a name. **Add host...** | 154 | then an existing session or **New session...**, which asks for a name. **Add host...** |
docs/superpowers/specs/2026-09-05-native-tiling-design.md
| Old | New | ||
|---|---|---|---|
| @@ -73,8 +73,8 @@ they are not prerequisites for the two-pane deliverable. | |||
| 73 | 73 | ||
| 74 | The defaults below are proposals to trial, rather than additional requirements. | 74 | The defaults below are proposals to trial, rather than additional requirements. |
| 75 | 75 | ||
| 76 | Use side-by-side and above/below in the interface: `v` names the vertical | 76 | Use side-by-side and above/below in the interface: `v` puts the new pane below, |
| 77 | divider, with the new pane to the right; `b` puts the new pane below. Show the | 77 | matching Sway's vertical split, while `b` puts the new pane beside the focus. Show the |
| 78 | pending direction at its originating pane so the orientation is unambiguous. | 78 | pending direction at its originating pane so the orientation is unambiguous. |
| 79 | 79 | ||
| 80 | Direction selection arms the focused pane for one insertion, immediately opens | 80 | Direction selection arms the focused pane for one insertion, immediately opens |
| @@ -125,8 +125,8 @@ The prefix opens a small command hint strip; its next key is consumed by the GUI | |||
| 125 | 125 | ||
| 126 | | After prefix | Action | | 126 | | After prefix | Action | |
| 127 | | --- | --- | | 127 | | --- | --- | |
| 128 | | `v` | Open host picker for side-by-side insertion (vertical divider) | | 128 | | `v` | Open host picker for above/below insertion (new pane below) | |
| 129 | | `b` | Open host picker for above/below insertion (new pane below) | | 129 | | `b` | Open host picker for side-by-side insertion (new pane beside) | |
| 130 | | Enter | Open host picker for insertion | | 130 | | Enter | Open host picker for insertion | |
| 131 | | `h` / `j` / `k` / `l`, or arrows | Focus left / down / up / right | | 131 | | `h` / `j` / `k` / `l`, or arrows | Focus left / down / up / right | |
| 132 | | `r` | Enter resize mode; `h/j/k/l` or arrows resize, Esc/Enter exits | | 132 | | `r` | Enter resize mode; `h/j/k/l` or arrows resize, Esc/Enter exits | |
| @@ -135,8 +135,9 @@ The prefix opens a small command hint strip; its next key is consumed by the GUI | |||
| 135 | | `p` | Open Pane actions (Retry, Choose session, Detach) for the focused pane | | 135 | | `p` | Open Pane actions (Retry, Choose session, Detach) for the focused pane | |
| 136 | | Esc | Cancel the command and pending split | | 136 | | Esc | Cancel the command and pending split | |
| 137 | 137 | ||
| 138 | The split and movement bindings above were agreed on 2026-09-05: reserve | 138 | The split and movement bindings above reserve `h/j/k/l` for directional |
| 139 | `h/j/k/l` for directional movement, with `v` and `b` selecting split direction. | 139 | movement, with `v` and `b` selecting split direction. A 2026-09-07 hands-on |
| 140 | trial aligned their meanings with Sway: `v` below and `b` beside. | ||
| 140 | The subsequent hands-on ergonomic trial makes `v`/`b` open host selection | 141 | The subsequent hands-on ergonomic trial makes `v`/`b` open host selection |
| 141 | immediately; a second prefix + Enter is unnecessary. Cancelling the host picker | 142 | immediately; a second prefix + Enter is unnecessary. Cancelling the host picker |
| 142 | retains the armed direction, and Esc outside the picker cancels it. | 143 | retains the armed direction, and Esc outside the picker cancels it. |
src/gui/frame.zig
| Old | New | ||
|---|---|---|---|
| @@ -1023,7 +1023,7 @@ const Header = struct { | |||
| 1023 | const focused = events.ui.rt.workspace.tab().focus == p.id; | 1023 | const focused = events.ui.rt.workspace.tab().focus == p.id; |
| 1024 | const pending = events.ui.rt.workspace.tab().pending; | 1024 | const pending = events.ui.rt.workspace.tab().pending; |
| 1025 | const ending = if (events.ui.pending_end) |end| end.key.pane == p.id and events.ui.rt.accepts(end.key) else false; | 1025 | const ending = if (events.ui.pending_end) |end| end.key.pane == p.id and events.ui.rt.accepts(end.key) else false; |
| 1026 | const hint = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else if (ending) " [End requested; waiting for daemon]" else if (focused and events.ui.resize_mode) (if (events.ui.notice.len != 0) events.ui.notice else " [resize: arrows/hjkl move divider, Enter/Esc finish]") else if (focused and events.ui.command_mode) " [command: v/b split, h/j/k/l focus, r resize, Enter picks session, Esc cancel]" else if (pending != null and pending.?.pane == p.id) (if (events.ui.picker != null and events.ui.picker.?.mode == .insert) (if (pending.?.direction == .beside) " [split beside]" else " [split below]") else (if (pending.?.direction == .beside) " [split beside: prefix Enter chooses session, Esc cancels]" else " [split below: prefix Enter chooses session, Esc cancels]")) else events.ui.notice; | 1026 | const hint = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else if (ending) " [End requested; waiting for daemon]" else if (focused and events.ui.resize_mode) (if (events.ui.notice.len != 0) events.ui.notice else " [resize: arrows/hjkl move divider, Enter/Esc finish]") else if (focused and events.ui.command_mode) " [command: v below, b beside, h/j/k/l focus, r resize, Enter picks session, Esc cancel]" else if (pending != null and pending.?.pane == p.id) (if (events.ui.picker != null and events.ui.picker.?.mode == .insert) (if (pending.?.direction == .beside) " [split beside]" else " [split below]") else (if (pending.?.direction == .beside) " [split beside: prefix Enter chooses session, Esc cancels]" else " [split below: prefix Enter chooses session, Esc cancels]")) else events.ui.notice; |
| 1027 | const label = events.ui.rt.workspace.pane(p.id).?.identity.label; | 1027 | const label = events.ui.rt.workspace.pane(p.id).?.identity.label; |
| 1028 | var status_buf: [48]u8 = undefined; | 1028 | var status_buf: [48]u8 = undefined; |
| 1029 | const status: []const u8 = switch (live.status.phase) { | 1029 | const status: []const u8 = switch (live.status.phase) { |
src/gui/interaction.zig
| Old | New | ||
|---|---|---|---|
| @@ -207,7 +207,7 @@ pub const Controller = struct { | |||
| 207 | self.intent_dirty = self.intent_dirty or before != ws.tab().focus; | 207 | self.intent_dirty = self.intent_dirty or before != ws.tab().focus; |
| 208 | } else switch (key) { | 208 | } else switch (key) { |
| 209 | .v, .b, .enter, .keypad_enter => { | 209 | .v, .b, .enter, .keypad_enter => { |
| 210 | if (key == .v or key == .b) ws.arm(if (key == .v) .beside else .stacked); | 210 | if (key == .v or key == .b) ws.arm(if (key == .v) .stacked else .beside); |
| 211 | self.openPicker(.insert) catch |err| { | 211 | self.openPicker(.insert) catch |err| { |
| 212 | self.notice = @errorName(err); | 212 | self.notice = @errorName(err); |
| 213 | self.dirty = true; | 213 | self.dirty = true; |
| @@ -222,7 +222,7 @@ pub const Controller = struct { | |||
| 222 | self.recovery.?.setNotice(self.rt.get(id).?.status.reasonText()); | 222 | self.recovery.?.setNotice(self.rt.get(id).?.status.reasonText()); |
| 223 | }, | 223 | }, |
| 224 | .escape => ws.cancel(), | 224 | .escape => ws.cancel(), |
| 225 | else => self.notice = "v/b split, hjkl focus, r resize, d detach, x end, p actions, Enter add", | 225 | else => self.notice = "v below, b beside, hjkl focus, r resize, d detach, x end, p actions, Enter add", |
| 226 | } | 226 | } |
| 227 | self.dirty = true; | 227 | self.dirty = true; |
| 228 | } | 228 | } |
| @@ -833,14 +833,14 @@ test "controller modal input takes precedence over the prefix" { | |||
| 833 | try std.testing.expect(ui.resize_mode and !ui.command_mode and ui.suppress_text); | 833 | try std.testing.expect(ui.resize_mode and !ui.command_mode and ui.suppress_text); |
| 834 | } | 834 | } |
| 835 | 835 | ||
| 836 | test "v/b opens insertion picker and Esc preserves then cancels pending direction" { | 836 | test "v opens below and b opens beside while Esc preserves then cancels direction" { |
| 837 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); | 837 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); |
| 838 | defer rt.deinit(); | 838 | defer rt.deinit(); |
| 839 | var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 }; | 839 | var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 }; |
| 840 | defer ui.deinit(); | 840 | defer ui.deinit(); |
| 841 | 841 | ||
| 842 | _ = try rt.add(.{ .via = "cat" }, "origin", 800, 600, ui.metrics); | 842 | _ = try rt.add(.{ .via = "cat" }, "origin", 800, 600, ui.metrics); |
| 843 | try ui.command(.b); | 843 | try ui.command(.v); |
| 844 | try std.testing.expect(ui.picker != null); | 844 | try std.testing.expect(ui.picker != null); |
| 845 | try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction); | 845 | try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction); |
| 846 | try ui.keyDown(.{ .code = 27, .kind = .escape }); | 846 | try ui.keyDown(.{ .code = 27, .kind = .escape }); |
| @@ -852,7 +852,7 @@ test "v/b opens insertion picker and Esc preserves then cancels pending directio | |||
| 852 | ui.keyUp(27); | 852 | ui.keyUp(27); |
| 853 | try std.testing.expect(rt.workspace.tab().pending == null); | 853 | try std.testing.expect(rt.workspace.tab().pending == null); |
| 854 | 854 | ||
| 855 | try ui.command(.v); | 855 | try ui.command(.b); |
| 856 | try std.testing.expectEqual(model.Direction.beside, rt.workspace.tab().pending.?.direction); | 856 | try std.testing.expectEqual(model.Direction.beside, rt.workspace.tab().pending.?.direction); |
| 857 | try ui.keyDown(.{ .code = 27, .kind = .escape }); | 857 | try ui.keyDown(.{ .code = 27, .kind = .escape }); |
| 858 | ui.keyUp(27); | 858 | ui.keyUp(27); |
test/native_journey.py
| Old | New | ||
|---|---|---|---|
| @@ -57,9 +57,9 @@ def basic_workspace(rig): | |||
| 57 | 'v/b did not produce one below and one beside split') | 57 | 'v/b did not produce one below and one beside split') |
| 58 | left_rect, right_rect, lower_rect = [by_id(state)[pane]['outer'] for pane in panes] | 58 | left_rect, right_rect, lower_rect = [by_id(state)[pane]['outer'] for pane in panes] |
| 59 | require(right_rect['x'] >= left_rect['x'] + left_rect['w'], | 59 | require(right_rect['x'] >= left_rect['x'] + left_rect['w'], |
| 60 | 'v pane is not beside the original pane') | 60 | 'b pane is not beside the original pane') |
| 61 | require(lower_rect['y'] >= right_rect['y'] + right_rect['h'], | 61 | require(lower_rect['y'] >= right_rect['y'] + right_rect['h'], |
| 62 | 'b pane is not below the focused pane') | 62 | 'v pane is not below the focused pane') |
| 63 | pty_sizes(rig, refs, [(p['id'], p['generation']) for p in state['panes']]) | 63 | pty_sizes(rig, refs, [(p['id'], p['generation']) for p in state['panes']]) |
| 64 | 64 | ||
| 65 | rig.focus(right) | 65 | rig.focus(right) |
test/native_lifecycle.py
| Old | New | ||
|---|---|---|---|
| @@ -182,10 +182,10 @@ def start_workspace(rig, *, quic=False, picker_checks=False): | |||
| 182 | rig.new_session('left') | 182 | rig.new_session('left') |
| 183 | rig.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached') | 183 | rig.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached') |
| 184 | if picker_checks: | 184 | if picker_checks: |
| 185 | rig.chord('v') | 185 | rig.chord('b') |
| 186 | armed = rig.picker('hosts') | 186 | armed = rig.picker('hosts') |
| 187 | require(armed['pending']['direction'] == 'beside', | 187 | require(armed['pending']['direction'] == 'beside', |
| 188 | 'v did not arm a split beside the focused pane') | 188 | 'b did not arm a split beside the focused pane') |
| 189 | stats = rig.command('d', 'stats', '--sock', b).stdout | 189 | stats = rig.command('d', 'stats', '--sock', b).stdout |
| 190 | require('attaches=0' in stats, 'picker attached its target before insertion: ' + stats) | 190 | require('attaches=0' in stats, 'picker attached its target before insertion: ' + stats) |
| 191 | require(rig.command('a', 'status', '--sock', b, '--session', 'right', | 191 | require(rig.command('a', 'status', '--sock', b, '--session', 'right', |
| @@ -201,10 +201,10 @@ def start_workspace(rig, *, quic=False, picker_checks=False): | |||
| 201 | rig.new_session('right') | 201 | rig.new_session('right') |
| 202 | rig.wait_state(lambda s: len(s['panes']) == 2 and all(p['phase'] == 'attached' for p in s['panes'])) | 202 | rig.wait_state(lambda s: len(s['panes']) == 2 and all(p['phase'] == 'attached' for p in s['panes'])) |
| 203 | if picker_checks: | 203 | if picker_checks: |
| 204 | rig.chord('b') | 204 | rig.chord('v') |
| 205 | armed = rig.picker('hosts') | 205 | armed = rig.picker('hosts') |
| 206 | require(armed['pending']['direction'] == 'stacked', | 206 | require(armed['pending']['direction'] == 'stacked', |
| 207 | 'b did not arm a split below the focused pane') | 207 | 'v did not arm a split below the focused pane') |
| 208 | rig.key('escape') | 208 | rig.key('escape') |
| 209 | rig.key('escape') | 209 | rig.key('escape') |
| 210 | rig.wait_state(lambda s: s['pending'] is None) | 210 | rig.wait_state(lambda s: s['pending'] is None) |
test/native_tiling.py
| Old | New | ||
|---|---|---|---|
| @@ -18,7 +18,7 @@ import time | |||
| 18 | 18 | ||
| 19 | 19 | ||
| 20 | DEADLINE = 5.0 | 20 | DEADLINE = 5.0 |
| 21 | SPLIT_BINDINGS = {"beside": "v", "stacked": "b"} | 21 | SPLIT_BINDINGS = {"beside": "b", "stacked": "v"} |
| 22 | 22 | ||
| 23 | 23 | ||
| 24 | def require(condition, message): | 24 | def require(condition, message): |