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:
parent
6677012a9a
commit
f6c1a6b92e
3 changed files with 76 additions and 46 deletions
14
README.md
14
README.md
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue