aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-17 10:16:40 +0200
committerDanilo M. <danix@danix.xyz>2026-10-17 10:16:40 +0200
commit70bbc970c75221c902c515efd124c47fd702e3b7 (patch)
treed35f6b1ffbb506b65430af7fc1eca2c432879a22
parente5399f3e8d8572f1db7c4391a7c571d4845500f1 (diff)
downloadquickshell-70bbc970c75221c902c515efd124c47fd702e3b7.tar.gz
quickshell-70bbc970c75221c902c515efd124c47fd702e3b7.zip
appearance moves into the settings window
The appearance shell existed because a wallpaper picker needed more room than the 600px drawer, so it ran as its own process with a full-screen overlay. The settings window now gives it that room, and its five tabs become five settings pages in the desktop process: Wallpaper, Theme, Icons & cursor, Sunset and Idle. appearance/ is gone, and with it a process, a keepalive window and a layer blur rule. The files move to desktop/modules/appearance/ unchanged in what they do. Each tab gets a small *Module.qml; the window's refresh-on-open calls the singletons' refresh(), which the panel's show() used to. The two tabs that were inline Components in AppearancePanel become ThemeTab and WallpaperTab, owning the notices the panel printed. Sunset, Idle and Icons drop their own Flickable so the page scrolls them and search can reveal a row; the wallpaper list virtualises its rows and scrolls itself, so it takes the page's new viewHeight instead. Search finds a colour scheme and an icon or cursor theme by name. The appearance IPC target moves to desktop/shell.qml with the same verbs, so udt-steps and any keybind change only the path. Git now follows the appearance pages and autostart in the sidebar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--AGENTS.md6
-rw-r--r--README.md4
-rw-r--r--appearance/AppearancePanel.qml662
l---------appearance/Theme.qml1
-rw-r--r--appearance/shell.qml33
-rw-r--r--desktop/Page.qml4
-rw-r--r--desktop/README.md18
-rw-r--r--desktop/island/README.md2
-rw-r--r--desktop/modules/appearance/Field.qml (renamed from appearance/Field.qml)1
-rw-r--r--desktop/modules/appearance/Hypridle.qml (renamed from appearance/Hypridle.qml)1
-rw-r--r--desktop/modules/appearance/Hyprsunset.qml (renamed from appearance/Hyprsunset.qml)1
-rw-r--r--desktop/modules/appearance/Icons.qml (renamed from appearance/Icons.qml)1
-rw-r--r--desktop/modules/appearance/IconsModule.qml34
-rw-r--r--desktop/modules/appearance/IconsTab.qml (renamed from appearance/IconsTab.qml)9
-rw-r--r--desktop/modules/appearance/IdleModule.qml32
-rw-r--r--desktop/modules/appearance/IdleTab.qml (renamed from appearance/IdleTab.qml)6
-rw-r--r--desktop/modules/appearance/MockScreen.qml (renamed from appearance/MockScreen.qml)1
-rw-r--r--desktop/modules/appearance/README.md (renamed from appearance/README.md)72
-rw-r--r--desktop/modules/appearance/Steps.qml (renamed from appearance/Steps.qml)1
-rw-r--r--desktop/modules/appearance/StepsWindow.qml (renamed from appearance/StepsWindow.qml)1
-rw-r--r--desktop/modules/appearance/SunsetModule.qml (renamed from desktop/modules/appearance/AppearanceModule.qml)29
-rw-r--r--desktop/modules/appearance/SunsetTab.qml (renamed from appearance/SunsetTab.qml)6
-rw-r--r--desktop/modules/appearance/Tab.qml (renamed from appearance/Tab.qml)1
-rw-r--r--desktop/modules/appearance/ThemeModule.qml33
-rw-r--r--desktop/modules/appearance/ThemeTab.qml179
-rw-r--r--desktop/modules/appearance/Toggle.qml (renamed from appearance/Toggle.qml)1
-rw-r--r--desktop/modules/appearance/Udt.qml (renamed from appearance/Udt.qml)1
-rw-r--r--desktop/modules/appearance/WallpaperModule.qml34
-rw-r--r--desktop/modules/appearance/WallpaperTab.qml357
-rw-r--r--desktop/modules/appearance/Wallpapers.qml (renamed from appearance/Wallpapers.qml)1
-rw-r--r--desktop/shell.qml31
31 files changed, 783 insertions, 780 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 4d1caba..2cde5ee 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,12 +12,12 @@ any of them runs alone, and running one does not require the others. `desktop/`
modules.
desktop/ the drawer (SUPER alone): user details, sound, mail, VMs, status,
- performance, clipboard, updates, appearance; and the
- settings window for pages set once, such as autostart
+ performance, clipboard, updates; and the settings
+ window for pages set once: wallpaper, theme, icons,
+ sunset, idle (the former appearance shell), autostart, git
island/ top-centre pills on DP-1: clock, volume, tray, layout, privacy,
calendar
assistant/ the assistant canvas on SUPER+Home, a view over desktop-assistant
- appearance/ wallpaper picker and colour scheme switcher
window-switcher/ expose: open windows scattered as live previews, on ALT+TAB
or the top-right hot corner of DP-1
keybinds/ live keybind reminder, on SUPER+k
diff --git a/README.md b/README.md
index 12d7ed7..1141645 100644
--- a/README.md
+++ b/README.md
@@ -15,9 +15,9 @@ repos stay independent, this one has no build-time dependency on that one.
## Implementations
- desktop/ the drawer: sound, mail, VMs, appearance
+ desktop/ the drawer: sound, mail, VMs; the settings window:
+ wallpaper, theme, icons, sunset, idle, autostart, git
island/ top-centre pills: clock, volume, tray, layout, privacy
- appearance/ wallpaper picker and colour scheme switcher
window-switcher/ open windows as live previews in a grid, on ALT+TAB
keybinds/ live keybind reminder, on SUPER+k
diff --git a/appearance/AppearancePanel.qml b/appearance/AppearancePanel.qml
deleted file mode 100644
index 77053b3..0000000
--- a/appearance/AppearancePanel.qml
+++ /dev/null
@@ -1,662 +0,0 @@
-// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
-//
-// This program is free software; you can redistribute it and/or modify
-// it under the terms of the GNU General Public License version 2 as
-// published by the Free Software Foundation.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-
-import Quickshell
-import Quickshell.Wayland
-import QtQuick
-
-Scope {
- id: root
-
- property string monitor: "DP-1"
- property bool open: false
- property string tab: "wallpaper"
- property string notice: ""
-
- readonly property var tabs: ["wallpaper", "theme", "sunset", "idle", "icons"]
- readonly property var tabLabels: ({
- wallpaper: "Wallpaper", theme: "Theme", sunset: "Sunset",
- idle: "Idle", icons: "Icons",
- })
-
- // What the mock screens show. Hovering a thumbnail previews it on the
- // targeted screen; the other keeps whatever is currently set.
- property string hovering: ""
-
- readonly property var screenObj:
- Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
-
- function show(which) {
- if (which) root.tab = which;
- root.notice = "";
- // Schemes and wallpapers can both have changed since this shell
- // started, which for an autostarted one is the whole session.
- Udt.refresh();
- Wallpapers.refresh();
- Hyprsunset.refresh();
- Hypridle.refresh();
- Icons.refresh();
- root.open = true;
- }
- function close() { root.open = false; }
- function toggle(which) { root.open ? close() : show(which); }
-
- Connections {
- target: Udt
- function onApplied(scheme, ok, message) {
- // install.sh reloads Hyprland, waybar, kitty and conky; these are
- // what it cannot reach.
- root.notice = ok
- ? `${scheme} applied. Restart Qt, GTK, btop and Joplin; neovim needs :colorscheme udt.`
- : `failed: ${message}`;
- }
- }
-
- Connections {
- target: Wallpapers
- function onApplied(ok, message) {
- root.notice = ok ? "" : `wallpaper failed: ${message}`;
- if (ok) root.close();
- }
- }
-
- // Quickshell exits once no window is visible, and this panel is closed
- // most of the time. See AGENTS.md.
- PanelWindow {
- visible: true
- implicitWidth: 1
- implicitHeight: 1
- color: "transparent"
- exclusionMode: ExclusionMode.Ignore
- mask: Region {}
- WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
- }
-
- LazyLoader {
- active: root.open
-
- PanelWindow {
- id: win
- screen: root.screenObj
-
- anchors { top: true; left: true; right: true; bottom: true }
- color: "transparent"
- exclusionMode: ExclusionMode.Ignore
- WlrLayershell.layer: WlrLayer.Overlay
- WlrLayershell.namespace: "quickshell-appearance"
- WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
-
- Rectangle {
- anchors.fill: parent
- color: "#000000"
- opacity: 0.5
- MouseArea { anchors.fill: parent; onClicked: root.close() }
- }
-
- // Keys reach a focused item, never the window itself.
- Item {
- anchors.fill: parent
- focus: true
- Keys.onEscapePressed: root.close()
- Keys.onPressed: event => {
- if (event.key === Qt.Key_Tab) {
- const i = root.tabs.indexOf(root.tab);
- root.tab = root.tabs[(i + 1) % root.tabs.length];
- event.accepted = true;
- }
- }
- }
-
- Rectangle {
- anchors.centerIn: parent
- width: win.width - 120
- height: win.height - 100
- radius: 14
- color: Qt.alpha(Theme.base, 0.72)
- border.width: 1
- border.color: Qt.alpha(Theme.text, 0.12)
-
- MouseArea { anchors.fill: parent }
-
- Column {
- anchors.fill: parent
- anchors.margins: 20
- spacing: 16
-
- // Centred against the panel, not against the hint: the row
- // reads as the drawer's own chrome rather than the first
- // line of a tab's content.
- Item {
- width: parent.width
- height: tabBar.implicitHeight
-
- Row {
- id: tabBar
- anchors.horizontalCenter: parent.horizontalCenter
- spacing: 8
- Repeater {
- model: root.tabs
- Tab {
- required property var modelData
- text: root.tabLabels[modelData]
- selected: root.tab === modelData
- onClicked: root.tab = modelData
- }
- }
- }
-
- Text {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- text: "Tab switches · Esc closes"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.overlay
- }
- }
-
- Text {
- visible: root.notice !== ""
- width: parent.width
- wrapMode: Text.Wrap
- text: root.notice
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- color: root.notice.indexOf("failed") === 0 ? Theme.red : Theme.green
- }
-
- Loader {
- width: parent.width
- height: parent.height - y
- // Existing tabs are inline Components; the new ones are
- // files. sourceComponent wins when it is non-null.
- sourceComponent: root.tab === "theme" ? themeTab
- : root.tab === "wallpaper" ? wallpaperTab : null
- source: root.tab === "sunset" ? Qt.resolvedUrl("SunsetTab.qml")
- : root.tab === "idle" ? Qt.resolvedUrl("IdleTab.qml")
- : root.tab === "icons" ? Qt.resolvedUrl("IconsTab.qml") : ""
- }
- }
- }
- }
- }
-
- // --- theme ------------------------------------------------------------
-
- Component {
- id: themeTab
-
- Flickable {
- contentHeight: col.implicitHeight
- clip: true
-
- Column {
- id: col
- width: parent.width
- spacing: 10
-
- Repeater {
- model: Udt.names
-
- Rectangle {
- required property string modelData
- readonly property var sw: Udt.swatches[modelData] ?? ({})
- readonly property bool isCurrent: Udt.current === modelData
-
- width: col.width
- implicitHeight: 96
- radius: 10
- color: hover.hovered ? Qt.alpha(Theme.surface, 0.75)
- : Qt.alpha(Theme.surface, 0.35)
- border.width: 1
- border.color: isCurrent ? Qt.alpha(Theme.accent, 0.6) : "transparent"
-
- HoverHandler { id: hover }
- MouseArea {
- anchors.fill: parent
- enabled: !Udt.applying && !isCurrent
- cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
- onClicked: Udt.apply(modelData)
- }
-
- Row {
- anchors.fill: parent
- anchors.margins: 14
- spacing: 16
-
- // A mock of the desktop in that scheme's colours:
- // swatches alone say what the colours are, this
- // says how they sit together.
- Rectangle {
- anchors.verticalCenter: parent.verticalCenter
- width: 150; height: 68
- radius: 6
- color: sw.bg ?? Theme.base
- border.width: 1
- border.color: Qt.alpha(sw.text ?? Theme.text, 0.15)
- clip: true
-
- Column {
- anchors.fill: parent
- anchors.margins: 7
- spacing: 5
-
- Row {
- spacing: 4
- Rectangle {
- width: 26; height: 8; radius: 2
- color: sw.accent ?? Theme.accent
- }
- Rectangle {
- width: 16; height: 8; radius: 2
- color: sw.surface ?? Theme.surface
- }
- Rectangle {
- width: 16; height: 8; radius: 2
- color: sw.surface ?? Theme.surface
- }
- }
-
- Rectangle {
- width: parent.width; height: 20; radius: 3
- color: sw.surface ?? Theme.surface
-
- Rectangle {
- anchors.verticalCenter: parent.verticalCenter
- x: 5; width: 60; height: 5; radius: 2
- color: Qt.alpha(sw.text ?? Theme.text, 0.75)
- }
- Rectangle {
- anchors.verticalCenter: parent.verticalCenter
- anchors.right: parent.right
- anchors.rightMargin: 5
- width: 22; height: 9; radius: 3
- color: sw.accent ?? Theme.accent
- }
- }
-
- Row {
- spacing: 4
- Rectangle { width: 9; height: 9; radius: 5; color: sw.green ?? Theme.green }
- Rectangle { width: 9; height: 9; radius: 5; color: sw.yellow ?? Theme.yellow }
- Rectangle { width: 9; height: 9; radius: 5; color: sw.red ?? Theme.red }
- }
- }
- }
-
- Column {
- anchors.verticalCenter: parent.verticalCenter
- spacing: 8
-
- Row {
- spacing: 8
- Text {
- text: modelData
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
- color: Theme.text
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- visible: isCurrent
- text: "current"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.accent
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- visible: Udt.applying
- text: "applying…"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.overlay
- }
- }
-
- Row {
- spacing: 5
- Repeater {
- model: [sw.bg, sw.surface, sw.text, sw.accent,
- sw.green, sw.yellow, sw.red]
- Rectangle {
- required property var modelData
- visible: modelData !== undefined && modelData !== ""
- width: 22; height: 22; radius: 4
- color: modelData ?? "transparent"
- border.width: 1
- border.color: Qt.alpha(Theme.text, 0.1)
- }
- }
- }
- }
- }
- }
- }
- }
- }
- }
-
- // --- wallpaper --------------------------------------------------------
-
- Component {
- id: wallpaperTab
-
- Row {
- spacing: 20
-
- Column {
- width: parent.width - mockPane.width - parent.spacing
- height: parent.height
- spacing: 12
-
- Row {
- spacing: 8
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: "Set on"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
- color: Theme.subtext
- }
- Tab {
- text: "Horizontal"
- selected: Wallpapers.target === "H"
- onClicked: Wallpapers.target = "H"
- }
- Tab {
- text: "Vertical"
- selected: Wallpapers.target === "V"
- onClicked: Wallpapers.target = "V"
- }
- Text {
- anchors.verticalCenter: parent.verticalCenter
- text: Wallpapers.scanning ? "scanning…"
- : Wallpapers.applying ? "applying…"
- : Wallpapers.files.length + " wallpapers"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.overlay
- }
- }
-
- // A folder is a header row followed, while open, by its
- // thumbnails a row at a time, all in one flat ListView, so only
- // the rows in view (and cacheBuffer around them) exist and
- // decode: a large folder loads as it scrolls. Each row holds a
- // group key and the index of its first file, start -1 for the
- // header. A toggle inserts or removes just that folder's rows,
- // so the rows around it, their decoded thumbnails and the
- // scroll position all survive it.
- ListView {
- id: wallList
- width: parent.width
- height: parent.height - y
- clip: true
- readonly property int cols: Math.max(1, Math.floor(width / 300))
- readonly property real cell: Math.floor(width / cols)
- readonly property real thumbHeight: cell * 9 / 16 + 26
- // The column is laid out after creation and reads negative
- // until then, which the buffer refuses with a warning.
- cacheBuffer: Math.max(0, thumbHeight * 2)
- model: ListModel { id: rows }
-
- function group(key) {
- return Wallpapers.groups.find(g => g.key === key) ?? { key: key, name: "", files: [] };
- }
- function rowsOf(g) {
- const out = [];
- for (let i = 0; i < g.files.length; i += cols) out.push({ key: g.key, start: i });
- return out;
- }
- function rebuild() {
- rows.clear();
- for (const g of Wallpapers.groups) {
- rows.append({ key: g.key, start: -1 });
- if (Wallpapers.expanded[g.key]) rows.append(rowsOf(g));
- }
- }
- function toggle(index) {
- const g = group(rows.get(index).key);
- const body = rowsOf(g);
- if (Wallpapers.expanded[g.key]) rows.remove(index + 1, body.length);
- else body.forEach((r, i) => rows.insert(index + 1 + i, r));
- Wallpapers.toggle(g.key);
- }
-
- // The width settles after creation, and a new column count
- // regroups every row, so both rebuild.
- Component.onCompleted: rebuild()
- onColsChanged: rebuild()
- Connections {
- target: Wallpapers
- function onGroupsChanged() { wallList.rebuild(); }
- }
-
- delegate: Item {
- id: row
- required property int index
- required property string key
- required property int start
- readonly property var group: wallList.group(key)
- readonly property bool open: !!Wallpapers.expanded[key]
- width: ListView.view.width
- height: start < 0 ? 40 : wallList.thumbHeight
-
- Rectangle {
- visible: row.start < 0
- anchors { fill: parent; topMargin: 6 }
- radius: 8
- color: Qt.alpha(Theme.surface, headerHover.hovered ? 0.8 : 0.4)
-
- HoverHandler { id: headerHover }
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- onClicked: wallList.toggle(row.index)
- }
-
- Text {
- id: chevron
- anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter }
- text: row.open ? "\uf077" : "\uf078"
- font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.subtext
- }
- Text {
- anchors { left: chevron.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
- text: row.group.name
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
- color: Theme.text
- }
- Text {
- anchors { right: parent.right; rightMargin: 12; verticalCenter: parent.verticalCenter }
- text: row.group.files.length
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.overlay
- }
- }
-
- Row {
- visible: row.start >= 0
-
- Repeater {
- model: row.start < 0 ? [] : row.group.files.slice(row.start, row.start + wallList.cols)
-
- delegate: Item {
- required property string modelData
- width: wallList.cell
- height: wallList.thumbHeight
-
- Rectangle {
- readonly property bool staged:
- Wallpapers.pendingH === modelData || Wallpapers.pendingV === modelData
-
- anchors.fill: parent
- anchors.margins: 6
- radius: 8
- color: Qt.alpha(Theme.surface, thumbHover.hovered ? 0.8 : 0.3)
- // Staged wins the border; otherwise it
- // says how well the image fills the
- // screen it would go to.
- border.width: 2
- border.color: staged ? Theme.accent
- : Wallpapers.qualityColor(modelData, Wallpapers.target)
- clip: true
-
- HoverHandler {
- id: thumbHover
- // Hovering previews this image on the mock
- // screen it would actually land on.
- onHoveredChanged:
- root.hovering = hovered ? modelData
- : (root.hovering === modelData ? "" : root.hovering)
- }
- MouseArea {
- anchors.fill: parent
- enabled: !Wallpapers.applying
- cursorShape: Qt.PointingHandCursor
- onClicked: Wallpapers.pick(modelData)
- }
-
- Column {
- anchors.fill: parent
- anchors.margins: 6
- spacing: 4
-
- Image {
- width: parent.width
- height: parent.height - 18
- source: "file://" + modelData
- asynchronous: true
- cache: false
- fillMode: Image.PreserveAspectCrop
- // Decode at display size: full-resolution
- // wallpapers would be megabytes each in memory.
- sourceSize.width: wallList.cell
- }
-
- Text {
- width: parent.width
- elide: Text.ElideMiddle
- text: Wallpapers.label(modelData)
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
- color: Theme.subtext
- }
- }
- }
- }
- }
- }
- }
- }
- }
-
- // The two screens as they sit on the desk: DP-3 rotated upright on
- // the left, DP-1 wide on the right, centred against each other the
- // way Hyprland has them. Hovering a thumbnail fills the screen it
- // would be set on; the other keeps what is on it now.
- Item {
- id: mockPane
- // A third of the panel rather than a fixed width, so the
- // preview keeps its share when the panel is resized.
- width: Math.round(parent.width * 0.33)
- height: parent.height
-
- // Divisor is the two panels side by side plus the gap; the
- // bezels and stands are drawn outside that, hence the margin.
- readonly property real scale: (width * 0.92) / (1080 + 2560 + 160)
-
- Column {
- anchors.centerIn: parent
- spacing: 14
-
- Row {
- spacing: Math.round(160 * mockPane.scale)
-
- MockScreen {
- anchors.verticalCenter: parent.verticalCenter
- label: "DP-3"
- targeted: Wallpapers.target === "V"
- source: (targeted && root.hovering !== "")
- ? root.hovering : Wallpapers.shown("V")
- // DP-3 has transform=1, so it stands upright.
- panelWidth: Math.round(1080 * mockPane.scale)
- panelHeight: Math.round(1920 * mockPane.scale)
- }
-
- MockScreen {
- anchors.verticalCenter: parent.verticalCenter
- label: "DP-1"
- targeted: Wallpapers.target === "H"
- source: (targeted && root.hovering !== "")
- ? root.hovering : Wallpapers.shown("H")
- panelWidth: Math.round(2560 * mockPane.scale)
- panelHeight: Math.round(1080 * mockPane.scale)
- }
- }
-
- Text {
- anchors.horizontalCenter: parent.horizontalCenter
- width: mockPane.width
- horizontalAlignment: Text.AlignHCenter
- wrapMode: Text.Wrap
- text: root.hovering !== "" ? Wallpapers.label(root.hovering)
- : Wallpapers.dirty ? "click Apply to set"
- : "click a wallpaper to stage it"
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
- color: Theme.overlay
- }
-
- // Why the image under consideration, hovered or else the
- // one staged for the target screen, will look worse than
- // its thumbnail.
- Text {
- readonly property string subject: root.hovering !== "" ? root.hovering
- : Wallpapers.target === "V" ? Wallpapers.pendingV : Wallpapers.pendingH
- anchors.horizontalCenter: parent.horizontalCenter
- width: mockPane.width
- visible: text !== ""
- horizontalAlignment: Text.AlignHCenter
- wrapMode: Text.Wrap
- text: subject !== "" ? Wallpapers.qualityNote(subject, Wallpapers.target) : ""
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
- color: Wallpapers.qualityColor(subject, Wallpapers.target)
- }
-
- // wallhaven-dl searches wallhaven.cc and finds larger
- // copies of low-res wallpapers. Launched through its
- // desktop entry, which needs no ~/bin on quickshell's PATH
- // and keeps the app's own taskbar icon. The panel closes
- // so the app window is not opened under the overlay.
- Tab {
- anchors.horizontalCenter: parent.horizontalCenter
- text: "Open wallhaven-dl"
- onClicked: {
- Quickshell.execDetached(["gtk-launch", "wallhaven-dl"]);
- root.close();
- }
- }
-
- Row {
- anchors.horizontalCenter: parent.horizontalCenter
- spacing: 8
-
- Tab {
- text: Wallpapers.applying ? "applying…" : "Apply"
- selected: Wallpapers.dirty && !Wallpapers.applying
- enabled: Wallpapers.dirty && !Wallpapers.applying
- onClicked: Wallpapers.apply()
- }
- Tab {
- text: "Reset"
- enabled: Wallpapers.dirty && !Wallpapers.applying
- onClicked: Wallpapers.clearPending()
- }
- }
- }
- }
- }
- }
-}
diff --git a/appearance/Theme.qml b/appearance/Theme.qml
deleted file mode 120000
index 3d2e40f..0000000
--- a/appearance/Theme.qml
+++ /dev/null
@@ -1 +0,0 @@
-../shared/Theme.qml \ No newline at end of file
diff --git a/appearance/shell.qml b/appearance/shell.qml
deleted file mode 100644
index e7e6555..0000000
--- a/appearance/shell.qml
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
-//
-// This program is free software; you can redistribute it and/or modify
-// it under the terms of the GNU General Public License version 2 as
-// published by the Free Software Foundation.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-
-import Quickshell
-import Quickshell.Io
-
-ShellRoot {
- AppearancePanel { id: panel }
- StepsWindow { id: stepsWindow }
-
- IpcHandler {
- target: "appearance"
- function toggle() { panel.toggle(""); }
- function wallpaper() { panel.toggle("wallpaper"); }
- function theme() { panel.toggle("theme"); }
- function sunset() { panel.toggle("sunset"); }
- function idle() { panel.toggle("idle"); }
- function icons() { panel.toggle("icons"); }
- // udt-steps calls this after writing a steps file; a keybind can too.
- function steps() { stepsWindow.poke(); }
- function selftest(): string {
- return Hyprsunset.selftest() + "\n" + Hypridle.selftest() + "\n" + Icons.selftest() + "\n" + Steps.selftest();
- }
- }
-}
diff --git a/desktop/Page.qml b/desktop/Page.qml
index 357c3a7..b29158e 100644
--- a/desktop/Page.qml
+++ b/desktop/Page.qml
@@ -27,6 +27,10 @@ Item {
// user page opens the git page).
signal go(string name)
+ // The height the content area shows, for content that scrolls itself
+ // (the wallpaper list) and so wants a fixed height, not the page's scroll.
+ readonly property real viewHeight: scroll.height
+
Item {
id: header
anchors { top: parent.top; left: parent.left; right: parent.right }
diff --git a/desktop/README.md b/desktop/README.md
index 089f86f..1295746 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -61,7 +61,7 @@ and flashes it, so a page needs no highlight code of its own.
modules/kdeconnect/ devices, battery, pairing, ping, clipboard, share, mount
modules/mail/ unread per account, threads, the watcher dot
modules/vm/ libvirt state, live stats, snapshots
- modules/appearance/ a tile that opens the separate appearance shell
+ modules/appearance/ wallpaper, theme, icons, sunset, idle: settings pages
modules/ai/ local AI engines, start/stop for the apps using them
modules/perf/ CPU, temperatures, GPU, network, disks, ~/.cache
modules/user/ the greeting row and the user's details page
@@ -70,8 +70,7 @@ and flashes it, so a page needs no highlight code of its own.
modules/autostart/ Hyprland's autostart.lua, in the settings window
Sound, mail, vm, network, bluetooth, kdeconnect and ai each carry their own README.
-Appearance is a tile onto the separate appearance shell, whose README covers the
-picker it opens.
+So does appearance, for its five settings pages.
## Writing a module
@@ -98,11 +97,11 @@ which: it reads those properties and does the obvious thing. A page is wrapped
in `Page.qml` for the header and back arrow, and a tile with no page is
fire-and-forget, so its click calls `activate()` and closes the drawer.
-`modules/appearance/AppearanceModule.qml` is the smallest complete example: a
-name, an icon, a label, and an `activate()` that launches the separate
-appearance shell. It has no page, and it owns only a `Process` that fires the
-picker on click, no always-active service. Sound, mail, vm, network, bluetooth
-and kdeconnect each add a service and a page on top of that same shape.
+The settings gear in `shell.qml` is the smallest complete example: a name, an
+icon, and an `activate()` that opens the settings window, with no page and no
+service. Sound, mail, vm, network, bluetooth and kdeconnect each add a service
+and a page on top of that same shape; `modules/appearance/ThemeModule.qml` is
+a small settings page.
Module files live under `desktop/modules/<name>/` and reference root types
(`Module`, `Page`, `Button`, `Theme`), so each needs `import "../.."`. A plain
@@ -149,7 +148,8 @@ below it sits where it will sit once notifications arrive. The bottom is a
fourth row scrolls inside that box rather than pushing into the notification
space, and a thin accent bar in the margin shows only when a row is hidden.
The order is Sound, Network, Bluetooth, KDE Connect, Mail, Status,
-Performance, Clipboard, VMs, AI, Appearance, the everyday ones first.
+Performance, Clipboard, VMs, AI, then the settings gear, the everyday ones
+first.
A page can close the whole drawer by emitting `dismiss` from its `Page`, as
the clipboard does after a copy; `back` returns to the grid.
diff --git a/desktop/island/README.md b/desktop/island/README.md
index 07c88cf..381f536 100644
--- a/desktop/island/README.md
+++ b/desktop/island/README.md
@@ -112,7 +112,7 @@ broken run and not "nothing is capturing".
Colours come only from `Theme`, which follows the unified-desktop-theme
palette live. Icons are freedesktop names resolved with
`Quickshell.iconPath(name, true)` through the active icon theme, so switching
-icon theme in the appearance panel changes the island. Nerd Font glyphs, in
+icon theme on the settings window's Icons page changes the island. Nerd Font glyphs, in
`Theme.iconFamily`, only where no named icon exists. The island defines no
colour and no icon theme of its own. The idle sliver is never the accent,
because udt can snap the accent to the same colour as a capture kind.
diff --git a/appearance/Field.qml b/desktop/modules/appearance/Field.qml
index 004bda6..ad6af85 100644
--- a/appearance/Field.qml
+++ b/desktop/modules/appearance/Field.qml
@@ -10,6 +10,7 @@
// GNU General Public License for more details.
import QtQuick
+import "../.."
Rectangle {
id: field
diff --git a/appearance/Hypridle.qml b/desktop/modules/appearance/Hypridle.qml
index 1696f5f..a1d3ecd 100644
--- a/appearance/Hypridle.qml
+++ b/desktop/modules/appearance/Hypridle.qml
@@ -14,6 +14,7 @@ pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
+import "../.."
// The hypridle side. Commands are fixed: only each listener's timeout and
// whether it is active are editable. Everything before the first listener is
diff --git a/appearance/Hyprsunset.qml b/desktop/modules/appearance/Hyprsunset.qml
index fc0d44a..5886735 100644
--- a/appearance/Hyprsunset.qml
+++ b/desktop/modules/appearance/Hyprsunset.qml
@@ -14,6 +14,7 @@ pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
+import "../.."
// The hyprsunset side: profiles in ~/.config/hypr/hyprsunset.conf, the
// location in hyprsunset-qt's own config, and the daemon. The file format is
diff --git a/appearance/Icons.qml b/desktop/modules/appearance/Icons.qml
index a6eaec5..24d3a2f 100644
--- a/appearance/Icons.qml
+++ b/desktop/modules/appearance/Icons.qml
@@ -45,6 +45,7 @@ for name in sys.argv[1:]:
// a legacy Xcursor binary that goes through xcur2png.
readonly property string cursorScript: `
import sys, os, glob, shutil, zipfile, subprocess
+import "../.."
outdir = sys.argv[1]
SAMPLES = {
"left_ptr": ["left_ptr", "default", "pointer"],
diff --git a/desktop/modules/appearance/IconsModule.qml b/desktop/modules/appearance/IconsModule.qml
new file mode 100644
index 0000000..f6559d1
--- /dev/null
+++ b/desktop/modules/appearance/IconsModule.qml
@@ -0,0 +1,34 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+// The icon and cursor theme, and the pointer size.
+Module {
+ name: "icons"
+ label: "Icons & cursor"
+ // nf-fa-mouse_pointer, present in Inconsolata Nerd Font.
+ icon: "\uf245"
+ settings: true
+ keywords: ["cursor", "pointer", "size", "gtk", "qt", "theme"]
+ searchItems: Icons.iconThemes.map(n => ({ text: "icons: " + n, key: "icon:" + n }))
+ .concat(Icons.cursorThemes.map(n => ({ text: "cursor: " + n, key: "cursor:" + n })))
+
+ function refresh() { Icons.refresh(); }
+
+ page: Component {
+ Page {
+ title: "Icons & cursor"
+ IconsTab { width: parent.width }
+ }
+ }
+}
diff --git a/appearance/IconsTab.qml b/desktop/modules/appearance/IconsTab.qml
index c4d82ca..777ea51 100644
--- a/appearance/IconsTab.qml
+++ b/desktop/modules/appearance/IconsTab.qml
@@ -12,10 +12,10 @@
import Quickshell
import Quickshell.Widgets
import QtQuick
+import "../.."
-Flickable {
- contentHeight: col.implicitHeight
- clip: true
+Item {
+ implicitHeight: col.implicitHeight
// Clicking a cursor card stages it; Apply commits. There is deliberately no
// live hover preview: hyprctl setcursor is not a read-only switch, Hyprland
@@ -51,6 +51,8 @@ Flickable {
Rectangle {
id: iconCard
required property string modelData
+ // What a search hit scrolls to (IconsModule's searchItems key).
+ objectName: "icon:" + modelData
readonly property bool current: Icons.currentIcon === modelData
width: 230; height: 150; radius: 10
color: current ? Qt.alpha(Theme.accent, 0.2) : Qt.alpha(Theme.surface, 0.35)
@@ -135,6 +137,7 @@ Flickable {
Rectangle {
id: cursorCard
required property string modelData
+ objectName: "cursor:" + modelData
readonly property bool current: Icons.currentCursor === modelData
readonly property bool staged: pendingCursor === modelData
width: 230; height: 150; radius: 10
diff --git a/desktop/modules/appearance/IdleModule.qml b/desktop/modules/appearance/IdleModule.qml
new file mode 100644
index 0000000..b5636a1
--- /dev/null
+++ b/desktop/modules/appearance/IdleModule.qml
@@ -0,0 +1,32 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+// hypridle's listeners: timeouts and whether each runs.
+Module {
+ name: "idle"
+ label: "Idle"
+ // nf-fa-hourglass, present in Inconsolata Nerd Font.
+ icon: "\uf254"
+ settings: true
+ keywords: ["hypridle", "lock", "timeout", "screen", "dpms", "suspend", "sleep"]
+
+ function refresh() { Hypridle.refresh(); }
+
+ page: Component {
+ Page {
+ title: "Idle"
+ IdleTab { width: parent.width }
+ }
+ }
+}
diff --git a/appearance/IdleTab.qml b/desktop/modules/appearance/IdleTab.qml
index 75ce84a..1199543 100644
--- a/appearance/IdleTab.qml
+++ b/desktop/modules/appearance/IdleTab.qml
@@ -10,10 +10,10 @@
// GNU General Public License for more details.
import QtQuick
+import "../.."
-Flickable {
- contentHeight: col.implicitHeight
- clip: true
+Item {
+ implicitHeight: col.implicitHeight
Column {
id: col
diff --git a/appearance/MockScreen.qml b/desktop/modules/appearance/MockScreen.qml
index 7a09ee2..7f6870b 100644
--- a/appearance/MockScreen.qml
+++ b/desktop/modules/appearance/MockScreen.qml
@@ -10,6 +10,7 @@
// GNU General Public License for more details.
import QtQuick
+import "../.."
// One monitor in the mock desk layout: a bezel, a stand, and the panel at the
// screen's real aspect ratio. Drawn rather than loaded from an SVG so it takes
diff --git a/appearance/README.md b/desktop/modules/appearance/README.md
index 7524322..b1823fb 100644
--- a/appearance/README.md
+++ b/desktop/modules/appearance/README.md
@@ -1,32 +1,28 @@
# appearance
-Wallpapers and colour scheme in one drawer. `SUPER+Return` opens it on the
-Wallpaper tab; Tab switches tabs, Escape closes.
+Five pages of the settings window: Wallpaper, Theme, Icons & cursor, Sunset
+and Idle. Until October 2026 they were the tabs of a separate `appearance/`
+shell with an overlay of its own; they moved into the desktop process when
+the settings window gave them the room that shell existed to provide.
- ┌─[ Wallpaper ]─[ Theme ]─[ Sunset ]─[ Idle ]─[ Icons ]──┐
- │ Set on [Horizontal] [Vertical] 1161 wallpapers │
- │ ▸ wallpapers 16 ┌──┐ ┌─────┐ │
- │ ▾ SFW 30 │ │ │ │ │
- │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ └┬─┘ └──┬──┘ │
- │ │ │ │ │ │ │ │ │ ═╧═ ══╧══ │
- │ └────┘ └────┘ └────┘ └────┘ enlarged 1.6×: … │
- │ ▸ system 727 [Apply] [Reset] │
- └────────────────────────────────────────────────────────┘
+Each page is a `*Module.qml` here wrapping a `*Tab.qml`, and the work is done
+by singletons (`Wallpapers`, `Udt`, `Icons`, `Hyprsunset`, `Hypridle`,
+`Steps`). The window's open calls each module's `refresh()`, which rescans:
+schemes, wallpapers and themes can all change during a session, and a scan
+done only at startup would go stale.
-## Running it
+The old shell's IPC target is kept in `desktop/shell.qml` with the same verbs,
+so its callers only changed the path:
- qs -p .
+ qs -p ~/Programming/GIT/quickshell/desktop ipc call appearance wallpaper
-It is started from `autostart.lua` and reached over IPC, so the shell has to
-be running for the keybind to work:
+Verbs: `wallpaper` (also `toggle`), `theme`, `sunset`, `idle`, `icons` open
+that page; `steps` pokes the steps window; `selftest` runs the parsers' own
+checks. Write the path out in full in the Hyprland config: `exec_cmd` has no
+shell to expand `~`.
- hl.bind(mainMod .. " + Return", hl.dsp.exec_cmd(
- "qs -p ~/Programming/GIT/quickshell/appearance ipc call appearance wallpaper"))
-
-Write that path out in full in the real config: `exec_cmd` has no shell to
-expand `~`. `ipc call appearance theme` opens the Theme tab.
-
-IPC verbs: `wallpaper`, `theme`, `sunset`, `idle`, `icons`, `steps`.
+Search finds a scheme by name and an icon or cursor theme by name, and
+scrolls to it.
## Sunset
@@ -56,7 +52,7 @@ Applying writes `gsettings` (which GTK3 and GTK4 both read), the Qt configs
hypr-theme's `environment.lua` `dofile()`s at login. A local file rather than
an edit to `environment.lua`, which is tracked and was left dirty by every
cursor switch. The GTK theme names do
-not come from `gsettings` alone here, so the drawer also rewrites
+not come from `gsettings` alone here, so the Icons page also rewrites
`gtk-3.0/settings.ini` and `gtk-4.0/settings.ini` to match. Apps and a relogin
are needed to see the rest.
@@ -99,11 +95,12 @@ image or else the staged one. The staged image keeps the accent border.
"Open wallhaven-dl" under the mock screens launches the wallhaven-dl app,
which searches wallhaven.cc and finds larger copies of low-res wallpapers,
-and closes the panel. It goes through `gtk-launch` and the app's desktop
+and closes the settings window, as a successful Apply does, so the desk
+shows. It goes through `gtk-launch` and the app's desktop
entry, so `~/bin` need not be on quickshell's PATH.
`wallp` does the actual work, including running `udt-accent`, so the accent
-follows the new wallpaper exactly as it does from a terminal. This panel
+follows the new wallpaper exactly as it does from a terminal. This page
replaces its qarma file dialog, not the script.
The two monitors are drawn at their real proportions, read from
@@ -113,7 +110,7 @@ each other because that is how Hyprland has them, both spanning y=0.
The bezel and stand are Rectangles rather than an SVG: no asset to ship, sharp
at any size, and the case stays a fixed near-black while the accent marks
-which screen is targeted. Their proportions come from the panel **width**, not
+which screen is targeted. Their proportions come from the pane **width**, not
its height. Scaling the stand off height gave the wide monitor a 20px stand
that was invisible and would have given the vertical one an absurd long neck.
@@ -129,9 +126,9 @@ are.
The list comes from the palette directory rather than a hardcoded set, so
adding a scheme to unified-desktop-theme is enough to make it appear here.
-Both the scheme list and the wallpaper scan are redone every time the panel
-opens: this shell is autostarted and runs all session, so a scan done only at
-startup would go stale the first time anything was added.
+Both the scheme list and the wallpaper scan are redone every time the
+settings window opens: the desktop process runs all session, so a scan done
+only at startup would go stale the first time anything was added.
Applying writes the `scheme =` line in `~/.config/udt/roles.conf` and runs
`install.sh`, which regenerates every themed config and reloads Hyprland,
@@ -169,16 +166,9 @@ palette parser uses an `exec` loop instead.
nothing.** The scheme loader reuses one `Process` for each scheme in turn and
stopped after the first until it set `running = false` immediately before.
-## Theme and blur
-
-`Theme.qml` is the shared one: the palette comes from
-`~/.cache/udt/udt-palette.qml` and is watched. Frosting is Hyprland's, matched
-on this window's namespace:
+## Theme
- hl.layer_rule({
- name = "blur-appearance",
- match = { namespace = "^(quickshell-appearance)$" },
- blur = true,
- xray = false,
- ignore_alpha = 0.1,
- })
+`Theme` is the desktop's shared one, through `import "../.."`: the palette
+comes from `~/.cache/udt/udt-palette.qml` and is watched. The settings window
+is a normal toplevel, so it takes Hyprland's ordinary window blur; the old
+`blur-appearance` layer rule went with the overlay.
diff --git a/appearance/Steps.qml b/desktop/modules/appearance/Steps.qml
index ec0a5b7..d309b79 100644
--- a/appearance/Steps.qml
+++ b/desktop/modules/appearance/Steps.qml
@@ -14,6 +14,7 @@ pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
+import "../.."
// The manual steps unified-desktop-theme cannot run itself: root copies into
// /boot and /usr/share/sddm, the finish on the homepage host, and the last
diff --git a/appearance/StepsWindow.qml b/desktop/modules/appearance/StepsWindow.qml
index 8117885..7349fcd 100644
--- a/appearance/StepsWindow.qml
+++ b/desktop/modules/appearance/StepsWindow.qml
@@ -12,6 +12,7 @@
import Quickshell
import Quickshell.Wayland
import QtQuick
+import "../.."
// One tab per consumer with manual steps pending. Centred on the vertical
// screen and kept open on purpose: the commands are pasted into a root shell
diff --git a/desktop/modules/appearance/AppearanceModule.qml b/desktop/modules/appearance/SunsetModule.qml
index ca9dc7d..5d37052 100644
--- a/desktop/modules/appearance/AppearanceModule.qml
+++ b/desktop/modules/appearance/SunsetModule.qml
@@ -9,27 +9,24 @@
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
-import Quickshell
-import Quickshell.Io
import QtQuick
import "../.."
-// Fire and forget. The appearance shell stays a separate process: a wallpaper
-// picker needs more room than a 600px drawer, so this tile only opens it.
+// hyprsunset's profiles and location.
Module {
- id: mod
+ name: "sunset"
+ label: "Sunset"
+ // nf-fa-moon_o, present in Inconsolata Nerd Font.
+ icon: "\uf186"
+ settings: true
+ keywords: ["hyprsunset", "night", "light", "temperature", "gamma", "location", "profile"]
- name: "appearance"
- icon: ""
- label: "Appearance"
+ function refresh() { Hyprsunset.refresh(); }
- function activate() {
- proc.running = false;
- proc.running = true;
- }
-
- property Process proc: Process {
- command: ["qs", "-p", `${Quickshell.env("HOME")}/Programming/GIT/quickshell/appearance`,
- "ipc", "call", "appearance", "wallpaper"]
+ page: Component {
+ Page {
+ title: "Sunset"
+ SunsetTab { width: parent.width }
+ }
}
}
diff --git a/appearance/SunsetTab.qml b/desktop/modules/appearance/SunsetTab.qml
index 50f6ad6..657a920 100644
--- a/appearance/SunsetTab.qml
+++ b/desktop/modules/appearance/SunsetTab.qml
@@ -10,10 +10,10 @@
// GNU General Public License for more details.
import QtQuick
+import "../.."
-Flickable {
- contentHeight: col.implicitHeight
- clip: true
+Item {
+ implicitHeight: col.implicitHeight
Column {
id: col
diff --git a/appearance/Tab.qml b/desktop/modules/appearance/Tab.qml
index bb2d210..d0a7a5e 100644
--- a/appearance/Tab.qml
+++ b/desktop/modules/appearance/Tab.qml
@@ -10,6 +10,7 @@
// GNU General Public License for more details.
import QtQuick
+import "../.."
Rectangle {
id: tab
diff --git a/desktop/modules/appearance/ThemeModule.qml b/desktop/modules/appearance/ThemeModule.qml
new file mode 100644
index 0000000..729ceb1
--- /dev/null
+++ b/desktop/modules/appearance/ThemeModule.qml
@@ -0,0 +1,33 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+// unified-desktop-theme's colour schemes.
+Module {
+ name: "theme"
+ label: "Theme"
+ // nf-fa-paintbrush, present in Inconsolata Nerd Font.
+ icon: "\uf1fc"
+ settings: true
+ keywords: ["colour", "color", "scheme", "palette", "accent", "udt"]
+ searchItems: Array.from(Udt.names).map(n => ({ text: n, key: n }))
+
+ function refresh() { Udt.refresh(); }
+
+ page: Component {
+ Page {
+ title: "Theme"
+ ThemeTab { width: parent.width }
+ }
+ }
+}
diff --git a/desktop/modules/appearance/ThemeTab.qml b/desktop/modules/appearance/ThemeTab.qml
new file mode 100644
index 0000000..acee97a
--- /dev/null
+++ b/desktop/modules/appearance/ThemeTab.qml
@@ -0,0 +1,179 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+// Colour schemes from unified-desktop-theme, each with its swatches and a mock
+// of a panel in its colours. Clicking one applies it through install.sh.
+Column {
+ id: tab
+ spacing: 10
+
+ // install.sh reloads Hyprland, kitty and conky; this says what it cannot
+ // reach, or why it failed.
+ property string notice: ""
+ Connections {
+ target: Udt
+ function onApplied(scheme, ok, message) {
+ tab.notice = ok
+ ? `${scheme} applied. Restart Qt, GTK, btop and Joplin; neovim needs :colorscheme udt.`
+ : `failed: ${message}`;
+ }
+ }
+
+ Text {
+ visible: tab.notice !== ""
+ width: parent.width
+ wrapMode: Text.Wrap
+ text: tab.notice
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: tab.notice.indexOf("failed") === 0 ? Theme.red : Theme.green
+ }
+
+ Repeater {
+ model: Udt.names
+
+ Rectangle {
+ required property string modelData
+ // What a search hit scrolls to (ThemeModule's searchItems key).
+ objectName: modelData
+ readonly property var sw: Udt.swatches[modelData] ?? ({})
+ readonly property bool isCurrent: Udt.current === modelData
+
+ width: tab.width
+ implicitHeight: 96
+ radius: 10
+ color: hover.hovered ? Qt.alpha(Theme.surface, 0.75)
+ : Qt.alpha(Theme.surface, 0.35)
+ border.width: 1
+ border.color: isCurrent ? Qt.alpha(Theme.accent, 0.6) : "transparent"
+
+ HoverHandler { id: hover }
+ MouseArea {
+ anchors.fill: parent
+ enabled: !Udt.applying && !isCurrent
+ cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: Udt.apply(modelData)
+ }
+
+ Row {
+ anchors.fill: parent
+ anchors.margins: 14
+ spacing: 16
+
+ // A mock of the desktop in that scheme's colours:
+ // swatches alone say what the colours are, this
+ // says how they sit together.
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 150; height: 68
+ radius: 6
+ color: sw.bg ?? Theme.base
+ border.width: 1
+ border.color: Qt.alpha(sw.text ?? Theme.text, 0.15)
+ clip: true
+
+ Column {
+ anchors.fill: parent
+ anchors.margins: 7
+ spacing: 5
+
+ Row {
+ spacing: 4
+ Rectangle {
+ width: 26; height: 8; radius: 2
+ color: sw.accent ?? Theme.accent
+ }
+ Rectangle {
+ width: 16; height: 8; radius: 2
+ color: sw.surface ?? Theme.surface
+ }
+ Rectangle {
+ width: 16; height: 8; radius: 2
+ color: sw.surface ?? Theme.surface
+ }
+ }
+
+ Rectangle {
+ width: parent.width; height: 20; radius: 3
+ color: sw.surface ?? Theme.surface
+
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ x: 5; width: 60; height: 5; radius: 2
+ color: Qt.alpha(sw.text ?? Theme.text, 0.75)
+ }
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ anchors.rightMargin: 5
+ width: 22; height: 9; radius: 3
+ color: sw.accent ?? Theme.accent
+ }
+ }
+
+ Row {
+ spacing: 4
+ Rectangle { width: 9; height: 9; radius: 5; color: sw.green ?? Theme.green }
+ Rectangle { width: 9; height: 9; radius: 5; color: sw.yellow ?? Theme.yellow }
+ Rectangle { width: 9; height: 9; radius: 5; color: sw.red ?? Theme.red }
+ }
+ }
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ Row {
+ spacing: 8
+ Text {
+ text: modelData
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
+ color: Theme.text
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: isCurrent
+ text: "current"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.accent
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: Udt.applying
+ text: "applying…"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.overlay
+ }
+ }
+
+ Row {
+ spacing: 5
+ Repeater {
+ model: [sw.bg, sw.surface, sw.text, sw.accent,
+ sw.green, sw.yellow, sw.red]
+ Rectangle {
+ required property var modelData
+ visible: modelData !== undefined && modelData !== ""
+ width: 22; height: 22; radius: 4
+ color: modelData ?? "transparent"
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.1)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/appearance/Toggle.qml b/desktop/modules/appearance/Toggle.qml
index 2bb56a2..1abdb77 100644
--- a/appearance/Toggle.qml
+++ b/desktop/modules/appearance/Toggle.qml
@@ -10,6 +10,7 @@
// GNU General Public License for more details.
import QtQuick
+import "../.."
Rectangle {
id: toggle
diff --git a/appearance/Udt.qml b/desktop/modules/appearance/Udt.qml
index cf97ba5..a4bf296 100644
--- a/appearance/Udt.qml
+++ b/desktop/modules/appearance/Udt.qml
@@ -14,6 +14,7 @@ pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
+import "../.."
// The unified-desktop-theme side: which schemes exist, what they look like,
// and switching between them.
diff --git a/desktop/modules/appearance/WallpaperModule.qml b/desktop/modules/appearance/WallpaperModule.qml
new file mode 100644
index 0000000..246e8f5
--- /dev/null
+++ b/desktop/modules/appearance/WallpaperModule.qml
@@ -0,0 +1,34 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+// The wallpaper picker. The thumbnail list scrolls itself, so the tab gets the
+// page's visible height instead of the page's scroll.
+Module {
+ name: "wallpaper"
+ label: "Wallpaper"
+ // nf-fa-picture_o, present in Inconsolata Nerd Font.
+ icon: "\uf03e"
+ settings: true
+ keywords: ["background", "screen", "monitor", "wallp", "wallhaven", "horizontal", "vertical"]
+
+ function refresh() { Wallpapers.refresh(); }
+
+ page: Component {
+ Page {
+ id: pg
+ title: "Wallpaper"
+ WallpaperTab { width: parent.width; height: pg.viewHeight; onDismiss: pg.dismiss() }
+ }
+ }
+}
diff --git a/desktop/modules/appearance/WallpaperTab.qml b/desktop/modules/appearance/WallpaperTab.qml
new file mode 100644
index 0000000..72d4dfe
--- /dev/null
+++ b/desktop/modules/appearance/WallpaperTab.qml
@@ -0,0 +1,357 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import QtQuick
+import "../.."
+
+// Wallpapers by folder, staged per screen and set together with Apply, beside
+// a mock of the two screens. Sized by its host: the thumbnail list scrolls
+// itself, so it needs a height rather than the page's scroll.
+Row {
+ id: tab
+ spacing: 20
+
+ // What the mock screens show. Hovering a thumbnail previews it on the
+ // targeted screen; the other keeps whatever is currently set.
+ property string hovering: ""
+ property string notice: ""
+ // Close the window: after a wallpaper is set, so the desk shows, and when
+ // wallhaven-dl opens.
+ signal dismiss
+
+ Connections {
+ target: Wallpapers
+ function onApplied(ok, message) {
+ tab.notice = ok ? "" : `wallpaper failed: ${message}`;
+ if (ok) tab.dismiss();
+ }
+ }
+
+ Column {
+ width: parent.width - mockPane.width - parent.spacing
+ height: parent.height
+ spacing: 12
+
+ Text {
+ visible: tab.notice !== ""
+ width: parent.width
+ wrapMode: Text.Wrap
+ text: tab.notice
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.red
+ }
+
+ Row {
+ spacing: 8
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Set on"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.subtext
+ }
+ Tab {
+ text: "Horizontal"
+ selected: Wallpapers.target === "H"
+ onClicked: Wallpapers.target = "H"
+ }
+ Tab {
+ text: "Vertical"
+ selected: Wallpapers.target === "V"
+ onClicked: Wallpapers.target = "V"
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: Wallpapers.scanning ? "scanning…"
+ : Wallpapers.applying ? "applying…"
+ : Wallpapers.files.length + " wallpapers"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.overlay
+ }
+ }
+
+ // A folder is a header row followed, while open, by its
+ // thumbnails a row at a time, all in one flat ListView, so only
+ // the rows in view (and cacheBuffer around them) exist and
+ // decode: a large folder loads as it scrolls. Each row holds a
+ // group key and the index of its first file, start -1 for the
+ // header. A toggle inserts or removes just that folder's rows,
+ // so the rows around it, their decoded thumbnails and the
+ // scroll position all survive it.
+ ListView {
+ id: wallList
+ width: parent.width
+ height: parent.height - y
+ clip: true
+ readonly property int cols: Math.max(1, Math.floor(width / 300))
+ readonly property real cell: Math.floor(width / cols)
+ readonly property real thumbHeight: cell * 9 / 16 + 26
+ // The column is laid out after creation and reads negative
+ // until then, which the buffer refuses with a warning.
+ cacheBuffer: Math.max(0, thumbHeight * 2)
+ model: ListModel { id: rows }
+
+ function group(key) {
+ return Wallpapers.groups.find(g => g.key === key) ?? { key: key, name: "", files: [] };
+ }
+ function rowsOf(g) {
+ const out = [];
+ for (let i = 0; i < g.files.length; i += cols) out.push({ key: g.key, start: i });
+ return out;
+ }
+ function rebuild() {
+ rows.clear();
+ for (const g of Wallpapers.groups) {
+ rows.append({ key: g.key, start: -1 });
+ if (Wallpapers.expanded[g.key]) rows.append(rowsOf(g));
+ }
+ }
+ function toggle(index) {
+ const g = group(rows.get(index).key);
+ const body = rowsOf(g);
+ if (Wallpapers.expanded[g.key]) rows.remove(index + 1, body.length);
+ else body.forEach((r, i) => rows.insert(index + 1 + i, r));
+ Wallpapers.toggle(g.key);
+ }
+
+ // The width settles after creation, and a new column count
+ // regroups every row, so both rebuild.
+ Component.onCompleted: rebuild()
+ onColsChanged: rebuild()
+ Connections {
+ target: Wallpapers
+ function onGroupsChanged() { wallList.rebuild(); }
+ }
+
+ delegate: Item {
+ id: row
+ required property int index
+ required property string key
+ required property int start
+ readonly property var group: wallList.group(key)
+ readonly property bool open: !!Wallpapers.expanded[key]
+ width: ListView.view.width
+ height: start < 0 ? 40 : wallList.thumbHeight
+
+ Rectangle {
+ visible: row.start < 0
+ anchors { fill: parent; topMargin: 6 }
+ radius: 8
+ color: Qt.alpha(Theme.surface, headerHover.hovered ? 0.8 : 0.4)
+
+ HoverHandler { id: headerHover }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: wallList.toggle(row.index)
+ }
+
+ Text {
+ id: chevron
+ anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter }
+ text: row.open ? "\uf077" : "\uf078"
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.subtext
+ }
+ Text {
+ anchors { left: chevron.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: row.group.name
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.text
+ }
+ Text {
+ anchors { right: parent.right; rightMargin: 12; verticalCenter: parent.verticalCenter }
+ text: row.group.files.length
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.overlay
+ }
+ }
+
+ Row {
+ visible: row.start >= 0
+
+ Repeater {
+ model: row.start < 0 ? [] : row.group.files.slice(row.start, row.start + wallList.cols)
+
+ delegate: Item {
+ required property string modelData
+ width: wallList.cell
+ height: wallList.thumbHeight
+
+ Rectangle {
+ readonly property bool staged:
+ Wallpapers.pendingH === modelData || Wallpapers.pendingV === modelData
+
+ anchors.fill: parent
+ anchors.margins: 6
+ radius: 8
+ color: Qt.alpha(Theme.surface, thumbHover.hovered ? 0.8 : 0.3)
+ // Staged wins the border; otherwise it
+ // says how well the image fills the
+ // screen it would go to.
+ border.width: 2
+ border.color: staged ? Theme.accent
+ : Wallpapers.qualityColor(modelData, Wallpapers.target)
+ clip: true
+
+ HoverHandler {
+ id: thumbHover
+ // Hovering previews this image on the mock
+ // screen it would actually land on.
+ onHoveredChanged:
+ tab.hovering = hovered ? modelData
+ : (tab.hovering === modelData ? "" : tab.hovering)
+ }
+ MouseArea {
+ anchors.fill: parent
+ enabled: !Wallpapers.applying
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Wallpapers.pick(modelData)
+ }
+
+ Column {
+ anchors.fill: parent
+ anchors.margins: 6
+ spacing: 4
+
+ Image {
+ width: parent.width
+ height: parent.height - 18
+ source: "file://" + modelData
+ asynchronous: true
+ cache: false
+ fillMode: Image.PreserveAspectCrop
+ // Decode at display size: full-resolution
+ // wallpapers would be megabytes each in memory.
+ sourceSize.width: wallList.cell
+ }
+
+ Text {
+ width: parent.width
+ elide: Text.ElideMiddle
+ text: Wallpapers.label(modelData)
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ color: Theme.subtext
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // The two screens as they sit on the desk: DP-3 rotated upright on
+ // the left, DP-1 wide on the right, centred against each other the
+ // way Hyprland has them. Hovering a thumbnail fills the screen it
+ // would be set on; the other keeps what is on it now.
+ Item {
+ id: mockPane
+ // A third of the panel rather than a fixed width, so the
+ // preview keeps its share when the panel is resized.
+ width: Math.round(parent.width * 0.33)
+ height: parent.height
+
+ // Divisor is the two panels side by side plus the gap; the
+ // bezels and stands are drawn outside that, hence the margin.
+ readonly property real scale: (width * 0.92) / (1080 + 2560 + 160)
+
+ Column {
+ anchors.centerIn: parent
+ spacing: 14
+
+ Row {
+ spacing: Math.round(160 * mockPane.scale)
+
+ MockScreen {
+ anchors.verticalCenter: parent.verticalCenter
+ label: "DP-3"
+ targeted: Wallpapers.target === "V"
+ source: (targeted && tab.hovering !== "")
+ ? tab.hovering : Wallpapers.shown("V")
+ // DP-3 has transform=1, so it stands upright.
+ panelWidth: Math.round(1080 * mockPane.scale)
+ panelHeight: Math.round(1920 * mockPane.scale)
+ }
+
+ MockScreen {
+ anchors.verticalCenter: parent.verticalCenter
+ label: "DP-1"
+ targeted: Wallpapers.target === "H"
+ source: (targeted && tab.hovering !== "")
+ ? tab.hovering : Wallpapers.shown("H")
+ panelWidth: Math.round(2560 * mockPane.scale)
+ panelHeight: Math.round(1080 * mockPane.scale)
+ }
+ }
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: mockPane.width
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.Wrap
+ text: tab.hovering !== "" ? Wallpapers.label(tab.hovering)
+ : Wallpapers.dirty ? "click Apply to set"
+ : "click a wallpaper to stage it"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.overlay
+ }
+
+ // Why the image under consideration, hovered or else the
+ // one staged for the target screen, will look worse than
+ // its thumbnail.
+ Text {
+ readonly property string subject: tab.hovering !== "" ? tab.hovering
+ : Wallpapers.target === "V" ? Wallpapers.pendingV : Wallpapers.pendingH
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: mockPane.width
+ visible: text !== ""
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.Wrap
+ text: subject !== "" ? Wallpapers.qualityNote(subject, Wallpapers.target) : ""
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Wallpapers.qualityColor(subject, Wallpapers.target)
+ }
+
+ // wallhaven-dl searches wallhaven.cc and finds larger
+ // copies of low-res wallpapers. Launched through its
+ // desktop entry, which needs no ~/bin on quickshell's PATH
+ // and keeps the app's own taskbar icon. The panel closes
+ // so the app window is not opened under the overlay.
+ Tab {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "Open wallhaven-dl"
+ onClicked: {
+ Quickshell.execDetached(["gtk-launch", "wallhaven-dl"]);
+ tab.dismiss();
+ }
+ }
+
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 8
+
+ Tab {
+ text: Wallpapers.applying ? "applying…" : "Apply"
+ selected: Wallpapers.dirty && !Wallpapers.applying
+ enabled: Wallpapers.dirty && !Wallpapers.applying
+ onClicked: Wallpapers.apply()
+ }
+ Tab {
+ text: "Reset"
+ enabled: Wallpapers.dirty && !Wallpapers.applying
+ onClicked: Wallpapers.clearPending()
+ }
+ }
+ }
+ }
+}
diff --git a/appearance/Wallpapers.qml b/desktop/modules/appearance/Wallpapers.qml
index 9f43675..7d671e1 100644
--- a/appearance/Wallpapers.qml
+++ b/desktop/modules/appearance/Wallpapers.qml
@@ -14,6 +14,7 @@ pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
+import "../.."
// The wallpaper side. Setting one is `wallp`'s job: it knows about the two
// outputs, the saved state and the accent run, and none of that is worth
diff --git a/desktop/shell.qml b/desktop/shell.qml
index 0d56e87..bb8c1df 100644
--- a/desktop/shell.qml
+++ b/desktop/shell.qml
@@ -72,9 +72,7 @@ ShellRoot {
UpdatesModule {},
VmModule {},
AiModule {},
- AppearanceModule {},
UserModule {},
- GitModule {},
NotesModule {},
// The settings window's entry point, a tile like the others.
Module {
@@ -83,7 +81,13 @@ ShellRoot {
icon: "\uf013"
function activate() { settings.show(""); }
},
+ WallpaperModule {},
+ ThemeModule {},
+ IconsModule {},
+ SunsetModule {},
+ IdleModule {},
AutostartModule {},
+ GitModule {},
]
}
@@ -94,6 +98,10 @@ ShellRoot {
modules: drawer.modules
}
+ // unified-desktop-theme's manual steps, its own window on DP-3. See
+ // modules/appearance/README.md.
+ StepsWindow { id: stepsWindow }
+
// The island on DP-1. It reads the sound module's service rather than
// binding PipeWire a second time.
Island {
@@ -114,7 +122,7 @@ ShellRoot {
// and a parameter with a JS default registers as QVariant, which IPC
// rejects. So the page name cannot be optional: `open` always takes a
// page, and the page-less grid is `toggle`, the zero-argument shape the
- // appearance shell uses for its routes and the waybar launcher calls.
+ // waybar launcher called.
IpcHandler {
target: "drawer"
function open(page: string) { drawer.show(page); }
@@ -130,6 +138,23 @@ ShellRoot {
function toggle() { if (settings.open) settings.close(); else settings.show(""); }
}
+ // The verbs of the appearance shell this replaced, so its callers only
+ // change the path: udt-steps calls `steps`.
+ // qs -p <this dir> ipc call appearance wallpaper
+ IpcHandler {
+ target: "appearance"
+ function toggle() { settings.show("wallpaper"); }
+ function wallpaper() { settings.show("wallpaper"); }
+ function theme() { settings.show("theme"); }
+ function sunset() { settings.show("sunset"); }
+ function idle() { settings.show("idle"); }
+ function icons() { settings.show("icons"); }
+ function steps() { stepsWindow.poke(); }
+ function selftest(): string {
+ return Hyprsunset.selftest() + "\n" + Hypridle.selftest() + "\n" + Icons.selftest() + "\n" + Steps.selftest();
+ }
+ }
+
// qs -p <this dir> ipc call assistant toggle -> SUPER+Home
IpcHandler {
target: "assistant"