diff --git a/README.md b/README.md index 5a0a3ea..f2b7ae0 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ Notes: | `Alt` (hold) | Show the tab-title overlay (centered) | | `Alt+Up` | Previous tab (hold to keep cycling) | | `Alt+Down` | Next tab (hold to keep cycling) | +| `Alt+Shift+Up` | Move the active tab earlier in the list | +| `Alt+Shift+Down` | Move the active tab later in the list | | `Alt+Delete` | Close the active tab (SIGTERM, SIGKILL fallback) | | `Ctrl+Shift+T` | Open a new terminal tab next to the current one | @@ -120,6 +122,13 @@ The tab-cycling hotkey only emits a physical press for the first tab switch; repeats are synthesized, so holding `Alt+Up/Down` keeps stepping through tabs while any other key stops it. +`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 +order. Moving off either end wraps around to the other end, and holding the +chord keeps 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. + `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); the client's process group gets a `SIGTERM`, escalated to `SIGKILL` after a diff --git a/tabbed-wlroots.c b/tabbed-wlroots.c index 548ccc5..2f7e28d 100644 --- a/tabbed-wlroots.c +++ b/tabbed-wlroots.c @@ -260,6 +260,9 @@ struct tabbed_host { struct wl_event_source *repeat_timer; uint32_t repeat_key; int repeat_dir; + /* What each repeat tick should do: REPEAT_CYCLE switches tabs, + REPEAT_REORDER moves the active tab within the list. */ + enum { REPEAT_CYCLE, REPEAT_REORDER } repeat_kind; }; static struct tabbed_server server; @@ -465,6 +468,7 @@ static void remove_socket_file(void) { } static void overlay_font_fini(void); +static void standalone_overlay_damage(void); /* Standalone native keyboard: one struct per libinput keyboard device, so modifier state is always read from the device that emitted the event (the @@ -2402,6 +2406,65 @@ static void cycle_tab(int dir) { } } +/* Move the active tab one position up (dir>0: later in the list) or down + (dir<0) within the overlay order, wrapping at the ends. The active + surface is unchanged; only its slot in server.apps[] moves, which the + overlay and cycling both iterate. Only slots that currently show a + toplevel (and aren't mid-close, mirroring cycle_tab) count as positions; + with fewer than two of them there is nowhere to move. */ +static void reorder_active_tab(int dir) { + int n = 0; + for (int i = 0; i < server.num_apps; i++) + if (server.apps[i]->toplevel && !server.apps[i]->closing) + n++; + if (n < 2) return; + int from = -1; + for (int i = 0; i < server.num_apps; i++) + if (server.apps[i]->toplevel == server.active) { + from = i; + break; + } + if (from < 0) return; + + int neighbor = -1; + if (dir > 0) { + for (int i = from + 1; i < server.num_apps; i++) + if (server.apps[i]->toplevel && !server.apps[i]->closing) { + neighbor = i; + break; + } + if (neighbor < 0) + for (int i = 0; i < server.num_apps; i++) + if (server.apps[i]->toplevel && !server.apps[i]->closing) { + neighbor = i; + break; + } + } else { + for (int i = from - 1; i >= 0; i--) + if (server.apps[i]->toplevel && !server.apps[i]->closing) { + neighbor = i; + break; + } + if (neighbor < 0) + for (int i = server.num_apps - 1; i >= 0; i--) + if (server.apps[i]->toplevel && !server.apps[i]->closing) { + neighbor = i; + break; + } + } + if (neighbor < 0 || neighbor == from) return; + + struct tabbed_app *tmp = server.apps[from]; + server.apps[from] = server.apps[neighbor]; + server.apps[neighbor] = tmp; + + tabbed_log(TABBED_LOG_INFO, "[reorder] app=%d dir=%d\n", + neighbor, dir); + needs_redraw = true; + if (server.standalone) + standalone_overlay_damage(); +} + /* Fixed keybind repeat: first repeat after 600ms, then every 40ms (25Hz). */ #define TABBED_REPEAT_DELAY_MS 600 #define TABBED_REPEAT_INTERVAL_MS 40 @@ -2418,17 +2481,21 @@ static int kbd_repeat_cb(void *data) { (void)data; if (host.repeat_key == 0) return 0; - cycle_tab(host.repeat_dir); + if (host.repeat_kind == REPEAT_REORDER) + reorder_active_tab(host.repeat_dir); + else + cycle_tab(host.repeat_dir); if (host.repeat_timer) wl_event_source_timer_update(host.repeat_timer, TABBED_REPEAT_INTERVAL_MS); return 0; } -static void kbd_repeat_start(int key, int dir) { +static void kbd_repeat_start(int key, int dir, int kind) { kbd_repeat_cancel(); host.repeat_key = key; host.repeat_dir = dir; + host.repeat_kind = kind; host.repeat_timer = wl_event_loop_add_timer(server.loop, kbd_repeat_cb, NULL); if (host.repeat_timer) wl_event_source_timer_update(host.repeat_timer, TABBED_REPEAT_DELAY_MS); @@ -3677,12 +3744,30 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state, prev = key == 103; next = key == 108; } + /* Alt+Shift+Up / Alt+Shift+Down move the active tab within the overlay + list (wrapping at the ends), without switching the active surface. + Checked before the plain Alt+arrow cycle below so Shift always means + "reorder, don't switch"; hold to keep moving via kbd_repeat_start. + Like cycling, the keys are forwarded to the focused client. */ + bool shift = (mods_depressed & (1u << 0)) != 0; + if (alt && shift && (prev || next)) { + if (state == WL_KEYBOARD_KEY_STATE_PRESSED) { + if (host.repeat_key != key) { + int dir = next ? 1 : -1; + reorder_active_tab(dir); + kbd_repeat_start(key, dir, REPEAT_REORDER); + } + } else { + kbd_repeat_cancel(); + } + return false; + } if (alt && (prev || next)) { if (state == WL_KEYBOARD_KEY_STATE_PRESSED) { if (host.repeat_key != key) { int dir = next ? 1 : -1; cycle_tab(dir); - kbd_repeat_start(key, dir); + kbd_repeat_start(key, dir, REPEAT_CYCLE); } } else { kbd_repeat_cancel(); diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 148e401..e07ddc3 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -49,6 +49,8 @@ KEY_RELEASED = 0 CTRL = 1 << 2 # Alt (Mod1) modifier bit (1u << 3). ALT = 1 << 3 +# Shift modifier bit (1u << 0). +SHIFT = 1 << 0 CTL_CMD_KEY = 1 CTL_CMD_MODS = 2 @@ -1371,6 +1373,65 @@ def test_alt_delete_second_tab_right_neighbor_two_tabs(): s.proc.terminate() +def test_alt_shift_arrows_reorder_and_wrap(): + """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 + order is then provable behaviorally: a Ctrl+Shift+T terminal claims the + slot right after the active tab, so after [R,B] -> [B,R] with B active + (slot 0) a new tab must land at index 1.""" + if not os.environ.get("WAYLAND_DISPLAY"): + pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") + old_term = os.environ.get("TERMINAL") + os.environ["TERMINAL"] = f"{WL} '{RED}'" + try: + s = _nested_session(f"{WL} '{RED}'", f"{WL} '{BLUE}'") + try: + s.wait_any_active() + # Make the second tab (index 1) active so the wrap direction and + # the insertion slot below are deterministic. + for _ in range(3): + if s.active() == 1: + break + if s.cycle_ack(+1) == 1: + break + if s.proc.poll() is not None: + pytest.fail("compositor exited while cycling to tab 1") + s.wait_active(1) + + # Move the active tab one step DOWN: [RED, BLUE] -> [BLUE, RED]. + before = s.active_count() + s.chord(ALT | SHIFT, DOWN) + s.wait_for(r"\[reorder\] app=0 dir=1") + time.sleep(0.3) + assert s.active() == 1, "reorder must not switch the active tab" + assert s.active_count() == before, \ + "reorder must not emit an active-tab transition" + + # Moving UP from the front wraps to the end: -> [RED, BLUE]. + s.chord(ALT | SHIFT, UP) + s.wait_for(r"\[reorder\] app=1 dir=-1") + 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.wait_for(r"\[claim\] app=2 pid=(\d+)") + assert s.proc.poll() is None, \ + "compositor exited after reorder + new tab" + finally: + s.close() + if s.proc.poll() is None: + s.proc.terminate() + finally: + if old_term is None: + os.environ.pop("TERMINAL", None) + else: + os.environ["TERMINAL"] = old_term + + def test_alt_delete_escalates_sigkill(tmp_path): """A client that traps SIGTERM must still be reaped by the SIGKILL escalation, so Alt+Delete never strands a lingering tab."""