diff --git a/docs/user/keybinds.md b/docs/user/keybinds.md index c7b39dd8..74264024 100644 --- a/docs/user/keybinds.md +++ b/docs/user/keybinds.md @@ -61,7 +61,9 @@ the newly exposed columns without requiring additional pointer motion. Bind `layout-scroll-drag` to a modified mouse button to pan a scrolling workspace directly. The strip follows the pointer along its configured axis, including overscroll and the same release settling used by the three-finger -gesture: +gesture. In the overview, the same bind pans the scrolling workspace row under +the pointer. Bare middle-button horizontal dragging provides the same overview +pan, while bare vertical dragging changes workspace rows: ```toml "Mod+MouseMiddle" = "layout-scroll-drag" diff --git a/docs/user/workspaces-overview.md b/docs/user/workspaces-overview.md index 0a74ef5c..d4e56d92 100644 --- a/docs/user/workspaces-overview.md +++ b/docs/user/workspaces-overview.md @@ -65,9 +65,11 @@ characters. Letter uniqueness ignores case, while badges preserve the case written in the configuration. Middle-click still closes a window card, but the close is sent on button -release. Drag the middle button vertically instead to step through workspace -rows without using the keyboard; moving beyond the drag threshold suppresses -the close. +release. Drag the middle button horizontally to pan the scrolling workspace row +under the pointer, or vertically to step through workspace rows without using +the keyboard. Moving beyond the drag threshold locks to the dominant axis and +suppresses the close. A configured `layout-scroll-drag` mouse bind takes +precedence and pans the row along its configured axis. An active client drag takes precedence. Umbriel ignores requests to open the overview until the pointer button that initiated the drag is released. diff --git a/src/input/cursor.cpp b/src/input/cursor.cpp index 4294553b..823e071e 100644 --- a/src/input/cursor.cpp +++ b/src/input/cursor.cpp @@ -751,7 +751,8 @@ namespace umbriel { } } if (bound.has_value()) { - if (bound->action == KeybindAction::LayoutScrollDrag && m_server->gestures()->beginPointerScroll()) { + if (bound->action == KeybindAction::LayoutScrollDrag + && m_server->gestures()->beginPointerScroll(m_cursor->x, m_cursor->y)) { setActiveConstraint(nullptr); m_grab = ScrollDragGrab{ .button = button, @@ -828,7 +829,7 @@ namespace umbriel { } return; } - overview->handleButton(button, pressed, m_cursor->x, m_cursor->y); + overview->handleButton(button, pressed, m_cursor->x, m_cursor->y, timeMsec); return; } @@ -1086,8 +1087,8 @@ namespace umbriel { if (Overview* overview = m_server->overview(); overview != nullptr && overview->active() && !m_server->sessionLocked() && !overviewPassthroughLayer(layer)) { if (overview->interactive()) { - overview->handleButton(BTN_LEFT, true, lx, ly); - overview->handleButton(BTN_LEFT, false, lx, ly); + overview->handleButton(BTN_LEFT, true, lx, ly, event->time_msec); + overview->handleButton(BTN_LEFT, false, lx, ly, event->time_msec); } return; } @@ -1186,7 +1187,7 @@ namespace umbriel { && overview->active() && !m_server->sessionLocked() && m_server->seat()->wlr()->drag == nullptr) { - overview->handleMotion(m_cursor->x, m_cursor->y); + overview->handleMotion(m_cursor->x, m_cursor->y, timeMsec); wlr_seat* seat = m_server->seat()->wlr(); if (overview->dragging()) { wlr_seat_pointer_clear_focus(seat); diff --git a/src/input/gestures.cpp b/src/input/gestures.cpp index d2101480..7a54ed40 100644 --- a/src/input/gestures.cpp +++ b/src/input/gestures.cpp @@ -181,7 +181,8 @@ namespace umbriel { return; } WorkspaceGroup* group = m_output != nullptr ? m_output->workspaceGroup() : nullptr; - if (group == nullptr || group->active() != m_scrollWorkspace) { + const bool overviewPointer = m_scrollSource == ScrollSource::OverviewPointer; + if (group == nullptr || (!overviewPointer && group->active() != m_scrollWorkspace)) { finishScroll(true, timeMsec); return; } @@ -195,7 +196,7 @@ namespace umbriel { m_scrollWorkspace->markArrange(false); } - bool Gestures::beginPointerScroll() { + bool Gestures::beginPointerScroll(double lx, double ly) { if (m_server->sessionLocked()) { return false; } @@ -203,7 +204,14 @@ namespace umbriel { cancelActive(); } if (Overview* overview = m_server->overview(); overview != nullptr && overview->active()) { - return false; + Workspace* workspace = overview->pointerScrollWorkspace(lx, ly); + Output* output = workspace != nullptr && workspace->group() != nullptr ? workspace->group()->output() : nullptr; + m_output = output; + if (output == nullptr || !beginScroll(workspace, 1.0 / overview->contentScale(), ScrollSource::OverviewPointer)) { + m_output = nullptr; + return false; + } + return true; } Output* output = m_server->outputFromWlr(m_server->preferredOutput()); Workspace* workspace = @@ -217,13 +225,15 @@ namespace umbriel { } void Gestures::updatePointerScroll(double dx, double dy, uint32_t timeMsec) { - if (m_state == State::Scroll && m_scrollSource == ScrollSource::Pointer) { + if (m_state == State::Scroll + && (m_scrollSource == ScrollSource::Pointer || m_scrollSource == ScrollSource::OverviewPointer)) { updateScroll(m_scrollVertical ? dy : dx, timeMsec); } } void Gestures::endPointerScroll(bool cancelled, uint32_t timeMsec) { - if (m_state == State::Scroll && m_scrollSource == ScrollSource::Pointer) { + if (m_state == State::Scroll + && (m_scrollSource == ScrollSource::Pointer || m_scrollSource == ScrollSource::OverviewPointer)) { finishScroll(cancelled, timeMsec); } } diff --git a/src/input/gestures.h b/src/input/gestures.h index 9f7384fe..eed859a5 100644 --- a/src/input/gestures.h +++ b/src/input/gestures.h @@ -24,13 +24,13 @@ namespace umbriel { // Mouse-button bindings use the same overscroll, velocity projection, and // column settling as the three-finger strip gesture, but pointer travel is // mapped one-to-one to content travel. - [[nodiscard]] bool beginPointerScroll(); + [[nodiscard]] bool beginPointerScroll(double lx, double ly); void updatePointerScroll(double dx, double dy, uint32_t timeMsec); void endPointerScroll(bool cancelled, uint32_t timeMsec); private: enum class State { Idle, Forward, Pending, Scroll, Switch, Overview, OverviewSelect }; - enum class ScrollSource { None, Swipe, Pointer }; + enum class ScrollSource { None, Swipe, Pointer, OverviewPointer }; static void onSwipeBegin(wl_listener* listener, void* data); static void onSwipeUpdate(wl_listener* listener, void* data); diff --git a/src/overview/overview.cpp b/src/overview/overview.cpp index 2076e668..fea003bc 100644 --- a/src/overview/overview.cpp +++ b/src/overview/overview.cpp @@ -3,6 +3,7 @@ #include "config/config.h" #include "core/log.h" #include "input/cursor.h" +#include "input/gestures.h" #include "input/seat.h" #include "layout/drop_target.h" #include "layout/layout.h" @@ -1756,6 +1757,9 @@ namespace umbriel { } void Overview::clearMiddlePress() { + if (m_middleScrolling) { + m_server->gestures()->endPointerScroll(true, 0); + } if (m_middleDragging) { m_server->cursor()->overrideCursor(nullptr); } @@ -1763,12 +1767,14 @@ namespace umbriel { m_middleOutput = nullptr; m_middlePressed = false; m_middleDragging = false; + m_middleHorizontal = false; + m_middleScrolling = false; m_middleAccumY = 0; } // -: input - bool Overview::handleButton(uint32_t button, bool pressed, double lx, double ly) { + bool Overview::handleButton(uint32_t button, bool pressed, double lx, double ly, uint32_t timeMsec) { if (!interactive()) { return true; // Swallow everything while zooming back in. } @@ -1777,6 +1783,10 @@ namespace umbriel { if (button == BTN_MIDDLE) { Card* card = m_middlePressCard; const bool closeCard = m_middlePressed && !m_middleDragging; + if (m_middleScrolling) { + m_server->gestures()->endPointerScroll(false, timeMsec); + m_middleScrolling = false; + } clearMiddlePress(); if (closeCard && card != nullptr && card->view != nullptr && card->view->mapped()) { wlr_xdg_toplevel_send_close(card->view->toplevel()); @@ -1813,6 +1823,8 @@ namespace umbriel { m_middleAccumY = 0; m_middlePressed = true; m_middleDragging = false; + m_middleHorizontal = false; + m_middleScrolling = false; return true; } if (button != BTN_LEFT) { @@ -1829,7 +1841,7 @@ namespace umbriel { return true; } - void Overview::handleMotion(double lx, double ly) { + void Overview::handleMotion(double lx, double ly, uint32_t timeMsec) { if (!interactive()) { return; } @@ -1841,10 +1853,26 @@ namespace umbriel { return; } m_middleDragging = true; + m_middleHorizontal = std::abs(dx) > std::abs(dy); + if (m_middleHorizontal) { + m_middleScrolling = m_server->gestures()->beginPointerScroll(m_middlePressX, m_middlePressY); + if (m_middleScrolling) { + m_server->gestures()->updatePointerScroll(dx, dy, timeMsec); + } + } m_middleAccumY = 0; + m_middlePressX = lx; m_middlePressY = ly; m_server->cursor()->overrideCursor("grabbing"); } + if (m_middleHorizontal) { + if (m_middleScrolling) { + m_server->gestures()->updatePointerScroll(lx - m_middlePressX, ly - m_middlePressY, timeMsec); + } + m_middlePressX = lx; + m_middlePressY = ly; + return; + } m_middleAccumY += ly - m_middlePressY; m_middlePressY = ly; while (m_middleAccumY <= -kMiddleScrollStepPx) { @@ -1906,6 +1934,13 @@ namespace umbriel { return true; } + Workspace* Overview::pointerScrollWorkspace(double lx, double ly) { + if (!interactive()) { + return nullptr; + } + return rowAt(lx, ly, nullptr, nullptr, true); + } + bool Overview::focusAdjacent(int direction) { if (!interactive()) { return false; diff --git a/src/overview/overview.h b/src/overview/overview.h index 82d7dc64..d964d7fe 100644 --- a/src/overview/overview.h +++ b/src/overview/overview.h @@ -86,8 +86,8 @@ namespace umbriel { void onOutputRemoved(Output* output); // Input entry points; called from Cursor/Keyboard while active. - bool handleButton(uint32_t button, bool pressed, double lx, double ly); - void handleMotion(double lx, double ly); + bool handleButton(uint32_t button, bool pressed, double lx, double ly, uint32_t timeMsec); + void handleMotion(double lx, double ly, uint32_t timeMsec); bool handleAxisNotch(bool vertical, double direction, double lx, double ly); bool handleFallbackKey(uint32_t keysym); // Focus a neighboring column while keeping the overview card strip in @@ -98,6 +98,11 @@ namespace umbriel { // up the real trees are hidden, so there is nothing to slide and switching is a discrete step rather than the // animated transition it is outside. bool selectRelativeWorkspace(int delta, Output* output); + // Resolve the visible workspace row under a pointer drag. Horizontal + // scrolling rows extend across the output because their cards may overhang + // the centered workspace preview. + [[nodiscard]] Workspace* pointerScrollWorkspace(double lx, double ly); + [[nodiscard]] double contentScale() const { return zoom(); } [[nodiscard]] bool dragging() const { return m_dragCard != nullptr || m_middlePressed; } private: @@ -251,6 +256,8 @@ namespace umbriel { double m_middleAccumY = 0; bool m_middlePressed = false; bool m_middleDragging = false; + bool m_middleHorizontal = false; + bool m_middleScrolling = false; Card* m_dragCard = nullptr; double m_dragOffsetX = 0; diff --git a/tests/harness/checks/315_scroll_drag.sh b/tests/harness/checks/315_scroll_drag.sh index e3808612..43b93fe2 100755 --- a/tests/harness/checks/315_scroll_drag.sh +++ b/tests/harness/checks/315_scroll_drag.sh @@ -123,10 +123,40 @@ fi "$UMBRIEL" msg column-focus-first > /dev/null wait_for_a_visible x > /dev/null -# The same physical button needs no modifier in overview. The first motion crosses the drag threshold; the second -# crosses one row step. Releasing after motion must not close the card under the original press. +# The configured modified drag pans the scrolling row under the pointer in the overview too. Overview cards are scaled, +# so the gesture maps pointer travel back through that scale before updating the workspace viewport. pointer move 560 360 "$UMBRIEL" msg overview-open > /dev/null +overview_before_x=$("$UMBRIEL" windows --json | jq -r '.[] | select(.title == "A") | .x') +pointer mod logo press "$BTN_MIDDLE" move 510 360 move 310 360 release "$BTN_MIDDLE" mod none +overview_after_x=$overview_before_x +for _ in $(seq 20); do + overview_after_x=$("$UMBRIEL" windows --json | jq -r '.[] | select(.title == "A") | .x') + ((overview_after_x < overview_before_x)) && break + sleep 0.1 +done +if ((overview_after_x >= overview_before_x)); then + echo "layout-scroll-drag did not pan the overview row: A x $overview_before_x -> $overview_after_x" + exit 1 +fi + +# Bare horizontal middle drag pans the same row without requiring the configured modifier. +bare_before_x=$overview_after_x +pointer press "$BTN_MIDDLE" move 360 360 move 560 360 release "$BTN_MIDDLE" +bare_after_x=$bare_before_x +for _ in $(seq 20); do + bare_after_x=$("$UMBRIEL" windows --json | jq -r '.[] | select(.title == "A") | .x') + ((bare_after_x > bare_before_x)) && break + sleep 0.1 +done +if ((bare_after_x <= bare_before_x)); then + echo "bare middle drag did not pan the overview row: A x $bare_before_x -> $bare_after_x" + exit 1 +fi + +# Bare vertical middle drag retains the overview's row navigation. The first motion crosses the drag threshold; the +# second crosses one row step. Releasing after motion must not close the card under the original press. +pointer move 560 360 pointer press "$BTN_MIDDLE" move 560 330 move 560 150 release "$BTN_MIDDLE" for _ in $(seq 20); do @@ -168,4 +198,4 @@ fi wait "$middle_click_pid" wait_for_count 3 -echo "mouse drag pans both layout axes, navigates overview rows, and preserves release-only middle-click close" +echo "bound and bare mouse drags pan overview strips, navigate rows, and preserve release-only middle-click close"