Make Settings sidebar content scrollable

This commit is contained in:
Gabriel Brown
2026-08-18 01:54:00 -04:00
parent b3b8e0d66d
commit cae72b5179
2 changed files with 249 additions and 122 deletions
@@ -11,6 +11,11 @@ Rectangle {
readonly property var results: SettingsSearch.search(root.query) readonly property var results: SettingsSearch.search(root.query)
onQueryChanged: {
if (sidebarScroll)
sidebarScroll.contentY = 0;
}
function pageLabel(page: string): string { function pageLabel(page: string): string {
const found = root.destinations.find(item => item.page === page); const found = root.destinations.find(item => item.page === page);
return found ? found.label : "Settings"; return found ? found.label : "Settings";
@@ -40,8 +45,15 @@ Rectangle {
border.width: 0 border.width: 0
Column { Column {
anchors.fill: parent id: sidebarHeader
anchors.margins: 18
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 spacing: 12
Text { Text {
@@ -103,149 +115,175 @@ Rectangle {
onClicked: searchInput.forceActiveFocus() 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 { Column {
width: parent.width id: scrollContent
spacing: 3
visible: root.query !== "" width: sidebarScroll.width
// ── Search results ──────────────────────────────────────────────
// Typing searches the settings themselves, not page names.
Column {
id: searchResults
Text {
width: parent.width width: parent.width
leftPadding: 4 spacing: 3
bottomPadding: 4 visible: root.query !== ""
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
Text {
width: parent.width width: parent.width
height: 44 leftPadding: 4
radius: 10 bottomPadding: 4
color: hitMouse.containsMouse ? Theme.alpha(Theme.fg, 0.08) : "transparent" text: root.results.length === 0
border.width: 0 ? "Nothing matches"
: root.results.length + (root.results.length === 1 ? " result" : " results")
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Column { Repeater {
anchors.left: parent.left model: root.results
anchors.right: parent.right
anchors.leftMargin: 12
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 1
Text { Rectangle {
width: parent.width id: hit
text: hit.modelData.label
color: Theme.fg required property var modelData
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize width: parent.width
elide: Text.ElideRight 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 { MouseArea {
width: parent.width id: hitMouse
text: hit.modelData.kind === "shortcut" anchors.fill: parent
? hit.modelData.detail hoverEnabled: true
: root.pageLabel(hit.modelData.page) cursorShape: Qt.PointingHandCursor
color: hit.modelData.kind === "shortcut" ? Theme.accent : Theme.fgMuted onClicked: {
font.family: Theme.fontFamily root.pageRequested(hit.modelData.page);
font.pixelSize: Theme.fontSizeSmall searchInput.text = "";
elide: Text.ElideRight }
}
}
MouseArea {
id: hitMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.pageRequested(hit.modelData.page);
searchInput.text = "";
} }
} }
} }
} }
}
Column { Column {
width: parent.width id: navigationList
spacing: 4
visible: root.query === ""
Repeater { width: parent.width
model: root.destinations spacing: 4
visible: root.query === ""
Rectangle { Repeater {
id: navItem model: root.destinations
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 { Rectangle {
width: 2 id: navItem
height: 18 required property var modelData
radius: 1 width: parent.width
anchors.left: parent.left height: 40
anchors.verticalCenter: parent.verticalCenter radius: 10
visible: navItem.modelData.page === root.selectedPage color: modelData.page === root.selectedPage
gradient: Gradient { ? Theme.alpha(Theme.accent, 0.17)
GradientStop { position: 0; color: Theme.accent } : (navMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha * 0.55) : Theme.alpha(Theme.fg, 0))
GradientStop { position: 1; color: Theme.accentSecondary } 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 { Text {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 13 anchors.leftMargin: 13
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: 25 width: 25
text: navItem.modelData.icon text: navItem.modelData.icon
color: navItem.modelData.page === root.selectedPage ? Theme.accent : Theme.fgDim color: navItem.modelData.page === root.selectedPage ? Theme.accent : Theme.fgDim
font.family: Theme.fontMono font.family: Theme.fontMono
font.pixelSize: 15 font.pixelSize: 15
} }
Text { Text {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 47 anchors.leftMargin: 47
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 9 anchors.rightMargin: 9
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: navItem.modelData.label text: navItem.modelData.label
color: navItem.modelData.page === root.selectedPage ? Theme.fg : Theme.fgDim color: navItem.modelData.page === root.selectedPage ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize font.pixelSize: Theme.fontSize
font.weight: navItem.modelData.page === root.selectedPage ? Font.Medium : Font.Normal font.weight: navItem.modelData.page === root.selectedPage ? Font.Medium : Font.Normal
elide: Text.ElideRight elide: Text.ElideRight
} }
MouseArea { MouseArea {
id: navMouse id: navMouse
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: root.pageRequested(navItem.modelData.page) onClicked: root.pageRequested(navItem.modelData.page)
}
} }
} }
} }
@@ -253,6 +291,8 @@ Rectangle {
} }
Rectangle { Rectangle {
id: healthFooter
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
+87
View File
@@ -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'