aboutsummaryrefslogtreecommitdiffstats
path: root/appearance/AppearancePanel.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-11 18:30:58 +0200
committerDanilo M. <danix@danix.xyz>2026-09-11 18:30:58 +0200
commit1b252e32d93e1e0207d2a69ee3445e1dbf6505ae (patch)
treed4280dae5cab886898e7e5e674508728e2d5150a /appearance/AppearancePanel.qml
parent43c31deaa313a8e471ecc3b5a55411a3b93b6f08 (diff)
downloadquickshell-1b252e32d93e1e0207d2a69ee3445e1dbf6505ae.tar.gz
quickshell-1b252e32d93e1e0207d2a69ee3445e1dbf6505ae.zip
feat(appearance): wallpaper picker and scheme switcher
One drawer for how the desktop looks, on SUPER+Return, replacing the qarma file dialog that key used to open. wallp is untouched and still does the work: the panel calls it with H= and V= and it still runs from a terminal. Clicking a thumbnail stages it rather than setting it, so both screens can be composed before anything changes and Apply is a single wallp call. The mock screens show the result: the staged pick where there is one, what is set where there is not, and the hovered thumbnail on the targeted screen. They are drawn at the real proportions from hyprctl monitors, DP-3 upright because it has transform=1, centred against each other as Hyprland has them. The scheme tab reads the palette pair unified-desktop-theme keeps for each scheme, so the swatches are that scheme's real colours and the list needs no edit when a scheme is added. Applying writes roles.conf and runs install.sh, and reloads nothing, because install.sh reloads nothing: the panel says what is still showing the old scheme rather than pretending the switch is done. Two QML traps are documented in AGENTS.md because neither announces itself: there is no String.matchAll, which threw inside a try and left every swatch empty, and assigning running = true to an already-running Process does nothing, which stopped the scheme loader after the first file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7NRsGyF9jGfPYS4zPqpN7
Diffstat (limited to 'appearance/AppearancePanel.qml')
-rw-r--r--appearance/AppearancePanel.qml504
1 files changed, 504 insertions, 0 deletions
diff --git a/appearance/AppearancePanel.qml b/appearance/AppearancePanel.qml
new file mode 100644
index 0000000..27a0196
--- /dev/null
+++ b/appearance/AppearancePanel.qml
@@ -0,0 +1,504 @@
+// 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: ""
+
+ // 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 = "";
+ 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 nothing, so the panel has to say what is
+ // still showing the old scheme rather than pretend it is done.
+ root.notice = ok
+ ? `${scheme} applied. Reload: hyprctl reload, waybar, kitty, conky. Qt and GTK apps need restarting.`
+ : `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) {
+ root.tab = root.tab === "theme" ? "wallpaper" : "theme";
+ 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
+
+ Row {
+ spacing: 8
+ Tab {
+ text: "Wallpaper"
+ selected: root.tab === "wallpaper"
+ onClicked: root.tab = "wallpaper"
+ }
+ Tab {
+ text: "Theme"
+ selected: root.tab === "theme"
+ onClicked: root.tab = "theme"
+ }
+ Item { width: 12; height: 1 }
+ Text {
+ 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
+ sourceComponent: root.tab === "theme" ? themeTab : wallpaperTab
+ }
+ }
+ }
+ }
+ }
+
+ // --- 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
+ }
+ }
+
+ GridView {
+ width: parent.width
+ height: parent.height - y
+ clip: true
+ cellWidth: Math.floor(width / Math.max(1, Math.floor(width / 300)))
+ cellHeight: cellWidth * 9 / 16 + 26
+ // Several hundred files: only the visible thumbnails decode.
+ cacheBuffer: cellHeight * 2
+ model: Wallpapers.files
+
+ delegate: Item {
+ required property string modelData
+ width: GridView.view.cellWidth
+ height: GridView.view.cellHeight
+
+ 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)
+ border.width: staged ? 2 : 1
+ border.color: staged ? Theme.accent
+ : thumbHover.hovered ? Qt.alpha(Theme.accent, 0.6)
+ : "transparent"
+ 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: 480
+ }
+
+ Text {
+ width: parent.width
+ elide: Text.ElideMiddle
+ text: Wallpapers.basename(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.basename(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
+ }
+
+ 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()
+ }
+ }
+ }
+ }
+ }
+ }
+}