From 30557b77886ae5456c549e33d3d141ffb04e5bc9 Mon Sep 17 00:00:00 2001 From: Lemmy Date: Wed, 26 Aug 2026 09:16:00 -0400 Subject: [PATCH] feat(actions): center the focused scrolling column --- docs/user/keybinds.md | 1 + examples/config.toml | 3 +- src/config/keybind_parse.cpp | 1 + src/config/keybind_parse.h | 1 + src/input/gestures.cpp | 17 +++-- src/input/gestures.h | 1 + src/layout/scrolling.cpp | 28 ++++++-- src/layout/scrolling.h | 6 +- src/scene/cheatsheet_rows.cpp | 1 + src/server/actions.cpp | 11 +++ src/workspace/workspace.cpp | 13 ++++ src/workspace/workspace.h | 1 + tests/harness/checks/110_scrolling_layout.sh | 30 +++++++- tests/harness/checks/120_vertical_layout.sh | 29 +++++++- tests/harness/checks/610_output_actions.sh | 11 +++ tests/unit/scrolling_layout.cpp | 74 ++++++++++++++++++-- 16 files changed, 207 insertions(+), 21 deletions(-) diff --git a/docs/user/keybinds.md b/docs/user/keybinds.md index eac1c25a..fc6043bc 100644 --- a/docs/user/keybinds.md +++ b/docs/user/keybinds.md @@ -101,6 +101,7 @@ These take no argument. | `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. | +| `column-center` | Center the focused column in the scrolling viewport; a no-op on a dwindle workspace. | | `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. | diff --git a/examples/config.toml b/examples/config.toml index 25ebc197..57a4a34c 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -229,7 +229,8 @@ follows_mouse = false # "Mod+Shift+O" = "dpms-off" # input activity powers displays back on # "Mod+Ctrl+O" = "dpms-off:DP-1" # target one connector # "Mod+Minus" = "window-modify-width:-0.1" -# "Mod+C" = "window-center" +# "Mod+C" = "column-center" +# "Mod+Shift+C" = "window-center" # "Mod+Shift+T" = "workspace-set-layout:toggle" # Media and volume (see docs/user/keybinds.md#example-media-and-brightness-keys) diff --git a/src/config/keybind_parse.cpp b/src/config/keybind_parse.cpp index 7584f0ad..e884a630 100644 --- a/src/config/keybind_parse.cpp +++ b/src/config/keybind_parse.cpp @@ -151,6 +151,7 @@ namespace umbriel { {"cheatsheet-close", "", KeybindAction::CheatsheetClose}, {"cheatsheet-open", "", KeybindAction::CheatsheetOpen}, {"cheatsheet-toggle", "", KeybindAction::CheatsheetToggle}, + {"column-center", "", KeybindAction::ColumnCenter}, {"column-move-left", "", KeybindAction::ColumnMoveLeft}, {"column-move-right", "", KeybindAction::ColumnMoveRight}, {"column-move-to-output-down", "", KeybindAction::ColumnMoveToOutputDown}, diff --git a/src/config/keybind_parse.h b/src/config/keybind_parse.h index b3b76858..0172fc47 100644 --- a/src/config/keybind_parse.h +++ b/src/config/keybind_parse.h @@ -99,6 +99,7 @@ namespace umbriel { DpmsOn, WorkspaceMoveDown, WorkspaceMoveUp, + ColumnCenter, Count, }; diff --git a/src/input/gestures.cpp b/src/input/gestures.cpp index b1b6782c..ec358fb7 100644 --- a/src/input/gestures.cpp +++ b/src/input/gestures.cpp @@ -266,6 +266,7 @@ namespace umbriel { m_scrollWorkspace = ws; m_viewportPrimary = ws->scrollViewportExtent(); m_scrollStart = scrolling->scroll(); + m_scrollStartCentered = scrolling->centeredRest(); ws->markArrange(false); m_state = State::Scroll; } else { @@ -304,11 +305,15 @@ namespace umbriel { return; } const auto maxScroll = static_cast(scrolling->maxScroll(m_viewportPrimary)); - if (target < 0) { - target = std::max(target * kOverscrollCompress, -0.1 * m_viewportPrimary); + // The strip can rest past its own edge (a centered end column sits half a viewport out), so measure the + // rubber-band from where it is rather than from the edges, or the first finger movement yanks it back in. + const double restLow = std::min(m_scrollStart, 0.0); + const double restHigh = std::max(m_scrollStart, maxScroll); + if (target < restLow) { + target = std::max(restLow + (target - restLow) * kOverscrollCompress, restLow - 0.1 * m_viewportPrimary); } - if (target > maxScroll) { - target = std::min(maxScroll + (target - maxScroll) * kOverscrollCompress, maxScroll + 0.1 * m_viewportPrimary); + if (target > restHigh) { + target = std::min(restHigh + (target - restHigh) * kOverscrollCompress, restHigh + 0.1 * m_viewportPrimary); } scrolling->setScroll(target); m_scrollWorkspace->markArrange(false); @@ -461,7 +466,7 @@ namespace umbriel { return; } if (cancelled) { - scrolling->setScroll(m_scrollStart); + scrolling->setScroll(m_scrollStart, m_scrollStartCentered); m_scrollWorkspace->markArrange(true); } else { // Snap to the column nearest the viewport center. @@ -511,6 +516,8 @@ namespace umbriel { m_scrollWorkspace->ensureFocusedVisible(); m_scrollWorkspace->markArrange(true); } + scrolling->setScroll(std::clamp(layout.scroll(), 0.0, maxScroll)); + m_scrollWorkspace->markArrange(true); } m_scrollWorkspace = nullptr; m_state = State::Idle; diff --git a/src/input/gestures.h b/src/input/gestures.h index 4fdef72e..664d8090 100644 --- a/src/input/gestures.h +++ b/src/input/gestures.h @@ -56,6 +56,7 @@ namespace umbriel { // Scroll state (horizontal 3-finger). Workspace* m_scrollWorkspace = nullptr; double m_scrollStart = 0; + bool m_scrollStartCentered = false; int m_viewportPrimary = 0; // Switch state (vertical 3-finger). diff --git a/src/layout/scrolling.cpp b/src/layout/scrolling.cpp index d713472b..bb5049c5 100644 --- a/src/layout/scrolling.cpp +++ b/src/layout/scrolling.cpp @@ -335,7 +335,20 @@ namespace umbriel { m_columns.insert(m_columns.begin() + destination, std::move(column)); } - void ScrollingLayout::setScroll(double scroll) { m_scroll = scroll; } + void ScrollingLayout::setScroll(double scroll, bool centeredRest) { + m_scroll = scroll; + m_centeredRest = centeredRest; + } + + bool ScrollingLayout::centerColumn(int columnIndex, int viewportPrimary) { + if (columnIndex < 0 || columnIndex >= static_cast(m_columns.size()) || viewportPrimary <= 0) { + return false; + } + const double target = static_cast(columnX(columnIndex, viewportPrimary)) + - (viewportPrimary - columnWidth(columnIndex, viewportPrimary)) / 2.0; + setScroll(target, true); + return true; + } double ScrollingLayout::targetScrollForEnsureVisible(int columnIndex, int viewportPrimary) const { if (columnIndex < 0 || columnIndex >= static_cast(m_columns.size()) || viewportPrimary <= 0) { @@ -344,12 +357,11 @@ namespace umbriel { const int x = columnX(columnIndex, viewportPrimary); const int width = columnWidth(columnIndex, viewportPrimary); const double max = static_cast(std::max(0, totalWidth(viewportPrimary) - viewportPrimary)); - // Already fully on screen: never move the strip. In particular, a column flush against an edge must not jump when - // it receives focus. Still bounded: a touchpad swipe parks the strip past an edge on purpose, and the edge column - // stays fully visible while it does, so returning that overscroll verbatim would make every reveal a no-op and - // leave the strip resting outside its own range. + // Already fully on screen: never move the strip, including one parked past an edge on purpose (column-center + // overshoots the range so edge columns can sit in the middle). A touchpad swipe that left the strip outside its + // range springs back where the gesture ends, in Gestures::finishScroll. if (m_scroll <= static_cast(x) && m_scroll >= static_cast(x + width - viewportPrimary)) { - return std::clamp(m_scroll, 0.0, max); + return m_centeredRest ? m_scroll : std::clamp(m_scroll, 0.0, max); } // Move by the shortest distance that reveals the whole column. A column entering from the right lands flush against @@ -383,7 +395,9 @@ namespace umbriel { } void ScrollingLayout::ensureVisible(int columnIndex, int viewportPrimary) { - m_scroll = targetScrollForEnsureVisible(columnIndex, viewportPrimary); + const double target = targetScrollForEnsureVisible(columnIndex, viewportPrimary); + m_centeredRest = m_centeredRest && target == m_scroll; + m_scroll = target; } void ScrollingLayout::arrange(const wlr_box& usable) { diff --git a/src/layout/scrolling.h b/src/layout/scrolling.h index c0a5ed7f..274e83ae 100644 --- a/src/layout/scrolling.h +++ b/src/layout/scrolling.h @@ -38,7 +38,10 @@ namespace umbriel { bool moveViewVertical(View* view, int direction) override; void removeView(View* view) override; void moveColumn(int from, int to) override; - void setScroll(double scroll); + // Raw scroll mutation. `centeredRest` is true only when restoring a saved column-center resting position. + void setScroll(double scroll, bool centeredRest = false); + bool centerColumn(int columnIndex, int viewportPrimary); + [[nodiscard]] bool centeredRest() const { return m_centeredRest; } // How much to subtract from the scroll offset when `columnIndex` is about // to lose its last view. Removing a lane closes the primary-axis space it // held. Compensation re-anchors content when that space was hidden toward @@ -89,6 +92,7 @@ namespace umbriel { std::vector m_columns; std::vector m_targets; double m_scroll = 0; + bool m_centeredRest = false; // Cross extent available during the last arrange, used to preserve existing // pixel sizes when a drop converts an outer gap into another stacked view. int m_lastAvailableCross = 0; diff --git a/src/scene/cheatsheet_rows.cpp b/src/scene/cheatsheet_rows.cpp index adcbbd58..bdb801ad 100644 --- a/src/scene/cheatsheet_rows.cpp +++ b/src/scene/cheatsheet_rows.cpp @@ -278,6 +278,7 @@ namespace { return Group::Focus; case A::ColumnMoveLeft: case A::ColumnMoveRight: + case A::ColumnCenter: case A::WindowMoveUp: case A::WindowMoveDown: case A::WindowMoveOrWorkspaceUp: diff --git a/src/server/actions.cpp b/src/server/actions.cpp index 09a73421..02d38763 100644 --- a/src/server/actions.cpp +++ b/src/server/actions.cpp @@ -591,6 +591,16 @@ namespace umbriel { return true; } + bool actionColumnCenter(Server& server, const Keybind& /*bind*/, std::string* /*error*/) { + if (scratchpadHoldsFocus(server)) { + return true; + } + if (Workspace* workspace = activeWorkspace(server)) { + workspace->centerFocusedColumn(); + } + return true; + } + bool actionFocusNext(Server& server, const Keybind& /*bind*/, std::string* /*error*/) { server.focusNextWindow(); return true; @@ -1068,6 +1078,7 @@ namespace umbriel { &actionDpms, &actionWorkspaceMove<1>, &actionWorkspaceMove<-1>, + &actionColumnCenter, }; consteval bool everyActionHasHandler() { diff --git a/src/workspace/workspace.cpp b/src/workspace/workspace.cpp index a861ab8c..b225d639 100644 --- a/src/workspace/workspace.cpp +++ b/src/workspace/workspace.cpp @@ -702,6 +702,19 @@ namespace umbriel { return true; } + bool Workspace::centerFocusedColumn() { + ScrollingLayout* scrolling = scrollingLayout(); + if (scrolling == nullptr || m_focusedView == nullptr) { + return false; + } + const int column = scrolling->columnOf(m_focusedView); + if (!scrolling->centerColumn(column, scrollViewportExtent())) { + return false; + } + markArrange(); + return true; + } + bool Workspace::modifyFocusedWidth(double delta) { const int column = m_layout->columnOf(m_focusedView); if (column < 0) { diff --git a/src/workspace/workspace.h b/src/workspace/workspace.h index febfcad2..479db0f9 100644 --- a/src/workspace/workspace.h +++ b/src/workspace/workspace.h @@ -97,6 +97,7 @@ namespace umbriel { bool moveFocusedVertical(int direction); bool cycleFocusedWidth(int direction); bool setFocusedWidth(double fraction); + bool centerFocusedColumn(); // Incremental width change: apply `delta` to the focused column's current // width fraction, clamped to [0.1, 1.0]. bool modifyFocusedWidth(double delta); diff --git a/tests/harness/checks/110_scrolling_layout.sh b/tests/harness/checks/110_scrolling_layout.sh index a3bf199d..3172a239 100755 --- a/tests/harness/checks/110_scrolling_layout.sh +++ b/tests/harness/checks/110_scrolling_layout.sh @@ -22,6 +22,7 @@ wait_for_windows() { # Output is 1280x720 (WLR_HEADLESS_OUTPUTS default mode). With the shipped defaults (gap 8, border 2, no outer border) the derived layout metrics are: totalBorderWidth = 2, edgePad = gap + border = 10, totalGap = gap + 2*border = 12 viewport = 1280 - 2*edgePad = 1260, height = 720 - 2*edgePad = 700 A 0.5 fraction column is then: round(0.5 * (viewport + totalGap)) - totalGap = round(636) - 12 = 624 readonly EXPECT_W=624 readonly EXPECT_H=700 +readonly EXPECT_CENTER_X=$(( (1280 - EXPECT_W) / 2 )) printf '\n[layout.scrolling]\ndefault_width_fraction = 0.5\n' >> "$UMBRIEL_CONFIG" "$UMBRIEL" msg config-reload > /dev/null @@ -64,6 +65,33 @@ if ! jq -e '[.[].x] | unique | length == 2' <<< "$windows" > /dev/null; then exit 1 fi +# The focused last column can rest beyond max scroll so its content is centered. +"$UMBRIEL" msg column-center > /dev/null +center_x=0 +for _ in $(seq 40); do + center_x=$("$UMBRIEL" windows --json | jq -r '.[] | select(.title == "harness-b") | .x') + [[ $center_x -eq $EXPECT_CENTER_X ]] && break + sleep 0.1 +done +if [[ $center_x -ne $EXPECT_CENTER_X ]]; then + echo "expected last column centered at x=$EXPECT_CENTER_X, got x=$center_x" + exit 1 +fi + +# The first column uses the corresponding negative resting offset. +"$UMBRIEL" msg window-focus-left > /dev/null +"$UMBRIEL" msg column-center > /dev/null +center_x=0 +for _ in $(seq 40); do + center_x=$("$UMBRIEL" windows --json | jq -r '.[] | select(.title == "harness-a") | .x') + [[ $center_x -eq $EXPECT_CENTER_X ]] && break + sleep 0.1 +done +if [[ $center_x -ne $EXPECT_CENTER_X ]]; then + echo "expected first column centered at x=$EXPECT_CENTER_X, got x=$center_x" + exit 1 +fi + # Floating toggle round trip: the focused window flips and comes back. "$UMBRIEL" msg window-toggle-floating > /dev/null for _ in $(seq 20); do @@ -85,4 +113,4 @@ if [[ $("$UMBRIEL" windows --json | jq '[.[] | select(.floating)] | length') -ne exit 1 fi -echo "2 clients tiled at ${EXPECT_W}x${EXPECT_H}, float round trip ok" +echo "2 clients tiled at ${EXPECT_W}x${EXPECT_H}, edge columns center, float round trip ok" diff --git a/tests/harness/checks/120_vertical_layout.sh b/tests/harness/checks/120_vertical_layout.sh index b308ac1d..d46aadbc 100755 --- a/tests/harness/checks/120_vertical_layout.sh +++ b/tests/harness/checks/120_vertical_layout.sh @@ -4,6 +4,7 @@ set -euo pipefail readonly EXPECT_W=1260 readonly EXPECT_H=344 +readonly EXPECT_CENTER_Y=$(( (720 - EXPECT_H) / 2 )) spawn_client() { foot --title="vertical-harness-$1" sh -c 'sleep 120' > /dev/null 2>&1 & @@ -56,4 +57,30 @@ if ! jq -e '[.[].y] | unique | length == 2' <<< "$windows" > /dev/null; then exit 1 fi -echo "2 clients tiled in vertical lanes at ${EXPECT_W}x${EXPECT_H}" +# Column centering follows the vertical layout's primary axis. +"$UMBRIEL" msg column-center > /dev/null +center_y=0 +for _ in $(seq 40); do + center_y=$("$UMBRIEL" windows --json | jq -r '.[] | select(.title == "vertical-harness-b") | .y') + [[ $center_y -eq $EXPECT_CENTER_Y ]] && break + sleep 0.1 +done +if [[ $center_y -ne $EXPECT_CENTER_Y ]]; then + echo "expected last vertical column centered at y=$EXPECT_CENTER_Y, got y=$center_y" + exit 1 +fi + +"$UMBRIEL" msg window-focus-up > /dev/null +"$UMBRIEL" msg column-center > /dev/null +center_y=0 +for _ in $(seq 40); do + center_y=$("$UMBRIEL" windows --json | jq -r '.[] | select(.title == "vertical-harness-a") | .y') + [[ $center_y -eq $EXPECT_CENTER_Y ]] && break + sleep 0.1 +done +if [[ $center_y -ne $EXPECT_CENTER_Y ]]; then + echo "expected first vertical column centered at y=$EXPECT_CENTER_Y, got y=$center_y" + exit 1 +fi + +echo "2 clients tiled in vertical lanes at ${EXPECT_W}x${EXPECT_H}, edge columns center" diff --git a/tests/harness/checks/610_output_actions.sh b/tests/harness/checks/610_output_actions.sh index 13238065..299eb937 100755 --- a/tests/harness/checks/610_output_actions.sh +++ b/tests/harness/checks/610_output_actions.sh @@ -367,6 +367,17 @@ if [[ $min_h -ge 600 ]]; then exit 1 fi +# column-center is a deliberate no-op outside the scrolling layout. +sleep 0.5 +dwindle_geometry=$("$UMBRIEL" windows --json | jq -c 'sort_by(.id) | map({id, x, y, w, h})') +accepts "column-center" +sleep 0.2 +after_center=$("$UMBRIEL" windows --json | jq -c 'sort_by(.id) | map({id, x, y, w, h})') +if [[ $after_center != "$dwindle_geometry" ]]; then + echo "column-center changed dwindle geometry: $dwindle_geometry -> $after_center" + exit 1 +fi + # The runtime switch must survive a window open: reconcileDynamic re-resolves # the configured layout, and the override keeps dwindle in force. spawn_client dwindle-d diff --git a/tests/unit/scrolling_layout.cpp b/tests/unit/scrolling_layout.cpp index 75ee239b..ede68626 100644 --- a/tests/unit/scrolling_layout.cpp +++ b/tests/unit/scrolling_layout.cpp @@ -635,10 +635,74 @@ UMBRIEL_TEST(ensureVisibleKeepsAFlushLeftColumnInPlace) { CHECK_EQ(fixture.layout.scrollAmountToEnsureVisible(1, kViewport), 0.0); } +UMBRIEL_TEST(centerColumnLetsTheFirstColumnRestPastStripStart) { + Fixture fixture; + fixture.addColumns(3); + const double expected = static_cast(fixture.layout.columnX(0, kViewport)) + - (kViewport - fixture.layout.columnWidth(0, kViewport)) / 2.0; + + CHECK(fixture.layout.centerColumn(0, kViewport)); + CHECK(expected < 0.0); + CHECK_EQ(fixture.layout.scroll(), expected); + CHECK(fixture.layout.centeredRest()); + + fixture.layout.ensureVisible(0, kViewport); + CHECK_EQ(fixture.layout.scroll(), expected); + CHECK_EQ(fixture.layout.scrollAmountToEnsureVisible(0, kViewport), 0.0); +} + +UMBRIEL_TEST(centerColumnLetsTheLastColumnRestPastStripEnd) { + Fixture fixture; + fixture.addColumns(3); + const double expected = static_cast(fixture.layout.columnX(2, kViewport)) + - (kViewport - fixture.layout.columnWidth(2, kViewport)) / 2.0; + + CHECK(fixture.layout.centerColumn(2, kViewport)); + CHECK(expected > static_cast(fixture.layout.maxScroll(kViewport))); + CHECK_EQ(fixture.layout.scroll(), expected); + CHECK(fixture.layout.centeredRest()); + + fixture.layout.ensureVisible(2, kViewport); + CHECK_EQ(fixture.layout.scroll(), expected); +} + +UMBRIEL_TEST(rawScrollDoesNotInheritAColumnCenterRest) { + Fixture fixture; + fixture.addColumns(3); + CHECK(fixture.layout.centerColumn(0, kViewport)); + + fixture.layout.setScroll(-126.0); + CHECK(!fixture.layout.centeredRest()); + fixture.layout.ensureVisible(0, kViewport); + CHECK_EQ(fixture.layout.scroll(), 0.0); +} + +UMBRIEL_TEST(revealingAHiddenColumnEndsTheCenteredRest) { + Fixture fixture; + fixture.addColumns(3); + CHECK(fixture.layout.centerColumn(0, kViewport)); + + fixture.layout.ensureVisible(2, kViewport); + CHECK(!fixture.layout.centeredRest()); + CHECK(fixture.layout.scroll() >= 0.0); + CHECK(fixture.layout.scroll() <= static_cast(fixture.layout.maxScroll(kViewport))); +} + +UMBRIEL_TEST(centerColumnRejectsInvalidGeometryWithoutMoving) { + Fixture fixture; + fixture.addColumns(2); + fixture.layout.setScroll(123.0); + + CHECK(!fixture.layout.centerColumn(-1, kViewport)); + CHECK(!fixture.layout.centerColumn(2, kViewport)); + CHECK(!fixture.layout.centerColumn(0, 0)); + CHECK_EQ(fixture.layout.scroll(), 123.0); + CHECK(!fixture.layout.centeredRest()); +} + UMBRIEL_TEST(ensureVisibleGivesBackLeftOverscroll) { - // A three-finger swipe past the left edge parks the strip at a negative scroll on purpose, and column 0 stays fully - // visible while it does. Gesture release settles through ensureVisible alone, so if the visible-column path handed - // that overscroll back the strip would rest outside its own range with no spring-back. + // Raw gesture overscroll is not a centered resting position. Even though column 0 remains fully visible, + // ensureVisible must return the strip to its normal range. Fixture fixture; fixture.addColumns(3); const double overscroll = -126.0; // The gesture caps overscroll at 0.1 * viewport. @@ -655,8 +719,8 @@ UMBRIEL_TEST(ensureVisibleGivesBackLeftOverscroll) { } UMBRIEL_TEST(ensureVisibleGivesBackRightOverscroll) { - // Mirrors the left extremity: past max scroll the last column is still fully visible, so the bound is what pulls the - // strip back rather than the reveal. + // Mirrors the left extremity: raw overscroll past max scroll is still temporary, even while the last column remains + // fully visible. Fixture fixture; fixture.addColumns(3); const double maxScroll = fixture.layout.maxScroll(kViewport);