Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/user/keybinds.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
3 changes: 2 additions & 1 deletion examples/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/config/keybind_parse.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
1 change: 1 addition & 0 deletions src/config/keybind_parse.h
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ namespace umbriel {
DpmsOn,
WorkspaceMoveDown,
WorkspaceMoveUp,
ColumnCenter,
Count,
};

Expand Down
17 changes: 12 additions & 5 deletions src/input/gestures.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -304,11 +305,15 @@ namespace umbriel {
return;
}
const auto maxScroll = static_cast<double>(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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/input/gestures.h
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
28 changes: 21 additions & 7 deletions src/layout/scrolling.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>(m_columns.size()) || viewportPrimary <= 0) {
return false;
}
const double target = static_cast<double>(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<int>(m_columns.size()) || viewportPrimary <= 0) {
Expand All @@ -344,12 +357,11 @@ namespace umbriel {
const int x = columnX(columnIndex, viewportPrimary);
const int width = columnWidth(columnIndex, viewportPrimary);
const double max = static_cast<double>(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<double>(x) && m_scroll >= static_cast<double>(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
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion src/layout/scrolling.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,6 +92,7 @@ namespace umbriel {
std::vector<Column> m_columns;
std::vector<Target> 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;
Expand Down
1 change: 1 addition & 0 deletions src/scene/cheatsheet_rows.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions src/server/actions.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1068,6 +1078,7 @@ namespace umbriel {
&actionDpms<true>,
&actionWorkspaceMove<1>,
&actionWorkspaceMove<-1>,
&actionColumnCenter,
};

consteval bool everyActionHasHandler() {
Expand Down
13 changes: 13 additions & 0 deletions src/workspace/workspace.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/workspace/workspace.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
30 changes: 29 additions & 1 deletion tests/harness/checks/110_scrolling_layout.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
29 changes: 28 additions & 1 deletion tests/harness/checks/120_vertical_layout.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 &
Expand Down Expand Up @@ -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"
11 changes: 11 additions & 0 deletions tests/harness/checks/610_output_actions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading