From 70bbc970c75221c902c515efd124c47fd702e3b7 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 17 Oct 2026 10:16:40 +0200 Subject: 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 --- AGENTS.md | 6 +- README.md | 4 +- appearance/AppearancePanel.qml | 662 ------------------------ appearance/Field.qml | 50 -- appearance/Hypridle.qml | 161 ------ appearance/Hyprsunset.qml | 326 ------------ appearance/Icons.qml | 334 ------------ appearance/IconsTab.qml | 225 -------- appearance/IdleTab.qml | 100 ---- appearance/MockScreen.qml | 94 ---- appearance/README.md | 184 ------- appearance/Steps.qml | 114 ---- appearance/StepsWindow.qml | 197 ------- appearance/SunsetTab.qml | 169 ------ appearance/Tab.qml | 45 -- appearance/Theme.qml | 1 - appearance/Toggle.qml | 56 -- appearance/Udt.qml | 152 ------ appearance/Wallpapers.qml | 243 --------- appearance/shell.qml | 33 -- desktop/Page.qml | 4 + desktop/README.md | 18 +- desktop/island/README.md | 2 +- desktop/modules/appearance/AppearanceModule.qml | 35 -- desktop/modules/appearance/Field.qml | 51 ++ desktop/modules/appearance/Hypridle.qml | 162 ++++++ desktop/modules/appearance/Hyprsunset.qml | 327 ++++++++++++ desktop/modules/appearance/Icons.qml | 335 ++++++++++++ desktop/modules/appearance/IconsModule.qml | 34 ++ desktop/modules/appearance/IconsTab.qml | 228 ++++++++ desktop/modules/appearance/IdleModule.qml | 32 ++ desktop/modules/appearance/IdleTab.qml | 100 ++++ desktop/modules/appearance/MockScreen.qml | 95 ++++ desktop/modules/appearance/README.md | 174 +++++++ desktop/modules/appearance/Steps.qml | 115 ++++ desktop/modules/appearance/StepsWindow.qml | 198 +++++++ desktop/modules/appearance/SunsetModule.qml | 32 ++ desktop/modules/appearance/SunsetTab.qml | 169 ++++++ desktop/modules/appearance/Tab.qml | 46 ++ desktop/modules/appearance/ThemeModule.qml | 33 ++ desktop/modules/appearance/ThemeTab.qml | 179 +++++++ desktop/modules/appearance/Toggle.qml | 57 ++ desktop/modules/appearance/Udt.qml | 153 ++++++ desktop/modules/appearance/WallpaperModule.qml | 34 ++ desktop/modules/appearance/WallpaperTab.qml | 357 +++++++++++++ desktop/modules/appearance/Wallpapers.qml | 244 +++++++++ desktop/shell.qml | 31 +- 47 files changed, 3202 insertions(+), 3199 deletions(-) delete mode 100644 appearance/AppearancePanel.qml delete mode 100644 appearance/Field.qml delete mode 100644 appearance/Hypridle.qml delete mode 100644 appearance/Hyprsunset.qml delete mode 100644 appearance/Icons.qml delete mode 100644 appearance/IconsTab.qml delete mode 100644 appearance/IdleTab.qml delete mode 100644 appearance/MockScreen.qml delete mode 100644 appearance/README.md delete mode 100644 appearance/Steps.qml delete mode 100644 appearance/StepsWindow.qml delete mode 100644 appearance/SunsetTab.qml delete mode 100644 appearance/Tab.qml delete mode 120000 appearance/Theme.qml delete mode 100644 appearance/Toggle.qml delete mode 100644 appearance/Udt.qml delete mode 100644 appearance/Wallpapers.qml delete mode 100644 appearance/shell.qml delete mode 100644 desktop/modules/appearance/AppearanceModule.qml create mode 100644 desktop/modules/appearance/Field.qml create mode 100644 desktop/modules/appearance/Hypridle.qml create mode 100644 desktop/modules/appearance/Hyprsunset.qml create mode 100644 desktop/modules/appearance/Icons.qml create mode 100644 desktop/modules/appearance/IconsModule.qml create mode 100644 desktop/modules/appearance/IconsTab.qml create mode 100644 desktop/modules/appearance/IdleModule.qml create mode 100644 desktop/modules/appearance/IdleTab.qml create mode 100644 desktop/modules/appearance/MockScreen.qml create mode 100644 desktop/modules/appearance/README.md create mode 100644 desktop/modules/appearance/Steps.qml create mode 100644 desktop/modules/appearance/StepsWindow.qml create mode 100644 desktop/modules/appearance/SunsetModule.qml create mode 100644 desktop/modules/appearance/SunsetTab.qml create mode 100644 desktop/modules/appearance/Tab.qml create mode 100644 desktop/modules/appearance/ThemeModule.qml create mode 100644 desktop/modules/appearance/ThemeTab.qml create mode 100644 desktop/modules/appearance/Toggle.qml create mode 100644 desktop/modules/appearance/Udt.qml create mode 100644 desktop/modules/appearance/WallpaperModule.qml create mode 100644 desktop/modules/appearance/WallpaperTab.qml create mode 100644 desktop/modules/appearance/Wallpapers.qml 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. -// -// 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/Field.qml b/appearance/Field.qml deleted file mode 100644 index 004bda6..0000000 --- a/appearance/Field.qml +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// 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 - -Rectangle { - id: field - property alias value: input.text - property string placeholder: "" - signal edited - - implicitWidth: 70 - height: 24 - radius: 5 - color: Qt.alpha(Theme.surface, 0.5) - border.width: 1 - border.color: input.activeFocus ? Qt.alpha(Theme.accent, 0.6) : Qt.alpha(Theme.text, 0.12) - - Text { - anchors.fill: parent - anchors.leftMargin: 6 - anchors.rightMargin: 6 - verticalAlignment: Text.AlignVCenter - visible: input.text === "" - text: field.placeholder - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - color: Theme.overlay - } - - TextInput { - id: input - anchors.fill: parent - anchors.leftMargin: 6 - anchors.rightMargin: 6 - verticalAlignment: TextInput.AlignVCenter - clip: true - selectByMouse: true - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - color: Theme.text - onEditingFinished: field.edited() - } -} diff --git a/appearance/Hypridle.qml b/appearance/Hypridle.qml deleted file mode 100644 index 1696f5f..0000000 --- a/appearance/Hypridle.qml +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// 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. - -pragma Singleton - -import Quickshell -import Quickshell.Io -import QtQuick - -// The hypridle side. Commands are fixed: only each listener's timeout and -// whether it is active are editable. Everything before the first listener is -// kept verbatim so the general block and the rationale comments survive. -Singleton { - id: root - - readonly property string home: Quickshell.env("HOME") - readonly property string confPath: `${home}/.config/hypr/hypridle.conf` - - property string prefix: "" - property var listeners: [] - property string tail: "" - property string notice: "" - property bool loaded: false - - function getField(body, key) { - const m = body.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, "m")); - return m ? m[1] : null; - } - - // A disabled listener is written every line prefixed with "# ". Strip it - // before reading fields. - function uncomment(body) { - return body.split("\n").map(l => l.replace(/^([ \t]*)#[ \t]?/, "$1")).join("\n"); - } - - // The closing brace is matched at the start of a line, because the dpms - // command contains a brace mid-line and a non-greedy match would stop - // inside it. A disabled listener has both its opening line and its closing - // brace commented, so the closing pattern allows a leading "#". The first - // listener's leading trivia is empty because its text is the prefix. - function parse(text) { - const re = /^([ \t]*)(#?)[ \t]*listener\s*\{([\s\S]*?)^[ \t]*#?[ \t]*\}/gm; - const found = []; - let firstStart = -1; - let prevEnd = 0; - let m; - while ((m = re.exec(text)) !== null) { - const start = m.index; - if (firstStart < 0) { firstStart = start; prevEnd = start; } - const leading = text.slice(prevEnd, start); - const enabled = m[2] !== "#"; - const body = enabled ? m[3] : root.uncomment(m[3]); - const t = root.getField(body, "timeout"); - found.push({ - leading: leading, - timeout: t === null ? 0 : parseInt(t, 10), - onTimeout: root.getField(body, "on-timeout") ?? "", - onResume: root.getField(body, "on-resume") ?? "", - enabled: enabled, - }); - prevEnd = start + m[0].length; - } - if (firstStart < 0) return { prefix: text, listeners: [], tail: "" }; - return { prefix: text.slice(0, firstStart), listeners: found, tail: text.slice(prevEnd) }; - } - - function serialize(prefix, list, tail) { - let out = prefix; - for (const l of list) { - const lines = ["listener {", ` timeout = ${l.timeout}`, - ` on-timeout = ${l.onTimeout}`]; - if (l.onResume) lines.push(` on-resume = ${l.onResume}`); - lines.push("}"); - const body = l.enabled ? lines.join("\n") : lines.map(x => "# " + x).join("\n"); - out += l.leading + body; - } - return out + tail; - } - - function describe(l) { - const cmd = l.onTimeout; - if (cmd.indexOf("notify-send") === 0) return "notify before lock"; - if (cmd.indexOf("loginctl lock-session") >= 0) return "lock session"; - if (cmd.indexOf("dpms") >= 0) return "monitors off"; - if (cmd.indexOf("loginctl suspend") >= 0) return "suspend"; - return cmd; - } - - function selftest(): string { - const fixture = String.raw`general { - lock_cmd = pidof hyprlock || hyprlock -} - -# 10:00s - screen lock -listener { - timeout = 600 - on-timeout = loginctl lock-session -} - -# 10:30s - monitor off -listener { - timeout = 630 - on-timeout = hyprctl dispatch "hl.dsp.dpms({ state = \"off\" })" - on-resume = hyprctl dispatch "hl.dsp.dpms({ state = \"on\" })" -} -`; - const p = root.parse(fixture); - if (p.listeners.length !== 2) return `SELFTEST Hypridle FAIL: ${p.listeners.length} listeners`; - if (p.listeners[0].timeout !== 600 || p.listeners[0].enabled !== true) - return "SELFTEST Hypridle FAIL: first listener"; - if (p.listeners[1].onTimeout.indexOf(String.raw`state = \"off\"`) < 0) - return "SELFTEST Hypridle FAIL: dpms command truncated"; - if (p.listeners[1].onResume.indexOf(String.raw`state = \"on\"`) < 0) - return "SELFTEST Hypridle FAIL: dpms on-resume lost"; - if (root.serialize(p.prefix, p.listeners, p.tail) !== fixture) - return "SELFTEST Hypridle FAIL: round-trip not byte-identical"; - const off = p.listeners.slice(); - off[1] = Object.assign({}, off[1], { enabled: false }); - const p2 = root.parse(root.serialize(p.prefix, off, p.tail)); - if (p2.listeners.length !== 2 || p2.listeners[1].enabled !== false || - p2.listeners[1].timeout !== 630) - return "SELFTEST Hypridle FAIL: disabled listener not round-tripped"; - return "SELFTEST Hypridle PASS"; - } - - function refresh() { - confProc.running = false; - confProc.running = true; - } - - Process { - id: confProc - command: ["cat", root.confPath] - stdout: StdioCollector { - onStreamFinished: { - const p = root.parse(text); - root.prefix = p.prefix; - root.listeners = p.listeners; - root.tail = p.tail; - root.loaded = true; - } - } - } - - FileView { id: confFile; path: root.confPath } - - function save() { - if (!root.loaded) return; // not loaded yet: writing would truncate the file - confFile.setText(root.serialize(root.prefix, root.listeners, root.tail)); - Quickshell.execDetached(["sh", "-c", "pkill -x hypridle; setsid -f hypridle"]); - root.notice = "saved; idle timers reset"; - } -} diff --git a/appearance/Hyprsunset.qml b/appearance/Hyprsunset.qml deleted file mode 100644 index fc0d44a..0000000 --- a/appearance/Hyprsunset.qml +++ /dev/null @@ -1,326 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// 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. - -pragma Singleton - -import Quickshell -import Quickshell.Io -import QtQuick - -// The hyprsunset side: profiles in ~/.config/hypr/hyprsunset.conf, the -// location in hyprsunset-qt's own config, and the daemon. The file format is -// byte-for-byte what ~/Programming/GIT/sunset-qt writes, so both tools edit -// the same file. -Singleton { - id: root - - readonly property string home: Quickshell.env("HOME") - readonly property string confPath: `${home}/.config/hypr/hyprsunset.conf` - readonly property string appConfPath: `${home}/.config/hyprsunset-qt/config` - - property var profiles: [] - property string lat: "" - property string lon: "" - property bool autoDetect: true - property string daemonCommand: "hyprsunset" - property string cachePathRaw: "~/.config/hyprsunset-qt/sun.json" - property string cachePath: `${home}/.config/hyprsunset-qt/sun.json` - property string notice: "" - property string sunSummary: "" - property bool busy: false - property bool confLoaded: false - property bool appLoaded: false - - readonly property string header: - "# Managed by hyprsunset-qt. Edits here are overwritten on save.\n" - - // QML has no String.matchAll; everything here is exec loops. - function getField(body, key) { - const m = body.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, "m")); - return m ? m[1] : null; - } - - function parseProfiles(text) { - const list = []; - const re = /profile\s*\{([\s\S]*?)\}/g; - let m; - while ((m = re.exec(text)) !== null) { - const body = m[1]; - const t = root.getField(body, "temperature"); - const g = root.getField(body, "gamma"); - list.push({ - time: root.getField(body, "time") ?? "", - identity: (root.getField(body, "identity") ?? "").toLowerCase() === "true", - temperature: t === null ? null : parseInt(t, 10), - gamma: g === null ? null : parseFloat(g), - }); - } - return list; - } - - // Byte-for-byte the same construction as sunset-qt's config.serialize(): - // a list of parts joined with newlines, so a no-op save writes the file - // back exactly as it was. - function serializeProfiles(list) { - const di = root.dayIndex(list); - const ni = root.nightIndex(list); - const parts = [root.header]; - for (let i = 0; i < list.length; i++) { - const p = list[i]; - if (i === di) parts.push("\n# day profile -- sunrise"); - else if (i === ni) parts.push("\n# night profile -- sunset"); - else parts.push("\n# profile"); - const lines = ["profile {", ` time = ${p.time}`]; - if (p.identity) lines.push(" identity = true"); - if (p.temperature !== null && p.temperature !== undefined) - lines.push(` temperature = ${p.temperature}`); - if (p.gamma !== null && p.gamma !== undefined) - lines.push(` gamma = ${p.gamma}`); - lines.push("}"); - parts.push(lines.join("\n")); - } - return parts.join("\n") + "\n"; - } - - function dayIndex(list) { - for (let i = 0; i < list.length; i++) if (list[i].identity) return i; - return null; - } - - function nightIndex(list) { - for (let i = 0; i < list.length; i++) - if (list[i].temperature !== null && list[i].temperature !== undefined) return i; - return null; - } - - function validTime(s) { return /^([01]?\d|2[0-3]):([0-5]\d)$/.test(s); } - function validTemperature(t) { return t >= 1000 && t <= 20000; } - function validGamma(g) { return g >= 0.0 && g <= 2.0; } - - function localHM(iso) { - const d = new Date(iso); - if (isNaN(d.getTime())) return ""; - return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2); - } - - function selftest(): string { - // A file exactly as hyprsunset-qt writes it. String.raw keeps the - // backslashes, none here, but keeps this fixture readable. - // Two blank lines after the header: sunset-qt's serializer joins parts - // with a newline and each part starts with one, so the file really has - // them. - const fixture = String.raw`# Managed by hyprsunset-qt. Edits here are overwritten on save. - - -# day profile -- sunrise -profile { - time = 05:42 - identity = true -} - -# night profile -- sunset -profile { - time = 21:02 - temperature = 5500 - gamma = 0.8 -} -`; - const parsed = root.parseProfiles(fixture); - if (parsed.length !== 2) return `SELFTEST Hyprsunset FAIL: parsed ${parsed.length} profiles`; - if (parsed[0].time !== "05:42" || parsed[0].identity !== true) - return "SELFTEST Hyprsunset FAIL: day profile wrong"; - if (parsed[1].temperature !== 5500 || parsed[1].gamma !== 0.8) - return "SELFTEST Hyprsunset FAIL: night profile wrong"; - if (root.dayIndex(parsed) !== 0 || root.nightIndex(parsed) !== 1) - return "SELFTEST Hyprsunset FAIL: day/night index wrong"; - if (root.serializeProfiles(parsed) !== fixture) - return "SELFTEST Hyprsunset FAIL: round-trip not byte-identical"; - if (!root.validTime("05:42") || root.validTime("24:00") || root.validTime("5:6")) - return "SELFTEST Hyprsunset FAIL: validTime"; - if (!root.validTemperature(1000) || root.validTemperature(20001)) - return "SELFTEST Hyprsunset FAIL: validTemperature"; - if (!root.validGamma(0.8) || root.validGamma(2.1)) - return "SELFTEST Hyprsunset FAIL: validGamma"; - return "SELFTEST Hyprsunset PASS"; - } - - function refresh() { - confProc.running = false; - confProc.running = true; - appProc.running = false; - appProc.running = true; - } - - Process { - id: confProc - command: ["cat", root.confPath] - stdout: StdioCollector { - onStreamFinished: { - root.profiles = root.parseProfiles(text); - root.confLoaded = true; - } - } - } - - // hyprsunset-qt's own settings: location + daemon command. - function parseAppConf(text) { - const out = {}; - let section = ""; - for (const raw of text.split("\n")) { - const line = raw.trim(); - if (!line || line.startsWith("#") || line.startsWith(";")) continue; - const sec = line.match(/^\[(.+)\]$/); - if (sec) { section = sec[1]; continue; } - const kv = line.match(/^([^=]+)=\s*(.*)$/); - if (kv) out[`${section}.${kv[1].trim()}`] = kv[2].trim(); - } - return out; - } - - function expandTilde(p) { - return p.startsWith("~/") ? root.home + p.slice(1) : p; - } - - Process { - id: appProc - command: ["cat", root.appConfPath] - stdout: StdioCollector { - onStreamFinished: { - const c = root.parseAppConf(text); - root.lat = c["location.lat"] ?? ""; - root.lon = c["location.lon"] ?? ""; - root.autoDetect = (c["location.auto_detect"] ?? "true") === "true"; - root.cachePathRaw = c["cache.path"] ?? "~/.config/hyprsunset-qt/sun.json"; - root.cachePath = root.expandTilde(root.cachePathRaw); - root.daemonCommand = c["daemon.command"] ?? "hyprsunset"; - root.appLoaded = true; - } - } - } - - FileView { id: confFile; path: root.confPath } - FileView { id: appFile; path: root.appConfPath } - FileView { id: cacheFile; path: root.cachePath } - - function appConfText() { - return `[location]\nlat = ${root.lat}\nlon = ${root.lon}\n` + - `auto_detect = ${root.autoDetect}\n\n` + - `[cache]\npath = ${root.cachePathRaw}\n\n` + - `[daemon]\ncommand = ${root.daemonCommand}\n\n`; - } - function saveSettings() { appFile.setText(root.appConfText()); } - - // hyprsunset holds Hyprland's CTM manager exclusively, and pkill only - // sends SIGTERM, so the replacement must wait for the old process to - // actually exit or it dies with "A CTM manager is already running". - function restart() { - Quickshell.execDetached(["sh", "-c", - `pkill -x hyprsunset; ` + - `for i in $(seq 50); do ` + - `pgrep -x hyprsunset >/dev/null 2>&1 || break; sleep 0.1; done; ` + - `setsid -f ${root.daemonCommand}`]); - } - - function save() { - if (!root.confLoaded || !root.appLoaded) return; - for (const p of root.profiles) { - if (!root.validTime(p.time)) { root.notice = `invalid time: ${p.time}`; return; } - if (p.temperature !== null && p.temperature !== undefined && - !root.validTemperature(p.temperature)) { - root.notice = `invalid temperature: ${p.temperature}`; return; - } - if (p.gamma !== null && p.gamma !== undefined && !root.validGamma(p.gamma)) { - root.notice = `invalid gamma: ${p.gamma}`; return; - } - } - confFile.setText(root.serializeProfiles(root.profiles)); - root.saveSettings(); - root.restart(); - root.notice = "saved + restarted"; - } - - // Live preview via the daemon's IPC. Identity wins, then temperature, then - // gamma, matching hyprsunset-qt. Nothing is written. - function preview() { - const i = root.nightIndex(root.profiles); - const target = i !== null ? root.profiles[i] - : (root.profiles.length ? root.profiles[0] : null); - if (!target) return; - if (target.identity) { - previewProc.command = ["hyprctl", "hyprsunset", "identity"]; - } else { - const parts = []; - if (target.temperature !== null && target.temperature !== undefined) - parts.push(`hyprctl hyprsunset temperature ${target.temperature}`); - if (target.gamma !== null && target.gamma !== undefined) - parts.push(`hyprctl hyprsunset gamma ${Math.round(target.gamma * 100)}`); - if (!parts.length) return; - previewProc.command = ["sh", "-c", parts.join("; ")]; - } - previewProc.running = false; - previewProc.running = true; - } - Process { id: previewProc } - - function detect() { - detectProc.running = false; - detectProc.running = true; - } - Process { - id: detectProc - command: ["curl", "-fsS", "http://ip-api.com/json"] - stdout: StdioCollector { - onStreamFinished: { - try { - const d = JSON.parse(text); - root.lat = String(d.lat); - root.lon = String(d.lon); - root.saveSettings(); - root.notice = `located ${root.lat}, ${root.lon}`; - } catch (e) { root.notice = `detect failed: ${e}`; } - } - } - } - - // sunrise-sunset.org is behind Cloudflare and 403s curl's default UA. - function fetchSun() { - const url = "https://api.sunrise-sunset.org/json?lat=" + - encodeURIComponent(root.lat) + "&lng=" + encodeURIComponent(root.lon) + - "&formatted=0"; - fetchProc.command = ["curl", "-fsS", "-A", - "Mozilla/5.0 (X11; Linux x86_64) hyprsunset-qt", url]; - fetchProc.running = false; - fetchProc.running = true; - root.busy = true; - } - Process { - id: fetchProc - stdout: StdioCollector { - onStreamFinished: { - root.busy = false; - let data; - try { data = JSON.parse(text); } - catch (e) { root.notice = `fetch failed: ${e}`; return; } - cacheFile.setText(JSON.stringify(data, null, 2)); - const r = data.results ?? {}; - const rise = r.sunrise ? root.localHM(r.sunrise) : ""; - const set = r.sunset ? root.localHM(r.sunset) : ""; - root.sunSummary = `sunrise ${rise || "—"} sunset ${set || "—"}`; - const di = root.dayIndex(root.profiles); - const ni = root.nightIndex(root.profiles); - const next = root.profiles.slice(); - if (di !== null && rise) next[di] = Object.assign({}, next[di], { time: rise }); - if (ni !== null && set) next[ni] = Object.assign({}, next[ni], { time: set }); - root.profiles = next; - } - } - } -} diff --git a/appearance/Icons.qml b/appearance/Icons.qml deleted file mode 100644 index a6eaec5..0000000 --- a/appearance/Icons.qml +++ /dev/null @@ -1,334 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// 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. - -pragma Singleton - -import Quickshell -import Quickshell.Io -import QtQuick - -// The icon and cursor themes installed on this machine. Previews are resolved -// from each theme, not just the active one: Quickshell.iconPath can only read -// the platform theme or QS_ICON_THEME, both fixed at load. -Singleton { - id: root - - readonly property string home: Quickshell.env("HOME") - readonly property string iconScript: ` -import sys -import gi -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk -SAMPLES = ["folder","text-x-generic","image-x-generic","network-wireless", - "audio-x-generic","video-x-generic","battery-full","printer"] -t = Gtk.IconTheme.new() -for name in sys.argv[1:]: - t.set_custom_theme(name) - for s in SAMPLES: - info = t.lookup_icon(s, 32, 0) - if info: - print("%s\\t%s\\t%s" % (name, s, info.get_filename())) - print("%s\\tEND\\t" % name) -` - // Four shapes per theme, so a card shows the cases that matter (arrow, - // hand, text, horizontal resize) instead of one ambiguous pointer. Each - // shape falls back through common aliases and through every storage form a - // theme may use: a hyprcursor .hlc zip, a shape directory with an SVG, or - // a legacy Xcursor binary that goes through xcur2png. - readonly property string cursorScript: ` -import sys, os, glob, shutil, zipfile, subprocess -outdir = sys.argv[1] -SAMPLES = { - "left_ptr": ["left_ptr", "default", "pointer"], - "hand2": ["hand2", "pointer", "hand"], - "xterm": ["xterm", "text", "ibeam"], - "resize": ["sb_h_double_arrow", "size_hor", "ew-resize", "h_double_arrow"], -} -roots = [os.path.expanduser("~/.icons"), - os.path.expanduser("~/.local/share/icons"), - "/usr/share/icons"] - -def pick(d, names): - for n in names: - for cand in (f"{d}/hyprcursors/{n}.hlc", f"{d}/hyprcursors/{n}/{n}.svg", - f"{d}/{n}.hlc", f"{d}/{n}/{n}.svg", f"{d}/cursors/{n}"): - if os.path.isfile(cand): - return cand - return None - -os.makedirs(outdir, exist_ok=True) -for name in sys.argv[2:]: - d = next((os.path.join(r, name) for r in roots if os.path.isdir(os.path.join(r, name))), None) - if not d: - continue - for shape, aliases in SAMPLES.items(): - c = pick(d, aliases) - if not c: - continue - out = f"{outdir}/{name}-{shape}.png" - try: - if c.endswith(".hlc"): - with zipfile.ZipFile(c) as z: - members = [e for e in z.namelist() if e.endswith(".svg")] - if not members: - members = [e for e in z.namelist() if e.endswith(".png")] - if not members: - continue - with open(out, "wb") as f: - f.write(z.read(members[0])) - elif c.endswith(".svg"): - shutil.copyfile(c, out) - else: - raw = f"{outdir}/raw-{name}-{shape}" - shutil.rmtree(raw, ignore_errors=True) - os.makedirs(raw, exist_ok=True) - subprocess.run(["xcur2png", "-d", raw, "-c", f"{raw}/out.conf", c], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - pngs = sorted(glob.glob(f"{raw}/*_*.png")) - if not pngs: - continue - shutil.copyfile(pngs[-1], out) - print(f"{name}\\t{shape}\\t{out}") - except Exception: - continue -` - - property var iconThemes: [] - property var cursorThemes: [] - property string currentIcon: "" - property string currentCursor: "" - property int cursorSize: 24 - property var iconPreview: ({}) - property var cursorPreview: ({}) - property bool scanning: true - property string notice: "" - - // Pure: classify works on entries shaped { name, index, cursors, manifest } - // so it can be tested without touching the filesystem. - function classify(entries) { - const icons = [], cursors = []; - for (const e of entries) { - if (e.cursors || e.manifest) cursors.push(e.name); - if (e.index && /Directories=\S/.test(e.index)) icons.push(e.name); - } - const uniq = a => a.filter((v, i) => a.indexOf(v) === i).sort(); - return { icons: uniq(icons), cursors: uniq(cursors) }; - } - - function selftest(): string { - const entries = [ - { name: "Material-Black-Plum-Suru", index: "Directories=32x32/apps\n", cursors: false, manifest: false }, - { name: "hypr_bibata-modern-amber", index: "", cursors: false, manifest: true }, - { name: "default", index: "Inherits=Bibata-Modern-Amber\n", cursors: false, manifest: false }, - { name: "breeze_cursors", index: "", cursors: true, manifest: false }, - { name: "Adwaita", index: "Directories=16x16/apps\n", cursors: true, manifest: false }, - ]; - const r = root.classify(entries); - if (r.icons.length !== 2 || r.icons.indexOf("Adwaita") < 0 || - r.icons.indexOf("Material-Black-Plum-Suru") < 0) - return `SELFTEST Icons FAIL: icons ${JSON.stringify(r.icons)}`; - if (r.cursors.length !== 3 || r.cursors.indexOf("hypr_bibata-modern-amber") < 0 || - r.cursors.indexOf("breeze_cursors") < 0 || r.cursors.indexOf("Adwaita") < 0) - return `SELFTEST Icons FAIL: cursors ${JSON.stringify(r.cursors)}`; - return "SELFTEST Icons PASS"; - } - - function refresh() { - scanProc.running = false; - scanProc.running = true; - curProc.running = false; - curProc.running = true; - } - - // One shell pass over every theme root. Format per line: - // name|hasIndex|cursors|manifest|Directories= - Process { - id: scanProc - command: ["sh", "-c", - `for d in ${root.home}/.icons/* ${root.home}/.local/share/icons/* ` + - `/usr/share/icons/*; do [ -d "$d" ] || continue; ` + - `[ -L "$d" ] && continue; ` + - `i=0; c=0; m=0; dirs=""; ` + - `[ -d "$d/cursors" ] && c=1; ` + - `[ -f "$d/manifest.hl" ] && m=1; ` + - `if [ -f "$d/index.theme" ]; then i=1; dirs=$(sed -n 's/^Directories=//p' "$d/index.theme"); fi; ` + - `echo "$(basename "$d")|$i|$c|$m|$dirs"; done`] - stdout: StdioCollector { - onStreamFinished: { - const entries = []; - for (const line of text.trim().split("\n")) { - const p = line.split("|"); - if (p.length < 5) continue; - entries.push({ name: p[0], index: p[1] === "1" ? "Directories=" + (p[4] || "x") : "", - cursors: p[2] === "1", manifest: p[3] === "1" }); - } - const r = root.classify(entries); - root.iconThemes = r.icons; - root.cursorThemes = r.cursors; - root.scanning = false; - if (root.iconThemes.length) root.previewIcons(); - if (root.cursorThemes.length) root.previewCursors(); - } - } - } - - // One python process for every icon theme, so opening the tab costs one - // spawn, not one per theme. - function previewIcons() { - if (!root.iconThemes.length) return; - iconProc.command = ["python3", "-c", root.iconScript].concat(root.iconThemes); - iconProc.running = false; - iconProc.running = true; - } - Process { - id: iconProc - stdout: StdioCollector { - onStreamFinished: { - const next = {}; - for (const line of text.split("\n")) { - const p = line.split("\t"); - if (p.length < 2 || p[1] === "END" || !p[2]) continue; - if (!next[p[0]]) next[p[0]] = {}; - next[p[0]][p[1]] = p[2]; - } - root.iconPreview = next; - } - } - } - - // gsettings for the current values. - Process { - id: curProc - command: ["sh", "-c", - `gsettings get org.gnome.desktop.interface icon-theme; ` + - // The system's cursor theme is often the `default` alias, a symlink - // to the real theme. The scan skips symlinked dirs, so resolve the - // current value to the target name or nothing would read as current. - `ct=$(gsettings get org.gnome.desktop.interface cursor-theme | sed "s/'//g"); ` + - `for r in ${root.home}/.icons ${root.home}/.local/share/icons /usr/share/icons; do ` + - `[ -L "$r/$ct" ] && ct=$(basename "$(readlink -f "$r/$ct")") && break; done; ` + - `printf '%s\\n' "$ct"; ` + - `gsettings get org.gnome.desktop.interface cursor-size`] - stdout: StdioCollector { - onStreamFinished: { - const lines = text.trim().split("\n"); - root.currentIcon = (lines[0] ?? "").replace(/'/g, ""); - root.currentCursor = (lines[1] ?? "").replace(/'/g, ""); - const size = parseInt(lines[2] ?? "", 10); - if (!isNaN(size)) root.cursorSize = size; - } - } - } - - // One python pass extracts four shapes for EVERY cursor theme and prints - // nameshapepath, so the whole row paints on open instead of - // waiting for a hover per card. A theme missing a shape simply shows fewer - // images rather than a broken one. - function previewCursors() { - if (!root.cursorThemes.length) return; - cursorProc.command = ["python3", "-c", root.cursorScript, - Quickshell.cachePath("cursors")].concat(root.cursorThemes); - cursorProc.running = false; - cursorProc.running = true; - } - Process { - id: cursorProc - stdout: StdioCollector { - onStreamFinished: { - const next = {}; - for (const line of text.split("\n")) { - const p = line.split("\t"); - if (p.length < 3 || !p[0] || !p[1] || !p[2]) continue; - if (!next[p[0]]) next[p[0]] = {}; - next[p[0]][p[1]] = p[2]; - } - root.cursorPreview = next; - } - } - } - - // Writes go through FileView so no shell quoting is involved. They are - // preloaded because setText on an unloaded FileView would write empty. - FileView { id: qt6File; path: `${root.home}/.config/qt6ct/qt6ct.conf`; blockLoading: true } - FileView { id: qt5File; path: `${root.home}/.config/qt5ct/qt5ct.conf`; blockLoading: true } - // The cursor lives in its own file, not in hypr-theme's environment.lua: - // which cursor a machine runs is local state, and editing a tracked file - // left hypr-theme dirty after every switch. environment.lua dofile()s it. - // Written whole, so it needs no preload. - FileView { id: cursorFile; path: `${root.home}/.config/udt/cursor.lua` } - function writeCursor() { - cursorFile.setText(`return { theme = ${JSON.stringify(root.currentCursor)}, size = ${root.cursorSize} }\n`); - } - // GTK3 and GTK4 read their own settings.ini, with the theme names written - // out; gsettings alone does not switch them. - FileView { id: gtk3File; path: `${root.home}/.config/gtk-3.0/settings.ini`; blockLoading: true } - FileView { id: gtk4File; path: `${root.home}/.config/gtk-4.0/settings.ini`; blockLoading: true } - - // Replaces a `key=value` line in both GTK settings files. A key the file - // does not carry is left alone, the same as the Qt configs. - function rewriteGtk(key, value) { - const files = [gtk3File, gtk4File]; - const re = new RegExp(`^${key}=.*`, "m"); - for (let i = 0; i < files.length; i++) { - const text = files[i].text(); - if (text !== "") files[i].setText(text.replace(re, `${key}=${value}`)); - } - } - - function gsettingsSet(key, value) { - gsetProc.command = ["gsettings", "set", "org.gnome.desktop.interface", key, value]; - gsetProc.running = false; - gsetProc.running = true; - } - Process { id: gsetProc } - - // The SDDM power icons resolve icon names from gsettings when udt-accent - // generates them, not when they run, so they are regenerated here, - // chained after the gsettings write they read. The island resolves its - // icons live and needs nothing. - readonly property string udtRepo: `${root.home}/Programming/GIT/unified-desktop-theme` - - function applyIcon(name) { - Quickshell.execDetached(["sh", "-c", - `gsettings set org.gnome.desktop.interface icon-theme "$1" || exit; ` + - `[ -e ~/.cache/udt/wpaper ] && ${JSON.stringify(root.udtRepo)}/bin/udt-accent ~/.cache/udt/wpaper`, - "sh", name]); - const files = [qt6File, qt5File]; - for (let i = 0; i < files.length; i++) { - const text = files[i].text(); - if (text !== "") files[i].setText(text.replace(/^icon_theme=.*/m, `icon_theme=${name}`)); - } - root.rewriteGtk("gtk-icon-theme-name", name); - root.currentIcon = name; - root.notice = `${name} set. Waybar and the SDDM icons follow; restart other apps to see it.`; - } - - function applyCursor(name) { - // Live switch first, then persistence. - Quickshell.execDetached(["hyprctl", "setcursor", name, String(root.cursorSize)]); - root.gsettingsSet("cursor-theme", name); - root.currentCursor = name; - root.writeCursor(); - root.rewriteGtk("gtk-cursor-theme-name", name); - root.rewriteGtk("gtk-cursor-theme-size", root.cursorSize); - root.notice = `${name} set live at ${root.cursorSize}px. cursor.lua updated for next login.`; - } - - function setCursorSize(size) { - root.cursorSize = size; - // Live on the current theme, then GTK and the next login's env. - Quickshell.execDetached(["hyprctl", "setcursor", root.currentCursor, String(size)]); - root.gsettingsSet("cursor-size", String(size)); - root.writeCursor(); - root.rewriteGtk("gtk-cursor-theme-size", size); - root.notice = `cursor size ${size}px. cursor.lua updated for next login.`; - } -} diff --git a/appearance/IconsTab.qml b/appearance/IconsTab.qml deleted file mode 100644 index c4d82ca..0000000 --- a/appearance/IconsTab.qml +++ /dev/null @@ -1,225 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// 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.Widgets -import QtQuick - -Flickable { - contentHeight: col.implicitHeight - clip: true - - // Clicking a cursor card stages it; Apply commits. There is deliberately no - // live hover preview: hyprctl setcursor is not a read-only switch, Hyprland - // persists the theme to org.gnome.desktop.interface cursor-theme, so - // hovering wrote the setting and a close left the last hovered theme set. - // The four rendered shapes on each card are the preview. - property string pendingCursor: "" - - Column { - id: col - width: parent.width - spacing: 12 - - Text { - text: Icons.scanning ? "scanning…" : `${Icons.iconThemes.length} icon themes · ${Icons.cursorThemes.length} cursor themes` - color: Theme.overlay - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - } - - Text { - text: "Icon theme" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } - } - - // Flow, not a horizontal strip: the cards wrap onto further rows rather - // than running off the right edge, and the tab scrolls vertically. - Flow { - width: parent.width - spacing: 10 - Repeater { - model: Icons.iconThemes - Rectangle { - id: iconCard - required property string 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) - border.width: current ? 2 : 1 - border.color: current ? Theme.accent : "transparent" - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: Icons.applyIcon(modelData) - } - - Column { - width: parent.width - 16 - anchors.centerIn: parent - spacing: 10 - Row { - anchors.horizontalCenter: parent.horizontalCenter - spacing: 8 - Repeater { - model: ["folder", "text-x-generic", "image-x-generic", "network-wireless"] - IconImage { - required property string modelData - readonly property string glyph: (Icons.iconPreview[iconCard.modelData] ?? {})[modelData] ?? "" - // Absolute paths must carry file://, or they - // resolve against the config's qrc base and - // fail; Gtk's builtin icons come back as - // /org/gtk gresource paths Qt cannot open. - source: glyph.indexOf("/") === 0 && glyph.indexOf("/org/gtk/") !== 0 - ? "file://" + glyph : "" - implicitSize: 44 - } - } - } - Text { - width: parent.width - horizontalAlignment: Text.AlignHCenter - text: iconCard.current ? modelData + " current" : modelData - color: Theme.text - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - wrapMode: Text.WrapAnywhere - maximumLineCount: 2 - elide: Text.ElideRight - } - } - } - } - } - - Row { - spacing: 10 - Text { - anchors.verticalCenter: parent.verticalCenter - text: "Cursor theme" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } - } - Item { width: 12; height: 1 } - Text { - anchors.verticalCenter: parent.verticalCenter - text: "Size" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - Repeater { - model: [16, 24, 32, 48, 64] - Tab { - required property var modelData - anchors.verticalCenter: parent.verticalCenter - text: String(modelData) - selected: Icons.cursorSize === modelData - onClicked: Icons.setCursorSize(modelData) - } - } - } - - Flow { - width: parent.width - spacing: 10 - Repeater { - model: Icons.cursorThemes - Rectangle { - id: cursorCard - required property string modelData - readonly property bool current: Icons.currentCursor === modelData - readonly property bool staged: pendingCursor === modelData - width: 230; height: 150; radius: 10 - color: current || staged ? Qt.alpha(Theme.accent, 0.2) : Qt.alpha(Theme.surface, 0.35) - border.width: current || staged ? 2 : 1 - border.color: staged ? Theme.accent - : current ? Qt.alpha(Theme.accent, 0.5) - : "transparent" - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: pendingCursor = modelData - } - - Column { - width: parent.width - 16 - anchors.centerIn: parent - spacing: 10 - Row { - anchors.horizontalCenter: parent.horizontalCenter - spacing: 8 - Repeater { - // The cases that tell themes apart: arrow, hand, - // text and horizontal resize. - model: ["left_ptr", "hand2", "xterm", "resize"] - Image { - required property string modelData - readonly property string p: (Icons.cursorPreview[cursorCard.modelData] ?? {})[modelData] ?? "" - width: 44; height: 44 - asynchronous: true - source: p !== "" ? "file://" + p : "" - fillMode: Image.PreserveAspectFit - } - } - } - Text { - width: parent.width - horizontalAlignment: Text.AlignHCenter - text: cursorCard.staged ? modelData + " staged" - : cursorCard.current ? modelData + " current" - : modelData - color: Theme.text - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - wrapMode: Text.WrapAnywhere - maximumLineCount: 2 - elide: Text.ElideRight - } - } - } - } - } - - Row { - spacing: 8 - Text { - anchors.verticalCenter: parent.verticalCenter - text: pendingCursor !== "" ? "click Apply to set" : "click a cursor theme to stage it" - color: Theme.overlay - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - } - Tab { - text: "Apply" - selected: pendingCursor !== "" - enabled: pendingCursor !== "" - onClicked: { - Icons.applyCursor(pendingCursor); - pendingCursor = ""; - } - } - Tab { - text: "Reset" - enabled: pendingCursor !== "" - onClicked: pendingCursor = "" - } - } - - Text { - width: parent.width - wrapMode: Text.Wrap - visible: Icons.notice !== "" - text: Icons.notice - color: Theme.green - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - } - -} diff --git a/appearance/IdleTab.qml b/appearance/IdleTab.qml deleted file mode 100644 index 75ce84a..0000000 --- a/appearance/IdleTab.qml +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// 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 - -Flickable { - contentHeight: col.implicitHeight - clip: true - - Column { - id: col - width: parent.width - spacing: 10 - - Text { - text: "Commands are fixed. Only the timeout and whether a listener runs are editable." - color: Theme.overlay - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - } - - Repeater { - model: Hypridle.listeners - Rectangle { - required property var modelData - required property int index - width: col.width - implicitHeight: 40 - radius: 8 - color: Qt.alpha(Theme.surface, modelData.enabled ? 0.4 : 0.2) - opacity: modelData.enabled ? 1 : 0.6 - - Row { - anchors.fill: parent - anchors.margins: 8 - spacing: 10 - - Toggle { - anchors.verticalCenter: parent.verticalCenter - checked: modelData.enabled - onToggled: Hypridle.listeners = Hypridle.listeners.map( - (l, i) => i === index ? Object.assign({}, l, { enabled: checked }) : l) - } - Text { - anchors.verticalCenter: parent.verticalCenter - width: 150 - text: Hypridle.describe(modelData) - color: Theme.text - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - Field { - anchors.verticalCenter: parent.verticalCenter - width: 70 - value: String(modelData.timeout) - placeholder: "seconds" - onEdited: Hypridle.listeners = Hypridle.listeners.map( - (l, i) => i === index - ? Object.assign({}, l, { timeout: parseInt(value, 10) || 0 }) : l) - } - Text { - anchors.verticalCenter: parent.verticalCenter - text: `${Math.floor(modelData.timeout / 60)}:${String(modelData.timeout % 60).padStart(2, "0")}` - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - } - Text { - anchors.verticalCenter: parent.verticalCenter - width: parent.width - 380 - elide: Text.ElideRight - text: modelData.onTimeout - color: Theme.overlay - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } - } - } - } - } - - Tab { - text: "Save + Restart hypridle" - selected: true - onClicked: Hypridle.save() - } - - Text { - width: parent.width - wrapMode: Text.Wrap - visible: Hypridle.notice !== "" - text: Hypridle.notice - color: Theme.green - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - } -} diff --git a/appearance/MockScreen.qml b/appearance/MockScreen.qml deleted file mode 100644 index 7a09ee2..0000000 --- a/appearance/MockScreen.qml +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// 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 - -// 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 -// its colours from the theme and stays sharp at any scale. -Item { - id: mock - - property string label: "" - property string source: "" - property bool targeted: false - - // The panel's size; the bezel and stand are added around it. - property real panelWidth: 100 - property real panelHeight: 100 - - readonly property real bezel: Math.max(4, Math.round(panelWidth * 0.02)) - // Proportional to width rather than height: the vertical monitor is tall - // and narrow, and a share of its height would give it an absurd stand. - readonly property real standHeight: Math.max(14, Math.round(panelWidth * 0.1)) - - implicitWidth: panelWidth + bezel * 2 - implicitHeight: panelHeight + bezel * 2 + standHeight - - Column { - anchors.horizontalCenter: parent.horizontalCenter - spacing: 0 - - // Case. - Rectangle { - width: mock.panelWidth + mock.bezel * 2 - height: mock.panelHeight + mock.bezel * 2 - radius: Math.max(3, mock.bezel) - color: "#1a1a1e" - border.width: 1 - border.color: mock.targeted ? Theme.accent : Qt.alpha("#ffffff", 0.14) - - // Screen. - Rectangle { - anchors.centerIn: parent - width: mock.panelWidth - height: mock.panelHeight - color: Theme.base - clip: true - - Image { - anchors.fill: parent - source: mock.source === "" ? "" : "file://" + mock.source - asynchronous: true - cache: false - // swaybg is called with -m fill, so the mock crops the same way. - fillMode: Image.PreserveAspectCrop - sourceSize.width: 640 - } - - Text { - anchors.centerIn: parent - visible: mock.source === "" - text: mock.label - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - color: Theme.overlay - } - } - } - - // Neck. - Rectangle { - anchors.horizontalCenter: parent.horizontalCenter - width: Math.max(8, Math.round(mock.panelWidth * 0.09)) - height: Math.round(mock.standHeight * 0.55) - color: "#26262e" - } - - // Foot. - Rectangle { - anchors.horizontalCenter: parent.horizontalCenter - width: Math.max(30, Math.round(mock.panelWidth * 0.32)) - height: Math.max(5, Math.round(mock.standHeight * 0.45)) - radius: height / 2 - color: "#2c2c35" - } - } -} diff --git a/appearance/README.md b/appearance/README.md deleted file mode 100644 index 7524322..0000000 --- a/appearance/README.md +++ /dev/null @@ -1,184 +0,0 @@ -# appearance - -Wallpapers and colour scheme in one drawer. `SUPER+Return` opens it on the -Wallpaper tab; Tab switches tabs, Escape closes. - - ┌─[ Wallpaper ]─[ Theme ]─[ Sunset ]─[ Idle ]─[ Icons ]──┐ - │ Set on [Horizontal] [Vertical] 1161 wallpapers │ - │ ▸ wallpapers 16 ┌──┐ ┌─────┐ │ - │ ▾ SFW 30 │ │ │ │ │ - │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ └┬─┘ └──┬──┘ │ - │ │ │ │ │ │ │ │ │ ═╧═ ══╧══ │ - │ └────┘ └────┘ └────┘ └────┘ enlarged 1.6×: … │ - │ ▸ system 727 [Apply] [Reset] │ - └────────────────────────────────────────────────────────┘ - -## Running it - - qs -p . - -It is started from `autostart.lua` and reached over IPC, so the shell has to -be running for the keybind to work: - - 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`. - -## Sunset - -A port of `hyprsunset-qt` (`~/Programming/GIT/sunset-qt`): profiles in -`~/.config/hypr/hyprsunset.conf`, location in -`~/.config/hyprsunset-qt/config`, sunrise/sunset from the same API and cache. -Both apps read and write the identical file format, so either can edit it. - -## Idle - -Timeout and enabled only; the commands in `~/.config/hypr/hypridle.conf` are -fixed. Everything before the first `listener` (the `general` block and the -comments explaining the design) is preserved verbatim. A disabled listener is -written commented out. Save restarts `hypridle`, which resets its timers. - -## Icons - -Switches the icon and cursor theme, and the pointer size. Icon previews come -from a GTK lookup per theme. Cursor previews come from one python pass per -open: for every theme it extracts four shapes (arrow, hand, text, horizontal -resize) out of a hyprcursor `.hlc`, a shape directory's SVG, or a legacy -Xcursor binary via `xcur2png`, so a card shows the cases that tell themes -apart. Hovering a card changes the real cursor and reverts it on leave. - -Applying writes `gsettings` (which GTK3 and GTK4 both read), the Qt configs -(`qt6ct`/`qt5ct`), and, for cursors, `~/.config/udt/cursor.lua`, which -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 -`gtk-3.0/settings.ini` and `gtk-4.0/settings.ini` to match. Apps and a relogin -are needed to see the rest. - -An icon switch also regenerates the two consumers that resolve icons by name -when they are generated rather than when they run: `udt-accent` rewrites the -SDDM power icons. It is chained after the `gsettings` write in one shell, -since it reads it. Waybar is gone, so the icon switch no longer reruns -waybar-theme-udt's `install.sh` or signals waybar. - -## Wallpapers - -Clicking a thumbnail **stages** it rather than setting it, so both screens can -be composed before anything changes, and Apply then makes a single -`wallp --set H=… V=…` call. The mock screens on the right show what the desk -would look like: the staged pick where there is one, what is currently set -where there is not, and the hovered thumbnail on the targeted screen while the -pointer is over it. - -The thumbnails are grouped by folder, each a header that opens and closes -it, closed to begin with and remembered while the shell runs. The local -groups are the top-level folders of `~/Pictures/wallpapers`, with the images -loose in it as a group of its own; after them come the distribution's, -`/usr/share/wallpapers`, as `system` for its loose images and `Plasma` for -its packages. A Plasma package holds one picture at several resolutions, -some in a dark version too, so it shows once: dark where there is one, then -the largest, named after the package rather than its `5120x2880.png`. - -The list is flat, one row of thumbnails per item, so only the rows in view -exist and decode, and a folder of hundreds loads as it scrolls. Opening or -closing a folder inserts or removes just its rows, which keeps the scroll -position and the thumbnails around it. A first version made each folder one -item, which loaded every thumbnail of a folder the moment it opened. - -Each thumbnail's border says how it will look on the targeted screen, from -its real size, which `file` reads from the header after each scan (half a -second for a thousand). `swaybg -m fill` scales to cover, so the measure is -how far it enlarges: not at all is green, up to 1.33x yellow, past that red. -Under the mock screens a note in the same colour says why, for the hovered -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 -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 -replaces its qarma file dialog, not the script. - -The two monitors are drawn at their real proportions, read from -`hyprctl monitors`: DP-1 is 2560x1080 and DP-3 is 1920x1080 with -`transform=1`, which makes it 1080x1920 on the desk. They are centred against -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 -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. - -## Themes - -Each scheme shows its real colours, parsed from the pair of files -unified-desktop-theme keeps for it: `palette/.conf` holds the colours -under the scheme's own names, `palette/roles-.conf` says what each -colour is for, so a role is resolved by looking its value up as a key in the -first file. Beside the swatches is a small mock of a panel painted in that -scheme, which says how the colours sit together rather than only what they -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. - -Applying writes the `scheme =` line in `~/.config/udt/roles.conf` and runs -`install.sh`, which regenerates every themed config and reloads Hyprland, -kitty and conky. Its whole output becomes the `install` tab of the -steps window below. The notice names what is left: Qt and GTK apps only reread -a theme when they restart, and neovim needs `:colorscheme udt`. - -## Steps - -A separate window, `StepsWindow.qml`, for the manual steps -unified-desktop-theme cannot run itself: the root copies into `/boot` and -`/usr/share/sddm`, the finish on the homepage host, and the last `install.sh` -log, shown first as "General recap". Its `udt-steps` writes one file per consumer under `~/.cache/udt/steps/` -and calls the `steps` verb, which reloads the tabs and opens the window if any -remain; an empty file set closes it. Nothing is watched: the poke is the -signal. The same verb reopens it after Close. - -Centred on DP-3, on the `top` layer with on-demand keyboard focus, so it can -stay open while commands are pasted into a terminal elsewhere. A line indented -two spaces is a command block with a Copy button (`wl-copy`), and the text is -selectable too. Done deletes the tab's file; the producer also clears it once -its consumer is up to date. - -The window's height comes from its content, so the scroll area is capped -against the screen and never against the window: binding it to the window's -own height collapsed the window to its margins. - -## Two QML traps met here - -**There is no `String.matchAll`.** QML's JS engine does not have it. It threw -inside a `try` and left every swatch empty with nothing in the log. The -palette parser uses an `exec` loop instead. - -**Assigning `running = true` to a Process that is already running does -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: - - hl.layer_rule({ - name = "blur-appearance", - match = { namespace = "^(quickshell-appearance)$" }, - blur = true, - xray = false, - ignore_alpha = 0.1, - }) diff --git a/appearance/Steps.qml b/appearance/Steps.qml deleted file mode 100644 index ec0a5b7..0000000 --- a/appearance/Steps.qml +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// 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. - -pragma Singleton - -import Quickshell -import Quickshell.Io -import QtQuick - -// 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 -// install.sh log. Each is a file in ~/.cache/udt/steps/, written by -// udt-steps, which then calls this shell's `steps` IPC verb. Nothing is -// watched: the poke is the signal, and a shell started later finds the files -// on its first reload. -Singleton { - id: root - - readonly property string dir: `${Quickshell.env("HOME")}/.cache/udt/steps` - - // [{ name, label, blocks: [{ cmd, text }] }]: the install.sh recap first, - // then one tab per consumer by name. A file with no label here shows - // under its own name, so a new producer needs no edit. - property var tabs: [] - readonly property var labels: ({ - install: "General recap", grub: "GRUB", sddm: "SDDM", homepage: "Homepage", - }) - signal loaded - - // A line indented two spaces is a command, and a run of them is one block - // with its own copy button. Blank lines split prose into paragraphs. The - // format is what the producers already print to a terminal. - function parse(text) { - const out = []; - const lines = text.split("\n"); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const cmd = line.startsWith(" "); - const last = out.length ? out[out.length - 1] : null; - if (!cmd && line.trim() === "") { - if (last) last.closed = true; - continue; - } - const body = cmd ? line.slice(2) : line; - if (last && last.cmd === cmd && !last.closed) last.lines.push(body); - else out.push({ cmd: cmd, lines: [body], closed: false }); - } - return out.map(b => ({ cmd: b.cmd, text: b.lines.join("\n") })); - } - - function commands(tab) { - return tab.blocks.filter(b => b.cmd).map(b => b.text).join("\n"); - } - - function copy(text) { - Quickshell.execDetached(["wl-copy", "--", text]); - } - - function refresh() { - listProc.running = false; - listProc.running = true; - } - - function dismiss(name) { - rmProc.command = ["rm", "-f", `${root.dir}/${name}`]; - rmProc.running = true; - } - - // One pass over every file, each prefixed by an ASCII record separator - // and its name. The names are udt-steps' [a-z0-9-], and its temporary - // files are dotfiles, which the glob skips. - Process { - id: listProc - command: ["sh", "-c", - `cd ${JSON.stringify(root.dir)} 2>/dev/null || exit 0; ` + - `for f in *; do [ -f "$f" ] && printf '\\036%s\\n' "$f" && cat "$f"; done`] - stdout: StdioCollector { - onStreamFinished: { - root.tabs = text.split("\x1e").filter(s => s.length).map(rec => { - const nl = rec.indexOf("\n"); - const name = rec.slice(0, nl); - return { name: name, label: root.labels[name] ?? name, - blocks: root.parse(rec.slice(nl + 1)) }; - }).sort((a, b) => (b.name === "install") - (a.name === "install") - || a.label.localeCompare(b.label)); - root.loaded(); - } - } - } - - Process { - id: rmProc - onExited: root.refresh() - } - - function selftest(): string { - const b = root.parse("Intro line\nstill intro\n cmd one\n cmd two\n\nNext para\n cmd three\n"); - if (b.length !== 4 || b[0].cmd || b[0].text !== "Intro line\nstill intro" || - !b[1].cmd || b[1].text !== "cmd one\ncmd two" || b[2].text !== "Next para" || - b[3].text !== "cmd three") - return `SELFTEST Steps FAIL: ${JSON.stringify(b)}`; - if (root.commands({ blocks: b }) !== "cmd one\ncmd two\ncmd three") - return "SELFTEST Steps FAIL: commands"; - return "SELFTEST Steps PASS"; - } -} diff --git a/appearance/StepsWindow.qml b/appearance/StepsWindow.qml deleted file mode 100644 index 8117885..0000000 --- a/appearance/StepsWindow.qml +++ /dev/null @@ -1,197 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// 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 - -// 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 -// elsewhere, so the window takes keyboard focus only when clicked and stays -// until closed. Done deletes a tab's file; the producer also clears it once -// the consumer is up to date. -Scope { - id: root - - property string monitor: "DP-3" - property bool open: false - property string tab: "" - - readonly property var screenObj: - Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] - readonly property var current: Steps.tabs.find(t => t.name === root.tab) ?? Steps.tabs[0] ?? null - - // A poke from udt-steps: reload, and open when something is pending. - // Also what a keybind calls to bring the window back after closing it. - function poke() { - root.opening = true; - Steps.refresh(); - } - property bool opening: false - - Connections { - target: Steps - function onLoaded() { - if (Steps.tabs.length === 0) root.open = false; - else if (root.opening) root.open = true; - root.opening = false; - } - } - - LazyLoader { - active: root.open - - PanelWindow { - id: win - screen: root.screenObj - // No anchors: a layer surface anchored nowhere is centred. - implicitWidth: 960 - implicitHeight: body.implicitHeight + 40 - color: "transparent" - exclusionMode: ExclusionMode.Ignore - WlrLayershell.layer: WlrLayer.Top - WlrLayershell.namespace: "quickshell-steps" - WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand - - Rectangle { - anchors.fill: parent - radius: 14 - color: Qt.alpha(Theme.base, 0.92) - border.width: 1 - border.color: Qt.alpha(Theme.text, 0.12) - - Item { - anchors.fill: parent - focus: true - Keys.onEscapePressed: root.open = false - } - - Column { - id: body - anchors { top: parent.top; left: parent.left; right: parent.right; margins: 20 } - spacing: 16 - - Item { - width: parent.width - height: tabBar.implicitHeight - - Row { - id: tabBar - spacing: 8 - Repeater { - model: Steps.tabs - Tab { - required property var modelData - text: modelData.label - selected: root.current !== null && root.current.name === modelData.name - onClicked: root.tab = modelData.name - } - } - } - - Tab { - anchors.right: parent.right - text: "Close" - onClicked: root.open = false - } - } - - Flickable { - width: parent.width - // Sized from the screen, never from the window: the - // window's height comes from this, and a binding back - // to it collapsed the whole thing to its margins. - height: Math.min(content.implicitHeight, - win.screen.height - 240 - tabBar.implicitHeight - actions.implicitHeight - 32) - contentHeight: content.implicitHeight - clip: true - - Column { - id: content - width: parent.width - spacing: 12 - - Repeater { - model: root.current ? root.current.blocks : [] - - Loader { - required property var modelData - width: content.width - sourceComponent: modelData.cmd ? cmdBlock : prose - property var block: modelData - } - } - } - } - - Row { - id: actions - spacing: 8 - anchors.right: parent.right - Tab { - text: "Copy all commands" - enabled: root.current !== null && Steps.commands(root.current) !== "" - onClicked: Steps.copy(Steps.commands(root.current)) - } - Tab { - text: "Done" - enabled: root.current !== null - onClicked: Steps.dismiss(root.current.name) - } - } - } - } - - Component { - id: prose - Text { - text: block.text - wrapMode: Text.Wrap - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } - color: Theme.text - } - } - - Component { - id: cmdBlock - Rectangle { - implicitHeight: Math.max(code.implicitHeight, copyBtn.implicitHeight) + 24 - radius: 8 - color: Qt.alpha(Theme.crust, 0.8) - border.width: 1 - border.color: Qt.alpha(Theme.accent, 0.3) - - // Selectable as well as copyable: sometimes one line of a - // block is what is wanted. - TextEdit { - id: code - anchors { left: parent.left; right: copyBtn.left; verticalCenter: parent.verticalCenter; margins: 12 } - text: block.text - readOnly: true - selectByMouse: true - wrapMode: TextEdit.WrapAnywhere - font { family: "Inconsolata Nerd Font Mono"; pixelSize: Theme.fontSize - 1 } - color: Theme.text - selectionColor: Qt.alpha(Theme.accent, 0.4) - selectedTextColor: Theme.text - } - - Tab { - id: copyBtn - anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } - text: "Copy" - onClicked: Steps.copy(block.text) - } - } - } - } - } -} diff --git a/appearance/SunsetTab.qml b/appearance/SunsetTab.qml deleted file mode 100644 index 50f6ad6..0000000 --- a/appearance/SunsetTab.qml +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// 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 - -Flickable { - contentHeight: col.implicitHeight - clip: true - - Column { - id: col - width: parent.width - spacing: 12 - - Row { - spacing: 8 - Text { - anchors.verticalCenter: parent.verticalCenter - text: "Lat" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - Field { width: 90; value: Hyprsunset.lat; onEdited: Hyprsunset.lat = value; placeholder: "lat" } - Text { - anchors.verticalCenter: parent.verticalCenter - text: "Lon" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - Field { width: 90; value: Hyprsunset.lon; onEdited: Hyprsunset.lon = value; placeholder: "lon" } - Toggle { - anchors.verticalCenter: parent.verticalCenter - checked: Hyprsunset.autoDetect - label: "auto" - onToggled: Hyprsunset.autoDetect = checked - } - Tab { text: "Detect"; onClicked: Hyprsunset.detect() } - Tab { text: Hyprsunset.busy ? "fetching…" : "Fetch sun times"; onClicked: Hyprsunset.fetchSun() } - Text { - anchors.verticalCenter: parent.verticalCenter - text: Hyprsunset.sunSummary - color: Theme.overlay - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - } - } - - Repeater { - model: Hyprsunset.profiles - Rectangle { - required property var modelData - required property int index - width: col.width - implicitHeight: 40 - radius: 8 - color: Qt.alpha(Theme.surface, 0.35) - - Row { - anchors.fill: parent - anchors.margins: 8 - spacing: 8 - - Text { - anchors.verticalCenter: parent.verticalCenter - text: index === Hyprsunset.dayIndex(Hyprsunset.profiles) ? "day" - : index === Hyprsunset.nightIndex(Hyprsunset.profiles) ? "night" : "profile" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - } - Field { - anchors.verticalCenter: parent.verticalCenter - width: 60 - value: modelData.time - placeholder: "HH:MM" - onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map( - (p, i) => i === index ? Object.assign({}, p, { time: value }) : p) - } - Toggle { - anchors.verticalCenter: parent.verticalCenter - checked: modelData.identity - label: "identity" - onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map( - (p, i) => i === index ? Object.assign({}, p, { identity: checked }) : p) - } - Toggle { - anchors.verticalCenter: parent.verticalCenter - checked: modelData.temperature !== null && modelData.temperature !== undefined - label: "temp" - onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map( - (p, i) => i === index ? Object.assign({}, p, - { temperature: checked ? (p.temperature ?? 5500) : null }) : p) - } - Field { - anchors.verticalCenter: parent.verticalCenter - width: 70 - value: modelData.temperature === null || modelData.temperature === undefined - ? "" : String(modelData.temperature) - placeholder: "5500" - onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map( - (p, i) => i === index ? Object.assign({}, p, - { temperature: value === "" ? null : parseInt(value, 10) }) : p) - } - Toggle { - anchors.verticalCenter: parent.verticalCenter - checked: modelData.gamma !== null && modelData.gamma !== undefined - label: "gamma" - onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map( - (p, i) => i === index ? Object.assign({}, p, - { gamma: checked ? (p.gamma ?? 1.0) : null }) : p) - } - Field { - anchors.verticalCenter: parent.verticalCenter - width: 60 - value: modelData.gamma === null || modelData.gamma === undefined - ? "" : String(modelData.gamma) - placeholder: "0.8" - onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map( - (p, i) => i === index ? Object.assign({}, p, - { gamma: value === "" ? null : parseFloat(value) }) : p) - } - Item { width: 8; height: 1 } - Tab { - anchors.verticalCenter: parent.verticalCenter - text: "✕" - onClicked: Hyprsunset.profiles = - Hyprsunset.profiles.filter((p, i) => i !== index) - } - // The Row is pinned to the card width, so parent.width is - // real, but bound it anyway: a narrow enough panel would - // make the subtraction negative and put the Row in a - // negative-width state. - Item { width: Math.max(0, parent.width - 640); height: 1 } - } - } - } - - Row { - spacing: 8 - Tab { - text: "+ Add profile" - onClicked: Hyprsunset.profiles = - Hyprsunset.profiles.concat([{ time: "0:00", identity: false, - temperature: null, gamma: null }]) - } - Tab { text: "Live preview"; onClicked: Hyprsunset.preview() } - Tab { - text: "Save + Restart" - selected: true - onClicked: Hyprsunset.save() - } - } - - Text { - width: parent.width - wrapMode: Text.Wrap - visible: Hyprsunset.notice !== "" - text: Hyprsunset.notice - color: Hyprsunset.notice.indexOf("invalid") === 0 ? Theme.red : Theme.green - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - } -} diff --git a/appearance/Tab.qml b/appearance/Tab.qml deleted file mode 100644 index bb2d210..0000000 --- a/appearance/Tab.qml +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// 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 - -Rectangle { - id: tab - property alias text: label.text - property bool selected: false - property bool enabled: true - signal clicked - - implicitWidth: label.implicitWidth + 24 - implicitHeight: label.implicitHeight + 14 - radius: 7 - opacity: enabled ? 1 : 0.4 - color: selected ? Qt.alpha(Theme.accent, 0.25) - : area.containsMouse ? Qt.alpha(Theme.surface, 0.8) - : Qt.alpha(Theme.surface, 0.35) - border.width: 1 - border.color: selected ? Qt.alpha(Theme.accent, 0.5) : "transparent" - - Text { - id: label - anchors.centerIn: parent - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } - color: tab.selected ? Theme.text : Theme.subtext - } - - MouseArea { - id: area - anchors.fill: parent - hoverEnabled: true - cursorShape: tab.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: if (tab.enabled) tab.clicked() - } -} 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/Toggle.qml b/appearance/Toggle.qml deleted file mode 100644 index 2bb56a2..0000000 --- a/appearance/Toggle.qml +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// 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 - -Rectangle { - id: toggle - property bool checked: false - property string label: "" - signal toggled - - implicitWidth: row.implicitWidth - implicitHeight: 22 - color: "transparent" - - Row { - id: row - anchors.verticalCenter: parent.verticalCenter - spacing: 6 - Rectangle { - anchors.verticalCenter: parent.verticalCenter - width: 16; height: 16; radius: 4 - color: toggle.checked ? Qt.alpha(Theme.accent, 0.7) : Qt.alpha(Theme.surface, 0.5) - border.width: 1 - border.color: toggle.checked ? Qt.alpha(Theme.accent, 0.9) : Qt.alpha(Theme.text, 0.2) - Text { - anchors.centerIn: parent - visible: toggle.checked - text: "✓" - font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 6; bold: true } - color: Theme.base - } - } - Text { - anchors.verticalCenter: parent.verticalCenter - visible: toggle.label !== "" - text: toggle.label - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - color: Theme.subtext - } - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { toggle.checked = !toggle.checked; toggle.toggled(); } - } -} diff --git a/appearance/Udt.qml b/appearance/Udt.qml deleted file mode 100644 index cf97ba5..0000000 --- a/appearance/Udt.qml +++ /dev/null @@ -1,152 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// 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. - -pragma Singleton - -import Quickshell -import Quickshell.Io -import QtQuick - -// The unified-desktop-theme side: which schemes exist, what they look like, -// and switching between them. -Singleton { - id: root - - readonly property string home: Quickshell.env("HOME") - readonly property string repo: `${home}/Programming/GIT/unified-desktop-theme` - readonly property string selector: `${home}/.config/udt/roles.conf` - - property string current: "" - // name -> { bg, surface, text, accent, red, green, yellow } - property var swatches: ({}) - property list names: [] - - property bool applying: false - signal applied(string scheme, bool ok, string message) - - // A scheme is two files: .conf holds colours under the scheme's own - // names, roles-.conf says what each colour is for. Resolving a role - // means looking its value up as a key in the first file. - function parseSwatch(rolesText, paletteText) { - // QML's JS engine has no String.matchAll, so this is an exec loop. - const colours = {}; - const re = /^(\w+)\s*=\s*(#[0-9a-fA-F]{6})/gm; - let m; - while ((m = re.exec(paletteText)) !== null) colours[m[1]] = m[2]; - - const role = key => { - const m = rolesText.match(new RegExp(`^${key}\\s*=\\s*(\\S+)`, "m")); - // A role can carry a /alpha or *shade suffix; the base colour is - // what a swatch shows. - return m ? colours[m[1].split(/[/*]/)[0]] ?? "" : ""; - }; - - return { - bg: role("bg"), surface: role("surface"), text: role("fg"), - accent: role("accent"), red: role("critical"), - green: role("success"), yellow: role("warning"), - }; - } - - // Which scheme is live. Watched, so switching it from an editor moves the - // panel's marker too. - FileView { - path: root.selector - watchChanges: true - onFileChanged: reload() - onLoaded: { - const m = text().match(/^\s*scheme\s*=\s*(\S+)/m); - if (m) root.current = m[1]; - } - } - - // The scheme list comes from the palette directory rather than the comment - // in roles.conf: adding a scheme is adding two files, and this then needs - // no edit at all. - // Rescanned whenever the panel opens: adding a scheme is adding two - // files, and a shell that has been running since before they appeared - // would otherwise keep showing the old list. - function refresh() { - listProc.running = false; - listProc.running = true; - } - - Process { - id: listProc - running: true - command: ["sh", "-c", - `ls ${root.repo}/palette/roles-*.conf 2>/dev/null | ` + - `sed 's|.*/roles-||; s|\\.conf$||' | sort`] - stdout: StdioCollector { - onStreamFinished: { - root.names = text.trim().split("\n").filter(s => s.length); - loadProc.next = 0; - loadProc.loadNext(); - } - } - } - - Process { - id: loadProc - property int next: 0 - property string scheme: "" - - function loadNext() { - if (next >= root.names.length) return; - scheme = root.names[next]; - next++; - // Both files at once, split on a marker: one process per scheme - // rather than two, and the pair is always consistent. - command = ["sh", "-c", - `cat ${root.repo}/palette/roles-${scheme}.conf; ` + - `echo '===SPLIT==='; cat ${root.repo}/palette/${scheme}.conf`]; - // Assigning true to an already-true `running` does nothing, and - // this Process is reused for every scheme in turn. - running = false; - running = true; - } - - stdout: StdioCollector { - onStreamFinished: { - const [rolesText, paletteText] = text.split("===SPLIT==="); - if (rolesText && paletteText) { - const next = Object.assign({}, root.swatches); - next[loadProc.scheme] = root.parseSwatch(rolesText, paletteText); - root.swatches = next; - } - loadProc.loadNext(); - } - } - } - - // Switching writes the scheme line and regenerates every config; - // install.sh also reloads what is running, and hands its whole output to - // the steps window's `install` tab itself. - function apply(scheme) { - if (applying || scheme === current) return; - applying = true; - applyProc.scheme = scheme; - applyProc.command = ["sh", "-c", - `sed -i 's|^scheme *=.*|scheme = ${scheme}|' ${JSON.stringify(root.selector)} && ` + - `${JSON.stringify(root.repo)}/install.sh >/dev/null 2>&1`]; - applyProc.running = true; - } - - Process { - id: applyProc - property string scheme: "" - onExited: code => { - root.applying = false; - root.applied(applyProc.scheme, code === 0, - code === 0 ? "" : `install.sh exited ${code}, its output is in the steps window`); - } - } -} diff --git a/appearance/Wallpapers.qml b/appearance/Wallpapers.qml deleted file mode 100644 index 9f43675..0000000 --- a/appearance/Wallpapers.qml +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// 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. - -pragma Singleton - -import Quickshell -import Quickshell.Io -import QtQuick - -// 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 -// reimplementing here. -Singleton { - id: root - - readonly property string home: Quickshell.env("HOME") - readonly property string dir: `${home}/Pictures/wallpapers` - // The distribution's wallpapers: loose images, and Plasma packages that - // each carry one picture at several resolutions under contents/images/, - // some with a dark version under contents/images_dark/. - readonly property string sysDir: "/usr/share/wallpapers" - - // Which screen a pick applies to. wallp calls them H and V. - property string target: "H" - - // Staged picks, empty until something is chosen. Selecting does not set - // anything: both screens can be composed and applied together, which is - // also a single wallp call rather than two. - property string pendingH: "" - property string pendingV: "" - readonly property bool dirty: pendingH !== "" || pendingV !== "" - - function pick(path) { - if (target === "V") pendingV = path; - else pendingH = path; - } - - function clearPending() { pendingH = ""; pendingV = ""; } - - // What a screen should show: the staged pick if there is one, else what - // is actually set. - function shown(which) { - if (which === "V") return pendingV !== "" ? pendingV : currentV; - return pendingH !== "" ? pendingH : currentH; - } - - property list files: [] - property bool scanning: true - property bool applying: false - signal applied(bool ok, string message) - - // Sorted by most recent first: the wallpaper wanted now is usually one - // added recently. -maxdepth keeps a stray git checkout from being walked. - function refresh() { - scanning = true; - scanProc.running = false; - scanProc.running = true; - } - - Process { - id: scanProc - running: true - // img DIR "DEPTH" [ACTIONS...]: the image files under DIR. DEPTH is - // left unquoted on purpose, so "-maxdepth 2" splits into two words - // and "" vanishes. - command: ["sh", "-c", - `img() { d=$1; o=$2; shift 2; find "$d" $o -type f ` + - `\\( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.webp' \\) "$@" 2>/dev/null; }; ` + - `img ${JSON.stringify(root.dir)} "-maxdepth 2" -not -path '*/.*' -printf '%T@ %p\\n' | sort -rn | cut -d' ' -f2-; ` + - `img ${JSON.stringify(root.sysDir)} "-maxdepth 1" | sort; ` + - `img ${JSON.stringify(root.sysDir)} "" -path '*/contents/images*/*'`] - stdout: StdioCollector { - onStreamFinished: { - root.files = text.trim().split("\n").filter(s => s.length); - root.scanning = false; - sizeProc.running = false; - sizeProc.command = ["sh", "-c", 'printf "%s\\0" "$@" | xargs -0 -r file -N -0', "sh"].concat(root.files); - sizeProc.running = true; - } - } - } - - // Each file's pixel size, by path, from `file`, which reads only the - // header: about half a second for a thousand. -0 ends the name with a - // NUL, since a name can hold the ": " that would otherwise end it. The - // size is the last WxH in the description, because a JPEG's comes after - // its "density 72x72". A file `file` gives no size for has no entry and - // gets no quality colour. - property var sizes: ({}) - Process { - id: sizeProc - stdout: StdioCollector { - onStreamFinished: { - const out = {}; - for (const line of text.split("\n")) { - const nul = line.indexOf("\0"); - if (nul < 0) continue; - const re = /(\d+) ?x ?(\d+)/g; - let m, last = null; - while ((m = re.exec(line.slice(nul + 1))) !== null) last = m; - if (last) out[line.slice(0, nul)] = { w: +last[1], h: +last[2] }; - } - root.sizes = out; - } - } - } - - // The screens wallp sets, as the mock draws them: DP-1 wide, DP-3 turned - // upright. - // ponytail: fixed, like the mock; read Quickshell.screens if a monitor - // changes. - readonly property var panels: ({ H: { w: 2560, h: 1080 }, V: { w: 1080, h: 1920 } }) - - // How far swaybg's `-m fill` has to enlarge an image to cover a screen, - // 0 when the size is unknown. At or under 1 it is downscaled and sharp; - // to 1.33 the softening is slight; past that it shows pixels. - function upscale(path, which) { - const s = sizes[path]; - const p = panels[which]; - return s && s.w > 0 && s.h > 0 ? Math.max(p.w / s.w, p.h / s.h) : 0; - } - function qualityColor(path, which) { - const u = upscale(path, which); - return u === 0 ? "transparent" : u <= 1 ? Theme.green : u <= 1.33 ? Theme.yellow : Theme.red; - } - // Why an image that fits will still look bad, or "" when it will not. - function qualityNote(path, which) { - const u = upscale(path, which); - if (u <= 1) return ""; - const s = sizes[path], p = panels[which]; - return `${s.w}×${s.h} is enlarged ${u.toFixed(2)}× to cover the ${p.w}×${p.h} screen: ` - + (u <= 1.33 ? "each image pixel is spread over more than one screen pixel, so it will look slightly soft." - : "each image pixel becomes a visible block, so it will look pixelated."); - } - - function basename(path) { return path.slice(path.lastIndexOf("/") + 1); } - - // The files by the subfolder they sit in, folders by name, each keeping - // the newest-first order of `files`. Images loose in `dir` itself form a - // group named after it, which sorts first. A folder with no images at - // the scanned depth has no group. The system ones follow: its loose - // images as "system", and the Plasma packages as "Plasma", one image - // each: the dark version where the package has one, else the light, and - // of those the largest by the WxH its file is named after. `key` is what - // `expanded` is indexed by, so a local folder named "system" stays apart. - readonly property var groups: { - const local = {}, system = [], plasma = {}; - for (const f of files) { - if (f.startsWith(dir + "/")) { - const rel = f.slice(dir.length + 1); - const key = rel.includes("/") ? rel.slice(0, rel.indexOf("/")) : ""; - if (!local[key]) local[key] = []; - local[key].push(f); - } else if (f.includes("/contents/images")) { - const pkg = f.slice(sysDir.length + 1).split("/")[0]; - if (!plasma[pkg] || rank(f) > rank(plasma[pkg])) plasma[pkg] = f; - } else { - system.push(f); - } - } - const byName = (a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }); - return Object.keys(local).sort(byName) - .map(k => ({ key: "local/" + k, name: k || basename(dir), files: local[k] })) - .concat([ - { key: "system", name: "system", files: system }, - { key: "plasma", name: "Plasma", files: Object.keys(plasma).sort(byName).map(k => plasma[k]) }, - ]) - .filter(g => g.files.length > 0); - } - - // Dark outranks any light, then size decides. - function rank(path) { - const m = /(\d+)x(\d+)\.\w+$/.exec(path); - return (path.includes("/contents/images_dark/") ? 1e12 : 0) + (m ? m[1] * m[2] : 0); - } - - // What a thumbnail is called: a Plasma package's file is named after its - // resolution, so it goes by the package instead. - function label(path) { - const i = path.indexOf("/contents/images"); - return i < 0 ? basename(path) : basename(path.slice(0, i)); - } - - // Which groups are open, by key. Here rather than in the panel, which is - // destroyed on close, so a folder left open is open next time. Reassigned - // whole, because bindings do not see a key change inside a var. - property var expanded: ({}) - function toggle(key) { - const e = Object.assign({}, expanded); - e[key] = !e[key]; - expanded = e; - } - - // What is on each screen now. wallp writes these on every set, so - // watching them keeps the mock honest even when set from a terminal. - property string currentH: "" - property string currentV: "" - - FileView { - path: `${root.home}/.config/wallp/wall_h` - watchChanges: true - onFileChanged: reload() - onLoaded: root.currentH = text().trim() - } - - FileView { - path: `${root.home}/.config/wallp/wall_v` - watchChanges: true - onFileChanged: reload() - onLoaded: root.currentV = text().trim() - } - - // wallp does the work, including running udt-accent, so the accent follows - // the new wallpaper exactly as it does from the command line. Both screens - // go in one call: wallp accepts H= and V= together. - function apply() { - if (applying || !dirty) return; - applying = true; - const args = []; - if (pendingH !== "") args.push(`H=${JSON.stringify(pendingH)}`); - if (pendingV !== "") args.push(`V=${JSON.stringify(pendingV)}`); - applyProc.command = ["sh", "-c", `wallp --set ${args.join(" ")} >/dev/null 2>&1`]; - applyProc.running = true; - } - - Process { - id: applyProc - stderr: StdioCollector { id: applyErr } - onExited: code => { - root.applying = false; - if (code === 0) root.clearPending(); - root.applied(code === 0, code === 0 ? "" : (applyErr.text.trim() || `wallp exited ${code}`)); - } - } -} 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. -// -// 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//` 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/desktop/modules/appearance/AppearanceModule.qml b/desktop/modules/appearance/AppearanceModule.qml deleted file mode 100644 index ca9dc7d..0000000 --- a/desktop/modules/appearance/AppearanceModule.qml +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// 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 -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. -Module { - id: mod - - name: "appearance" - icon: "" - label: "Appearance" - - 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"] - } -} diff --git a/desktop/modules/appearance/Field.qml b/desktop/modules/appearance/Field.qml new file mode 100644 index 0000000..ad6af85 --- /dev/null +++ b/desktop/modules/appearance/Field.qml @@ -0,0 +1,51 @@ +// Copyright (C) 2026 Danilo M. +// +// 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 "../.." + +Rectangle { + id: field + property alias value: input.text + property string placeholder: "" + signal edited + + implicitWidth: 70 + height: 24 + radius: 5 + color: Qt.alpha(Theme.surface, 0.5) + border.width: 1 + border.color: input.activeFocus ? Qt.alpha(Theme.accent, 0.6) : Qt.alpha(Theme.text, 0.12) + + Text { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + verticalAlignment: Text.AlignVCenter + visible: input.text === "" + text: field.placeholder + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + + TextInput { + id: input + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + verticalAlignment: TextInput.AlignVCenter + clip: true + selectByMouse: true + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.text + onEditingFinished: field.edited() + } +} diff --git a/desktop/modules/appearance/Hypridle.qml b/desktop/modules/appearance/Hypridle.qml new file mode 100644 index 0000000..a1d3ecd --- /dev/null +++ b/desktop/modules/appearance/Hypridle.qml @@ -0,0 +1,162 @@ +// Copyright (C) 2026 Danilo M. +// +// 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. + +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 +// kept verbatim so the general block and the rationale comments survive. +Singleton { + id: root + + readonly property string home: Quickshell.env("HOME") + readonly property string confPath: `${home}/.config/hypr/hypridle.conf` + + property string prefix: "" + property var listeners: [] + property string tail: "" + property string notice: "" + property bool loaded: false + + function getField(body, key) { + const m = body.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, "m")); + return m ? m[1] : null; + } + + // A disabled listener is written every line prefixed with "# ". Strip it + // before reading fields. + function uncomment(body) { + return body.split("\n").map(l => l.replace(/^([ \t]*)#[ \t]?/, "$1")).join("\n"); + } + + // The closing brace is matched at the start of a line, because the dpms + // command contains a brace mid-line and a non-greedy match would stop + // inside it. A disabled listener has both its opening line and its closing + // brace commented, so the closing pattern allows a leading "#". The first + // listener's leading trivia is empty because its text is the prefix. + function parse(text) { + const re = /^([ \t]*)(#?)[ \t]*listener\s*\{([\s\S]*?)^[ \t]*#?[ \t]*\}/gm; + const found = []; + let firstStart = -1; + let prevEnd = 0; + let m; + while ((m = re.exec(text)) !== null) { + const start = m.index; + if (firstStart < 0) { firstStart = start; prevEnd = start; } + const leading = text.slice(prevEnd, start); + const enabled = m[2] !== "#"; + const body = enabled ? m[3] : root.uncomment(m[3]); + const t = root.getField(body, "timeout"); + found.push({ + leading: leading, + timeout: t === null ? 0 : parseInt(t, 10), + onTimeout: root.getField(body, "on-timeout") ?? "", + onResume: root.getField(body, "on-resume") ?? "", + enabled: enabled, + }); + prevEnd = start + m[0].length; + } + if (firstStart < 0) return { prefix: text, listeners: [], tail: "" }; + return { prefix: text.slice(0, firstStart), listeners: found, tail: text.slice(prevEnd) }; + } + + function serialize(prefix, list, tail) { + let out = prefix; + for (const l of list) { + const lines = ["listener {", ` timeout = ${l.timeout}`, + ` on-timeout = ${l.onTimeout}`]; + if (l.onResume) lines.push(` on-resume = ${l.onResume}`); + lines.push("}"); + const body = l.enabled ? lines.join("\n") : lines.map(x => "# " + x).join("\n"); + out += l.leading + body; + } + return out + tail; + } + + function describe(l) { + const cmd = l.onTimeout; + if (cmd.indexOf("notify-send") === 0) return "notify before lock"; + if (cmd.indexOf("loginctl lock-session") >= 0) return "lock session"; + if (cmd.indexOf("dpms") >= 0) return "monitors off"; + if (cmd.indexOf("loginctl suspend") >= 0) return "suspend"; + return cmd; + } + + function selftest(): string { + const fixture = String.raw`general { + lock_cmd = pidof hyprlock || hyprlock +} + +# 10:00s - screen lock +listener { + timeout = 600 + on-timeout = loginctl lock-session +} + +# 10:30s - monitor off +listener { + timeout = 630 + on-timeout = hyprctl dispatch "hl.dsp.dpms({ state = \"off\" })" + on-resume = hyprctl dispatch "hl.dsp.dpms({ state = \"on\" })" +} +`; + const p = root.parse(fixture); + if (p.listeners.length !== 2) return `SELFTEST Hypridle FAIL: ${p.listeners.length} listeners`; + if (p.listeners[0].timeout !== 600 || p.listeners[0].enabled !== true) + return "SELFTEST Hypridle FAIL: first listener"; + if (p.listeners[1].onTimeout.indexOf(String.raw`state = \"off\"`) < 0) + return "SELFTEST Hypridle FAIL: dpms command truncated"; + if (p.listeners[1].onResume.indexOf(String.raw`state = \"on\"`) < 0) + return "SELFTEST Hypridle FAIL: dpms on-resume lost"; + if (root.serialize(p.prefix, p.listeners, p.tail) !== fixture) + return "SELFTEST Hypridle FAIL: round-trip not byte-identical"; + const off = p.listeners.slice(); + off[1] = Object.assign({}, off[1], { enabled: false }); + const p2 = root.parse(root.serialize(p.prefix, off, p.tail)); + if (p2.listeners.length !== 2 || p2.listeners[1].enabled !== false || + p2.listeners[1].timeout !== 630) + return "SELFTEST Hypridle FAIL: disabled listener not round-tripped"; + return "SELFTEST Hypridle PASS"; + } + + function refresh() { + confProc.running = false; + confProc.running = true; + } + + Process { + id: confProc + command: ["cat", root.confPath] + stdout: StdioCollector { + onStreamFinished: { + const p = root.parse(text); + root.prefix = p.prefix; + root.listeners = p.listeners; + root.tail = p.tail; + root.loaded = true; + } + } + } + + FileView { id: confFile; path: root.confPath } + + function save() { + if (!root.loaded) return; // not loaded yet: writing would truncate the file + confFile.setText(root.serialize(root.prefix, root.listeners, root.tail)); + Quickshell.execDetached(["sh", "-c", "pkill -x hypridle; setsid -f hypridle"]); + root.notice = "saved; idle timers reset"; + } +} diff --git a/desktop/modules/appearance/Hyprsunset.qml b/desktop/modules/appearance/Hyprsunset.qml new file mode 100644 index 0000000..5886735 --- /dev/null +++ b/desktop/modules/appearance/Hyprsunset.qml @@ -0,0 +1,327 @@ +// Copyright (C) 2026 Danilo M. +// +// 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. + +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 +// byte-for-byte what ~/Programming/GIT/sunset-qt writes, so both tools edit +// the same file. +Singleton { + id: root + + readonly property string home: Quickshell.env("HOME") + readonly property string confPath: `${home}/.config/hypr/hyprsunset.conf` + readonly property string appConfPath: `${home}/.config/hyprsunset-qt/config` + + property var profiles: [] + property string lat: "" + property string lon: "" + property bool autoDetect: true + property string daemonCommand: "hyprsunset" + property string cachePathRaw: "~/.config/hyprsunset-qt/sun.json" + property string cachePath: `${home}/.config/hyprsunset-qt/sun.json` + property string notice: "" + property string sunSummary: "" + property bool busy: false + property bool confLoaded: false + property bool appLoaded: false + + readonly property string header: + "# Managed by hyprsunset-qt. Edits here are overwritten on save.\n" + + // QML has no String.matchAll; everything here is exec loops. + function getField(body, key) { + const m = body.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, "m")); + return m ? m[1] : null; + } + + function parseProfiles(text) { + const list = []; + const re = /profile\s*\{([\s\S]*?)\}/g; + let m; + while ((m = re.exec(text)) !== null) { + const body = m[1]; + const t = root.getField(body, "temperature"); + const g = root.getField(body, "gamma"); + list.push({ + time: root.getField(body, "time") ?? "", + identity: (root.getField(body, "identity") ?? "").toLowerCase() === "true", + temperature: t === null ? null : parseInt(t, 10), + gamma: g === null ? null : parseFloat(g), + }); + } + return list; + } + + // Byte-for-byte the same construction as sunset-qt's config.serialize(): + // a list of parts joined with newlines, so a no-op save writes the file + // back exactly as it was. + function serializeProfiles(list) { + const di = root.dayIndex(list); + const ni = root.nightIndex(list); + const parts = [root.header]; + for (let i = 0; i < list.length; i++) { + const p = list[i]; + if (i === di) parts.push("\n# day profile -- sunrise"); + else if (i === ni) parts.push("\n# night profile -- sunset"); + else parts.push("\n# profile"); + const lines = ["profile {", ` time = ${p.time}`]; + if (p.identity) lines.push(" identity = true"); + if (p.temperature !== null && p.temperature !== undefined) + lines.push(` temperature = ${p.temperature}`); + if (p.gamma !== null && p.gamma !== undefined) + lines.push(` gamma = ${p.gamma}`); + lines.push("}"); + parts.push(lines.join("\n")); + } + return parts.join("\n") + "\n"; + } + + function dayIndex(list) { + for (let i = 0; i < list.length; i++) if (list[i].identity) return i; + return null; + } + + function nightIndex(list) { + for (let i = 0; i < list.length; i++) + if (list[i].temperature !== null && list[i].temperature !== undefined) return i; + return null; + } + + function validTime(s) { return /^([01]?\d|2[0-3]):([0-5]\d)$/.test(s); } + function validTemperature(t) { return t >= 1000 && t <= 20000; } + function validGamma(g) { return g >= 0.0 && g <= 2.0; } + + function localHM(iso) { + const d = new Date(iso); + if (isNaN(d.getTime())) return ""; + return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2); + } + + function selftest(): string { + // A file exactly as hyprsunset-qt writes it. String.raw keeps the + // backslashes, none here, but keeps this fixture readable. + // Two blank lines after the header: sunset-qt's serializer joins parts + // with a newline and each part starts with one, so the file really has + // them. + const fixture = String.raw`# Managed by hyprsunset-qt. Edits here are overwritten on save. + + +# day profile -- sunrise +profile { + time = 05:42 + identity = true +} + +# night profile -- sunset +profile { + time = 21:02 + temperature = 5500 + gamma = 0.8 +} +`; + const parsed = root.parseProfiles(fixture); + if (parsed.length !== 2) return `SELFTEST Hyprsunset FAIL: parsed ${parsed.length} profiles`; + if (parsed[0].time !== "05:42" || parsed[0].identity !== true) + return "SELFTEST Hyprsunset FAIL: day profile wrong"; + if (parsed[1].temperature !== 5500 || parsed[1].gamma !== 0.8) + return "SELFTEST Hyprsunset FAIL: night profile wrong"; + if (root.dayIndex(parsed) !== 0 || root.nightIndex(parsed) !== 1) + return "SELFTEST Hyprsunset FAIL: day/night index wrong"; + if (root.serializeProfiles(parsed) !== fixture) + return "SELFTEST Hyprsunset FAIL: round-trip not byte-identical"; + if (!root.validTime("05:42") || root.validTime("24:00") || root.validTime("5:6")) + return "SELFTEST Hyprsunset FAIL: validTime"; + if (!root.validTemperature(1000) || root.validTemperature(20001)) + return "SELFTEST Hyprsunset FAIL: validTemperature"; + if (!root.validGamma(0.8) || root.validGamma(2.1)) + return "SELFTEST Hyprsunset FAIL: validGamma"; + return "SELFTEST Hyprsunset PASS"; + } + + function refresh() { + confProc.running = false; + confProc.running = true; + appProc.running = false; + appProc.running = true; + } + + Process { + id: confProc + command: ["cat", root.confPath] + stdout: StdioCollector { + onStreamFinished: { + root.profiles = root.parseProfiles(text); + root.confLoaded = true; + } + } + } + + // hyprsunset-qt's own settings: location + daemon command. + function parseAppConf(text) { + const out = {}; + let section = ""; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith(";")) continue; + const sec = line.match(/^\[(.+)\]$/); + if (sec) { section = sec[1]; continue; } + const kv = line.match(/^([^=]+)=\s*(.*)$/); + if (kv) out[`${section}.${kv[1].trim()}`] = kv[2].trim(); + } + return out; + } + + function expandTilde(p) { + return p.startsWith("~/") ? root.home + p.slice(1) : p; + } + + Process { + id: appProc + command: ["cat", root.appConfPath] + stdout: StdioCollector { + onStreamFinished: { + const c = root.parseAppConf(text); + root.lat = c["location.lat"] ?? ""; + root.lon = c["location.lon"] ?? ""; + root.autoDetect = (c["location.auto_detect"] ?? "true") === "true"; + root.cachePathRaw = c["cache.path"] ?? "~/.config/hyprsunset-qt/sun.json"; + root.cachePath = root.expandTilde(root.cachePathRaw); + root.daemonCommand = c["daemon.command"] ?? "hyprsunset"; + root.appLoaded = true; + } + } + } + + FileView { id: confFile; path: root.confPath } + FileView { id: appFile; path: root.appConfPath } + FileView { id: cacheFile; path: root.cachePath } + + function appConfText() { + return `[location]\nlat = ${root.lat}\nlon = ${root.lon}\n` + + `auto_detect = ${root.autoDetect}\n\n` + + `[cache]\npath = ${root.cachePathRaw}\n\n` + + `[daemon]\ncommand = ${root.daemonCommand}\n\n`; + } + function saveSettings() { appFile.setText(root.appConfText()); } + + // hyprsunset holds Hyprland's CTM manager exclusively, and pkill only + // sends SIGTERM, so the replacement must wait for the old process to + // actually exit or it dies with "A CTM manager is already running". + function restart() { + Quickshell.execDetached(["sh", "-c", + `pkill -x hyprsunset; ` + + `for i in $(seq 50); do ` + + `pgrep -x hyprsunset >/dev/null 2>&1 || break; sleep 0.1; done; ` + + `setsid -f ${root.daemonCommand}`]); + } + + function save() { + if (!root.confLoaded || !root.appLoaded) return; + for (const p of root.profiles) { + if (!root.validTime(p.time)) { root.notice = `invalid time: ${p.time}`; return; } + if (p.temperature !== null && p.temperature !== undefined && + !root.validTemperature(p.temperature)) { + root.notice = `invalid temperature: ${p.temperature}`; return; + } + if (p.gamma !== null && p.gamma !== undefined && !root.validGamma(p.gamma)) { + root.notice = `invalid gamma: ${p.gamma}`; return; + } + } + confFile.setText(root.serializeProfiles(root.profiles)); + root.saveSettings(); + root.restart(); + root.notice = "saved + restarted"; + } + + // Live preview via the daemon's IPC. Identity wins, then temperature, then + // gamma, matching hyprsunset-qt. Nothing is written. + function preview() { + const i = root.nightIndex(root.profiles); + const target = i !== null ? root.profiles[i] + : (root.profiles.length ? root.profiles[0] : null); + if (!target) return; + if (target.identity) { + previewProc.command = ["hyprctl", "hyprsunset", "identity"]; + } else { + const parts = []; + if (target.temperature !== null && target.temperature !== undefined) + parts.push(`hyprctl hyprsunset temperature ${target.temperature}`); + if (target.gamma !== null && target.gamma !== undefined) + parts.push(`hyprctl hyprsunset gamma ${Math.round(target.gamma * 100)}`); + if (!parts.length) return; + previewProc.command = ["sh", "-c", parts.join("; ")]; + } + previewProc.running = false; + previewProc.running = true; + } + Process { id: previewProc } + + function detect() { + detectProc.running = false; + detectProc.running = true; + } + Process { + id: detectProc + command: ["curl", "-fsS", "http://ip-api.com/json"] + stdout: StdioCollector { + onStreamFinished: { + try { + const d = JSON.parse(text); + root.lat = String(d.lat); + root.lon = String(d.lon); + root.saveSettings(); + root.notice = `located ${root.lat}, ${root.lon}`; + } catch (e) { root.notice = `detect failed: ${e}`; } + } + } + } + + // sunrise-sunset.org is behind Cloudflare and 403s curl's default UA. + function fetchSun() { + const url = "https://api.sunrise-sunset.org/json?lat=" + + encodeURIComponent(root.lat) + "&lng=" + encodeURIComponent(root.lon) + + "&formatted=0"; + fetchProc.command = ["curl", "-fsS", "-A", + "Mozilla/5.0 (X11; Linux x86_64) hyprsunset-qt", url]; + fetchProc.running = false; + fetchProc.running = true; + root.busy = true; + } + Process { + id: fetchProc + stdout: StdioCollector { + onStreamFinished: { + root.busy = false; + let data; + try { data = JSON.parse(text); } + catch (e) { root.notice = `fetch failed: ${e}`; return; } + cacheFile.setText(JSON.stringify(data, null, 2)); + const r = data.results ?? {}; + const rise = r.sunrise ? root.localHM(r.sunrise) : ""; + const set = r.sunset ? root.localHM(r.sunset) : ""; + root.sunSummary = `sunrise ${rise || "—"} sunset ${set || "—"}`; + const di = root.dayIndex(root.profiles); + const ni = root.nightIndex(root.profiles); + const next = root.profiles.slice(); + if (di !== null && rise) next[di] = Object.assign({}, next[di], { time: rise }); + if (ni !== null && set) next[ni] = Object.assign({}, next[ni], { time: set }); + root.profiles = next; + } + } + } +} diff --git a/desktop/modules/appearance/Icons.qml b/desktop/modules/appearance/Icons.qml new file mode 100644 index 0000000..24d3a2f --- /dev/null +++ b/desktop/modules/appearance/Icons.qml @@ -0,0 +1,335 @@ +// Copyright (C) 2026 Danilo M. +// +// 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. + +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +// The icon and cursor themes installed on this machine. Previews are resolved +// from each theme, not just the active one: Quickshell.iconPath can only read +// the platform theme or QS_ICON_THEME, both fixed at load. +Singleton { + id: root + + readonly property string home: Quickshell.env("HOME") + readonly property string iconScript: ` +import sys +import gi +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk +SAMPLES = ["folder","text-x-generic","image-x-generic","network-wireless", + "audio-x-generic","video-x-generic","battery-full","printer"] +t = Gtk.IconTheme.new() +for name in sys.argv[1:]: + t.set_custom_theme(name) + for s in SAMPLES: + info = t.lookup_icon(s, 32, 0) + if info: + print("%s\\t%s\\t%s" % (name, s, info.get_filename())) + print("%s\\tEND\\t" % name) +` + // Four shapes per theme, so a card shows the cases that matter (arrow, + // hand, text, horizontal resize) instead of one ambiguous pointer. Each + // shape falls back through common aliases and through every storage form a + // theme may use: a hyprcursor .hlc zip, a shape directory with an SVG, or + // 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"], + "hand2": ["hand2", "pointer", "hand"], + "xterm": ["xterm", "text", "ibeam"], + "resize": ["sb_h_double_arrow", "size_hor", "ew-resize", "h_double_arrow"], +} +roots = [os.path.expanduser("~/.icons"), + os.path.expanduser("~/.local/share/icons"), + "/usr/share/icons"] + +def pick(d, names): + for n in names: + for cand in (f"{d}/hyprcursors/{n}.hlc", f"{d}/hyprcursors/{n}/{n}.svg", + f"{d}/{n}.hlc", f"{d}/{n}/{n}.svg", f"{d}/cursors/{n}"): + if os.path.isfile(cand): + return cand + return None + +os.makedirs(outdir, exist_ok=True) +for name in sys.argv[2:]: + d = next((os.path.join(r, name) for r in roots if os.path.isdir(os.path.join(r, name))), None) + if not d: + continue + for shape, aliases in SAMPLES.items(): + c = pick(d, aliases) + if not c: + continue + out = f"{outdir}/{name}-{shape}.png" + try: + if c.endswith(".hlc"): + with zipfile.ZipFile(c) as z: + members = [e for e in z.namelist() if e.endswith(".svg")] + if not members: + members = [e for e in z.namelist() if e.endswith(".png")] + if not members: + continue + with open(out, "wb") as f: + f.write(z.read(members[0])) + elif c.endswith(".svg"): + shutil.copyfile(c, out) + else: + raw = f"{outdir}/raw-{name}-{shape}" + shutil.rmtree(raw, ignore_errors=True) + os.makedirs(raw, exist_ok=True) + subprocess.run(["xcur2png", "-d", raw, "-c", f"{raw}/out.conf", c], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + pngs = sorted(glob.glob(f"{raw}/*_*.png")) + if not pngs: + continue + shutil.copyfile(pngs[-1], out) + print(f"{name}\\t{shape}\\t{out}") + except Exception: + continue +` + + property var iconThemes: [] + property var cursorThemes: [] + property string currentIcon: "" + property string currentCursor: "" + property int cursorSize: 24 + property var iconPreview: ({}) + property var cursorPreview: ({}) + property bool scanning: true + property string notice: "" + + // Pure: classify works on entries shaped { name, index, cursors, manifest } + // so it can be tested without touching the filesystem. + function classify(entries) { + const icons = [], cursors = []; + for (const e of entries) { + if (e.cursors || e.manifest) cursors.push(e.name); + if (e.index && /Directories=\S/.test(e.index)) icons.push(e.name); + } + const uniq = a => a.filter((v, i) => a.indexOf(v) === i).sort(); + return { icons: uniq(icons), cursors: uniq(cursors) }; + } + + function selftest(): string { + const entries = [ + { name: "Material-Black-Plum-Suru", index: "Directories=32x32/apps\n", cursors: false, manifest: false }, + { name: "hypr_bibata-modern-amber", index: "", cursors: false, manifest: true }, + { name: "default", index: "Inherits=Bibata-Modern-Amber\n", cursors: false, manifest: false }, + { name: "breeze_cursors", index: "", cursors: true, manifest: false }, + { name: "Adwaita", index: "Directories=16x16/apps\n", cursors: true, manifest: false }, + ]; + const r = root.classify(entries); + if (r.icons.length !== 2 || r.icons.indexOf("Adwaita") < 0 || + r.icons.indexOf("Material-Black-Plum-Suru") < 0) + return `SELFTEST Icons FAIL: icons ${JSON.stringify(r.icons)}`; + if (r.cursors.length !== 3 || r.cursors.indexOf("hypr_bibata-modern-amber") < 0 || + r.cursors.indexOf("breeze_cursors") < 0 || r.cursors.indexOf("Adwaita") < 0) + return `SELFTEST Icons FAIL: cursors ${JSON.stringify(r.cursors)}`; + return "SELFTEST Icons PASS"; + } + + function refresh() { + scanProc.running = false; + scanProc.running = true; + curProc.running = false; + curProc.running = true; + } + + // One shell pass over every theme root. Format per line: + // name|hasIndex|cursors|manifest|Directories= + Process { + id: scanProc + command: ["sh", "-c", + `for d in ${root.home}/.icons/* ${root.home}/.local/share/icons/* ` + + `/usr/share/icons/*; do [ -d "$d" ] || continue; ` + + `[ -L "$d" ] && continue; ` + + `i=0; c=0; m=0; dirs=""; ` + + `[ -d "$d/cursors" ] && c=1; ` + + `[ -f "$d/manifest.hl" ] && m=1; ` + + `if [ -f "$d/index.theme" ]; then i=1; dirs=$(sed -n 's/^Directories=//p' "$d/index.theme"); fi; ` + + `echo "$(basename "$d")|$i|$c|$m|$dirs"; done`] + stdout: StdioCollector { + onStreamFinished: { + const entries = []; + for (const line of text.trim().split("\n")) { + const p = line.split("|"); + if (p.length < 5) continue; + entries.push({ name: p[0], index: p[1] === "1" ? "Directories=" + (p[4] || "x") : "", + cursors: p[2] === "1", manifest: p[3] === "1" }); + } + const r = root.classify(entries); + root.iconThemes = r.icons; + root.cursorThemes = r.cursors; + root.scanning = false; + if (root.iconThemes.length) root.previewIcons(); + if (root.cursorThemes.length) root.previewCursors(); + } + } + } + + // One python process for every icon theme, so opening the tab costs one + // spawn, not one per theme. + function previewIcons() { + if (!root.iconThemes.length) return; + iconProc.command = ["python3", "-c", root.iconScript].concat(root.iconThemes); + iconProc.running = false; + iconProc.running = true; + } + Process { + id: iconProc + stdout: StdioCollector { + onStreamFinished: { + const next = {}; + for (const line of text.split("\n")) { + const p = line.split("\t"); + if (p.length < 2 || p[1] === "END" || !p[2]) continue; + if (!next[p[0]]) next[p[0]] = {}; + next[p[0]][p[1]] = p[2]; + } + root.iconPreview = next; + } + } + } + + // gsettings for the current values. + Process { + id: curProc + command: ["sh", "-c", + `gsettings get org.gnome.desktop.interface icon-theme; ` + + // The system's cursor theme is often the `default` alias, a symlink + // to the real theme. The scan skips symlinked dirs, so resolve the + // current value to the target name or nothing would read as current. + `ct=$(gsettings get org.gnome.desktop.interface cursor-theme | sed "s/'//g"); ` + + `for r in ${root.home}/.icons ${root.home}/.local/share/icons /usr/share/icons; do ` + + `[ -L "$r/$ct" ] && ct=$(basename "$(readlink -f "$r/$ct")") && break; done; ` + + `printf '%s\\n' "$ct"; ` + + `gsettings get org.gnome.desktop.interface cursor-size`] + stdout: StdioCollector { + onStreamFinished: { + const lines = text.trim().split("\n"); + root.currentIcon = (lines[0] ?? "").replace(/'/g, ""); + root.currentCursor = (lines[1] ?? "").replace(/'/g, ""); + const size = parseInt(lines[2] ?? "", 10); + if (!isNaN(size)) root.cursorSize = size; + } + } + } + + // One python pass extracts four shapes for EVERY cursor theme and prints + // nameshapepath, so the whole row paints on open instead of + // waiting for a hover per card. A theme missing a shape simply shows fewer + // images rather than a broken one. + function previewCursors() { + if (!root.cursorThemes.length) return; + cursorProc.command = ["python3", "-c", root.cursorScript, + Quickshell.cachePath("cursors")].concat(root.cursorThemes); + cursorProc.running = false; + cursorProc.running = true; + } + Process { + id: cursorProc + stdout: StdioCollector { + onStreamFinished: { + const next = {}; + for (const line of text.split("\n")) { + const p = line.split("\t"); + if (p.length < 3 || !p[0] || !p[1] || !p[2]) continue; + if (!next[p[0]]) next[p[0]] = {}; + next[p[0]][p[1]] = p[2]; + } + root.cursorPreview = next; + } + } + } + + // Writes go through FileView so no shell quoting is involved. They are + // preloaded because setText on an unloaded FileView would write empty. + FileView { id: qt6File; path: `${root.home}/.config/qt6ct/qt6ct.conf`; blockLoading: true } + FileView { id: qt5File; path: `${root.home}/.config/qt5ct/qt5ct.conf`; blockLoading: true } + // The cursor lives in its own file, not in hypr-theme's environment.lua: + // which cursor a machine runs is local state, and editing a tracked file + // left hypr-theme dirty after every switch. environment.lua dofile()s it. + // Written whole, so it needs no preload. + FileView { id: cursorFile; path: `${root.home}/.config/udt/cursor.lua` } + function writeCursor() { + cursorFile.setText(`return { theme = ${JSON.stringify(root.currentCursor)}, size = ${root.cursorSize} }\n`); + } + // GTK3 and GTK4 read their own settings.ini, with the theme names written + // out; gsettings alone does not switch them. + FileView { id: gtk3File; path: `${root.home}/.config/gtk-3.0/settings.ini`; blockLoading: true } + FileView { id: gtk4File; path: `${root.home}/.config/gtk-4.0/settings.ini`; blockLoading: true } + + // Replaces a `key=value` line in both GTK settings files. A key the file + // does not carry is left alone, the same as the Qt configs. + function rewriteGtk(key, value) { + const files = [gtk3File, gtk4File]; + const re = new RegExp(`^${key}=.*`, "m"); + for (let i = 0; i < files.length; i++) { + const text = files[i].text(); + if (text !== "") files[i].setText(text.replace(re, `${key}=${value}`)); + } + } + + function gsettingsSet(key, value) { + gsetProc.command = ["gsettings", "set", "org.gnome.desktop.interface", key, value]; + gsetProc.running = false; + gsetProc.running = true; + } + Process { id: gsetProc } + + // The SDDM power icons resolve icon names from gsettings when udt-accent + // generates them, not when they run, so they are regenerated here, + // chained after the gsettings write they read. The island resolves its + // icons live and needs nothing. + readonly property string udtRepo: `${root.home}/Programming/GIT/unified-desktop-theme` + + function applyIcon(name) { + Quickshell.execDetached(["sh", "-c", + `gsettings set org.gnome.desktop.interface icon-theme "$1" || exit; ` + + `[ -e ~/.cache/udt/wpaper ] && ${JSON.stringify(root.udtRepo)}/bin/udt-accent ~/.cache/udt/wpaper`, + "sh", name]); + const files = [qt6File, qt5File]; + for (let i = 0; i < files.length; i++) { + const text = files[i].text(); + if (text !== "") files[i].setText(text.replace(/^icon_theme=.*/m, `icon_theme=${name}`)); + } + root.rewriteGtk("gtk-icon-theme-name", name); + root.currentIcon = name; + root.notice = `${name} set. Waybar and the SDDM icons follow; restart other apps to see it.`; + } + + function applyCursor(name) { + // Live switch first, then persistence. + Quickshell.execDetached(["hyprctl", "setcursor", name, String(root.cursorSize)]); + root.gsettingsSet("cursor-theme", name); + root.currentCursor = name; + root.writeCursor(); + root.rewriteGtk("gtk-cursor-theme-name", name); + root.rewriteGtk("gtk-cursor-theme-size", root.cursorSize); + root.notice = `${name} set live at ${root.cursorSize}px. cursor.lua updated for next login.`; + } + + function setCursorSize(size) { + root.cursorSize = size; + // Live on the current theme, then GTK and the next login's env. + Quickshell.execDetached(["hyprctl", "setcursor", root.currentCursor, String(size)]); + root.gsettingsSet("cursor-size", String(size)); + root.writeCursor(); + root.rewriteGtk("gtk-cursor-theme-size", size); + root.notice = `cursor size ${size}px. cursor.lua updated for next login.`; + } +} 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. +// +// 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/desktop/modules/appearance/IconsTab.qml b/desktop/modules/appearance/IconsTab.qml new file mode 100644 index 0000000..777ea51 --- /dev/null +++ b/desktop/modules/appearance/IconsTab.qml @@ -0,0 +1,228 @@ +// Copyright (C) 2026 Danilo M. +// +// 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.Widgets +import QtQuick +import "../.." + +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 + // persists the theme to org.gnome.desktop.interface cursor-theme, so + // hovering wrote the setting and a close left the last hovered theme set. + // The four rendered shapes on each card are the preview. + property string pendingCursor: "" + + Column { + id: col + width: parent.width + spacing: 12 + + Text { + text: Icons.scanning ? "scanning…" : `${Icons.iconThemes.length} icon themes · ${Icons.cursorThemes.length} cursor themes` + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Text { + text: "Icon theme" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + } + + // Flow, not a horizontal strip: the cards wrap onto further rows rather + // than running off the right edge, and the tab scrolls vertically. + Flow { + width: parent.width + spacing: 10 + Repeater { + model: Icons.iconThemes + 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) + border.width: current ? 2 : 1 + border.color: current ? Theme.accent : "transparent" + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Icons.applyIcon(modelData) + } + + Column { + width: parent.width - 16 + anchors.centerIn: parent + spacing: 10 + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 8 + Repeater { + model: ["folder", "text-x-generic", "image-x-generic", "network-wireless"] + IconImage { + required property string modelData + readonly property string glyph: (Icons.iconPreview[iconCard.modelData] ?? {})[modelData] ?? "" + // Absolute paths must carry file://, or they + // resolve against the config's qrc base and + // fail; Gtk's builtin icons come back as + // /org/gtk gresource paths Qt cannot open. + source: glyph.indexOf("/") === 0 && glyph.indexOf("/org/gtk/") !== 0 + ? "file://" + glyph : "" + implicitSize: 44 + } + } + } + Text { + width: parent.width + horizontalAlignment: Text.AlignHCenter + text: iconCard.current ? modelData + " current" : modelData + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + wrapMode: Text.WrapAnywhere + maximumLineCount: 2 + elide: Text.ElideRight + } + } + } + } + } + + Row { + spacing: 10 + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Cursor theme" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + } + Item { width: 12; height: 1 } + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Size" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Repeater { + model: [16, 24, 32, 48, 64] + Tab { + required property var modelData + anchors.verticalCenter: parent.verticalCenter + text: String(modelData) + selected: Icons.cursorSize === modelData + onClicked: Icons.setCursorSize(modelData) + } + } + } + + Flow { + width: parent.width + spacing: 10 + Repeater { + model: Icons.cursorThemes + 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 + color: current || staged ? Qt.alpha(Theme.accent, 0.2) : Qt.alpha(Theme.surface, 0.35) + border.width: current || staged ? 2 : 1 + border.color: staged ? Theme.accent + : current ? Qt.alpha(Theme.accent, 0.5) + : "transparent" + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: pendingCursor = modelData + } + + Column { + width: parent.width - 16 + anchors.centerIn: parent + spacing: 10 + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 8 + Repeater { + // The cases that tell themes apart: arrow, hand, + // text and horizontal resize. + model: ["left_ptr", "hand2", "xterm", "resize"] + Image { + required property string modelData + readonly property string p: (Icons.cursorPreview[cursorCard.modelData] ?? {})[modelData] ?? "" + width: 44; height: 44 + asynchronous: true + source: p !== "" ? "file://" + p : "" + fillMode: Image.PreserveAspectFit + } + } + } + Text { + width: parent.width + horizontalAlignment: Text.AlignHCenter + text: cursorCard.staged ? modelData + " staged" + : cursorCard.current ? modelData + " current" + : modelData + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + wrapMode: Text.WrapAnywhere + maximumLineCount: 2 + elide: Text.ElideRight + } + } + } + } + } + + Row { + spacing: 8 + Text { + anchors.verticalCenter: parent.verticalCenter + text: pendingCursor !== "" ? "click Apply to set" : "click a cursor theme to stage it" + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Tab { + text: "Apply" + selected: pendingCursor !== "" + enabled: pendingCursor !== "" + onClicked: { + Icons.applyCursor(pendingCursor); + pendingCursor = ""; + } + } + Tab { + text: "Reset" + enabled: pendingCursor !== "" + onClicked: pendingCursor = "" + } + } + + Text { + width: parent.width + wrapMode: Text.Wrap + visible: Icons.notice !== "" + text: Icons.notice + color: Theme.green + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + } + +} 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. +// +// 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/desktop/modules/appearance/IdleTab.qml b/desktop/modules/appearance/IdleTab.qml new file mode 100644 index 0000000..1199543 --- /dev/null +++ b/desktop/modules/appearance/IdleTab.qml @@ -0,0 +1,100 @@ +// Copyright (C) 2026 Danilo M. +// +// 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 "../.." + +Item { + implicitHeight: col.implicitHeight + + Column { + id: col + width: parent.width + spacing: 10 + + Text { + text: "Commands are fixed. Only the timeout and whether a listener runs are editable." + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Repeater { + model: Hypridle.listeners + Rectangle { + required property var modelData + required property int index + width: col.width + implicitHeight: 40 + radius: 8 + color: Qt.alpha(Theme.surface, modelData.enabled ? 0.4 : 0.2) + opacity: modelData.enabled ? 1 : 0.6 + + Row { + anchors.fill: parent + anchors.margins: 8 + spacing: 10 + + Toggle { + anchors.verticalCenter: parent.verticalCenter + checked: modelData.enabled + onToggled: Hypridle.listeners = Hypridle.listeners.map( + (l, i) => i === index ? Object.assign({}, l, { enabled: checked }) : l) + } + Text { + anchors.verticalCenter: parent.verticalCenter + width: 150 + text: Hypridle.describe(modelData) + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Field { + anchors.verticalCenter: parent.verticalCenter + width: 70 + value: String(modelData.timeout) + placeholder: "seconds" + onEdited: Hypridle.listeners = Hypridle.listeners.map( + (l, i) => i === index + ? Object.assign({}, l, { timeout: parseInt(value, 10) || 0 }) : l) + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: `${Math.floor(modelData.timeout / 60)}:${String(modelData.timeout % 60).padStart(2, "0")}` + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Text { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 380 + elide: Text.ElideRight + text: modelData.onTimeout + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } + } + } + } + + Tab { + text: "Save + Restart hypridle" + selected: true + onClicked: Hypridle.save() + } + + Text { + width: parent.width + wrapMode: Text.Wrap + visible: Hypridle.notice !== "" + text: Hypridle.notice + color: Theme.green + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + } +} diff --git a/desktop/modules/appearance/MockScreen.qml b/desktop/modules/appearance/MockScreen.qml new file mode 100644 index 0000000..7f6870b --- /dev/null +++ b/desktop/modules/appearance/MockScreen.qml @@ -0,0 +1,95 @@ +// Copyright (C) 2026 Danilo M. +// +// 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 "../.." + +// 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 +// its colours from the theme and stays sharp at any scale. +Item { + id: mock + + property string label: "" + property string source: "" + property bool targeted: false + + // The panel's size; the bezel and stand are added around it. + property real panelWidth: 100 + property real panelHeight: 100 + + readonly property real bezel: Math.max(4, Math.round(panelWidth * 0.02)) + // Proportional to width rather than height: the vertical monitor is tall + // and narrow, and a share of its height would give it an absurd stand. + readonly property real standHeight: Math.max(14, Math.round(panelWidth * 0.1)) + + implicitWidth: panelWidth + bezel * 2 + implicitHeight: panelHeight + bezel * 2 + standHeight + + Column { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 0 + + // Case. + Rectangle { + width: mock.panelWidth + mock.bezel * 2 + height: mock.panelHeight + mock.bezel * 2 + radius: Math.max(3, mock.bezel) + color: "#1a1a1e" + border.width: 1 + border.color: mock.targeted ? Theme.accent : Qt.alpha("#ffffff", 0.14) + + // Screen. + Rectangle { + anchors.centerIn: parent + width: mock.panelWidth + height: mock.panelHeight + color: Theme.base + clip: true + + Image { + anchors.fill: parent + source: mock.source === "" ? "" : "file://" + mock.source + asynchronous: true + cache: false + // swaybg is called with -m fill, so the mock crops the same way. + fillMode: Image.PreserveAspectCrop + sourceSize.width: 640 + } + + Text { + anchors.centerIn: parent + visible: mock.source === "" + text: mock.label + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + } + } + + // Neck. + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + width: Math.max(8, Math.round(mock.panelWidth * 0.09)) + height: Math.round(mock.standHeight * 0.55) + color: "#26262e" + } + + // Foot. + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + width: Math.max(30, Math.round(mock.panelWidth * 0.32)) + height: Math.max(5, Math.round(mock.standHeight * 0.45)) + radius: height / 2 + color: "#2c2c35" + } + } +} diff --git a/desktop/modules/appearance/README.md b/desktop/modules/appearance/README.md new file mode 100644 index 0000000..b1823fb --- /dev/null +++ b/desktop/modules/appearance/README.md @@ -0,0 +1,174 @@ +# appearance + +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. + +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. + +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 ~/Programming/GIT/quickshell/desktop ipc call appearance wallpaper + +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 `~`. + +Search finds a scheme by name and an icon or cursor theme by name, and +scrolls to it. + +## Sunset + +A port of `hyprsunset-qt` (`~/Programming/GIT/sunset-qt`): profiles in +`~/.config/hypr/hyprsunset.conf`, location in +`~/.config/hyprsunset-qt/config`, sunrise/sunset from the same API and cache. +Both apps read and write the identical file format, so either can edit it. + +## Idle + +Timeout and enabled only; the commands in `~/.config/hypr/hypridle.conf` are +fixed. Everything before the first `listener` (the `general` block and the +comments explaining the design) is preserved verbatim. A disabled listener is +written commented out. Save restarts `hypridle`, which resets its timers. + +## Icons + +Switches the icon and cursor theme, and the pointer size. Icon previews come +from a GTK lookup per theme. Cursor previews come from one python pass per +open: for every theme it extracts four shapes (arrow, hand, text, horizontal +resize) out of a hyprcursor `.hlc`, a shape directory's SVG, or a legacy +Xcursor binary via `xcur2png`, so a card shows the cases that tell themes +apart. Hovering a card changes the real cursor and reverts it on leave. + +Applying writes `gsettings` (which GTK3 and GTK4 both read), the Qt configs +(`qt6ct`/`qt5ct`), and, for cursors, `~/.config/udt/cursor.lua`, which +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 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. + +An icon switch also regenerates the two consumers that resolve icons by name +when they are generated rather than when they run: `udt-accent` rewrites the +SDDM power icons. It is chained after the `gsettings` write in one shell, +since it reads it. Waybar is gone, so the icon switch no longer reruns +waybar-theme-udt's `install.sh` or signals waybar. + +## Wallpapers + +Clicking a thumbnail **stages** it rather than setting it, so both screens can +be composed before anything changes, and Apply then makes a single +`wallp --set H=… V=…` call. The mock screens on the right show what the desk +would look like: the staged pick where there is one, what is currently set +where there is not, and the hovered thumbnail on the targeted screen while the +pointer is over it. + +The thumbnails are grouped by folder, each a header that opens and closes +it, closed to begin with and remembered while the shell runs. The local +groups are the top-level folders of `~/Pictures/wallpapers`, with the images +loose in it as a group of its own; after them come the distribution's, +`/usr/share/wallpapers`, as `system` for its loose images and `Plasma` for +its packages. A Plasma package holds one picture at several resolutions, +some in a dark version too, so it shows once: dark where there is one, then +the largest, named after the package rather than its `5120x2880.png`. + +The list is flat, one row of thumbnails per item, so only the rows in view +exist and decode, and a folder of hundreds loads as it scrolls. Opening or +closing a folder inserts or removes just its rows, which keeps the scroll +position and the thumbnails around it. A first version made each folder one +item, which loaded every thumbnail of a folder the moment it opened. + +Each thumbnail's border says how it will look on the targeted screen, from +its real size, which `file` reads from the header after each scan (half a +second for a thousand). `swaybg -m fill` scales to cover, so the measure is +how far it enlarges: not at all is green, up to 1.33x yellow, past that red. +Under the mock screens a note in the same colour says why, for the hovered +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 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 page +replaces its qarma file dialog, not the script. + +The two monitors are drawn at their real proportions, read from +`hyprctl monitors`: DP-1 is 2560x1080 and DP-3 is 1920x1080 with +`transform=1`, which makes it 1080x1920 on the desk. They are centred against +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 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. + +## Themes + +Each scheme shows its real colours, parsed from the pair of files +unified-desktop-theme keeps for it: `palette/.conf` holds the colours +under the scheme's own names, `palette/roles-.conf` says what each +colour is for, so a role is resolved by looking its value up as a key in the +first file. Beside the swatches is a small mock of a panel painted in that +scheme, which says how the colours sit together rather than only what they +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 +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, +kitty and conky. Its whole output becomes the `install` tab of the +steps window below. The notice names what is left: Qt and GTK apps only reread +a theme when they restart, and neovim needs `:colorscheme udt`. + +## Steps + +A separate window, `StepsWindow.qml`, for the manual steps +unified-desktop-theme cannot run itself: the root copies into `/boot` and +`/usr/share/sddm`, the finish on the homepage host, and the last `install.sh` +log, shown first as "General recap". Its `udt-steps` writes one file per consumer under `~/.cache/udt/steps/` +and calls the `steps` verb, which reloads the tabs and opens the window if any +remain; an empty file set closes it. Nothing is watched: the poke is the +signal. The same verb reopens it after Close. + +Centred on DP-3, on the `top` layer with on-demand keyboard focus, so it can +stay open while commands are pasted into a terminal elsewhere. A line indented +two spaces is a command block with a Copy button (`wl-copy`), and the text is +selectable too. Done deletes the tab's file; the producer also clears it once +its consumer is up to date. + +The window's height comes from its content, so the scroll area is capped +against the screen and never against the window: binding it to the window's +own height collapsed the window to its margins. + +## Two QML traps met here + +**There is no `String.matchAll`.** QML's JS engine does not have it. It threw +inside a `try` and left every swatch empty with nothing in the log. The +palette parser uses an `exec` loop instead. + +**Assigning `running = true` to a Process that is already running does +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 + +`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/desktop/modules/appearance/Steps.qml b/desktop/modules/appearance/Steps.qml new file mode 100644 index 0000000..d309b79 --- /dev/null +++ b/desktop/modules/appearance/Steps.qml @@ -0,0 +1,115 @@ +// Copyright (C) 2026 Danilo M. +// +// 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. + +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 +// install.sh log. Each is a file in ~/.cache/udt/steps/, written by +// udt-steps, which then calls this shell's `steps` IPC verb. Nothing is +// watched: the poke is the signal, and a shell started later finds the files +// on its first reload. +Singleton { + id: root + + readonly property string dir: `${Quickshell.env("HOME")}/.cache/udt/steps` + + // [{ name, label, blocks: [{ cmd, text }] }]: the install.sh recap first, + // then one tab per consumer by name. A file with no label here shows + // under its own name, so a new producer needs no edit. + property var tabs: [] + readonly property var labels: ({ + install: "General recap", grub: "GRUB", sddm: "SDDM", homepage: "Homepage", + }) + signal loaded + + // A line indented two spaces is a command, and a run of them is one block + // with its own copy button. Blank lines split prose into paragraphs. The + // format is what the producers already print to a terminal. + function parse(text) { + const out = []; + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const cmd = line.startsWith(" "); + const last = out.length ? out[out.length - 1] : null; + if (!cmd && line.trim() === "") { + if (last) last.closed = true; + continue; + } + const body = cmd ? line.slice(2) : line; + if (last && last.cmd === cmd && !last.closed) last.lines.push(body); + else out.push({ cmd: cmd, lines: [body], closed: false }); + } + return out.map(b => ({ cmd: b.cmd, text: b.lines.join("\n") })); + } + + function commands(tab) { + return tab.blocks.filter(b => b.cmd).map(b => b.text).join("\n"); + } + + function copy(text) { + Quickshell.execDetached(["wl-copy", "--", text]); + } + + function refresh() { + listProc.running = false; + listProc.running = true; + } + + function dismiss(name) { + rmProc.command = ["rm", "-f", `${root.dir}/${name}`]; + rmProc.running = true; + } + + // One pass over every file, each prefixed by an ASCII record separator + // and its name. The names are udt-steps' [a-z0-9-], and its temporary + // files are dotfiles, which the glob skips. + Process { + id: listProc + command: ["sh", "-c", + `cd ${JSON.stringify(root.dir)} 2>/dev/null || exit 0; ` + + `for f in *; do [ -f "$f" ] && printf '\\036%s\\n' "$f" && cat "$f"; done`] + stdout: StdioCollector { + onStreamFinished: { + root.tabs = text.split("\x1e").filter(s => s.length).map(rec => { + const nl = rec.indexOf("\n"); + const name = rec.slice(0, nl); + return { name: name, label: root.labels[name] ?? name, + blocks: root.parse(rec.slice(nl + 1)) }; + }).sort((a, b) => (b.name === "install") - (a.name === "install") + || a.label.localeCompare(b.label)); + root.loaded(); + } + } + } + + Process { + id: rmProc + onExited: root.refresh() + } + + function selftest(): string { + const b = root.parse("Intro line\nstill intro\n cmd one\n cmd two\n\nNext para\n cmd three\n"); + if (b.length !== 4 || b[0].cmd || b[0].text !== "Intro line\nstill intro" || + !b[1].cmd || b[1].text !== "cmd one\ncmd two" || b[2].text !== "Next para" || + b[3].text !== "cmd three") + return `SELFTEST Steps FAIL: ${JSON.stringify(b)}`; + if (root.commands({ blocks: b }) !== "cmd one\ncmd two\ncmd three") + return "SELFTEST Steps FAIL: commands"; + return "SELFTEST Steps PASS"; + } +} diff --git a/desktop/modules/appearance/StepsWindow.qml b/desktop/modules/appearance/StepsWindow.qml new file mode 100644 index 0000000..7349fcd --- /dev/null +++ b/desktop/modules/appearance/StepsWindow.qml @@ -0,0 +1,198 @@ +// Copyright (C) 2026 Danilo M. +// +// 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 +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 +// elsewhere, so the window takes keyboard focus only when clicked and stays +// until closed. Done deletes a tab's file; the producer also clears it once +// the consumer is up to date. +Scope { + id: root + + property string monitor: "DP-3" + property bool open: false + property string tab: "" + + readonly property var screenObj: + Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] + readonly property var current: Steps.tabs.find(t => t.name === root.tab) ?? Steps.tabs[0] ?? null + + // A poke from udt-steps: reload, and open when something is pending. + // Also what a keybind calls to bring the window back after closing it. + function poke() { + root.opening = true; + Steps.refresh(); + } + property bool opening: false + + Connections { + target: Steps + function onLoaded() { + if (Steps.tabs.length === 0) root.open = false; + else if (root.opening) root.open = true; + root.opening = false; + } + } + + LazyLoader { + active: root.open + + PanelWindow { + id: win + screen: root.screenObj + // No anchors: a layer surface anchored nowhere is centred. + implicitWidth: 960 + implicitHeight: body.implicitHeight + 40 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.namespace: "quickshell-steps" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + + Rectangle { + anchors.fill: parent + radius: 14 + color: Qt.alpha(Theme.base, 0.92) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.12) + + Item { + anchors.fill: parent + focus: true + Keys.onEscapePressed: root.open = false + } + + Column { + id: body + anchors { top: parent.top; left: parent.left; right: parent.right; margins: 20 } + spacing: 16 + + Item { + width: parent.width + height: tabBar.implicitHeight + + Row { + id: tabBar + spacing: 8 + Repeater { + model: Steps.tabs + Tab { + required property var modelData + text: modelData.label + selected: root.current !== null && root.current.name === modelData.name + onClicked: root.tab = modelData.name + } + } + } + + Tab { + anchors.right: parent.right + text: "Close" + onClicked: root.open = false + } + } + + Flickable { + width: parent.width + // Sized from the screen, never from the window: the + // window's height comes from this, and a binding back + // to it collapsed the whole thing to its margins. + height: Math.min(content.implicitHeight, + win.screen.height - 240 - tabBar.implicitHeight - actions.implicitHeight - 32) + contentHeight: content.implicitHeight + clip: true + + Column { + id: content + width: parent.width + spacing: 12 + + Repeater { + model: root.current ? root.current.blocks : [] + + Loader { + required property var modelData + width: content.width + sourceComponent: modelData.cmd ? cmdBlock : prose + property var block: modelData + } + } + } + } + + Row { + id: actions + spacing: 8 + anchors.right: parent.right + Tab { + text: "Copy all commands" + enabled: root.current !== null && Steps.commands(root.current) !== "" + onClicked: Steps.copy(Steps.commands(root.current)) + } + Tab { + text: "Done" + enabled: root.current !== null + onClicked: Steps.dismiss(root.current.name) + } + } + } + } + + Component { + id: prose + Text { + text: block.text + wrapMode: Text.Wrap + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } + color: Theme.text + } + } + + Component { + id: cmdBlock + Rectangle { + implicitHeight: Math.max(code.implicitHeight, copyBtn.implicitHeight) + 24 + radius: 8 + color: Qt.alpha(Theme.crust, 0.8) + border.width: 1 + border.color: Qt.alpha(Theme.accent, 0.3) + + // Selectable as well as copyable: sometimes one line of a + // block is what is wanted. + TextEdit { + id: code + anchors { left: parent.left; right: copyBtn.left; verticalCenter: parent.verticalCenter; margins: 12 } + text: block.text + readOnly: true + selectByMouse: true + wrapMode: TextEdit.WrapAnywhere + font { family: "Inconsolata Nerd Font Mono"; pixelSize: Theme.fontSize - 1 } + color: Theme.text + selectionColor: Qt.alpha(Theme.accent, 0.4) + selectedTextColor: Theme.text + } + + Tab { + id: copyBtn + anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } + text: "Copy" + onClicked: Steps.copy(block.text) + } + } + } + } + } +} diff --git a/desktop/modules/appearance/SunsetModule.qml b/desktop/modules/appearance/SunsetModule.qml new file mode 100644 index 0000000..5d37052 --- /dev/null +++ b/desktop/modules/appearance/SunsetModule.qml @@ -0,0 +1,32 @@ +// Copyright (C) 2026 Danilo M. +// +// 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 "../.." + +// hyprsunset's profiles and location. +Module { + 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"] + + function refresh() { Hyprsunset.refresh(); } + + page: Component { + Page { + title: "Sunset" + SunsetTab { width: parent.width } + } + } +} diff --git a/desktop/modules/appearance/SunsetTab.qml b/desktop/modules/appearance/SunsetTab.qml new file mode 100644 index 0000000..657a920 --- /dev/null +++ b/desktop/modules/appearance/SunsetTab.qml @@ -0,0 +1,169 @@ +// Copyright (C) 2026 Danilo M. +// +// 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 "../.." + +Item { + implicitHeight: col.implicitHeight + + Column { + id: col + width: parent.width + spacing: 12 + + Row { + spacing: 8 + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Lat" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Field { width: 90; value: Hyprsunset.lat; onEdited: Hyprsunset.lat = value; placeholder: "lat" } + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Lon" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Field { width: 90; value: Hyprsunset.lon; onEdited: Hyprsunset.lon = value; placeholder: "lon" } + Toggle { + anchors.verticalCenter: parent.verticalCenter + checked: Hyprsunset.autoDetect + label: "auto" + onToggled: Hyprsunset.autoDetect = checked + } + Tab { text: "Detect"; onClicked: Hyprsunset.detect() } + Tab { text: Hyprsunset.busy ? "fetching…" : "Fetch sun times"; onClicked: Hyprsunset.fetchSun() } + Text { + anchors.verticalCenter: parent.verticalCenter + text: Hyprsunset.sunSummary + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + } + + Repeater { + model: Hyprsunset.profiles + Rectangle { + required property var modelData + required property int index + width: col.width + implicitHeight: 40 + radius: 8 + color: Qt.alpha(Theme.surface, 0.35) + + Row { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: index === Hyprsunset.dayIndex(Hyprsunset.profiles) ? "day" + : index === Hyprsunset.nightIndex(Hyprsunset.profiles) ? "night" : "profile" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Field { + anchors.verticalCenter: parent.verticalCenter + width: 60 + value: modelData.time + placeholder: "HH:MM" + onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, { time: value }) : p) + } + Toggle { + anchors.verticalCenter: parent.verticalCenter + checked: modelData.identity + label: "identity" + onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, { identity: checked }) : p) + } + Toggle { + anchors.verticalCenter: parent.verticalCenter + checked: modelData.temperature !== null && modelData.temperature !== undefined + label: "temp" + onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, + { temperature: checked ? (p.temperature ?? 5500) : null }) : p) + } + Field { + anchors.verticalCenter: parent.verticalCenter + width: 70 + value: modelData.temperature === null || modelData.temperature === undefined + ? "" : String(modelData.temperature) + placeholder: "5500" + onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, + { temperature: value === "" ? null : parseInt(value, 10) }) : p) + } + Toggle { + anchors.verticalCenter: parent.verticalCenter + checked: modelData.gamma !== null && modelData.gamma !== undefined + label: "gamma" + onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, + { gamma: checked ? (p.gamma ?? 1.0) : null }) : p) + } + Field { + anchors.verticalCenter: parent.verticalCenter + width: 60 + value: modelData.gamma === null || modelData.gamma === undefined + ? "" : String(modelData.gamma) + placeholder: "0.8" + onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, + { gamma: value === "" ? null : parseFloat(value) }) : p) + } + Item { width: 8; height: 1 } + Tab { + anchors.verticalCenter: parent.verticalCenter + text: "✕" + onClicked: Hyprsunset.profiles = + Hyprsunset.profiles.filter((p, i) => i !== index) + } + // The Row is pinned to the card width, so parent.width is + // real, but bound it anyway: a narrow enough panel would + // make the subtraction negative and put the Row in a + // negative-width state. + Item { width: Math.max(0, parent.width - 640); height: 1 } + } + } + } + + Row { + spacing: 8 + Tab { + text: "+ Add profile" + onClicked: Hyprsunset.profiles = + Hyprsunset.profiles.concat([{ time: "0:00", identity: false, + temperature: null, gamma: null }]) + } + Tab { text: "Live preview"; onClicked: Hyprsunset.preview() } + Tab { + text: "Save + Restart" + selected: true + onClicked: Hyprsunset.save() + } + } + + Text { + width: parent.width + wrapMode: Text.Wrap + visible: Hyprsunset.notice !== "" + text: Hyprsunset.notice + color: Hyprsunset.notice.indexOf("invalid") === 0 ? Theme.red : Theme.green + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + } +} diff --git a/desktop/modules/appearance/Tab.qml b/desktop/modules/appearance/Tab.qml new file mode 100644 index 0000000..d0a7a5e --- /dev/null +++ b/desktop/modules/appearance/Tab.qml @@ -0,0 +1,46 @@ +// Copyright (C) 2026 Danilo M. +// +// 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 "../.." + +Rectangle { + id: tab + property alias text: label.text + property bool selected: false + property bool enabled: true + signal clicked + + implicitWidth: label.implicitWidth + 24 + implicitHeight: label.implicitHeight + 14 + radius: 7 + opacity: enabled ? 1 : 0.4 + color: selected ? Qt.alpha(Theme.accent, 0.25) + : area.containsMouse ? Qt.alpha(Theme.surface, 0.8) + : Qt.alpha(Theme.surface, 0.35) + border.width: 1 + border.color: selected ? Qt.alpha(Theme.accent, 0.5) : "transparent" + + Text { + id: label + anchors.centerIn: parent + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: tab.selected ? Theme.text : Theme.subtext + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + cursorShape: tab.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: if (tab.enabled) tab.clicked() + } +} 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. +// +// 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. +// +// 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/desktop/modules/appearance/Toggle.qml b/desktop/modules/appearance/Toggle.qml new file mode 100644 index 0000000..1abdb77 --- /dev/null +++ b/desktop/modules/appearance/Toggle.qml @@ -0,0 +1,57 @@ +// Copyright (C) 2026 Danilo M. +// +// 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 "../.." + +Rectangle { + id: toggle + property bool checked: false + property string label: "" + signal toggled + + implicitWidth: row.implicitWidth + implicitHeight: 22 + color: "transparent" + + Row { + id: row + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 16; height: 16; radius: 4 + color: toggle.checked ? Qt.alpha(Theme.accent, 0.7) : Qt.alpha(Theme.surface, 0.5) + border.width: 1 + border.color: toggle.checked ? Qt.alpha(Theme.accent, 0.9) : Qt.alpha(Theme.text, 0.2) + Text { + anchors.centerIn: parent + visible: toggle.checked + text: "✓" + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 6; bold: true } + color: Theme.base + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: toggle.label !== "" + text: toggle.label + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { toggle.checked = !toggle.checked; toggle.toggled(); } + } +} diff --git a/desktop/modules/appearance/Udt.qml b/desktop/modules/appearance/Udt.qml new file mode 100644 index 0000000..a4bf296 --- /dev/null +++ b/desktop/modules/appearance/Udt.qml @@ -0,0 +1,153 @@ +// Copyright (C) 2026 Danilo M. +// +// 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. + +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. +Singleton { + id: root + + readonly property string home: Quickshell.env("HOME") + readonly property string repo: `${home}/Programming/GIT/unified-desktop-theme` + readonly property string selector: `${home}/.config/udt/roles.conf` + + property string current: "" + // name -> { bg, surface, text, accent, red, green, yellow } + property var swatches: ({}) + property list names: [] + + property bool applying: false + signal applied(string scheme, bool ok, string message) + + // A scheme is two files: .conf holds colours under the scheme's own + // names, roles-.conf says what each colour is for. Resolving a role + // means looking its value up as a key in the first file. + function parseSwatch(rolesText, paletteText) { + // QML's JS engine has no String.matchAll, so this is an exec loop. + const colours = {}; + const re = /^(\w+)\s*=\s*(#[0-9a-fA-F]{6})/gm; + let m; + while ((m = re.exec(paletteText)) !== null) colours[m[1]] = m[2]; + + const role = key => { + const m = rolesText.match(new RegExp(`^${key}\\s*=\\s*(\\S+)`, "m")); + // A role can carry a /alpha or *shade suffix; the base colour is + // what a swatch shows. + return m ? colours[m[1].split(/[/*]/)[0]] ?? "" : ""; + }; + + return { + bg: role("bg"), surface: role("surface"), text: role("fg"), + accent: role("accent"), red: role("critical"), + green: role("success"), yellow: role("warning"), + }; + } + + // Which scheme is live. Watched, so switching it from an editor moves the + // panel's marker too. + FileView { + path: root.selector + watchChanges: true + onFileChanged: reload() + onLoaded: { + const m = text().match(/^\s*scheme\s*=\s*(\S+)/m); + if (m) root.current = m[1]; + } + } + + // The scheme list comes from the palette directory rather than the comment + // in roles.conf: adding a scheme is adding two files, and this then needs + // no edit at all. + // Rescanned whenever the panel opens: adding a scheme is adding two + // files, and a shell that has been running since before they appeared + // would otherwise keep showing the old list. + function refresh() { + listProc.running = false; + listProc.running = true; + } + + Process { + id: listProc + running: true + command: ["sh", "-c", + `ls ${root.repo}/palette/roles-*.conf 2>/dev/null | ` + + `sed 's|.*/roles-||; s|\\.conf$||' | sort`] + stdout: StdioCollector { + onStreamFinished: { + root.names = text.trim().split("\n").filter(s => s.length); + loadProc.next = 0; + loadProc.loadNext(); + } + } + } + + Process { + id: loadProc + property int next: 0 + property string scheme: "" + + function loadNext() { + if (next >= root.names.length) return; + scheme = root.names[next]; + next++; + // Both files at once, split on a marker: one process per scheme + // rather than two, and the pair is always consistent. + command = ["sh", "-c", + `cat ${root.repo}/palette/roles-${scheme}.conf; ` + + `echo '===SPLIT==='; cat ${root.repo}/palette/${scheme}.conf`]; + // Assigning true to an already-true `running` does nothing, and + // this Process is reused for every scheme in turn. + running = false; + running = true; + } + + stdout: StdioCollector { + onStreamFinished: { + const [rolesText, paletteText] = text.split("===SPLIT==="); + if (rolesText && paletteText) { + const next = Object.assign({}, root.swatches); + next[loadProc.scheme] = root.parseSwatch(rolesText, paletteText); + root.swatches = next; + } + loadProc.loadNext(); + } + } + } + + // Switching writes the scheme line and regenerates every config; + // install.sh also reloads what is running, and hands its whole output to + // the steps window's `install` tab itself. + function apply(scheme) { + if (applying || scheme === current) return; + applying = true; + applyProc.scheme = scheme; + applyProc.command = ["sh", "-c", + `sed -i 's|^scheme *=.*|scheme = ${scheme}|' ${JSON.stringify(root.selector)} && ` + + `${JSON.stringify(root.repo)}/install.sh >/dev/null 2>&1`]; + applyProc.running = true; + } + + Process { + id: applyProc + property string scheme: "" + onExited: code => { + root.applying = false; + root.applied(applyProc.scheme, code === 0, + code === 0 ? "" : `install.sh exited ${code}, its output is in the steps window`); + } + } +} 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. +// +// 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. +// +// 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/desktop/modules/appearance/Wallpapers.qml b/desktop/modules/appearance/Wallpapers.qml new file mode 100644 index 0000000..7d671e1 --- /dev/null +++ b/desktop/modules/appearance/Wallpapers.qml @@ -0,0 +1,244 @@ +// Copyright (C) 2026 Danilo M. +// +// 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. + +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 +// reimplementing here. +Singleton { + id: root + + readonly property string home: Quickshell.env("HOME") + readonly property string dir: `${home}/Pictures/wallpapers` + // The distribution's wallpapers: loose images, and Plasma packages that + // each carry one picture at several resolutions under contents/images/, + // some with a dark version under contents/images_dark/. + readonly property string sysDir: "/usr/share/wallpapers" + + // Which screen a pick applies to. wallp calls them H and V. + property string target: "H" + + // Staged picks, empty until something is chosen. Selecting does not set + // anything: both screens can be composed and applied together, which is + // also a single wallp call rather than two. + property string pendingH: "" + property string pendingV: "" + readonly property bool dirty: pendingH !== "" || pendingV !== "" + + function pick(path) { + if (target === "V") pendingV = path; + else pendingH = path; + } + + function clearPending() { pendingH = ""; pendingV = ""; } + + // What a screen should show: the staged pick if there is one, else what + // is actually set. + function shown(which) { + if (which === "V") return pendingV !== "" ? pendingV : currentV; + return pendingH !== "" ? pendingH : currentH; + } + + property list files: [] + property bool scanning: true + property bool applying: false + signal applied(bool ok, string message) + + // Sorted by most recent first: the wallpaper wanted now is usually one + // added recently. -maxdepth keeps a stray git checkout from being walked. + function refresh() { + scanning = true; + scanProc.running = false; + scanProc.running = true; + } + + Process { + id: scanProc + running: true + // img DIR "DEPTH" [ACTIONS...]: the image files under DIR. DEPTH is + // left unquoted on purpose, so "-maxdepth 2" splits into two words + // and "" vanishes. + command: ["sh", "-c", + `img() { d=$1; o=$2; shift 2; find "$d" $o -type f ` + + `\\( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.webp' \\) "$@" 2>/dev/null; }; ` + + `img ${JSON.stringify(root.dir)} "-maxdepth 2" -not -path '*/.*' -printf '%T@ %p\\n' | sort -rn | cut -d' ' -f2-; ` + + `img ${JSON.stringify(root.sysDir)} "-maxdepth 1" | sort; ` + + `img ${JSON.stringify(root.sysDir)} "" -path '*/contents/images*/*'`] + stdout: StdioCollector { + onStreamFinished: { + root.files = text.trim().split("\n").filter(s => s.length); + root.scanning = false; + sizeProc.running = false; + sizeProc.command = ["sh", "-c", 'printf "%s\\0" "$@" | xargs -0 -r file -N -0', "sh"].concat(root.files); + sizeProc.running = true; + } + } + } + + // Each file's pixel size, by path, from `file`, which reads only the + // header: about half a second for a thousand. -0 ends the name with a + // NUL, since a name can hold the ": " that would otherwise end it. The + // size is the last WxH in the description, because a JPEG's comes after + // its "density 72x72". A file `file` gives no size for has no entry and + // gets no quality colour. + property var sizes: ({}) + Process { + id: sizeProc + stdout: StdioCollector { + onStreamFinished: { + const out = {}; + for (const line of text.split("\n")) { + const nul = line.indexOf("\0"); + if (nul < 0) continue; + const re = /(\d+) ?x ?(\d+)/g; + let m, last = null; + while ((m = re.exec(line.slice(nul + 1))) !== null) last = m; + if (last) out[line.slice(0, nul)] = { w: +last[1], h: +last[2] }; + } + root.sizes = out; + } + } + } + + // The screens wallp sets, as the mock draws them: DP-1 wide, DP-3 turned + // upright. + // ponytail: fixed, like the mock; read Quickshell.screens if a monitor + // changes. + readonly property var panels: ({ H: { w: 2560, h: 1080 }, V: { w: 1080, h: 1920 } }) + + // How far swaybg's `-m fill` has to enlarge an image to cover a screen, + // 0 when the size is unknown. At or under 1 it is downscaled and sharp; + // to 1.33 the softening is slight; past that it shows pixels. + function upscale(path, which) { + const s = sizes[path]; + const p = panels[which]; + return s && s.w > 0 && s.h > 0 ? Math.max(p.w / s.w, p.h / s.h) : 0; + } + function qualityColor(path, which) { + const u = upscale(path, which); + return u === 0 ? "transparent" : u <= 1 ? Theme.green : u <= 1.33 ? Theme.yellow : Theme.red; + } + // Why an image that fits will still look bad, or "" when it will not. + function qualityNote(path, which) { + const u = upscale(path, which); + if (u <= 1) return ""; + const s = sizes[path], p = panels[which]; + return `${s.w}×${s.h} is enlarged ${u.toFixed(2)}× to cover the ${p.w}×${p.h} screen: ` + + (u <= 1.33 ? "each image pixel is spread over more than one screen pixel, so it will look slightly soft." + : "each image pixel becomes a visible block, so it will look pixelated."); + } + + function basename(path) { return path.slice(path.lastIndexOf("/") + 1); } + + // The files by the subfolder they sit in, folders by name, each keeping + // the newest-first order of `files`. Images loose in `dir` itself form a + // group named after it, which sorts first. A folder with no images at + // the scanned depth has no group. The system ones follow: its loose + // images as "system", and the Plasma packages as "Plasma", one image + // each: the dark version where the package has one, else the light, and + // of those the largest by the WxH its file is named after. `key` is what + // `expanded` is indexed by, so a local folder named "system" stays apart. + readonly property var groups: { + const local = {}, system = [], plasma = {}; + for (const f of files) { + if (f.startsWith(dir + "/")) { + const rel = f.slice(dir.length + 1); + const key = rel.includes("/") ? rel.slice(0, rel.indexOf("/")) : ""; + if (!local[key]) local[key] = []; + local[key].push(f); + } else if (f.includes("/contents/images")) { + const pkg = f.slice(sysDir.length + 1).split("/")[0]; + if (!plasma[pkg] || rank(f) > rank(plasma[pkg])) plasma[pkg] = f; + } else { + system.push(f); + } + } + const byName = (a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }); + return Object.keys(local).sort(byName) + .map(k => ({ key: "local/" + k, name: k || basename(dir), files: local[k] })) + .concat([ + { key: "system", name: "system", files: system }, + { key: "plasma", name: "Plasma", files: Object.keys(plasma).sort(byName).map(k => plasma[k]) }, + ]) + .filter(g => g.files.length > 0); + } + + // Dark outranks any light, then size decides. + function rank(path) { + const m = /(\d+)x(\d+)\.\w+$/.exec(path); + return (path.includes("/contents/images_dark/") ? 1e12 : 0) + (m ? m[1] * m[2] : 0); + } + + // What a thumbnail is called: a Plasma package's file is named after its + // resolution, so it goes by the package instead. + function label(path) { + const i = path.indexOf("/contents/images"); + return i < 0 ? basename(path) : basename(path.slice(0, i)); + } + + // Which groups are open, by key. Here rather than in the panel, which is + // destroyed on close, so a folder left open is open next time. Reassigned + // whole, because bindings do not see a key change inside a var. + property var expanded: ({}) + function toggle(key) { + const e = Object.assign({}, expanded); + e[key] = !e[key]; + expanded = e; + } + + // What is on each screen now. wallp writes these on every set, so + // watching them keeps the mock honest even when set from a terminal. + property string currentH: "" + property string currentV: "" + + FileView { + path: `${root.home}/.config/wallp/wall_h` + watchChanges: true + onFileChanged: reload() + onLoaded: root.currentH = text().trim() + } + + FileView { + path: `${root.home}/.config/wallp/wall_v` + watchChanges: true + onFileChanged: reload() + onLoaded: root.currentV = text().trim() + } + + // wallp does the work, including running udt-accent, so the accent follows + // the new wallpaper exactly as it does from the command line. Both screens + // go in one call: wallp accepts H= and V= together. + function apply() { + if (applying || !dirty) return; + applying = true; + const args = []; + if (pendingH !== "") args.push(`H=${JSON.stringify(pendingH)}`); + if (pendingV !== "") args.push(`V=${JSON.stringify(pendingV)}`); + applyProc.command = ["sh", "-c", `wallp --set ${args.join(" ")} >/dev/null 2>&1`]; + applyProc.running = true; + } + + Process { + id: applyProc + stderr: StdioCollector { id: applyErr } + onExited: code => { + root.applying = false; + if (code === 0) root.clearPending(); + root.applied(code === 0, code === 0 ? "" : (applyErr.text.trim() || `wallp exited ${code}`)); + } + } +} 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 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 ipc call assistant toggle -> SUPER+Home IpcHandler { target: "assistant" -- cgit v1.2.3