diff --git a/docs/user/layout.md b/docs/user/layout.md index e03a182c..490f7f59 100644 --- a/docs/user/layout.md +++ b/docs/user/layout.md @@ -94,7 +94,7 @@ expand_single_column = true | `default_width_fraction` | float | unset | Initial strip-axis extent for new columns (0.1-1.0). The packaged config sets `0.5`; a matching output or workspace rule can override it. When it is unset at every level, the client chooses its initial extent. | | `center_underfull_strip` | bool | `true` | Center the complete strip when it is shorter than the viewport. Disable to align it at the start edge. | | `center_focused` | bool | `false` | Always center the focused column. | -| `expand_single_column` | bool | `false` | Fill the viewport for a workspace's lone tiled column. Client size hints and viewport bounds still apply. The packaged config enables this. | +| `expand_single_column` | bool | `false` | Fill the viewport for a workspace's lone tiled column until its width is explicitly changed. Client size hints and viewport bounds still apply. The packaged config enables this. | ### Horizontal and vertical scrolling @@ -154,10 +154,12 @@ does not resize existing columns, and a column moved to another output retains its stored fraction. Re-tiling a floating window or expelling a window into a new column creates a column using the current default. -`expand_single_column` affects only how a lone tiled column is displayed. It -does not rewrite the stored fraction, so the configured or client-selected -width applies again when a second column appears. Explicit -`default_maximize` and `default_maximize_to_edges` window rules take precedence. +`expand_single_column` initially fills the viewport without rewriting the +column's stored fraction. Explicitly resizing the column, cycling its width, or +setting its width disables automatic expansion for that column. Otherwise, the +configured or client-selected width applies again when a second column appears. +Explicit `default_maximize` and `default_maximize_to_edges` window rules take +precedence. When focus moves to a hidden or partially hidden column, Umbriel reveals it by the shortest distance needed to show it completely. A column entering from the diff --git a/docs/user/workspaces.md b/docs/user/workspaces.md index 1675ffb1..051ded2e 100644 --- a/docs/user/workspaces.md +++ b/docs/user/workspaces.md @@ -172,7 +172,7 @@ Strut edges are resolved independently. A rule that sets only | `layout.scrolling.center_underfull_strip` | bool | Center the complete strip whenever it is narrower than the viewport. Disable to left-align underfull strips. | | `layout.scrolling.center_focused` | bool | Always center the focused column, including when the setting changes on config reload. | | `layout.scrolling.direction` | string | `"horizontal"` or `"vertical"` scroll axis. | -| `layout.scrolling.expand_single_column` | bool | Fill the viewport for a workspace's lone tiled column, subject to client size hints and viewport bounds. Disable to keep the configured/default width. | +| `layout.scrolling.expand_single_column` | bool | Fill the viewport for a workspace's lone tiled column until its width is explicitly changed. Client size hints and viewport bounds still apply. Disable to always keep the configured/default width. | | `layout.master.position` | string | Side occupied by the master area: `"left"` or `"right"`. | | `layout.master.default_width_fraction` | float | Master area fraction when both areas exist (0.1-0.9). | | `layout.master.new_on_top` | bool | Place newly opened windows at the top of the stack. Disable to place them at the bottom. | diff --git a/examples/config.toml b/examples/config.toml index 10d84246..bda9bfe3 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -412,7 +412,7 @@ bottom = 0 default_width_fraction = 0.5 # Initial width for new columns, 0.1-1.0 center_underfull_strip = true # Center a strip narrower than the viewport center_focused = false # Always center the focused column -expand_single_column = true # Let a lone column fill the viewport +expand_single_column = true # Fill a lone column until its width is explicitly changed [layout.dwindle] # preserve_split = false # Keep every split direction fixed after creation diff --git a/src/layout/layout.h b/src/layout/layout.h index 779bdb34..b1027af9 100644 --- a/src/layout/layout.h +++ b/src/layout/layout.h @@ -120,6 +120,7 @@ namespace umbriel { double bottomGapWeight = 0.0; double widthFrac = 0.5; double savedWidthFrac = 0.0; + bool autoExpand = true; }; struct LayoutTarget { diff --git a/src/layout/scrolling.cpp b/src/layout/scrolling.cpp index 7a3777ec..9398057c 100644 --- a/src/layout/scrolling.cpp +++ b/src/layout/scrolling.cpp @@ -31,6 +31,7 @@ namespace umbriel { double bottomGapWeight = 0.0; double widthFraction = 0.5; double savedWidthFraction = 0.0; + bool autoExpand = true; double viewportCenterFraction = 0.5; }; @@ -167,6 +168,7 @@ namespace umbriel { .bottomGapWeight = column.bottomGapWeight, .widthFraction = column.widthFrac, .savedWidthFraction = column.savedWidthFrac, + .autoExpand = column.autoExpand, .viewportCenterFraction = 0.5, }; if (viewportPrimary > 0) { @@ -222,6 +224,7 @@ namespace umbriel { .bottomGapWeight = saved.bottomGapWeight, .widthFrac = saved.widthFraction, .savedWidthFrac = saved.savedWidthFraction, + .autoExpand = saved.autoExpand, }; for (const ScrollingSnapshot::Row& row : saved.rows) { View* view = (*resolved)[static_cast(row.member)]; @@ -309,7 +312,7 @@ namespace umbriel { return std::max(1, viewportPrimary + 2 * edgePad); } int width = 0; - if (m_columns.size() == 1 && expandSingleColumn()) { + if (m_columns.size() == 1 && expandSingleColumn() && column.autoExpand) { // Fill the viewport without touching the stored fraction. Client size hints still apply to tiled columns. width = viewportPrimary; } else { @@ -346,6 +349,14 @@ namespace umbriel { return true; } + bool ScrollingLayout::setUserWidthFraction(int columnIndex, double fraction) { + if (!setWidthFraction(columnIndex, fraction)) { + return false; + } + m_columns[static_cast(columnIndex)].autoExpand = false; + return true; + } + int ScrollingLayout::centeringOffset(int viewportPrimary) const { if (!m_config->scrolling.centerUnderfullStrip) { return 0; @@ -792,6 +803,7 @@ namespace umbriel { Column& column = m_columns[static_cast(columnIndex)]; column.widthFrac = nextFractionPreset(m_config->widthPresets, column.widthFrac, direction); column.savedWidthFrac = 0.0; + column.autoExpand = false; return true; } @@ -800,6 +812,7 @@ namespace umbriel { return false; } Column& column = m_columns[static_cast(columnIndex)]; + column.autoExpand = false; if (column.savedWidthFrac > 0.0) { column.widthFrac = column.savedWidthFrac; column.savedWidthFrac = 0.0; @@ -1022,7 +1035,7 @@ namespace umbriel { const int gap = layout.layoutConfig()->totalGap; auto setColumnPrimaryPx = [&](int columnIndex, int extent) { const double fraction = static_cast(extent + gap) / static_cast(viewportPrimary + gap); - layout.setWidthFraction(columnIndex, fraction); + layout.setUserWidthFraction(columnIndex, fraction); }; const bool centerUnderfullStrip = m_startStripPrimaryPx < viewportPrimary && layout.layoutConfig()->scrolling.centerUnderfullStrip; diff --git a/src/layout/scrolling.h b/src/layout/scrolling.h index ab496222..622ff4a5 100644 --- a/src/layout/scrolling.h +++ b/src/layout/scrolling.h @@ -34,6 +34,7 @@ namespace umbriel { [[nodiscard]] int columnX(int columnIndex, int viewportPrimary) const; [[nodiscard]] int columnWidth(int columnIndex, int viewportPrimary) const; bool setWidthFromPixels(int columnIndex, int viewportPrimary, int width); + bool setUserWidthFraction(int columnIndex, double fraction); [[nodiscard]] bool isFullWidth(int columnIndex) const override; [[nodiscard]] int maxScroll(int viewportPrimary) const { return std::max(0, totalWidth(viewportPrimary) - viewportPrimary); diff --git a/src/workspace/workspace.cpp b/src/workspace/workspace.cpp index f63f63e5..465ab117 100644 --- a/src/workspace/workspace.cpp +++ b/src/workspace/workspace.cpp @@ -1133,7 +1133,9 @@ namespace umbriel { m_focusedView->setMaximizedToEdges(false); } const int column = m_layout->columnOf(m_focusedView); - if (!m_layout->setWidthFraction(column, fraction)) { + const bool changed = scrollingLayout() != nullptr ? scrollingLayout()->setUserWidthFraction(column, fraction) + : m_layout->setWidthFraction(column, fraction); + if (!changed) { return false; } wlr_xdg_toplevel_set_maximized(m_focusedView->toplevel(), false); diff --git a/tests/harness/checks/517_expand_single_resize.sh b/tests/harness/checks/517_expand_single_resize.sh new file mode 100755 index 00000000..72402e63 --- /dev/null +++ b/tests/harness/checks/517_expand_single_resize.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly BTN_RIGHT=273 +readonly OUTPUT_W=1280 +readonly OUTPUT_H=720 +readonly POINTER="${UMBRIEL_POINTER_CLIENT:-./build-debug/tests/pointer-client}" + +cat >> "$UMBRIEL_CONFIG" <<'EOF' +[animation] +enabled = false +[layout.scrolling] +expand_single_column = true +center_underfull_strip = true +EOF +"$UMBRIEL" msg config-reload > /dev/null + +foot --title=expand-single-resize sh -c 'sleep 120' > /dev/null 2>&1 & +for _ in $(seq 60); do + window=$("$UMBRIEL" windows --json | jq -c '.[] | select(.title == "expand-single-resize")') + [[ -n $window ]] && break + sleep 0.1 +done +if [[ -z ${window:-} ]]; then + echo "timed out waiting for expand-single-resize" + exit 1 +fi + +before=$window +"$POINTER" "$OUTPUT_W" "$OUTPUT_H" \ + move "$(jq -r '.x + .w - 20 | floor' <<< "$before")" "$(jq -r '.y + .h / 2 | floor' <<< "$before")" \ + mod logo press "$BTN_RIGHT" move 1000 360 release "$BTN_RIGHT" mod none + +for _ in $(seq 60); do + after=$("$UMBRIEL" windows --json | jq -c '.[] | select(.title == "expand-single-resize")') + [[ $(jq -r '.w' <<< "$after") -lt $(jq -r '.w' <<< "$before") ]] && break + sleep 0.1 +done + +if [[ $(jq -r '.w' <<< "$after") -ge $(jq -r '.w' <<< "$before") ]]; then + echo "right-edge drag did not shrink the expanded lone column: before=$before after=$after" + exit 1 +fi + +echo "right-edge drag resized an automatically expanded lone column: before=$(jq -r '.w' <<< "$before") after=$(jq -r '.w' <<< "$after")" diff --git a/tests/unit/scrolling_layout.cpp b/tests/unit/scrolling_layout.cpp index 703833b5..bca31172 100644 --- a/tests/unit/scrolling_layout.cpp +++ b/tests/unit/scrolling_layout.cpp @@ -314,6 +314,14 @@ UMBRIEL_TEST(expandSingleColumnTrueFillsALoneColumn) { CHECK_EQ(fixture.layout.columnWidth(0, kViewport), kViewport); } +UMBRIEL_TEST(expandSingleColumnSurvivesInitialWidthSeeding) { + Fixture fixture; + fixture.config.scrolling.expandSingleColumn = true; + fixture.addColumns(1); + CHECK(fixture.layout.setWidthFraction(0, 0.5)); + CHECK_EQ(fixture.layout.columnWidth(0, kViewport), kViewport); +} + UMBRIEL_TEST(expandSingleColumnTrueHonorsClientMaxWidth) { Fixture fixture; fixture.config.scrolling.expandSingleColumn = true; @@ -339,6 +347,20 @@ UMBRIEL_TEST(expandSingleColumnTrueReexpandsTheLastSurvivor) { CHECK_EQ(fixture.layout.columnWidth(0, kViewport), kViewport); } +UMBRIEL_TEST(pointerResizeOverridesSingleColumnExpansion) { + Fixture fixture; + fixture.config.scrolling.expandSingleColumn = true; + fixture.addColumns(1); + fixture.layout.arrange(kUsable); + + auto resize = fixture.layout.beginResize(stub(0), WLR_EDGE_RIGHT, kUsable); + CHECK(resize != nullptr); + resize->applyDelta(-100.0, 0.0, kUsable); + fixture.layout.arrange(kUsable); + + CHECK_EQ(fixture.layout.columnWidth(0, kViewport), kViewport - 100); +} + UMBRIEL_TEST(twoHalfColumnsTileExactlyAcrossTheViewport) { Fixture fixture; fixture.addColumns(2);