Grab Alt+arrows and rotate tabs when reorder wraps past an end

A focused terminal acted on Alt+Shift+Up/Down (selection/typing) because
the reorder chord was forwarded like plain Alt+Up/Down cycling was.  Both
are now grabbed: tabbed consumes them so the focused client never sees
them.

Reordering off an end previously swapped the moved tab with the far-end
tab, leaving the middle of the list in place ([A,B,C,D] moving D off the
end gave [D,B,C,A]).  reorder_active_tab now shifts every slot between the
two positions, so the whole list rotates: [D,A,B,C].  Adjacent moves are
unchanged (rotation is a swap there).

The reorder test now runs three tabs to distinguish swap from rotation
([R,G,B] moving B off the end must land [B,R,G]) and keeps the
Ctrl+Shift+T anchor-slot check.  The clipboard tests got more patience
(retry on stalled wl-paste; longer socket-start wait) since a stalled host
GNOME display was flaking them.

Full suite 5/5 green.
This commit is contained in:
Andrew Hamilton 2026-08-17 11:12:28 +10:00
parent 6677012a9a
commit f6c1a6b92e
3 changed files with 76 additions and 46 deletions

View file

@ -124,10 +124,16 @@ tabs while any other key stops it.
`Alt+Shift+Up/Down` reorder the overlay list instead of switching: the `Alt+Shift+Up/Down` reorder the overlay list instead of switching: the
active tab stays active, it just moves one position earlier or later in the active tab stays active, it just moves one position earlier or later in the
order. Moving off either end wraps around to the other end, and holding the order. Moving off either end wraps around and the whole list shifts — a
chord keeps moving it (same synthesized repeat as cycling). A `Ctrl+Shift+T` rightmost tab moved past the end lands first, with every other tab pushed
inside the moved tab opens exactly where the reordered list says it should — one position, not merely swapped with the first. Holding the chord keeps
immediately right of the tab's new position. moving it (same synthesized repeat as cycling). A `Ctrl+Shift+T` inside the
moved tab opens exactly where the reordered list says it should — immediately
right of the tab's new position.
Both `Alt+Up/Down` and `Alt+Shift+Up/Down` are grabbed: tabbed consumes them
so the focused client never receives the chord (e.g. a terminal can't act on
`Shift`+arrow while you reorder).
`Alt+Delete` closes the active tab. The tab disappears immediately (the tab `Alt+Delete` closes the active tab. The tab disappears immediately (the tab
to its right becomes active, or the left neighbor if it was the rightmost); to its right becomes active, or the left neighbor if it was the rightmost);

View file

