new-tab chord is Alt+Shift+T (prefix-based) and grabbed

Change Ctrl+Shift+T to Alt+Shift+T (Super+Shift+T under --modifier super),
matching the other tab-management chords.  It is now grabbed: process_key_event
returns true so the focused client never sees the chord, instead of passing it
through.
This commit is contained in:
Andrew Hamilton 2026-08-18 13:47:00 +10:00
parent e3cba0f7d7
commit 5d840a09dd
3 changed files with 34 additions and 35 deletions

View file

@ -77,7 +77,7 @@ as children of tabbed-wlroots and connect to its private Wayland socket, so
`DISPLAY` is unset for them and their `WAYLAND_DISPLAY` points at the tabbed `DISPLAY` is unset for them and their `WAYLAND_DISPLAY` points at the tabbed
compositor (clients are launched with the GDK and Qt Wayland backends forced). compositor (clients are launched with the GDK and Qt Wayland backends forced).
`Ctrl+Shift+T` opens the terminal named by the `$TERMINAL` environment variable `Alt+Shift+T` opens the terminal named by the `$TERMINAL` environment variable
when it is set (see Key bindings); otherwise it falls back to `alacritty`. The when it is set (see Key bindings); otherwise it falls back to `alacritty`. The
value is used verbatim, so it may carry arguments, e.g. value is used verbatim, so it may carry arguments, e.g.
`TERMINAL="foot -e htop" tabbed-wlroots ...`. `TERMINAL="foot -e htop" tabbed-wlroots ...`.
@ -107,10 +107,10 @@ startup is always the one from the last argument.
If a command never connects — e.g. `sleep 999` — the rest of the command line If a command never connects — e.g. `sleep 999` — the rest of the command line
never starts and the window stays black; the compositor itself keeps running never starts and the window stays black; the compositor itself keeps running
normally (`Ctrl+Shift+T` still works) until you close it. normally (`Alt+Shift+T` still works) until you close it.
Later tab openings follow the same no-steal rule: a brand-new tab never yanks Later tab openings follow the same no-steal rule: a brand-new tab never yanks
the active surface on its own. `Ctrl+Shift+T` still focuses its fresh the active surface on its own. `Alt+Shift+T` still focuses its fresh
terminal, and a sub-window of the tab you are currently looking at becomes terminal, and a sub-window of the tab you are currently looking at becomes
active; every other new window or tab just appears in the list, hidden, until active; every other new window or tab just appears in the list, hidden, until
you cycle to it. you cycle to it.
@ -125,12 +125,13 @@ you cycle to it.
| `Alt+Shift+Up` | Move the active tab earlier in the list | | `Alt+Shift+Up` | Move the active tab earlier in the list |
| `Alt+Shift+Down` | Move the active tab later in the list | | `Alt+Shift+Down` | Move the active tab later in the list |
| `Alt+Delete` | Close the active tab (SIGTERM, SIGKILL fallback) | | `Alt+Delete` | Close the active tab (SIGTERM, SIGKILL fallback) |
| `Ctrl+Shift+T` | Open a new terminal tab next to the current one | | `Alt+Shift+T` | Open a new terminal tab next to the current one |
The tab-management prefix is `Alt` by default. Pass `--modifier super` to The tab-management prefix is `Alt` by default. Pass `--modifier super` to
use `Super` (Windows/Meta key, Mod4) instead: `Super` hold shows the use `Super` (Windows/Meta key, Mod4) instead: `Super` hold shows the
overlay, `Super+Up/Down` cycle, `Super+Shift+Up/Down` reorder, and overlay, `Super+Up/Down` cycle, `Super+Shift+Up/Down` reorder,
`Super+Delete` closes. `Ctrl+Shift+T` always uses Ctrl and is unchanged. `Super+Delete` closes, and `Super+Shift+T` opens a new terminal -- the
new-tab chord follows the prefix like every other binding.
The `Super` variant is meant for standalone/DRM and labwc-style hosts The `Super` variant is meant for standalone/DRM and labwc-style hosts
(including the Raspberry Pi, where nothing reserves it); under GNOME the (including the Raspberry Pi, where nothing reserves it); under GNOME the
host grabs most `Super` chords before the nested compositor ever sees them host grabs most `Super` chords before the nested compositor ever sees them
@ -148,7 +149,7 @@ tab's app icon, resolved from its Wayland `app_id` via the desktop
files and icon themes in `XDG_DATA_DIRS` (PNG via libpng, SVG via files and icon themes in `XDG_DATA_DIRS` (PNG via libpng, SVG via
librsvg); tabs without a resolvable icon get a generic placeholder. librsvg); tabs without a resolvable icon get a generic placeholder.
`Ctrl+Shift+T` launches a fresh terminal (`$TERMINAL`, default `Alt+Shift+T` launches a fresh terminal (`$TERMINAL`, default
`alacritty`) as a new tab placed immediately to the right of the current tab, `alacritty`) as a new tab placed immediately to the right of the current tab,
and it becomes the active tab once it maps (the one exception to the and it becomes the active tab once it maps (the one exception to the
no-map-steal rule at startup, along with sub-windows of the shown tab). no-map-steal rule at startup, along with sub-windows of the shown tab).
@ -162,7 +163,7 @@ active tab stays active, it just moves one position earlier or later in the
order. Moving off either end wraps around and the whole list shifts — a order. Moving off either end wraps around and the whole list shifts — a
rightmost tab moved past the end lands first, with every other tab pushed rightmost tab moved past the end lands first, with every other tab pushed
one position, not merely swapped with the first. Holding the chord keeps one position, not merely swapped with the first. Holding the chord keeps
moving it (same synthesized repeat as cycling). A `Ctrl+Shift+T` inside the moving it (same synthesized repeat as cycling). An `Alt+Shift+T` inside the
moved tab opens exactly where the reordered list says it should — immediately moved tab opens exactly where the reordered list says it should — immediately
right of the tab's new position. right of the tab's new position.
@ -207,7 +208,7 @@ pass instead of being drawn into a host window.
- `WAYLAND_DISPLAY` is ignored in standalone mode (it never connects to an - `WAYLAND_DISPLAY` is ignored in standalone mode (it never connects to an
outer compositor), except as part of the requested `WLR_BACKENDS`. outer compositor), except as part of the requested `WLR_BACKENDS`.
- Real keyboard/pointer devices are attached to the tabbed seat directly - Real keyboard/pointer devices are attached to the tabbed seat directly
(native input); the `Ctrl-Shift-T` and `Alt`-overlay hotkeys still work off (native input); the `Alt+Shift+T` and `Alt`-overlay hotkeys still work off
those devices. those devices.
- `Ctrl-Alt-F1..F12` switch virtual terminals while running on the DRM - `Ctrl-Alt-F1..F12` switch virtual terminals while running on the DRM
backend (via libseat/logind); the terminal sees nothing of the chord. backend (via libseat/logind); the terminal sees nothing of the chord.

