From cae72b5179fcc9fbebf815de1a6bf7f31e83bd4e Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 01:50:06 -0400 Subject: [PATCH] Make Settings sidebar content scrollable --- .../modules/settings/SettingsSidebar.qml | 284 ++++++++++-------- .../settings-sidebar-layout-contract.sh | 87 ++++++ 2 files changed, 249 insertions(+), 122 deletions(-) create mode 100755 tests/quickshell/settings-sidebar-layout-contract.sh diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index 02ffacf..38e009b 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -11,6 +11,11 @@ Rectangle { readonly property var results: SettingsSearch.search(root.query) + onQueryChanged: { + if (sidebarScroll) + sidebarScroll.contentY = 0; + } + function pageLabel(page: string): string { const found = root.destinations.find(item => item.page === page); return found ? found.label : "Settings"; @@ -40,8 +45,15 @@ Rectangle { border.width: 0 Column { - anchors.fill: parent - anchors.margins: 18 + id: sidebarHeader + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 18 + anchors.rightMargin: 18 + anchors.topMargin: 18 + height: implicitHeight spacing: 12 Text { @@ -103,149 +115,175 @@ Rectangle { onClicked: searchInput.forceActiveFocus() } } + } + + Flickable { + id: sidebarScroll + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: sidebarHeader.bottom + anchors.bottom: healthFooter.top + anchors.leftMargin: 18 + anchors.rightMargin: 18 + anchors.topMargin: 12 + anchors.bottomMargin: 12 + contentWidth: width + contentHeight: scrollContent.implicitHeight + flickableDirection: Flickable.VerticalFlick + boundsBehavior: Flickable.StopAtBounds + clip: true - // ── Search results ────────────────────────────────────────────────── - // Typing searches the settings themselves, not the twelve page names. - // "gaps", "wallpaper", and "screenshot" all used to find nothing, which - // made the app feel far smaller than it is. Column { - width: parent.width - spacing: 3 - visible: root.query !== "" + id: scrollContent + + width: sidebarScroll.width + + // ── Search results ────────────────────────────────────────────── + // Typing searches the settings themselves, not page names. + Column { + id: searchResults - Text { width: parent.width - leftPadding: 4 - bottomPadding: 4 - text: root.results.length === 0 - ? "Nothing matches" - : root.results.length + (root.results.length === 1 ? " result" : " results") - color: Theme.fgMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSmall - } - - Repeater { - model: root.results - - Rectangle { - id: hit - - required property var modelData + spacing: 3 + visible: root.query !== "" + Text { width: parent.width - height: 44 - radius: 10 - color: hitMouse.containsMouse ? Theme.alpha(Theme.fg, 0.08) : "transparent" - border.width: 0 + leftPadding: 4 + bottomPadding: 4 + text: root.results.length === 0 + ? "Nothing matches" + : root.results.length + (root.results.length === 1 ? " result" : " results") + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } - Column { - anchors.left: parent.left - anchors.right: parent.right - anchors.leftMargin: 12 - anchors.rightMargin: 10 - anchors.verticalCenter: parent.verticalCenter - spacing: 1 + Repeater { + model: root.results - Text { - width: parent.width - text: hit.modelData.label - color: Theme.fg - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - elide: Text.ElideRight + Rectangle { + id: hit + + required property var modelData + + width: parent.width + height: 44 + radius: 10 + color: hitMouse.containsMouse ? Theme.alpha(Theme.fg, 0.08) : "transparent" + border.width: 0 + + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 12 + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Text { + width: parent.width + text: hit.modelData.label + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + elide: Text.ElideRight + } + + Text { + width: parent.width + text: hit.modelData.kind === "shortcut" + ? hit.modelData.detail + : root.pageLabel(hit.modelData.page) + color: hit.modelData.kind === "shortcut" ? Theme.accent : Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } } - Text { - width: parent.width - text: hit.modelData.kind === "shortcut" - ? hit.modelData.detail - : root.pageLabel(hit.modelData.page) - color: hit.modelData.kind === "shortcut" ? Theme.accent : Theme.fgMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSmall - elide: Text.ElideRight - } - } - - MouseArea { - id: hitMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - root.pageRequested(hit.modelData.page); - searchInput.text = ""; + MouseArea { + id: hitMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.pageRequested(hit.modelData.page); + searchInput.text = ""; + } } } } } - } - Column { - width: parent.width - spacing: 4 - visible: root.query === "" + Column { + id: navigationList - Repeater { - model: root.destinations + width: parent.width + spacing: 4 + visible: root.query === "" - Rectangle { - id: navItem - required property var modelData - width: parent.width - height: 40 - radius: 10 - color: modelData.page === root.selectedPage - ? Theme.alpha(Theme.accent, 0.17) - : (navMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha * 0.55) : Theme.alpha(Theme.fg, 0)) - border.width: modelData.page === root.selectedPage ? 1 : 0 - border.color: Theme.alpha(Theme.accent, 0.26) + Repeater { + model: root.destinations Rectangle { - width: 2 - height: 18 - radius: 1 - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - visible: navItem.modelData.page === root.selectedPage - gradient: Gradient { - GradientStop { position: 0; color: Theme.accent } - GradientStop { position: 1; color: Theme.accentSecondary } + id: navItem + required property var modelData + width: parent.width + height: 40 + radius: 10 + color: modelData.page === root.selectedPage + ? Theme.alpha(Theme.accent, 0.17) + : (navMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha * 0.55) : Theme.alpha(Theme.fg, 0)) + border.width: modelData.page === root.selectedPage ? 1 : 0 + border.color: Theme.alpha(Theme.accent, 0.26) + + Rectangle { + width: 2 + height: 18 + radius: 1 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + visible: navItem.modelData.page === root.selectedPage + gradient: Gradient { + GradientStop { position: 0; color: Theme.accent } + GradientStop { position: 1; color: Theme.accentSecondary } + } } - } - Text { - anchors.left: parent.left - anchors.leftMargin: 13 - anchors.verticalCenter: parent.verticalCenter - width: 25 - text: navItem.modelData.icon - color: navItem.modelData.page === root.selectedPage ? Theme.accent : Theme.fgDim - font.family: Theme.fontMono - font.pixelSize: 15 - } + Text { + anchors.left: parent.left + anchors.leftMargin: 13 + anchors.verticalCenter: parent.verticalCenter + width: 25 + text: navItem.modelData.icon + color: navItem.modelData.page === root.selectedPage ? Theme.accent : Theme.fgDim + font.family: Theme.fontMono + font.pixelSize: 15 + } - Text { - anchors.left: parent.left - anchors.leftMargin: 47 - anchors.right: parent.right - anchors.rightMargin: 9 - anchors.verticalCenter: parent.verticalCenter - text: navItem.modelData.label - color: navItem.modelData.page === root.selectedPage ? Theme.fg : Theme.fgDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - font.weight: navItem.modelData.page === root.selectedPage ? Font.Medium : Font.Normal - elide: Text.ElideRight - } + Text { + anchors.left: parent.left + anchors.leftMargin: 47 + anchors.right: parent.right + anchors.rightMargin: 9 + anchors.verticalCenter: parent.verticalCenter + text: navItem.modelData.label + color: navItem.modelData.page === root.selectedPage ? Theme.fg : Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: navItem.modelData.page === root.selectedPage ? Font.Medium : Font.Normal + elide: Text.ElideRight + } - MouseArea { - id: navMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.pageRequested(navItem.modelData.page) + MouseArea { + id: navMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.pageRequested(navItem.modelData.page) + } } } } @@ -253,6 +291,8 @@ Rectangle { } Rectangle { + id: healthFooter + anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom diff --git a/tests/quickshell/settings-sidebar-layout-contract.sh b/tests/quickshell/settings-sidebar-layout-contract.sh new file mode 100755 index 0000000..0a97804 --- /dev/null +++ b/tests/quickshell/settings-sidebar-layout-contract.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +sidebar="$repo_dir/config/dot/quickshell/modules/settings/SettingsSidebar.qml" + +fail() { + printf 'settings sidebar layout contract: %s\n' "$1" >&2 + exit 1 +} + +python3 - "$sidebar" <<'PY' || fail 'sidebar does not keep its header and footer pinned around one vertical scroll surface' +import re +import sys + + +text = open(sys.argv[1], encoding="utf-8").read() + + +def object_block(type_name: str, object_id: str) -> tuple[int, int, str]: + pattern = re.compile( + rf"\b{re.escape(type_name)}\s*\{{(?:(?!\n\s*[A-Za-z][A-Za-z0-9.]*\s*\{{).)*?" + rf"\bid\s*:\s*{re.escape(object_id)}\b", + re.S, + ) + match = pattern.search(text) + if not match: + raise AssertionError(f"missing {type_name} id {object_id}") + + start = match.start() + opening = text.index("{", start) + depth = 0 + in_string = False + escaped = False + index = opening + while index < len(text): + character = text[index] + if in_string: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + in_string = False + elif character == '"': + in_string = True + elif character == "{": + depth += 1 + elif character == "}": + depth -= 1 + if depth == 0: + return start, index + 1, text[start:index + 1] + index += 1 + raise AssertionError(f"unterminated {type_name} id {object_id}") + + +try: + header_start, header_end, header = object_block("Column", "sidebarHeader") + scroll_start, scroll_end, scroll = object_block("Flickable", "sidebarScroll") + footer_start, _, footer = object_block("Rectangle", "healthFooter") + + assert header_start < scroll_start < scroll_end < footer_start + assert re.search(r"anchors\.top\s*:\s*parent\.top", header) + assert re.search(r"\bid\s*:\s*searchInput\b", header) + + assert re.search(r"anchors\.top\s*:\s*sidebarHeader\.bottom", scroll) + assert re.search(r"anchors\.bottom\s*:\s*healthFooter\.top", scroll) + assert re.search(r"contentWidth\s*:\s*width", scroll) + assert re.search(r"contentHeight\s*:\s*scrollContent\.implicitHeight", scroll) + assert re.search(r"flickableDirection\s*:\s*Flickable\.VerticalFlick", scroll) + assert re.search(r"boundsBehavior\s*:\s*Flickable\.StopAtBounds", scroll) + assert re.search(r"clip\s*:\s*true", scroll) + assert scroll.count("Flickable {") == 1 + + assert re.search(r"\bid\s*:\s*scrollContent\b", scroll) + assert re.search(r"\bid\s*:\s*searchResults\b", scroll) + assert re.search(r"\bid\s*:\s*navigationList\b", scroll) + assert re.search(r"visible\s*:\s*root\.query\s*!==\s*\"\"", scroll) + assert re.search(r"visible\s*:\s*root\.query\s*===\s*\"\"", scroll) + assert re.search(r"anchors\.bottom\s*:\s*parent\.bottom", footer) +except AssertionError as error: + print(error, file=sys.stderr) + raise SystemExit(1) +PY + +printf 'settings sidebar layout contract: PASS\n'