aboutsummaryrefslogtreecommitdiffstats
path: root/appearance/AppearancePanel.qml
diff options
context:
space:
mode:
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()
+ }
+ }
+ }
+ }
+ }
+ }
+}