View file

@ -129,7 +129,7 @@ struct tabbed_app {
struct wlr_texture *icon_wlr; struct wlr_texture *icon_wlr;
bool icon_resolved; /* true once a load attempt has been made */ bool icon_resolved; /* true once a load attempt has been made */
/* Slot created by add_tab() (Ctrl+Shift+T) awaiting its toplevel. Lets /* Slot created by add_tab() (Alt+Shift+T) awaiting its toplevel. Lets
handle_new_toplevel claim exactly this slot instead of an earlier handle_new_toplevel claim exactly this slot instead of an earlier
stale free one (a closed tab's hole), so a new terminal keeps its stale free one (a closed tab's hole), so a new terminal keeps its
"right of the current tab" position. */ "right of the current tab" position. */
@ -137,7 +137,7 @@ struct tabbed_app {
/* Where a tab's slot came from. ORIGIN_CLI slots hold the command-line /* Where a tab's slot came from. ORIGIN_CLI slots hold the command-line
commands and are forked one at a time (see maybe_launch_next), so the commands and are forked one at a time (see maybe_launch_next), so the
tab order mirrors the command line. ORIGIN_ADD_TAB is a Ctrl+Shift+T tab order mirrors the command line. ORIGIN_ADD_TAB is an Alt+Shift+T
terminal. ORIGIN_SUB is a second toplevel from an already-connected terminal. ORIGIN_SUB is a second toplevel from an already-connected
client and is glued right of its parent tab. ORIGIN_EXTERNAL is a client and is glued right of its parent tab. ORIGIN_EXTERNAL is a
client we did not launch (e.g. started from our own shell). */ client we did not launch (e.g. started from our own shell). */
@ -306,12 +306,12 @@ static volatile sig_atomic_t needs_redraw;
static bool frame_pending; static bool frame_pending;
static bool shutting_down; static bool shutting_down;
/* Terminal launched for the Ctrl+Shift+T "new tab" binding. Defaults to /* Terminal launched for the Alt+Shift+T "new tab" binding. Defaults to
alacritty (sane no-config font size, unlike foot's 8pt); overridable via alacritty (sane no-config font size, unlike foot's 8pt); overridable via
the $TERMINAL environment variable. The value is used verbatim by sh -c, the $TERMINAL environment variable. The value is used verbatim by sh -c,
so it may carry arguments. */ so it may carry arguments. */
static const char *term_cmd = "alacritty"; static const char *term_cmd = "alacritty";
static bool term_key_held; /* Ctrl+Shift+T currently down (repeat latch) */ static bool term_key_held; /* Alt+Shift+T currently down (repeat latch) */
static bool delete_key_held; /* prefix+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, /* The modifier prefix for the tab-management chords: hold = show overlay,
@ -2236,7 +2236,7 @@ static void handle_toplevel_map(struct wl_listener *listener, void *data) {
tabbed_log(TABBED_LOG_DEBUG, "[diag] map app=%d\n", app_index(app)); tabbed_log(TABBED_LOG_DEBUG, "[diag] map app=%d\n", app_index(app));
/* A new tab never steals focus on its own. The exceptions: a /* A new tab never steals focus on its own. The exceptions: a
Ctrl+Shift+T terminal (add_tab), a sub-window whose parent tab is the Alt+Shift+T terminal (add_tab), a sub-window whose parent tab is the
one currently shown, and the one-shot startup crown of the last one currently shown, and the one-shot startup crown of the last
command-line command (only while nothing is focused yet). Every other command-line command (only while nothing is focused yet). Every other
map stays hidden -- the overlay and cycling reach it, but it does not map stays hidden -- the overlay and cycling reach it, but it does not
@ -2266,7 +2266,7 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) {
&cpid, NULL, NULL); &cpid, NULL, NULL);
struct tabbed_app *app = NULL; struct tabbed_app *app = NULL;
/* Prefer a slot prepared by add_tab() (Ctrl+Shift+T): its position was /* Prefer a slot prepared by add_tab() (Alt+Shift+T): its position was
chosen to sit right next to the tab the user launched it from, so an chosen to sit right next to the tab the user launched it from, so an
earlier stale free slot (a closed tab's hole) must not steal it. */ earlier stale free slot (a closed tab's hole) must not steal it. */
for (int i = 0; i < server.num_apps && !app; i++) for (int i = 0; i < server.num_apps && !app; i++)
@ -3962,18 +3962,16 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state,
return true; return true;
} }
} }
/* Ctrl+Shift+T opens a new terminal tab ($TERMINAL, default alacritty). /* Alt+Shift+T opens a new terminal tab ($TERMINAL, default alacritty).
The tab is inserted right after the current one and becomes active when The tab is inserted right after the current one and becomes active when
it maps. Grabbed here so the focused client never sees it; a latch it maps. Grabbed so the focused client never sees the chord; a latch
drops host-synthesized repeats so one hold can't open a terminal per drops host-synthesized repeats so one hold can't open a terminal per
repeat. */ repeat. */
{ {
bool ctrl = (mods_depressed & (1u << 2)) != 0;
bool shift = (mods_depressed & (1u << 0)) != 0;
bool is_t = server.xkb_state bool is_t = server.xkb_state
? (sym == XKB_KEY_t || sym == XKB_KEY_T) ? (sym == XKB_KEY_t || sym == XKB_KEY_T)
: key == 20; : key == 20;
if (ctrl && shift && is_t) { if (alt && shift && is_t) {
kbd_repeat_cancel(); kbd_repeat_cancel();
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) { if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
if (!term_key_held) { if (!term_key_held) {
@ -3990,7 +3988,7 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state,
} else { } else {
term_key_held = false; term_key_held = false;
} }
return false; return true;
} }
} }
/* any other key activity stops the tab-switch repeat */ /* any other key activity stops the tab-switch repeat */
@ -5158,7 +5156,7 @@ int main(int argc, char *argv[]) {
synth_input_only = v && v[0] && v[0] != '0'; synth_input_only = v && v[0] && v[0] != '0';
} }
/* Ctrl+Shift+T opens a terminal named by $TERMINAL when set, else the /* Alt+Shift+T opens a terminal named by $TERMINAL when set, else the
alacritty default. Every command-line argument is one tab launcher, alacritty default. Every command-line argument is one tab launcher,
except --screen-mode WxH[@Hz], which selects the native output mode and except --screen-mode WxH[@Hz], which selects the native output mode and
is consumed here, and --modifier alt|super, which picks the modifier is consumed here, and --modifier alt|super, which picks the modifier
@ -5255,7 +5253,7 @@ int main(int argc, char *argv[]) {
int total_apps = napps; int total_apps = napps;
bool default_tab = false; bool default_tab = false;
if (total_apps == 0) { if (total_apps == 0) {
/* No arguments: open one terminal tab, as Ctrl+Shift+T would. */ /* No arguments: open one terminal tab, as Alt+Shift+T would. */
default_tab = true; default_tab = true;
total_apps = 1; total_apps = 1;
} }
@ -5583,8 +5581,8 @@ int main(int argc, char *argv[]) {
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: running. " tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: running. "
"%s+Up/Down switches tabs; %s+Shift+Up/Down reorders; " "%s+Up/Down switches tabs; %s+Shift+Up/Down reorders; "
"%s+Delete closes the active tab; " "%s+Delete closes the active tab; "
"Ctrl+Shift+T opens a terminal (%s).\n", "%s+Shift+T opens a terminal (%s).\n",
prefix, prefix, prefix, term_cmd); prefix, prefix, prefix, prefix, term_cmd);
/* Standalone event loop: no host display, so drive the server loop alone. /* 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 The native output's frame signal (or the headless 60Hz timer) wakes the

View file

@ -1300,8 +1300,8 @@ def _nested_session(*tabs, extra_args=()):
return Session(proc) return Session(proc)
def test_ctrl_shift_t_becomes_active(session): def test_alt_shift_t_becomes_active(session):
"""Ctrl+Shift+T's fresh terminal still becomes the active tab on map """Alt+Shift+T's fresh terminal still becomes the active tab on map
(the add_tab exception to the no-steal rule), while other new tabs never (the add_tab exception to the no-steal rule), while other new tabs never
steal focus.""" steal focus."""
old_term = os.environ.get("TERMINAL") old_term = os.environ.get("TERMINAL")
@ -1309,16 +1309,16 @@ def test_ctrl_shift_t_becomes_active(session):
try: try:
session.wait_any_active() session.wait_any_active()
before = session.active_count() before = session.active_count()
session.chord(CTRL | SHIFT, 20) session.chord(ALT | SHIFT, 20)
deadline = time.monotonic() + 15.0 deadline = time.monotonic() + 15.0
while session.active_count() <= before: while session.active_count() <= before:
assert session.proc.poll() is None, \ assert session.proc.poll() is None, \
"compositor exited after Ctrl+Shift+T" "compositor exited after Alt+Shift+T"
assert time.monotonic() < deadline, \ assert time.monotonic() < deadline, \
"Ctrl+Shift+T's terminal never became active" "Alt+Shift+T's terminal never became active"
time.sleep(0.1) time.sleep(0.1)
assert session.active_count() == before + 1, \ assert session.active_count() == before + 1, \
"Ctrl+Shift+T should produce exactly one focus transition" "Alt+Shift+T should produce exactly one focus transition"
assert session.proc.poll() is None assert session.proc.poll() is None
finally: finally:
if old_term is None: if old_term is None:
@ -1470,7 +1470,7 @@ def test_alt_shift_arrows_reorder_and_wrap():
switching the active surface; moving off an end wraps to the other end and switching the active surface; moving off an end wraps to the other end and
the whole list shifts (a rightmost tab moved off the end lands first with the whole list shifts (a rightmost tab moved off the end lands first with
every other tab pushed one place, not swapped into place). The resulting every other tab pushed one place, not swapped into place). The resulting
order is provable behaviorally: a Ctrl+Shift+T terminal claims the slot order is provable behaviorally: an Alt+Shift+T terminal claims the slot
right after the active tab's new position.""" 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)")
@ -1508,17 +1508,17 @@ def test_alt_shift_arrows_reorder_and_wrap():
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"
# With the active tab wrapped to slot 0, a new Ctrl+Shift+T tab # With the active tab wrapped to slot 0, a new Alt+Shift+T tab
# is anchored right after it: the newest claim must be app=1. # is anchored right after it: the newest claim must be app=1.
claims = re.findall(r"\[claim\] app=(\d+) pid=(\d+)", s.read_log()) claims = re.findall(r"\[claim\] app=(\d+) pid=(\d+)", s.read_log())
s.chord(CTRL | SHIFT, 20) s.chord(ALT | SHIFT, 20)
deadline = time.monotonic() + 15.0 deadline = time.monotonic() + 15.0
while len(re.findall(r"\[claim\] app=(\d+) pid=(\d+)", while len(re.findall(r"\[claim\] app=(\d+) pid=(\d+)",
s.read_log())) <= len(claims): s.read_log())) <= len(claims):
assert s.proc.poll() is None, \ assert s.proc.poll() is None, \
"compositor exited after reorder + new tab" "compositor exited after reorder + new tab"
assert time.monotonic() < deadline, \ assert time.monotonic() < deadline, \
"Ctrl+Shift+T never claimed a slot" "Alt+Shift+T never claimed a slot"
time.sleep(0.1) time.sleep(0.1)
newest = re.findall(r"\[claim\] app=(\d+) pid=(\d+)", newest = re.findall(r"\[claim\] app=(\d+) pid=(\d+)",
s.read_log())[-1] s.read_log())[-1]