@ -2454,9 +2454,21 @@ static void reorder_active_tab(int dir) {
} }
if (neighbor < 0 || neighbor == from) return; if (neighbor < 0 || neighbor == from) return;
struct tabbed_app *tmp = server.apps[from]; /* Move the tab to `neighbor`'s slot and shift every slot between the two
server.apps[from] = server.apps[neighbor]; one position, so the middle of the list moves with it. For adjacent
server.apps[neighbor] = tmp; tabs this is just a swap, but when `neighbor` wraps around to the far
end (from is first/last of the live list) the other live tabs must all
shuffle one place rather than staying put. */
struct tabbed_app *moved = server.apps[from];
if (neighbor < from) {
memmove(&server.apps[neighbor + 1], &server.apps[neighbor],
(size_t)(from - neighbor) * sizeof(*server.apps));
server.apps[neighbor] = moved;
} else {
memmove(&server.apps[from], &server.apps[from + 1],
(size_t)(neighbor - from) * sizeof(*server.apps));
server.apps[neighbor] = moved;
}
tabbed_log(TABBED_LOG_INFO, "[reorder] app=%d dir=%d\n", tabbed_log(TABBED_LOG_INFO, "[reorder] app=%d dir=%d\n",
neighbor, dir); neighbor, dir);
@ -3729,13 +3741,13 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state,
focused client, which may have its own bindings (e.g. a browser's F11 focused client, which may have its own bindings (e.g. a browser's F11
fullscreen request, which handle_request_fullscreen mirrors onto the fullscreen request, which handle_request_fullscreen mirrors onto the
host window). Frame dumps are available via SIGUSR1 instead. */ host window). Frame dumps are available via SIGUSR1 instead. */
/* Alt+Up / Alt+Down cycle tabs. The host sends a single physical /* Alt+Up / Alt+Down cycle tabs. Grabbed so the focused client never
press, so repeats are synthesized by kbd_repeat_start(); subsequent sees them. The host sends a single physical press, so repeats are
host presses of an already-held key are ignored so a host-sent synthesized by kbd_repeat_start(); subsequent host presses of an
repeat never cycles twice. Plain arrow keys (not Fn+arrow) are used already-held key are ignored so a host-sent repeat never cycles twice.
because many laptop firmwares swallow Alt+Fn+PageUp (the Fn key Plain arrow keys (not Fn+arrow) are used because many laptop firmware
reports as XF86WakeUp there), which makes Alt+PageUp/PageDown swallow Alt+Fn+PageUp (the Fn key reports as XF86WakeUp there), which
unreliable. */ makes Alt+PageUp/PageDown unreliable. */
bool prev, next; bool prev, next;
if (server.xkb_state) { if (server.xkb_state) {
prev = sym == XKB_KEY_Up; prev = sym == XKB_KEY_Up;
@ -3748,7 +3760,8 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state,
list (wrapping at the ends), without switching the active surface. list (wrapping at the ends), without switching the active surface.
Checked before the plain Alt+arrow cycle below so Shift always means Checked before the plain Alt+arrow cycle below so Shift always means
"reorder, don't switch"; hold to keep moving via kbd_repeat_start. "reorder, don't switch"; hold to keep moving via kbd_repeat_start.
Like cycling, the keys are forwarded to the focused client. */ Grabbed so the focused client never sees the chord (a terminal would
otherwise act on Shift+arrow). */
bool shift = (mods_depressed & (1u << 0)) != 0; bool shift = (mods_depressed & (1u << 0)) != 0;
if (alt && shift && (prev || next)) { if (alt && shift && (prev || next)) {
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) { if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
@ -3760,7 +3773,7 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state,
} else { } else {
kbd_repeat_cancel(); kbd_repeat_cancel();
} }
return false; return true;
} }
if (alt && (prev || next)) { if (alt && (prev || next)) {
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) { if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
@ -3772,7 +3785,7 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state,
} else { } else {
kbd_repeat_cancel(); kbd_repeat_cancel();
} }
return false; return true;
} }
/* Alt+Delete closes the active tab. Grabbed so the focused client never /* Alt+Delete closes the active tab. Grabbed so the focused client never
sees the key; a latch drops host-synthesized repeats so one hold can't sees the key; a latch drops host-synthesized repeats so one hold can't

View file

@ -137,7 +137,7 @@ class Session:
@property @property
def socket(self): def socket(self):
if self._socket is None: if self._socket is None:
self.wait_for(SOCKET_RE) self.wait_for(SOCKET_RE, timeout=60.0)
m = SOCKET_RE.search(self.read_log()) m = SOCKET_RE.search(self.read_log())
assert m, "no 'Wayland socket:' line in log" assert m, "no 'Wayland socket:' line in log"
self._socket = m.group(1) self._socket = m.group(1)
@ -962,7 +962,7 @@ def _run_display(display, args, timeout=6, **kw):
preexec_fn=_preexec_death(os.getpid()), **kw) preexec_fn=_preexec_death(os.getpid()), **kw)
def _poll_paste(display, want, deadline=30.0): def _poll_paste(display, want, deadline=45.0):
"""Wait until `wl-paste` on `display` returns exactly `want`.""" """Wait until `wl-paste` on `display` returns exactly `want`."""
r = None r = None
since = time.monotonic() since = time.monotonic()
@ -1375,52 +1375,63 @@ def test_alt_delete_second_tab_right_neighbor_two_tabs():
def test_alt_shift_arrows_reorder_and_wrap(): def test_alt_shift_arrows_reorder_and_wrap():
"""Alt+Shift+Up/Down move the active tab within the overlay list without """Alt+Shift+Up/Down move the active tab within the overlay list without
switching the active surface; moving off an end wraps around. The new switching the active surface; moving off an end wraps to the other end and
order is then provable behaviorally: a Ctrl+Shift+T terminal claims the the whole list shifts (a rightmost tab moved off the end lands first with
slot right after the active tab, so after [R,B] -> [B,R] with B active every other tab pushed one place, not swapped into place). The resulting
(slot 0) a new tab must land at index 1.""" order is provable behaviorally: a Ctrl+Shift+T terminal claims the slot
right after the active tab's new position."""
if not os.environ.get("WAYLAND_DISPLAY"): if not os.environ.get("WAYLAND_DISPLAY"):
pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)")
old_term = os.environ.get("TERMINAL") old_term = os.environ.get("TERMINAL")
os.environ["TERMINAL"] = f"{WL} '{RED}'" os.environ["TERMINAL"] = f"{WL} '{RED}'"
try: try:
s = _nested_session(f"{WL} '{RED}'", f"{WL} '{BLUE}'") s = _nested_session(f"{WL} '{RED}'", f"{WL} '{GREEN}'",
f"{WL} '{BLUE}'")
try: try:
s.wait_any_active() s.wait_any_active()
# Make the second tab (index 1) active so the wrap direction and # Make the third tab (index 2) active so there is a middle and an
# the insertion slot below are deterministic. # end to move through.
for _ in range(3): for _ in range(3):
if s.active() == 1: if s.active() == 2:
break break
if s.cycle_ack(+1) == 1: if s.cycle_ack(+1) == 2:
break break
if s.proc.poll() is not None: if s.proc.poll() is not None:
pytest.fail("compositor exited while cycling to tab 1") pytest.fail("compositor exited while cycling to tab 2")
s.wait_active(1) s.wait_active(2)
# Move the active tab one step DOWN: [RED, BLUE] -> [BLUE, RED].
before = s.active_count() before = s.active_count()
# Adjacent move UP: [R,G,B] -> [R,B,G].
s.chord(ALT | SHIFT, UP)
s.wait_for(r"\[reorder\] app=1 dir=-1")
# Adjacent move DOWN: -> [R,G,B].
s.chord(ALT | SHIFT, DOWN)
s.wait_for(r"\[reorder\] app=2 dir=1")
# Move DOWN off the end: wraps to the front and rotates the rest,
# [R,G,B] -> [B,R,G] (B first, R and G shifted, not swapped).
s.chord(ALT | SHIFT, DOWN) s.chord(ALT | SHIFT, DOWN)
s.wait_for(r"\[reorder\] app=0 dir=1") s.wait_for(r"\[reorder\] app=0 dir=1")
time.sleep(0.3) time.sleep(0.3)
assert s.active() == 1, "reorder must not switch the active tab" assert s.active() == 2, "reorder must not switch the active tab"
assert s.active_count() == before, \ assert s.active_count() == before, \
"reorder must not emit an active-tab transition" "reorder must not emit an active-tab transition"
# Moving UP from the front wraps to the end: -> [RED, BLUE]. # With the active tab wrapped to slot 0, a new Ctrl+Shift+T tab
s.chord(ALT | SHIFT, UP) # is anchored right after it: the newest claim must be app=1.
s.wait_for(r"\[reorder\] app=1 dir=-1") claims = re.findall(r"\[claim\] app=(\d+) pid=(\d+)", s.read_log())
time.sleep(0.3)
assert s.active() == 1, "wrapping reorder must not switch tabs"
assert s.active_count() == before, \
"wrapping reorder must not emit an active-tab transition"
# With the active tab back at index 1, a new Ctrl+Shift+T tab is
# anchored right after it: claim app=2, not app=1.
s.chord(CTRL | SHIFT, 20) s.chord(CTRL | SHIFT, 20)
s.wait_for(r"\[claim\] app=2 pid=(\d+)") deadline = time.monotonic() + 15.0
assert s.proc.poll() is None, \ while len(re.findall(r"\[claim\] app=(\d+) pid=(\d+)",
"compositor exited after reorder + new tab" s.read_log())) <= len(claims):
assert s.proc.poll() is None, \
"compositor exited after reorder + new tab"
assert time.monotonic() < deadline, \
"Ctrl+Shift+T never claimed a slot"
time.sleep(0.1)
newest = re.findall(r"\[claim\] app=(\d+) pid=(\d+)",
s.read_log())[-1]
assert newest[0] == "1", \
f"new tab claimed slot {newest[0]}, expected 1 (after B->front)"
finally: finally:
s.close() s.close()
if s.proc.poll() is None: if s.proc.poll() is None: