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.