diff --git a/README.md b/README.md index c0742d6..5c13b4e 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,21 @@ Notes: | `Alt+Delete` | Close the active tab (SIGTERM, SIGKILL fallback) | | `Ctrl+Shift+T` | Open a new terminal tab next to the current one | -Holding `Alt` shows a passive overlay listing every tab's title with the +The tab-management prefix is `Alt` by default. Pass `--modifier super` to +use `Super` (Windows/Meta key, Mod4) instead: `Super` hold shows the +overlay, `Super+Up/Down` cycle, `Super+Shift+Up/Down` reorder, and +`Super+Delete` closes. `Ctrl+Shift+T` always uses Ctrl and is unchanged. +The `Super` variant is meant for standalone/DRM and labwc-style hosts +(including the Raspberry Pi, where nothing reserves it); under GNOME the +host grabs most `Super` chords before the nested compositor ever sees them +(e.g. `Super+Up` maximizes the tabbed window), so keep the default `Alt` +there. labwc's own defaults also leave `Super+Shift+arrow` and +`Super+Delete` free even when tabbed is nested inside labwc. + +Holding the tab-management modifier (`Alt`, or `Super` with `--modifier +super`) shows a passive overlay listing every tab's title with the active tab highlighted; the list updates live as you cycle with `Up`/`Down` -and disappears on `Alt` release. It is drawn by tabbed itself, so it works +and disappears on release. It is drawn by tabbed itself, so it works on hosts with or without a title bar (the overlay text uses a system font via FreeType; set `TABBED_FONT` to pick one). Each row also shows the tab's app icon, resolved from its Wayland `app_id` via the desktop diff --git a/tabbed-wlroots.c b/tabbed-wlroots.c index 37a73f4..126a293 100644 --- a/tabbed-wlroots.c +++ b/tabbed-wlroots.c @@ -170,7 +170,9 @@ struct tabbed_server { struct xkb_context *xkb_ctx; struct xkb_keymap *xkb_keymap; struct xkb_state *xkb_state; - xkb_mod_index_t xkb_alt_mod; + /* Modifier index of the tab-management prefix (Alt or Super/Mod4, + per --modifier). */ + xkb_mod_index_t xkb_prefix_mod; /* A client's fullscreen request is honored and mirrored onto the host window, and vice versa. */ @@ -279,7 +281,15 @@ static bool shutting_down; so it may carry arguments. */ static const char *term_cmd = "alacritty"; static bool term_key_held; /* Ctrl+Shift+T currently down (repeat latch) */ -static bool delete_key_held; /* Alt+Delete currently down (repeat latch) */ +static bool delete_key_held; /* prefix+Delete currently down (repeat latch) */ + +/* The modifier prefix for the tab-management chords: hold = show overlay, + Up/Down = cycle, Shift+Up/Down = reorder, Delete = close. Defaults to + Alt; --modifier super selects Super (Mod4), which is a good fit for + standalone/DRM and labwc-style hosts that do not reserve it the way + GNOME does. */ +enum tabbed_prefix { PREFIX_ALT, PREFIX_SUPER }; +static enum tabbed_prefix tab_prefix = PREFIX_ALT; /* ===================== Clipboard bridge ===================== */ @@ -3324,8 +3334,12 @@ static void xkb_state_reload(void) if (!server.xkb_keymap) return; server.xkb_state = xkb_state_new(server.xkb_keymap); - server.xkb_alt_mod = - xkb_keymap_mod_get_index(server.xkb_keymap, XKB_MOD_NAME_ALT); + server.xkb_prefix_mod = xkb_keymap_mod_get_index(server.xkb_keymap, + tab_prefix == PREFIX_SUPER ? XKB_MOD_NAME_MOD4 + : XKB_MOD_NAME_ALT); + tabbed_log(TABBED_LOG_DEBUG, "tabbed-wlroots: prefix modifier index=%u " + "(%s)\n", (unsigned)server.xkb_prefix_mod, + tab_prefix == PREFIX_SUPER ? "Super/Mod4" : "Alt"); } static void host_kbd_keymap(void *data, struct wl_keyboard *kbd, uint32_t format, @@ -3692,6 +3706,18 @@ static struct wl_listener standalone_cursor_motion_listener = { .notify = standalone_cursor_motion, }; +/* Is the configured tab-management prefix (Alt, or Super/Mod4 with + --modifier super) currently pressed? Uses the xkb modifier index when a + keymap is compiled; with no keymap (xkb init failed) it falls back to the + raw wl_keyboard modifier bit: mod1/alt = 1<<3, mod4/super = 1<<6. */ +static bool prefix_active(uint32_t mods_depressed) { + if (server.xkb_state) + return xkb_state_mod_index_is_active(server.xkb_state, + server.xkb_prefix_mod, XKB_STATE_MODS_EFFECTIVE); + return (mods_depressed & (tab_prefix == PREFIX_SUPER ? (1u << 6) + : (1u << 3))) != 0; +} + /* Shared key handling for both the host keyboard and native input devices: tab-switch bindings + overlay visibility, with the caller forwarding the key to the seat afterwards. `mods_depressed` uses the wl_keyboard modifier @@ -3710,10 +3736,7 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state, : XKB_KEY_NoSymbol; /* With no compiled keymap (xkb init failed) fall back to the raw evdev keycodes / modifier bits for the hotkey matching below. */ - bool alt = server.xkb_state - ? xkb_state_mod_index_is_active(server.xkb_state, - server.xkb_alt_mod, XKB_STATE_MODS_EFFECTIVE) - : (mods_depressed & (1u << 3)) != 0; + bool alt = prefix_active(mods_depressed); if (alt != host.overlay_visible) update_overlay_visible(alt); char symbuf[32]; @@ -3787,7 +3810,7 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state, } return true; } - /* Alt+Delete closes the active tab. Grabbed so the focused client never + /* prefix+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 close a tab per repeat. The active surface already moved to the right neighbor inside close_active_tab(), so the tab is gone this frame even @@ -3852,10 +3875,7 @@ static void process_modifiers(uint32_t mods_depressed, uint32_t mods_latched, if (server.xkb_state) xkb_state_update_mask(server.xkb_state, mods_depressed, mods_latched, mods_locked, group, group, group); - bool alt = server.xkb_state - ? xkb_state_mod_index_is_active(server.xkb_state, - server.xkb_alt_mod, XKB_STATE_MODS_EFFECTIVE) - : (mods_depressed & (1u << 3)) != 0; + bool alt = prefix_active(mods_depressed); if (alt != host.overlay_visible) update_overlay_visible(alt); if (host.repeat_key && !alt) @@ -4992,9 +5012,10 @@ int main(int argc, char *argv[]) { /* Ctrl+Shift+T opens a terminal named by $TERMINAL when set, else the alacritty default. Every command-line argument is one tab launcher, except --screen-mode WxH[@Hz], which selects the native output mode and - is consumed here. Handle all of this up front so a missing command (or - a bad request) can be reported and the process can exit before any - window is created. */ + is consumed here, and --modifier alt|super, which picks the modifier + prefix for the tab-management chords. Handle all of this up front so a + missing command (or a bad request) can be reported and the process can + exit before any window is created. */ { const char *env_term = getenv("TERMINAL"); if (env_term && env_term[0]) @@ -5009,11 +5030,15 @@ int main(int argc, char *argv[]) { for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { fprintf(stderr, - "usage: %s [--screen-mode WxH[@Hz]] [prog...]\n" + "usage: %s [--screen-mode WxH[@Hz]] [--modifier alt|super] " + "[prog...]\n" " Select the native output mode (standalone only): " "e.g. --screen-mode 1920x1080\n" " or --screen-mode 1920x1080@60. A request nothing " "supports prints every mode and exits.\n" + " --modifier picks the tab-management modifier " + "(default alt); 'super' suits standalone/DRM and " + "labwc-style hosts.\n" " Each is one shell command; quote it when it " "takes args, e.g. \"firefox -no-remote\".\n" " With no arguments, a single terminal tab opens " @@ -5033,6 +5058,38 @@ int main(int argc, char *argv[]) { val = argv[++i]; } else if (strncmp(argv[i], "--screen-mode=", 14) == 0) { val = argv[i] + 14; + } else if (strcmp(argv[i], "--modifier") == 0) { + if (i + 1 >= argc) { + fprintf(stderr, "tabbed-wlroots: --modifier needs a value, " + "e.g. --modifier super\n"); + free(appv); + return 2; + } + const char *m = argv[++i]; + if (strcmp(m, "alt") == 0) { + tab_prefix = PREFIX_ALT; + } else if (strcmp(m, "super") == 0) { + tab_prefix = PREFIX_SUPER; + } else { + fprintf(stderr, "tabbed-wlroots: bad --modifier '%s' " + "(use 'alt' or 'super')\n", m); + free(appv); + return 2; + } + continue; + } else if (strncmp(argv[i], "--modifier=", 11) == 0) { + const char *m = argv[i] + 11; + if (strcmp(m, "alt") == 0) { + tab_prefix = PREFIX_ALT; + } else if (strcmp(m, "super") == 0) { + tab_prefix = PREFIX_SUPER; + } else { + fprintf(stderr, "tabbed-wlroots: bad --modifier '%s' " + "(use 'alt' or 'super')\n", m); + free(appv); + return 2; + } + continue; } if (val) { if (!parse_screen_mode(val)) { @@ -5366,9 +5423,12 @@ int main(int argc, char *argv[]) { launch_client(i); } + const char *prefix = tab_prefix == PREFIX_SUPER ? "Super" : "Alt"; tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: running. " - "Alt+Up/Down switches tabs; Alt+Delete closes the active tab; " - "Ctrl+Shift+T opens a terminal (%s).\n", term_cmd); + "%s+Up/Down switches tabs; %s+Shift+Up/Down reorders; " + "%s+Delete closes the active tab; " + "Ctrl+Shift+T opens a terminal (%s).\n", + prefix, prefix, prefix, term_cmd); /* Standalone event loop: no host display, so drive the server loop alone. The native output's frame signal (or the headless 60Hz timer) wakes the diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 0d536ac..757aea1 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -51,6 +51,8 @@ CTRL = 1 << 2 ALT = 1 << 3 # Shift modifier bit (1u << 0). SHIFT = 1 << 0 +# Super/Mod4 modifier bit (1u << 6). +SUPER = 1 << 6 CTL_CMD_KEY = 1 CTL_CMD_MODS = 2 @@ -1271,10 +1273,11 @@ def test_tab_death_activates_left_neighbor(session): third.wait(timeout=5) -def _nested_session(*tabs): +def _nested_session(*tabs, extra_args=()): """Launch a nested compositor with an arbitrary tab list (each tab is a - shell command string, as on the CLI). The `session` fixture exists for the - common two-tab case; tests needing a specific tab lineup use this.""" + shell command string, as on the CLI). extra_args are passed on the CLI + before the tabs (e.g. --modifier super). The `session` fixture exists for + the common two-tab case; tests needing a specific tab lineup use this.""" for path in (BIN, WL): if not os.access(path, os.X_OK): pytest.skip(f"build first: {path}") @@ -1284,7 +1287,7 @@ def _nested_session(*tabs): pass proc = subprocess.Popen( ["env", "TABBED_LOG=2", "TABBED_SYNTH_INPUT=1", "stdbuf", "-oL", - BIN] + list(tabs), + BIN, *extra_args] + list(tabs), stdin=subprocess.PIPE, stdout=open(LOG, "ab"), stderr=subprocess.STDOUT, preexec_fn=_preexec_death(os.getpid()), @@ -1443,6 +1446,51 @@ def test_alt_shift_arrows_reorder_and_wrap(): os.environ["TERMINAL"] = old_term +def test_modifier_super_flag(): + """--modifier super moves the tab-management chords to Super/Mod4: cycle, + reorder and close all work, while the Alt chords stop doing anything.""" + if not os.environ.get("WAYLAND_DISPLAY"): + pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") + s = _nested_session(f"{WL} '{RED}'", f"{WL} '{BLUE}'", + extra_args=["--modifier", "super"]) + try: + start = s.wait_any_active() + assert s.proc.poll() is None, "compositor exited with --modifier super" + + # Super+Down cycles; a plain Alt+Down must not. + s.chord(SUPER, DOWN) + other = s.wait_active_change(start, timeout=5.0) + assert other == 1 - start, "Super+Down did not cycle the other tab" + s.chord(ALT, DOWN) + time.sleep(0.3) + assert s.active() == other, \ + "Alt+Down still cycled in --modifier super mode" + + # Super+Shift+Up reorders the active tab. + s.chord(SUPER | SHIFT, UP) + s.wait_for(r"\[reorder\] app=\d+ dir=-1") + + # Super+Delete closes the active tab: the compositor logs the close + # and hands active off (a NEW active-tab line appears). Can't use + # wait_active_change here: reorder changed the active tab's slot + # without logging one, so the post-close index can match the + # pre-close value even though the tab underneath changed. + count = s.active_count() + s.chord(SUPER, DELETE) + s.wait_for(r"tabbed-wlroots: closing tab") + deadline = time.monotonic() + 5.0 + while s.active_count() <= count: + assert s.proc.poll() is None, \ + "compositor exited after Super+Delete" + assert time.monotonic() < deadline, \ + "active never handed off after Super+Delete" + time.sleep(0.1) + finally: + s.close() + if s.proc.poll() is None: + s.proc.terminate() + + 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."""