diff options
31 files changed, 783 insertions, 780 deletions
@@ -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 @@ -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" |
