From bc0245bc43e9e2ee8d593f6b398963b01324e0fc Mon Sep 17 00:00:00 2001 From: Brandon Talbot Date: Sun, 23 Aug 2026 05:10:31 +0000 Subject: [PATCH] feat(actions): add cross-workspace vertical focus and move variants Add new IPC actions that extend the existing vertical focus/movement commands to cross workspace boundaries: - window-focus-or-workspace-up/down - window-move-or-workspace-up/down When the focused window is already at the top/bottom of its column, instead of silently doing nothing, these actions switch to the adjacent workspace and restore focus or move the window there. This matches the behavior of Niri and feels more natural for scrollable-tiling layouts where workspace boundaries are meant to be seamless. The original window-focus-up/down and window-move-up/down actions remain unchanged for users who prefer workspace-local behavior. docs/user/keybinds.md updated to document the new actions. --- docs/user/keybinds.md | 2 + src/config/keybind_parse.cpp | 4 + src/config/keybind_parse.h | 4 + src/scene/cheatsheet_rows.cpp | 4 + src/server/actions.cpp | 54 ++++++++++++ tests/harness/checks/610_output_actions.sh | 99 +++++++++++++++++++++- tests/unit/cheatsheet_rows.cpp | 7 ++ 7 files changed, 173 insertions(+), 1 deletion(-) diff --git a/docs/user/keybinds.md b/docs/user/keybinds.md index abf35318..eac1c25a 100644 --- a/docs/user/keybinds.md +++ b/docs/user/keybinds.md @@ -97,10 +97,12 @@ These take no argument. |--------|--------------| | `window-focus-left` / `window-focus-right` | Move focus to the adjacent window along the row. | | `window-focus-up` / `window-focus-down` | Move focus to the adjacent window along the column. | +| `window-focus-or-workspace-up` / `window-focus-or-workspace-down` | Move focus up or down within the column; at the boundary, switch to the adjacent workspace and restore its focus. | | `window-focus-next` | Cycle focus to the next mapped window on the active workspace. | | `window-move-to-workspace-next` / `window-move-to-workspace-previous` | Move the focused window to the adjacent workspace and follow it. These actions do not wrap around. | | `column-move-left` / `column-move-right` | Move the focused window's column left or right. | | `window-move-up` / `window-move-down` | Move the focused window up or down within its column. | +| `window-move-or-workspace-up` / `window-move-or-workspace-down` | Move the focused window up or down within its column; at the boundary, move it to the adjacent workspace. | | `window-consume-left` | Pull the focused window into the column to its left. | | `window-expel-right` | Pop the focused window out of its column into a new column to the right. | | `window-cycle-width` | Cycle the focused column through its preset widths. | diff --git a/src/config/keybind_parse.cpp b/src/config/keybind_parse.cpp index c659a8db..7584f0ad 100644 --- a/src/config/keybind_parse.cpp +++ b/src/config/keybind_parse.cpp @@ -187,11 +187,15 @@ namespace umbriel { {"window-focus-down", "", KeybindAction::WindowFocusDown}, {"window-focus-left", "", KeybindAction::WindowFocusLeft}, {"window-focus-next", "", KeybindAction::WindowFocusNext}, + {"window-focus-or-workspace-down", "", KeybindAction::WindowFocusOrWorkspaceDown}, + {"window-focus-or-workspace-up", "", KeybindAction::WindowFocusOrWorkspaceUp}, {"window-focus-right", "", KeybindAction::WindowFocusRight}, {"window-focus-switch-floating", "", KeybindAction::WindowFocusSwitchFloating}, {"window-focus-up", "", KeybindAction::WindowFocusUp}, {"window-modify-width", "", KeybindAction::WindowModifyWidth, ActionArgKind::WidthDelta}, {"window-move-down", "", KeybindAction::WindowMoveDown}, + {"window-move-or-workspace-down", "", KeybindAction::WindowMoveOrWorkspaceDown}, + {"window-move-or-workspace-up", "", KeybindAction::WindowMoveOrWorkspaceUp}, {"window-move-to-output-down", "", KeybindAction::WindowMoveToOutputDown}, {"window-move-to-output-left", "", KeybindAction::WindowMoveToOutputLeft}, {"window-move-to-output-right", "", KeybindAction::WindowMoveToOutputRight}, diff --git a/src/config/keybind_parse.h b/src/config/keybind_parse.h index 048175f9..b3b76858 100644 --- a/src/config/keybind_parse.h +++ b/src/config/keybind_parse.h @@ -31,11 +31,15 @@ namespace umbriel { WindowFocusRight, WindowFocusUp, WindowFocusDown, + WindowFocusOrWorkspaceUp, + WindowFocusOrWorkspaceDown, WindowFocusSwitchFloating, ColumnMoveLeft, ColumnMoveRight, WindowMoveUp, WindowMoveDown, + WindowMoveOrWorkspaceUp, + WindowMoveOrWorkspaceDown, WindowConsumeLeft, WindowExpelRight, WindowCycleWidth, diff --git a/src/scene/cheatsheet_rows.cpp b/src/scene/cheatsheet_rows.cpp index 2a023726..adcbbd58 100644 --- a/src/scene/cheatsheet_rows.cpp +++ b/src/scene/cheatsheet_rows.cpp @@ -270,6 +270,8 @@ namespace { case A::WindowFocusRight: case A::WindowFocusUp: case A::WindowFocusDown: + case A::WindowFocusOrWorkspaceUp: + case A::WindowFocusOrWorkspaceDown: case A::WindowFocusNext: case A::WindowFocusId: case A::WindowFocusSwitchFloating: @@ -278,6 +280,8 @@ namespace { case A::ColumnMoveRight: case A::WindowMoveUp: case A::WindowMoveDown: + case A::WindowMoveOrWorkspaceUp: + case A::WindowMoveOrWorkspaceDown: case A::WindowConsumeLeft: case A::WindowExpelRight: case A::WindowCycleWidth: diff --git a/src/server/actions.cpp b/src/server/actions.cpp index 4c085d77..09a73421 100644 --- a/src/server/actions.cpp +++ b/src/server/actions.cpp @@ -435,6 +435,31 @@ namespace umbriel { return true; } + template + bool actionFocusVerticalOrWorkspace(Server& server, const Keybind& /*bind*/, std::string* /*error*/) { + if (Workspace* workspace = activeWorkspace(server)) { + if (View* target = workspace->focusVertical(Direction)) { + server.focusView(target, FocusReason::Directional); + } else { + // No window in this direction within the current workspace. + // Switch to the adjacent workspace, matching Niri's behavior. + WorkspaceGroup* group = workspace->group(); + if (group == nullptr) { + return true; + } + const size_t index = workspace->index(); + if (Direction < 0 && index == 0) { + return true; + } + Workspace* targetWorkspace = group->workspaceAt(index + static_cast(Direction)); + if (targetWorkspace != nullptr && targetWorkspace != group->active()) { + group->select(targetWorkspace); + } + } + } + return true; + } + template bool actionMoveColumn(Server& server, const Keybind& /*bind*/, std::string* /*error*/) { if (Workspace* workspace = activeWorkspace(server)) { workspace->moveFocusedColumn(Direction); @@ -449,6 +474,31 @@ namespace umbriel { return true; } + template + bool actionMoveVerticalOrWorkspace(Server& server, const Keybind& /*bind*/, std::string* /*error*/) { + if (Workspace* workspace = activeWorkspace(server)) { + if (!workspace->moveFocusedVertical(Direction)) { + Workspace* source = activeWorkspace(server); + if (source == nullptr || source->group() == nullptr) { + return true; + } + WorkspaceGroup* group = source->group(); + const size_t index = source->index(); + if (Direction < 0 && index == 0) { + return true; + } + Workspace* target = group->workspaceAt(index + static_cast(Direction)); + if (target == nullptr || target == source) { + return true; + } + if (View* view = source->focusedView()) { + moveViewToWorkspace(server, *view, *target); + } + } + } + return true; + } + bool actionConsumeLeft(Server& server, const Keybind& /*bind*/, std::string* /*error*/) { if (Workspace* workspace = activeWorkspace(server)) { workspace->consumeFocusedLeft(); @@ -950,11 +1000,15 @@ namespace umbriel { &actionFocusAdjacent<1>, &actionFocusVertical<-1>, &actionFocusVertical<1>, + &actionFocusVerticalOrWorkspace<-1>, + &actionFocusVerticalOrWorkspace<1>, &actionFocusSwitchFloating, &actionMoveColumn<-1>, &actionMoveColumn<1>, &actionMoveVertical<-1>, &actionMoveVertical<1>, + &actionMoveVerticalOrWorkspace<-1>, + &actionMoveVerticalOrWorkspace<1>, &actionConsumeLeft, &actionExpelRight, &actionCycleWidth<1>, diff --git a/tests/harness/checks/610_output_actions.sh b/tests/harness/checks/610_output_actions.sh index 3a928d6d..13238065 100755 --- a/tests/harness/checks/610_output_actions.sh +++ b/tests/harness/checks/610_output_actions.sh @@ -108,6 +108,103 @@ if [[ $returned_workspace != "$start_workspace" ]]; then exit 1 fi +# The cross-workspace variants first use an available vertical neighbor. Only +# the workspace boundary falls through to workspace navigation. +spawn_client vertical-local +wait_for_windows 2 +local_id=$("$UMBRIEL" windows --json | jq -r '.[] | select(.title == "vertical-local") | .id') +accepts "window-focus:$local_id" +accepts "window-consume-left" +stacked=false +for _ in $(seq 40); do + if "$UMBRIEL" windows --json | jq -e \ + 'length == 2 and (.[0].workspace == .[1].workspace) and ([.[].y] | unique | length == 2)' > /dev/null; then + stacked=true + break + fi + sleep 0.1 +done +if [[ $stacked != true ]]; then + echo "expected two rows in one column before vertical navigation" + exit 1 +fi +read -r top_id bottom_id <<< "$("$UMBRIEL" windows --json | jq -r 'sort_by(.y) | "\(.[0].id) \(.[1].id)"')" +accepts "window-focus:$top_id" +accepts "window-focus-or-workspace-down" +bottom_active=false +for _ in $(seq 40); do + bottom_active=$("$UMBRIEL" windows --json | jq -r --arg id "$bottom_id" '.[] | select(.id == $id) | .active') + [[ $bottom_active == true ]] && break + sleep 0.1 +done +if [[ $bottom_active != true ]]; then + echo "expected focus-down variant to use the lower row before changing workspaces" + exit 1 +fi +if ! "$UMBRIEL" windows --json | jq -e --arg workspace "$start_workspace" \ + 'all(.[]; .workspace == $workspace)' > /dev/null; then + echo "vertical neighbor focus unexpectedly changed workspaces" + exit 1 +fi + +accepts "window-move-or-workspace-up" +local_moved=false +for _ in $(seq 40); do + local_moved=$("$UMBRIEL" windows --json | jq -r --arg id "$bottom_id" --arg other "$top_id" \ + '([.[] | select(.id == $id) | .y][0]) < ([.[] | select(.id == $other) | .y][0])') + [[ $local_moved == true ]] && break + sleep 0.1 +done +if [[ $local_moved != true ]]; then + echo "expected move-up variant to reorder rows before changing workspaces" + exit 1 +fi +accepts "window-close" +wait_for_windows 1 + +accepts "window-focus-or-workspace-down" +active_now=true +for _ in $(seq 40); do + active_now=$("$UMBRIEL" windows --json | jq -r '.[0].active') + [[ $active_now == false ]] && break + sleep 0.1 +done +if [[ $active_now != false ]]; then + echo "expected focus-down variant to switch at the workspace boundary" + exit 1 +fi +accepts "window-focus-or-workspace-up" +for _ in $(seq 40); do + active_now=$("$UMBRIEL" windows --json | jq -r '.[0].active') + [[ $active_now == true ]] && break + sleep 0.1 +done +if [[ $active_now != true ]]; then + echo "expected focus-up variant to return and restore focus" + exit 1 +fi + +accepts "window-move-or-workspace-down" +for _ in $(seq 40); do + returned_workspace=$("$UMBRIEL" windows --json | jq -r '.[0].workspace') + [[ $returned_workspace == "$moved_workspace" ]] && break + sleep 0.1 +done +if [[ $returned_workspace != "$moved_workspace" ]]; then + echo "expected move-down variant to cross the workspace boundary" + exit 1 +fi +accepts "window-move-or-workspace-up" +for _ in $(seq 40); do + returned_workspace=$("$UMBRIEL" windows --json | jq -r '.[0].workspace') + [[ $returned_workspace == "$start_workspace" ]] && break + sleep 0.1 +done +if [[ $returned_workspace != "$start_workspace" ]]; then + echo "expected move-up variant to return to $start_workspace" + exit 1 +fi + # window-modify-width: Headless output is 1280x720 with the shipped defaults (gap 8, border 2): viewport 1260, so -0.2 shrinks a column by about 252px. # The exact geometry math lives in 110_scrolling_layout.sh (624 wide at 0.5). before_w=$(jq -r '.[0].w' <<< "$("$UMBRIEL" windows --json)") @@ -309,4 +406,4 @@ if [[ $min_h -ge 600 ]]; then exit 1 fi -echo "directional actions reject on one output; workspace moves preserve width and other actions behave" +echo "local and cross-workspace actions, width preservation, centering, and layout switching behave" diff --git a/tests/unit/cheatsheet_rows.cpp b/tests/unit/cheatsheet_rows.cpp index 437c632d..0e38050c 100644 --- a/tests/unit/cheatsheet_rows.cpp +++ b/tests/unit/cheatsheet_rows.cpp @@ -207,6 +207,13 @@ UMBRIEL_TEST(groupTitlesArePlainTextNotMarkup) { } } +UMBRIEL_TEST(crossWorkspaceDirectionalActionsUseExpectedGroups) { + CHECK(umbriel::groupForAction(KeybindAction::WindowFocusOrWorkspaceUp) == umbriel::Group::Focus); + CHECK(umbriel::groupForAction(KeybindAction::WindowFocusOrWorkspaceDown) == umbriel::Group::Focus); + CHECK(umbriel::groupForAction(KeybindAction::WindowMoveOrWorkspaceUp) == umbriel::Group::MoveSize); + CHECK(umbriel::groupForAction(KeybindAction::WindowMoveOrWorkspaceDown) == umbriel::Group::MoveSize); +} + UMBRIEL_TEST(everyActionMapsToAGroupWithATitle) { // groupForAction has no default arm to fall through to, so a new action that // is never grouped would show up here.