diff --git a/configuration.nix b/configuration.nix index 0e630c6..b646a90 100644 --- a/configuration.nix +++ b/configuration.nix @@ -7,8 +7,7 @@ ./hardware-configuration.nix # Generated hardware scan results ./modules/system.nix # Bootloader, networking, time, sound, hardening, nix-ld ./modules/drivers.nix # NVIDIA Open GPU kernel modules and graphics - ./modules/desktop.nix # Hyprland compositor and desktop environment - ./modules/theme.nix # Shared color palette; GTK dark+squared theming + ./modules/desktop.nix # Budgie desktop environment ./modules/users.nix # User accounts and global configurations ./modules/apps.nix # All installed packages: apps, CLI tools, dev toolchains ./modules/scripts.nix # Custom system scripts and helpers diff --git a/dotfiles/kitty/kitty.conf b/dotfiles/kitty/kitty.conf deleted file mode 100644 index 3bd540c..0000000 --- a/dotfiles/kitty/kitty.conf +++ /dev/null @@ -1,60 +0,0 @@ -# Kitty terminal configuration -# Deployed to ~/.config/kitty/kitty.conf via systemd.tmpfiles in -# modules/desktop.nix. - -# --- Shell: run ZSH directly, with shell integration --- -shell zsh -shell_integration enabled - -# --- Font --- -font_family JetBrainsMono Nerd Font -bold_font auto -italic_font auto -bold_italic_font auto -font_size 11.0 - -# --- Squared, minimal window chrome --- -window_padding_width 8 -window_margin_width 0 -window_border_width 0 -draw_minimal_borders yes -hide_window_decorations yes -confirm_os_window_close 0 - -# --- Tab bar: flat, square --- -tab_bar_edge top -tab_bar_style powerline -tab_powerline_style slanted -tab_bar_min_tabs 2 - -# --- Cursor --- -cursor_shape block -cursor_blink_interval 0 - -# --- Colors --- -# Base/accent colors (background, foreground, selection, borders, tabs) -# come from the Quickshell Theme picker (dotfiles/quickshell/Theme.qml), -# via kitty-theme-set — kitty has a built-in config-file watcher (see -# `kitten __watch_conf__` in its process list) that also watches included -# files, so this picks up changes live with no restart needed. -include theme.conf - -# ANSI color0-15 palette (used for terminal output — ls, git diff, etc.) -# stays fixed regardless of accent/mode: these need to stay mutually -# distinguishable hues, not track a single accent color. -color0 #0d0d0d -color8 #444444 -color1 #e06c75 -color9 #e06c75 -color2 #98c379 -color10 #98c379 -color3 #e5c07b -color11 #e5c07b -color4 #61afef -color12 #61afef -color5 #c678dd -color13 #c678dd -color6 #56b6c2 -color14 #56b6c2 -color7 #e0e0e0 -color15 #ffffff diff --git a/dotfiles/mako/config b/dotfiles/mako/config deleted file mode 100644 index 6373415..0000000 --- a/dotfiles/mako/config +++ /dev/null @@ -1,30 +0,0 @@ -# Mako notification daemon configuration -# Deployed to ~/.config/mako/config via systemd.tmpfiles in -# modules/desktop.nix. Squared, sharp, no rounded corners. -# -# Anchored top-center to match the custom polkit dialog (PolkitDialog.qml) -# so both "system popup" surfaces show up in the same place. - -background-color=#0d0d0dee -text-color=#e0e0e0ff -border-color=#333333ff -border-size=2 -border-radius=0 -padding=10 -margin=12 -width=420 -font=JetBrainsMono Nerd Font 10 -default-timeout=5000 -max-visible=5 -layer=overlay -anchor=top-center - -[urgency=low] -border-color=#333333ff - -[urgency=normal] -border-color=#8fbcbbff - -[urgency=critical] -border-color=#e06c75ff -default-timeout=0 diff --git a/dotfiles/quickshell/BottomBar.qml b/dotfiles/quickshell/BottomBar.qml deleted file mode 100644 index 144d200..0000000 --- a/dotfiles/quickshell/BottomBar.qml +++ /dev/null @@ -1,447 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Hyprland -import Quickshell.Io -import Quickshell.Wayland -import Quickshell.Widgets - -PanelWindow { - id: root - required property var screen - required property var leftBar - required property var rightBar - - // Right-click context menu state — set by whichever taskbar window - // entry was right-clicked, cleared after an action or a second - // right-click on the same entry. Anchored via anchor.window + a - // one-shot computed x (not anchor.item!) — anchoring a popup directly - // to a Repeater delegate crashed Quickshell (PopupAnchor::setItem / - // onItemWindowChanged segfaults when the delegate it's watching gets - // destroyed/recreated, which happens often since the window list - // rebuilds its model on every toplevel change). root itself is a - // stable, never-recreated window, so anchoring to it is safe — same - // pattern the Left/RightBar flyouts already use successfully. - property var ctxMenuToplevel: null - property real ctxMenuX: 0 - function closeCtxMenu() { - root.ctxMenuToplevel = null; - } - - anchors { left: true; right: true; bottom: true } - implicitHeight: 46 - exclusiveZone: 46 - color: Theme.bg - focusable: false - - // Special workspaces (e.g. "special:showdesktop", used by the - // Super+D show-desktop toggle) are an implementation detail, not a - // real switchable workspace — Hyprland keeps their metadata around - // even once empty, so they'd otherwise show up here forever. - property var visibleWorkspaces: { - var out = []; - var all = Hyprland.workspaces.values; - for (var i = 0; i < all.length; i++) { - if (all[i].name.indexOf("special:") !== 0) out.push(all[i]); - } - return out; - } - - // Only workspaces that actually exist (have windows, or are/were - // focused) get a pill — showing all 10 possible slots took up too - // much space. Always shown in numeric order (1, 2, 3, ...) regardless - // of creation/focus order, matching the Super+1..9,0 keybinds. - property var workspacePills: { - var out = []; - for (var i = 0; i < root.visibleWorkspaces.length; i++) { - var w = root.visibleWorkspaces[i]; - out.push({ number: w.id, ws: w }); - } - out.sort(function (a, b) { return a.number - b.number; }); - return out; - } - - // Window list ordered by workspace number (1, then 2, ...) instead of - // whatever arbitrary order the compositor reports toplevels in. - // - // The base list comes from ToplevelManager (the generic Wayland - // wlr-foreign-toplevel-management module), NOT Hyprland.toplevels — - // Hyprland.toplevels is built by parsing Hyprland's own IPC event - // stream, and apps that open/close many short-lived helper windows - // fast (Steam is the textbook case: steamwebhelper, dummy GL context - // windows, etc.) can desync it from reality, leaving phantom entries - // that `hyprctl clients` doesn't even show. ToplevelManager reflects - // live protocol state directly, so it can't go stale like that. - // Hyprland.toplevels is only used as a lookup to find each window's - // workspace id for sorting — a lookup miss just sorts that window last, - // it can't add a window that isn't really there. - property var orderedToplevels: { - var genList = ToplevelManager.toplevels.values; - var hyprList = Hyprland.toplevels.values; - var out = []; - for (var i = 0; i < genList.length; i++) { - var gt = genList[i]; - var wsId = 999999; - for (var j = 0; j < hyprList.length; j++) { - if (hyprList[j].wayland === gt) { - wsId = (hyprList[j].workspace && hyprList[j].workspace.id > 0) ? hyprList[j].workspace.id : 999999; - break; - } - } - out.push({ tl: gt, wsId: wsId }); - } - out.sort(function (a, b) { return a.wsId - b.wsId; }); - return out; - } - - Rectangle { anchors.top: parent.top; width: parent.width; height: 1; color: Theme.border } - - // --- Left bar toggle --------------------------------------------- - Rectangle { - id: leftToggle - anchors.left: parent.left - anchors.leftMargin: 8 - anchors.verticalCenter: parent.verticalCenter - width: 32 - height: 32 - color: ltMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: root.leftBar.expanded ? 2 : 1 - border.color: root.leftBar.expanded ? Theme.accent : Theme.border - Text { - anchors.centerIn: parent - text: root.leftBar.expanded ? "<" : ">" - color: root.leftBar.expanded ? Theme.accent : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 14 - font.bold: true - } - MouseArea { - id: ltMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - root.leftBar.expanded = !root.leftBar.expanded; - if (!root.leftBar.expanded) root.leftBar.activePanel = ""; - } - } - } - - Row { - anchors.left: leftToggle.right - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: 8 - spacing: 4 - - Repeater { - model: root.workspacePills - delegate: Rectangle { - property var ws: modelData.ws - property int number: modelData.number - width: 32 - height: 28 - color: ws.focused ? Theme.accent : (wsMa.containsMouse ? Theme.bgAlt : "transparent") - border.width: 1 - border.color: ws.urgent ? Theme.danger : Theme.border - Text { - anchors.centerIn: parent - text: number - color: ws.focused ? Theme.bg : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - MouseArea { - id: wsMa - anchors.fill: parent - hoverEnabled: true - onClicked: ws.activate() - } - } - } - } - - Rectangle { - anchors.left: parent.left - anchors.leftMargin: 8 + 32 + 8 + (root.workspacePills.length * 36) + 4 - anchors.top: parent.top - anchors.topMargin: 6 - width: 1 - height: 28 - color: Theme.border - } - - // --- Weather badge --------------------------------------------------- - // Independent of RightFlyout's own weatherPanel (which only exists - // while the Loader has it active) — this needs to keep showing a - // temperature even when the panel's closed, so it fetches on its own - // timer rather than reading state from a panel instance that mostly - // doesn't exist. Reads the same city override (if any) so the badge - // and the full panel never disagree about which location they're - // showing. - Item { - id: wxBadgeState - property var data: ({}) - property bool haveData: !!data.current - property string city: "" - - function describeIcon(code) { - if (code === 0) return "☀"; - if (code === 1) return "🌤"; - if (code === 2) return "⛅"; - if (code === 3) return "☁"; - if (code === 45 || code === 48) return "🌫"; - if (code >= 51 && code <= 57) return "🌦"; - if (code >= 61 && code <= 67) return "🌧"; - if (code >= 71 && code <= 77) return "❄"; - if (code >= 80 && code <= 82) return "🌧"; - if (code === 85 || code === 86) return "🌨"; - if (code >= 95) return "⛈"; - return "?"; - } - - Process { - id: wxBadgeCityLoadProc - command: ["cat", Quickshell.stateDir + "/weather-settings.json"] - stdout: StdioCollector { - onStreamFinished: { - try { - var d = JSON.parse(text); - if (d.city) wxBadgeState.city = d.city; - } catch (e) { /* no override saved — auto-detect stands */ } - wxBadgeFetchProc.command = wxBadgeState.city.length > 0 - ? ["weather-fetch", wxBadgeState.city] : ["weather-fetch"]; - wxBadgeFetchProc.running = true; - } - } - } - Process { - id: wxBadgeFetchProc - stdout: StdioCollector { - onStreamFinished: { - try { wxBadgeState.data = JSON.parse(text); } catch (e) { /* keep last good reading */ } - } - } - } - Timer { - interval: 900000 - running: true - repeat: true - onTriggered: wxBadgeCityLoadProc.running = true - } - Component.onCompleted: wxBadgeCityLoadProc.running = true - } - - Rectangle { - id: weatherBadge - visible: wxBadgeState.haveData - anchors.right: rightToggle.left - anchors.rightMargin: 8 - anchors.verticalCenter: parent.verticalCenter - width: wxBadgeLabel.implicitWidth + 16 - height: 32 - color: wxBadgeMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.border - Text { - id: wxBadgeLabel - anchors.centerIn: parent - text: wxBadgeState.haveData - ? (wxBadgeState.describeIcon(wxBadgeState.data.current.weather_code) + " " + Math.round(wxBadgeState.data.current.temperature_2m) + "°C") - : "" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - MouseArea { - id: wxBadgeMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - root.rightBar.expanded = true; - root.rightBar.toggle("weather"); - } - } - } - - // --- Right bar toggle --------------------------------------------- - Rectangle { - id: rightToggle - anchors.right: parent.right - anchors.rightMargin: 8 - anchors.verticalCenter: parent.verticalCenter - width: 32 - height: 32 - color: rtMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: root.rightBar.expanded ? 2 : 1 - border.color: root.rightBar.expanded ? Theme.accent : Theme.border - Text { - anchors.centerIn: parent - text: root.rightBar.expanded ? ">" : "<" - color: root.rightBar.expanded ? Theme.accent : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 14 - font.bold: true - } - MouseArea { - id: rtMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - root.rightBar.expanded = !root.rightBar.expanded; - if (!root.rightBar.expanded) root.rightBar.activePanel = ""; - } - } - } - - Row { - anchors.left: parent.left - anchors.leftMargin: 8 + 32 + 8 + (root.workspacePills.length * 36) + 16 - anchors.right: weatherBadge.left - anchors.rightMargin: 8 - anchors.verticalCenter: parent.verticalCenter - spacing: 4 - clip: true - - Repeater { - model: root.orderedToplevels - delegate: Rectangle { - id: tlDelegate - property var tl: modelData.tl - // Resolved via the app's .desktop entry (matched by Wayland - // app-id) rather than trusting a raw icon name/path from - // the toplevel itself, which isn't exposed by the protocol. - property string iconSrc: { - if (!tl.appId) return ""; - var entry = DesktopEntries.heuristicLookup(tl.appId); - if (!entry || !entry.icon) return ""; - return Quickshell.iconPath(entry.icon, ""); - } - width: Math.min(220, label.implicitWidth + (iconSrc !== "" ? 46 : 24)) - height: 28 - color: tl.activated ? Theme.accent : (tlMa.containsMouse ? Theme.bgAlt : Theme.bgAlt) - border.width: 1 - border.color: root.ctxMenuToplevel === tl ? Theme.accent : Theme.border - - IconImage { - id: appIcon - visible: tlDelegate.iconSrc !== "" - implicitSize: 16 - anchors.left: parent.left - anchors.leftMargin: 8 - anchors.verticalCenter: parent.verticalCenter - source: tlDelegate.iconSrc - } - Text { - id: label - anchors.left: appIcon.visible ? appIcon.right : parent.left - anchors.leftMargin: appIcon.visible ? 6 : 8 - anchors.right: parent.right - anchors.rightMargin: 8 - anchors.verticalCenter: parent.verticalCenter - text: tl.title || tl.appId || "?" - color: tl.activated ? Theme.bg : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - elide: Text.ElideRight - } - MouseArea { - id: tlMa - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton - onClicked: (mouse) => { - if (mouse.button === Qt.RightButton) { - if (root.ctxMenuToplevel === tl) { root.closeCtxMenu(); return; } - var pos = tlDelegate.mapToItem(null, 0, 0); - root.ctxMenuX = pos.x; - root.ctxMenuToplevel = tl; - return; - } - if (mouse.button === Qt.MiddleButton) tl.close(); - else tl.activate(); - } - } - } - } - } - - // --- Taskbar entry context menu ------------------------------------ - // Window control options exposed by the wlr-foreign-toplevel-management - // protocol (fullscreen/maximized are writable properties on any - // toplevel — there's no per-app capability flag to check, so these are - // always offered as a best-effort "if the app honors it" set). - // Loaded lazily (not instantiated until first opened) so the popup - // and its anchor only exist while actually needed. - Loader { - active: root.ctxMenuToplevel !== null - sourceComponent: PopupWindow { - id: ctxMenu - visible: true - anchor.window: root - anchor.rect.x: root.ctxMenuX - anchor.rect.y: 0 - anchor.edges: Edges.Top - anchor.gravity: Edges.Top - anchor.margins.bottom: 4 - implicitWidth: 150 - implicitHeight: ctxCol.implicitHeight + 12 - color: "transparent" - - Rectangle { - anchors.fill: parent - color: Theme.bg - border.width: 1 - border.color: Theme.border - - Column { - id: ctxCol - x: 6 - y: 6 - width: parent.width - 12 - spacing: 2 - - Repeater { - model: { - var tl = root.ctxMenuToplevel; - if (!tl) return []; - return [ - { label: tl.fullscreen ? "Exit Fullscreen" : "Fullscreen", action: "fullscreen", danger: false }, - { label: tl.maximized ? "Restore" : "Maximize", action: "maximize", danger: false }, - { label: "Close", action: "close", danger: true } - ]; - } - delegate: Rectangle { - width: parent.width - height: 26 - color: itemMa.containsMouse ? Theme.bgAlt : "transparent" - Text { - anchors.left: parent.left - anchors.leftMargin: 8 - anchors.verticalCenter: parent.verticalCenter - text: modelData.label - color: modelData.danger ? Theme.danger : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - MouseArea { - id: itemMa - anchors.fill: parent - hoverEnabled: true - onClicked: { - var tl = root.ctxMenuToplevel; - if (tl) { - if (modelData.action === "fullscreen") tl.fullscreen = !tl.fullscreen; - else if (modelData.action === "maximize") tl.maximized = !tl.maximized; - else if (modelData.action === "close") tl.close(); - } - root.closeCtxMenu(); - } - } - } - } - } - } - } - } -} diff --git a/dotfiles/quickshell/CloseButton.qml b/dotfiles/quickshell/CloseButton.qml deleted file mode 100644 index 0c1c1f2..0000000 --- a/dotfiles/quickshell/CloseButton.qml +++ /dev/null @@ -1,31 +0,0 @@ -import QtQuick - -// Small close button using a plain "x" glyph, not a nerd-font icon — the -// left-bar icons taught us those can silently fail to render. -Rectangle { - id: root - signal clicked() - - width: 24 - height: 24 - color: mouseArea.containsMouse ? Theme.borderDim : "transparent" - border.width: 1 - border.color: Theme.border - - Text { - anchors.centerIn: parent - text: "x" - color: Theme.fgDim2 - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - } - - MouseArea { - id: mouseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.clicked() - } -} diff --git a/dotfiles/quickshell/DeleteTileButton.qml b/dotfiles/quickshell/DeleteTileButton.qml deleted file mode 100644 index c435444..0000000 --- a/dotfiles/quickshell/DeleteTileButton.qml +++ /dev/null @@ -1,36 +0,0 @@ -import QtQuick - -// Small "x" corner button for deleting a grid tile (wallpapers) — only -// meant to be shown while the tile itself is hovered, matching the -// hover-to-reveal pattern used for clearing a single notification. -Rectangle { - id: root - signal clicked() - - width: 18 - height: 18 - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 4 - z: 1 - color: mouseArea.containsMouse ? Theme.danger : Theme.bg - border.width: 1 - border.color: Theme.danger - - Text { - anchors.centerIn: parent - text: "×" - color: mouseArea.containsMouse ? Theme.bg : Theme.danger - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - } - - MouseArea { - id: mouseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.clicked() - } -} diff --git a/dotfiles/quickshell/DigitalClock.qml b/dotfiles/quickshell/DigitalClock.qml deleted file mode 100644 index 509ccaa..0000000 --- a/dotfiles/quickshell/DigitalClock.qml +++ /dev/null @@ -1,31 +0,0 @@ -import QtQuick -import Quickshell - -// 24h time + "day monthname year" date, using the system locale (pl_PL) for -// the month name — matches how Poland actually writes dates. -Column { - id: root - spacing: 2 - - SystemClock { - id: clock - precision: SystemClock.Minutes - enabled: true - } - - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: Qt.formatTime(clock.date, "HH:mm") - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 20 - font.bold: true - } - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: Qt.formatDate(clock.date, "d MMMM yyyy") - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } -} diff --git a/dotfiles/quickshell/IconButton.qml b/dotfiles/quickshell/IconButton.qml deleted file mode 100644 index b2034a8..0000000 --- a/dotfiles/quickshell/IconButton.qml +++ /dev/null @@ -1,33 +0,0 @@ -import QtQuick - -Rectangle { - id: root - property string glyph: "" - property bool active: false - property color fg: Theme.fg - property color accent: Theme.accent - signal clicked() - - width: 48 - height: 48 - color: mouseArea.containsMouse ? Theme.bgAlt : "transparent" - border.width: active ? 2 : 1 - border.color: active ? accent : Theme.borderDim - radius: 0 - - Text { - anchors.centerIn: parent - text: root.glyph - color: root.active ? root.accent : root.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 21 - } - - MouseArea { - id: mouseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.clicked() - } -} diff --git a/dotfiles/quickshell/LeftBar.qml b/dotfiles/quickshell/LeftBar.qml deleted file mode 100644 index ac683bb..0000000 --- a/dotfiles/quickshell/LeftBar.qml +++ /dev/null @@ -1,162 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io -import Quickshell.Wayland - -PanelWindow { - id: root - required property var screen - property string activePanel: "" - property bool expanded: false - - anchors { top: true; left: true; bottom: true } - // Collapsed = zero reserved space; expand/collapse is triggered from - // the taskbar (BottomBar), not a button on this bar itself. - implicitWidth: expanded ? 220 : 0 - exclusiveZone: expanded ? 220 : 0 - color: Theme.bg - focusable: false - - Process { id: launcher; command: [] } - function launch(cmd) { launcher.command = cmd; launcher.running = true; } - - // Only "booru" needs the popup's keyboard grab (it's the only panel - // with a text field). - function needsGrab(panel) { return panel === "booru"; } - - function toggle(panel) { - if (root.activePanel === panel) { - root.activePanel = ""; - return; - } - if (root.activePanel !== "" && root.needsGrab(root.activePanel) !== root.needsGrab(panel)) { - // Switching directly between a grabbing and a non-grabbing - // panel without closing the popup first doesn't work — an - // xdg_popup's grab can only be established when the surface - // is first created, not toggled on one that's already - // mapped. Force a close, then reopen on the next tick so the - // popup actually gets torn down and recreated with the right - // grab state. - root.activePanel = ""; - Qt.callLater(function () { root.activePanel = panel; }); - return; - } - root.activePanel = panel; - } - - // Resolves the real, locale-correct Pictures folder at click time - // (e.g. ~/Obrazy on pl_PL) instead of assuming the English name. - Process { - id: picturesLauncher - command: ["sh", "-c", "thunar \"$(xdg-user-dir PICTURES)\""] - } - - Column { - visible: root.expanded - anchors.top: parent.top - anchors.horizontalCenter: parent.horizontalCenter - anchors.topMargin: 14 - spacing: 14 - width: parent.width - 24 - - Column { - width: parent.width - spacing: 6 - - Text { - text: "SHORTCUTS" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - SidebarRow { - label: "Dotfiles / NixOS" - onClicked: root.launch(["thunar", "/etc/nixos"]) - } - SidebarRow { - label: "Pictures" - onClicked: picturesLauncher.running = true - } - } - - Column { - width: parent.width - spacing: 6 - - Text { - text: "APPEARANCE" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - SidebarRow { - label: "Theme" - active: root.activePanel === "theme" - onClicked: root.toggle("theme") - } - SidebarRow { - label: "Display Settings" - active: root.activePanel === "settings" - onClicked: root.toggle("settings") - } - SidebarRow { - label: "Wallpapers" - active: root.activePanel === "wallpapers" - onClicked: root.toggle("wallpapers") - } - SidebarRow { - label: "Booru" - active: root.activePanel === "booru" - onClicked: root.toggle("booru") - } - SidebarRow { - label: "Cursor" - active: root.activePanel === "cursor" - onClicked: root.toggle("cursor") - } - } - } - - PanelWindow { - id: flyout - screen: root.screen - anchors { top: true; left: true } - // No margin here on purpose: Hyprland already insets layer-shell - // surfaces past other surfaces' exclusive zones on the same edge - // (confirmed via `hyprctl layers` — BottomBar gets the same - // treatment for free, sitting at x=220 with zero margin of its - // own). A margin of 220 here was stacking on top of that - // automatic inset, pushing the flyout 220px further right than - // intended and leaving a ~220px gap between it and the sidebar. - visible: root.activePanel !== "" - implicitWidth: 320 - implicitHeight: flyoutContent.implicitHeight - exclusiveZone: 0 - color: "transparent" - WlrLayershell.layer: WlrLayer.Overlay - // On-demand layer-shell focus, not an xdg_popup grab: this used to - // be a PopupWindow with grabFocus, but an xdg_popup grab gets - // dismissed by the compositor the instant anything else so much as - // touches focus/surface state elsewhere — mapping mpvpaper's - // layer-shell surface (or even hyprpaper's own "wallpaper" IPC call - // recreating its surface) was enough to trigger that, silently - // closing the whole flyout AND dropping keyboard input, which is - // what let keystrokes fall through to whatever window was focused - // before the flyout opened instead of reaching the text field. - // OnDemand only claims focus when something inside is actually - // clicked, and isn't torn down by unrelated surfaces. Panels with - // no text input don't need it at all. - WlrLayershell.keyboardFocus: root.needsGrab(root.activePanel) - ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None - - LeftFlyout { - id: flyoutContent - anchors.fill: parent - screen: root.screen - activePanel: root.activePanel - onRequestClose: root.activePanel = "" - } - } -} diff --git a/dotfiles/quickshell/LeftFlyout.qml b/dotfiles/quickshell/LeftFlyout.qml deleted file mode 100644 index 87828ea..0000000 --- a/dotfiles/quickshell/LeftFlyout.qml +++ /dev/null @@ -1,2618 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Hyprland -import Quickshell.Io -import Quickshell.Widgets - -Rectangle { - id: root - property string activePanel: "" - property var screen: null - signal requestClose() - - width: 320 - implicitHeight: header.height + loader.item.implicitHeight + 16 - color: Theme.bg - border.width: 2 - border.color: Theme.border - - property var titles: ({ - theme: "Theme", - settings: "Displays", - wallpapers: "Wallpapers", - booru: "Booru", - cursor: "Cursor" - }) - - Item { - id: header - width: parent.width - height: 40 - - Text { - anchors.left: parent.left - anchors.leftMargin: 14 - anchors.verticalCenter: parent.verticalCenter - text: root.titles[root.activePanel] || "" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 14 - font.bold: true - } - - CloseButton { - anchors.right: parent.right - anchors.rightMargin: 10 - anchors.verticalCenter: parent.verticalCenter - onClicked: root.requestClose() - } - } - Rectangle { anchors.top: header.bottom; width: parent.width; height: 1; color: Theme.border } - - Loader { - id: loader - y: header.height + 8 - x: 12 - width: parent.width - 24 - sourceComponent: { - switch (root.activePanel) { - case "theme": return themePanel; - case "settings": return settingsPanel; - case "wallpapers": return wallpapersPanel; - case "booru": return booruPanel; - case "cursor": return cursorPanel; - default: return emptyPanel; - } - } - } - - Component { - id: emptyPanel - Item { implicitHeight: 1 } - } - - // --- Theme -------------------------------------------------------------- - // Everything here just calls Theme.setAccent/setDark/randomAccent — - // Theme.qml itself owns the disk persistence, so there's nothing to - // wire up here beyond the picker UI. - Component { - id: themePanel - Column { - width: loader.width - spacing: 16 - - Column { - width: parent.width - spacing: 8 - Text { - text: "ACCENT COLOR" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - Flow { - width: parent.width - spacing: 8 - Repeater { - model: Theme.accentNames - delegate: Rectangle { - property string name: modelData - property color swatch: Theme.accentPalette[name] - width: 40; height: 40 - color: swatch - border.width: Theme.accentName === name ? 3 : 1 - border.color: Theme.accentName === name ? Theme.fg : Theme.border - MouseArea { - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: Theme.setAccent(name) - } - } - } - Rectangle { - width: 40; height: 40 - color: Theme.bgAlt - border.width: 1 - border.color: Theme.border - Text { - anchors.centerIn: parent - text: "RND" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - MouseArea { - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: Theme.randomAccent() - } - } - } - } - - Column { - width: parent.width - spacing: 8 - Text { - text: "MODE" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - Row { - spacing: 6 - Repeater { - model: [ - { label: "Dark", value: true }, - { label: "Light", value: false } - ] - delegate: Rectangle { - property bool val: modelData.value - width: 90; height: 30 - color: Theme.dark === val ? Theme.accent : (modeMa.containsMouse ? Theme.bgAlt : "transparent") - border.width: 1 - border.color: Theme.dark === val ? Theme.accent : Theme.border - Text { - anchors.centerIn: parent - text: modelData.label - color: Theme.dark === val ? Theme.bg : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - } - MouseArea { - id: modeMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: Theme.setDark(val) - } - } - } - } - } - - Text { - text: "Applies everywhere immediately. GTK apps (Thunar) follow separately\nand may need a restart to fully match." - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - wrapMode: Text.WordWrap - width: parent.width - } - } - } - - // --- Displays --------------------------------------------------------- - // Mode/scale/rotation/color are edited locally per monitor (starting - // from its current live values) and only actually applied when "Apply" - // is clicked, via `hyprctl keyword monitor ...` — same mechanism - // Hyprland itself uses, so it's an immediate, real change, not a - // config-file edit. display-set (modules/scripts/display-set.sh) then - // also persists the exact same descriptor into display.conf so it - // survives a Hyprland restart. - Component { - id: settingsPanel - Column { - id: dispRoot - width: loader.width - spacing: 16 - - // Hyprland's `monitor` keyword takes the FULL descriptor every - // time — there's no way to change just one field. hyprctl's own - // readback (Hyprland.monitors / lastIpcObject) covers most of - // that (mode, scale, transform, bitdepth via currentFormat, cm, - // sdrBrightness/Saturation, vrr) but NOT icc (never reported - // back anywhere in `hyprctl monitors -j`). So this tracks - // whatever WE last applied per monitor, keyed by name, and - // every apply (including a plain drag-to-reposition on the - // canvas below) resends the last-known-good value for every - // field, live-readback for anything never customized — - // otherwise repositioning a monitor with a custom ICC profile - // would silently drop that profile on the next apply. - property var overrides: ({}) - function ovr(name) { return dispRoot.overrides[name] || ({}); } - function setOvr(name, patch) { - var updated = {}; - for (var k in dispRoot.overrides) updated[k] = dispRoot.overrides[k]; - var merged = {}; - var cur = updated[name] || ({}); - for (var k2 in cur) merged[k2] = cur[k2]; - for (var k3 in patch) merged[k3] = patch[k3]; - updated[name] = merged; - dispRoot.overrides = updated; - } - function posFor(name, lx, ly) { - var o = dispRoot.overrides[name]; - return (o && o.x !== undefined) ? { x: o.x, y: o.y } : { x: lx, y: ly }; - } - - Text { - visible: Hyprland.monitors.values.length === 0 - text: "No monitor data" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - - // -- Arrangement canvas -------------------------------------- - // Drag-to-position, like every other DE's display panel — the - // only way to set position at all before this (previously the - // per-monitor Apply always resent the monitor's own current - // x/y unchanged, so multi-monitor layout couldn't be edited - // from this panel). - Column { - id: arrColumn - visible: Hyprland.monitors.values.length > 0 - width: parent.width - spacing: 6 - - Text { - text: "Arrangement" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 13 - font.bold: true - } - Text { - text: "Drag to position screens — edges snap together." - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - } - - // Logical (post-scale) bounding box of every monitor's - // current-or-pending position, fit into the canvas — this - // is what Hyprland actually tiles monitors in, not raw - // pixel mode size. - property real cScale: { - var maxX = 1, maxY = 1; - for (var i = 0; i < Hyprland.monitors.values.length; i++) { - var mm = Hyprland.monitors.values[i]; - var p = dispRoot.posFor(mm.name, mm.x, mm.y); - maxX = Math.max(maxX, p.x + mm.width / mm.scale); - maxY = Math.max(maxY, p.y + mm.height / mm.scale); - } - return Math.min((loader.width - 4) / maxX, 130 / maxY); - } - - Item { - id: arrCanvas - width: loader.width - height: 140 - - Repeater { - model: Hyprland.monitors - delegate: Rectangle { - id: monRect - property var m: modelData - property real lw: m.width / m.scale - property real lh: m.height / m.scale - property var pos: dispRoot.posFor(m.name, m.x, m.y) - property bool dragging: false - property real baseX: pos.x * arrColumn.cScale - property real baseY: pos.y * arrColumn.cScale - property real offX: 0 - property real offY: 0 - width: Math.max(28, lw * arrColumn.cScale) - height: Math.max(28, lh * arrColumn.cScale) - x: baseX + (dragging ? offX : 0) - y: baseY + (dragging ? offY : 0) - color: dragging ? Theme.accent : Theme.bgAlt - border.width: 2 - border.color: m.focused ? Theme.accent : Theme.border - - Text { - anchors.centerIn: parent - text: monRect.m.name - color: monRect.dragging ? Theme.bg : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - - Process { id: moveProc } - - MouseArea { - anchors.fill: parent - // Nothing to arrange a single screen relative - // to — disabled outright rather than just - // no-op-ing onReleased, so it doesn't even - // hover/cursor-hint as draggable. - enabled: Hyprland.monitors.values.length > 1 - hoverEnabled: true - cursorShape: Qt.SizeAllCursor - property real pressX: 0 - property real pressY: 0 - onPressed: (mouse) => { - monRect.dragging = true; - pressX = mouse.x; - pressY = mouse.y; - } - onPositionChanged: (mouse) => { - if (!monRect.dragging) return; - monRect.offX += (mouse.x - pressX); - monRect.offY += (mouse.y - pressY); - } - onReleased: { - var scale = arrColumn.cScale; - var rawX = (monRect.baseX + monRect.offX) / scale; - var rawY = (monRect.baseY + monRect.offY) / scale; - - // Snap to any other monitor's edges within a - // small threshold — hand-aligning pixel-exact - // is otherwise a losing game against a - // ~300px canvas representing a desktop - // that's thousands of pixels wide. - var snapPx = 10 / scale; - var bestX = rawX, bestY = rawY, bestDX = snapPx, bestDY = snapPx; - for (var i = 0; i < Hyprland.monitors.values.length; i++) { - var other = Hyprland.monitors.values[i]; - if (other.name === monRect.m.name) continue; - var op = dispRoot.posFor(other.name, other.x, other.y); - var ow = other.width / other.scale, oh = other.height / other.scale; - var xs = [op.x - monRect.lw, op.x, op.x + ow - monRect.lw, op.x + ow]; - var ys = [op.y - monRect.lh, op.y, op.y + oh - monRect.lh, op.y + oh]; - for (var xi = 0; xi < xs.length; xi++) { - var dx = Math.abs(xs[xi] - rawX); - if (dx < bestDX) { bestDX = dx; bestX = xs[xi]; } - } - for (var yi = 0; yi < ys.length; yi++) { - var dy = Math.abs(ys[yi] - rawY); - if (dy < bestDY) { bestDY = dy; bestY = ys[yi]; } - } - } - - var finalX = Math.round(bestX); - var finalY = Math.round(bestY); - monRect.dragging = false; - monRect.offX = 0; - monRect.offY = 0; - - var ov = dispRoot.ovr(monRect.m.name); - var ipc = monRect.m.lastIpcObject || ({}); - var bitdepth = ov.bitdepth !== undefined ? ov.bitdepth : - ((ipc.currentFormat && ipc.currentFormat.indexOf("2101010") !== -1) ? "10" : "8"); - var cm = ov.cm !== undefined ? ov.cm : (ipc.colorManagementPreset || "srgb"); - var sdrB = ov.sdrBrightness !== undefined ? ov.sdrBrightness : (ipc.sdrBrightness !== undefined ? ipc.sdrBrightness : 1.0); - var sdrS = ov.sdrSaturation !== undefined ? ov.sdrSaturation : (ipc.sdrSaturation !== undefined ? ipc.sdrSaturation : 1.0); - var vrr = ov.vrr !== undefined ? ov.vrr : !!ipc.vrr; - var icc = ov.icc !== undefined ? ov.icc : ""; - var mode = monRect.m.width + "x" + monRect.m.height + "@" + (ipc.refreshRate || 60).toFixed(2) + "Hz"; - var tail = mode + "," + finalX + "x" + finalY + "," + monRect.m.scale.toFixed(2) - + ",transform," + (ipc.transform || 0) - + ",bitdepth," + bitdepth - + ",cm," + cm - + ",sdrbrightness," + sdrB.toFixed(2) - + ",sdrsaturation," + sdrS.toFixed(2) - + ",vrr," + (vrr ? "1" : "0") - + (icc.length > 0 ? ",icc," + icc : ""); - - dispRoot.setOvr(monRect.m.name, { - x: finalX, y: finalY, bitdepth: bitdepth, cm: cm, - sdrBrightness: sdrB, sdrSaturation: sdrS, vrr: vrr, icc: icc - }); - moveProc.command = ["display-set", monRect.m.name, tail]; - moveProc.running = true; - } - } - } - } - } - } - - Rectangle { width: parent.width; height: 1; color: Theme.borderDim } - - Repeater { - model: Hyprland.monitors - delegate: Column { - id: monDelegate - width: loader.width - spacing: 8 - property var mon: modelData - property var ipc: mon.lastIpcObject || ({}) - property var modes: ipc.availableModes || [] - property var ov: dispRoot.ovr(mon.name) - // Picks the mode matching both the current resolution - // AND the closest actual refresh rate — matching on - // resolution alone (and just taking whichever mode - // happened to be listed first for it) was showing - // "60Hz" as "current" regardless of what the monitor - // was really running at, since 60Hz is what this - // monitor's availableModes lists first per resolution. - property string selectedMode: { - var cur = mon.width + "x" + mon.height; - var curRate = ipc.refreshRate || 0; - var best = ""; - var bestDiff = 999999; - for (var i = 0; i < monDelegate.modes.length; i++) { - var m = monDelegate.modes[i]; - if (m.indexOf(cur + "@") !== 0) continue; - var rate = parseFloat(m.substring(m.indexOf("@") + 1)); - var diff = Math.abs(rate - curRate); - if (diff < bestDiff) { bestDiff = diff; best = m; } - } - if (best !== "") return best; - return monDelegate.modes.length > 0 ? monDelegate.modes[0] : (cur + "@60.00Hz"); - } - property real selectedScale: mon.scale - property int selectedTransform: ipc.transform || 0 - property bool modesOpen: false - - // Bit depth Hyprland actually requests from the GPU for - // this output — 8 or 10 only. Not the monitor panel's - // real physical depth (plenty of "8-bit" panels are - // 6-bit+FRC underneath): Hyprland has no way to query or - // change that, it's fixed in the panel's own hardware. - property string selectedBitdepth: monDelegate.ov.bitdepth !== undefined ? monDelegate.ov.bitdepth : - ((ipc.currentFormat && ipc.currentFormat.indexOf("2101010") !== -1) ? "10" : "8") - property string selectedCm: monDelegate.ov.cm !== undefined ? monDelegate.ov.cm : (ipc.colorManagementPreset || "srgb") - property real selectedSdrBrightness: monDelegate.ov.sdrBrightness !== undefined ? monDelegate.ov.sdrBrightness : - (ipc.sdrBrightness !== undefined ? ipc.sdrBrightness : 1.0) - property real selectedSdrSaturation: monDelegate.ov.sdrSaturation !== undefined ? monDelegate.ov.sdrSaturation : - (ipc.sdrSaturation !== undefined ? ipc.sdrSaturation : 1.0) - property bool selectedVrr: monDelegate.ov.vrr !== undefined ? monDelegate.ov.vrr : !!ipc.vrr - // Never read back from hyprctl (see dispRoot.overrides - // comment above) — blank here just means "nothing typed - // this session", not "no profile is active". - property string selectedIcc: monDelegate.ov.icc !== undefined ? monDelegate.ov.icc : "" - - Process { id: applyProc; command: [] } - - Text { - text: mon.name + (mon.focused ? " (focused)" : "") - color: mon.focused ? Theme.accent : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 13 - font.bold: true - } - Text { - text: ipc.description || "" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - - // -- Mode picker -- - Text { - text: "Resolution / refresh rate" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - Rectangle { - width: monDelegate.width - height: 30 - color: modeMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.border - Text { - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter - text: monDelegate.selectedMode - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - Text { - anchors.right: parent.right - anchors.rightMargin: 10 - anchors.verticalCenter: parent.verticalCenter - text: monDelegate.modesOpen ? "▲" : "▼" - color: Theme.fgDim - font.pixelSize: 10 - } - MouseArea { - id: modeMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: monDelegate.modesOpen = !monDelegate.modesOpen - } - } - Flickable { - visible: monDelegate.modesOpen - width: monDelegate.width - // Bounded by the actual content height (never a - // self-reference back to this Flickable's own - // height — that circular binding was collapsing - // this to 0 and silently hiding every mode but the - // current one) capped at 160 so a long mode list - // scrolls instead of growing the flyout forever. - height: Math.min(160, modeListCol.height) - contentHeight: modeListCol.height - clip: true - - Column { - id: modeListCol - width: monDelegate.width - Repeater { - model: monDelegate.modes - delegate: Rectangle { - width: monDelegate.width - height: 26 - color: modeItemMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.borderDim - Text { - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter - text: modelData - color: monDelegate.selectedMode === modelData ? Theme.accent : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - MouseArea { - id: modeItemMa - anchors.fill: parent - hoverEnabled: true - onClicked: { - monDelegate.selectedMode = modelData; - monDelegate.modesOpen = false; - } - } - } - } - } - } - - // -- Scale -- - Text { - text: "Scale: " + monDelegate.selectedScale.toFixed(2) + "x" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - Row { - spacing: 6 - Rectangle { - width: 30; height: 26 - color: scaleDownMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.border - Text { anchors.centerIn: parent; text: "-"; color: Theme.fg; font.pixelSize: 14 } - MouseArea { - id: scaleDownMa - anchors.fill: parent - hoverEnabled: true - onClicked: monDelegate.selectedScale = Math.max(0.5, Math.round((monDelegate.selectedScale - 0.05) * 100) / 100) - } - } - Rectangle { - width: 30; height: 26 - color: scaleUpMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.border - Text { anchors.centerIn: parent; text: "+"; color: Theme.fg; font.pixelSize: 14 } - MouseArea { - id: scaleUpMa - anchors.fill: parent - hoverEnabled: true - onClicked: monDelegate.selectedScale = Math.min(3.0, Math.round((monDelegate.selectedScale + 0.05) * 100) / 100) - } - } - } - - // -- Rotation -- - Text { - text: "Rotation" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - Row { - spacing: 6 - Repeater { - model: [0, 1, 2, 3] - delegate: Rectangle { - property int t: modelData - width: 50; height: 26 - color: monDelegate.selectedTransform === t ? Theme.accent : (rotMa.containsMouse ? Theme.bgAlt : "transparent") - border.width: 1 - border.color: monDelegate.selectedTransform === t ? Theme.accent : Theme.border - Text { - anchors.centerIn: parent - text: [0, 90, 180, 270][t] + "°" - color: monDelegate.selectedTransform === t ? Theme.bg : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - MouseArea { - id: rotMa - anchors.fill: parent - hoverEnabled: true - onClicked: monDelegate.selectedTransform = t - } - } - } - } - - // -- Bit depth -- - Text { - text: "Bit depth" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - Row { - spacing: 6 - Repeater { - model: ["8", "10"] - delegate: Rectangle { - property string bd: modelData - width: 64; height: 26 - color: monDelegate.selectedBitdepth === bd ? Theme.accent : (bdMa.containsMouse ? Theme.bgAlt : "transparent") - border.width: 1 - border.color: monDelegate.selectedBitdepth === bd ? Theme.accent : Theme.border - Text { - anchors.centerIn: parent - text: bd + "-bit" - color: monDelegate.selectedBitdepth === bd ? Theme.bg : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - MouseArea { - id: bdMa - anchors.fill: parent - hoverEnabled: true - onClicked: monDelegate.selectedBitdepth = bd - } - } - } - } - - // -- Color management preset -- - Text { - text: "Color profile" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - Row { - spacing: 6 - Repeater { - model: [ - { v: "srgb", label: "sRGB" }, - { v: "wide", label: "Wide" }, - { v: "edid", label: "EDID" }, - { v: "hdredid", label: "HDR" } - ] - delegate: Rectangle { - property string cv: modelData.v - width: (monDelegate.width - 18) / 4; height: 26 - color: monDelegate.selectedCm === cv ? Theme.accent : (cmMa.containsMouse ? Theme.bgAlt : "transparent") - border.width: 1 - border.color: monDelegate.selectedCm === cv ? Theme.accent : Theme.border - Text { - anchors.centerIn: parent - text: modelData.label - color: monDelegate.selectedCm === cv ? Theme.bg : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - MouseArea { - id: cmMa - anchors.fill: parent - hoverEnabled: true - onClicked: monDelegate.selectedCm = cv - } - } - } - } - - // -- SDR brightness/saturation -- only meaningful once - // the panel isn't running straight sRGB (Hyprland warns - // about exactly this: wide/HDR presets need these to - // compensate tone-mapping, sRGB doesn't touch them). - Text { - visible: monDelegate.selectedCm !== "srgb" - text: "SDR brightness: " + monDelegate.selectedSdrBrightness.toFixed(2) + "x" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - Row { - visible: monDelegate.selectedCm !== "srgb" - spacing: 6 - Rectangle { - width: 30; height: 26 - color: sdrBDownMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.border - Text { anchors.centerIn: parent; text: "-"; color: Theme.fg; font.pixelSize: 14 } - MouseArea { - id: sdrBDownMa - anchors.fill: parent - hoverEnabled: true - onClicked: monDelegate.selectedSdrBrightness = Math.max(0.1, Math.round((monDelegate.selectedSdrBrightness - 0.05) * 100) / 100) - } - } - Rectangle { - width: 30; height: 26 - color: sdrBUpMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.border - Text { anchors.centerIn: parent; text: "+"; color: Theme.fg; font.pixelSize: 14 } - MouseArea { - id: sdrBUpMa - anchors.fill: parent - hoverEnabled: true - onClicked: monDelegate.selectedSdrBrightness = Math.min(3.0, Math.round((monDelegate.selectedSdrBrightness + 0.05) * 100) / 100) - } - } - } - Text { - visible: monDelegate.selectedCm !== "srgb" - text: "SDR saturation: " + monDelegate.selectedSdrSaturation.toFixed(2) + "x" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - Row { - visible: monDelegate.selectedCm !== "srgb" - spacing: 6 - Rectangle { - width: 30; height: 26 - color: sdrSDownMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.border - Text { anchors.centerIn: parent; text: "-"; color: Theme.fg; font.pixelSize: 14 } - MouseArea { - id: sdrSDownMa - anchors.fill: parent - hoverEnabled: true - onClicked: monDelegate.selectedSdrSaturation = Math.max(0.1, Math.round((monDelegate.selectedSdrSaturation - 0.05) * 100) / 100) - } - } - Rectangle { - width: 30; height: 26 - color: sdrSUpMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.border - Text { anchors.centerIn: parent; text: "+"; color: Theme.fg; font.pixelSize: 14 } - MouseArea { - id: sdrSUpMa - anchors.fill: parent - hoverEnabled: true - onClicked: monDelegate.selectedSdrSaturation = Math.min(2.0, Math.round((monDelegate.selectedSdrSaturation + 0.05) * 100) / 100) - } - } - } - - // -- ICC profile -- - Text { - text: "ICC profile (optional)" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - Row { - spacing: 6 - Rectangle { - width: monDelegate.width - 62 - height: 30 - color: Theme.bgAlt - border.width: 1 - border.color: Theme.border - TextInput { - id: iccInput - anchors.fill: parent - anchors.margins: 8 - text: monDelegate.selectedIcc - onTextEdited: monDelegate.selectedIcc = text - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - clip: true - Text { - visible: iccInput.text.length === 0 - text: "/path/to/profile.icc" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - } - } - Rectangle { - width: 56; height: 30 - color: clearIccMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.border - Text { anchors.centerIn: parent; text: "Clear"; color: Theme.fg; font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 10 } - MouseArea { - id: clearIccMa - anchors.fill: parent - hoverEnabled: true - onClicked: monDelegate.selectedIcc = "" - } - } - } - - // -- VRR -- - Item { - width: monDelegate.width - height: 26 - Text { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: "Variable refresh rate" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - ToggleSwitch { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - checked: monDelegate.selectedVrr - onToggled: monDelegate.selectedVrr = !monDelegate.selectedVrr - } - } - - // -- Apply -- - Rectangle { - width: monDelegate.width - height: 30 - color: applyMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 2 - border.color: Theme.accent - Text { - anchors.centerIn: parent - text: "Apply" - color: Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - } - MouseArea { - id: applyMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - var pos = dispRoot.posFor(monDelegate.mon.name, monDelegate.mon.x, monDelegate.mon.y); - var tail = monDelegate.selectedMode + "," + pos.x + "x" + pos.y + "," + monDelegate.selectedScale.toFixed(2) - + ",transform," + monDelegate.selectedTransform - + ",bitdepth," + monDelegate.selectedBitdepth - + ",cm," + monDelegate.selectedCm - + ",sdrbrightness," + monDelegate.selectedSdrBrightness.toFixed(2) - + ",sdrsaturation," + monDelegate.selectedSdrSaturation.toFixed(2) - + ",vrr," + (monDelegate.selectedVrr ? "1" : "0") - + (monDelegate.selectedIcc.length > 0 ? ",icc," + monDelegate.selectedIcc : ""); - - dispRoot.setOvr(monDelegate.mon.name, { - x: pos.x, y: pos.y, bitdepth: monDelegate.selectedBitdepth, cm: monDelegate.selectedCm, - sdrBrightness: monDelegate.selectedSdrBrightness, sdrSaturation: monDelegate.selectedSdrSaturation, - vrr: monDelegate.selectedVrr, icc: monDelegate.selectedIcc - }); - applyProc.command = ["display-set", monDelegate.mon.name, tail]; - applyProc.running = true; - } - } - } - - Rectangle { width: monDelegate.width; height: 1; color: Theme.border } - } - } - } - } - - // --- Wallpapers ------------------------------------------------------- - Component { - id: wallpapersPanel - Column { - id: wpCol - width: loader.width - spacing: 14 - property var staticImages: [] - property var animatedImages: [] - property string current: "" - // $HOME/Wallpapers rather than $HOME/Pictures/Wallpapers: this - // system's locale (pl_PL) means the real Pictures dir is - // ~/Obrazy, not ~/Pictures — living under $HOME sidesteps that. - property string wallpapersDir: Quickshell.env("HOME") + "/Wallpapers" - - // Real disk-backed persistence — PersistentProperties turned - // out not to actually survive a Quickshell restart (verified: - // no backing file for it exists anywhere on disk), so this - // reads/writes its own small JSON file under Quickshell's - // state dir instead. - Item { - id: wpSettings - property string fitMode: "cover" // static only: cover|contain - property string muted: "1" // animated only: 1|0 - property string speed: "1.0" // animated only - property string autoPause: "1" // animated only: 1|0 - property string stateFile: Quickshell.stateDir + "/wallpaper-settings.json" - - function save() { - var json = JSON.stringify({ - fitMode: wpSettings.fitMode, - muted: wpSettings.muted, - speed: wpSettings.speed, - autoPause: wpSettings.autoPause - }); - wpSaveProc.command = ["sh", "-c", - "mkdir -p \"$(dirname \"$2\")\" && printf '%s' \"$1\" > \"$2\"", - "--", json, wpSettings.stateFile]; - wpSaveProc.running = true; - } - - Process { id: wpSaveProc } - Process { - id: wpLoadProc - command: ["cat", wpSettings.stateFile] - stdout: StdioCollector { - onStreamFinished: { - try { - var d = JSON.parse(text); - if (d.fitMode) wpSettings.fitMode = d.fitMode; - if (d.muted) wpSettings.muted = d.muted; - if (d.speed) wpSettings.speed = d.speed; - if (d.autoPause) wpSettings.autoPause = d.autoPause; - } catch (e) { /* no state file yet — defaults stand */ } - } - } - } - Component.onCompleted: wpLoadProc.running = true - } - - function refresh() { - staticListProc.running = true; - animatedListProc.running = true; - currentProc.running = true; - } - - // Reads back the actually-active wallpaper so the grid can - // highlight it on open — wpCol.current previously only ever - // got set by clicking a tile *this session*, so a wallpaper - // set before Quickshell last restarted (including the normal - // wallpaper-restore-on-login path) never showed as selected - // even though it really was active. wallpaper-set's own state - // file is a real `wallpaper-set ...` command line - // (shell-quoted via printf %q) — sourcing it with the - // function shadowed to just echo its first argument is a - // simple, correct way to pull the path back out without - // re-implementing shell-quote parsing in JS. - Process { - id: currentProc - command: ["bash", "-c", - 'wallpaper-set() { printf "%s\\n" "$1"; }; source "$1" 2>/dev/null', - "--", Quickshell.env("HOME") + "/.config/hypr/wallpaper.conf"] - stdout: StdioCollector { - onStreamFinished: { - var p = text.trim(); - if (p.length > 0) wpCol.current = p; - } - } - } - - function apply(path) { - setProc.command = ["wallpaper-set", path, wpSettings.fitMode, - wpSettings.muted, wpSettings.speed, wpSettings.autoPause]; - setProc.running = true; - wpCol.current = path; - } - - function deleteWallpaper(path) { - // Moves to the freedesktop trash (same as Thunar's own - // delete) rather than `rm`, which would permanently - // destroy the file with zero undo — there's no confirm - // dialog on this button, so it needs to be recoverable. - deleteProc.command = ["wallpaper-delete", path]; - deleteProc.running = true; - } - - Process { - id: staticListProc - command: ["find", wpCol.wallpapersDir, "-maxdepth", "1", "-type", "f", - "(", "-iname", "*.jpg", "-o", "-iname", "*.jpeg", "-o", "-iname", "*.png", - "-o", "-iname", "*.webp", "-o", "-iname", "*.bmp", ")"] - stdout: StdioCollector { - onStreamFinished: { - wpCol.staticImages = text.split("\n").filter((s) => s.length > 0); - } - } - } - Process { - id: animatedListProc - command: ["find", wpCol.wallpapersDir, "-maxdepth", "1", "-type", "f", - "(", "-iname", "*.mp4", "-o", "-iname", "*.webm", "-o", "-iname", "*.mkv", - "-o", "-iname", "*.mov", "-o", "-iname", "*.gif", ")"] - stdout: StdioCollector { - onStreamFinished: { - wpCol.animatedImages = text.split("\n").filter((s) => s.length > 0); - wpCol.refreshThumbs(); - } - } - } - - // path -> cached preview JPEG, filled in by wallpaper-thumbs - // (modules/scripts/wallpaper-thumbs.sh) once it's generated a - // real first-frame thumbnail for each animated file — a tile - // with no entry here just falls back to the play-icon glyph - // (thumbnail not generated yet, or ffmpeg couldn't read that - // file). Re-run any time the animated file list changes, not - // just once, so newly-added videos get a thumbnail without - // needing a full panel close/reopen. - property var thumbMap: ({}) - property string thumbCacheDir: Quickshell.cacheDir + "/wallpaper-thumbs" - function refreshThumbs() { - if (wpCol.animatedImages.length === 0) return; - thumbProc.command = ["wallpaper-thumbs", wpCol.thumbCacheDir].concat(wpCol.animatedImages); - thumbProc.running = true; - } - Process { - id: thumbProc - stdout: StdioCollector { - onStreamFinished: { - var map = {}; - var lines = text.split("\n").filter((s) => s.length > 0); - for (var i = 0; i < lines.length; i++) { - var parts = lines[i].split("\t"); - if (parts.length === 2) map[parts[0]] = parts[1]; - } - wpCol.thumbMap = map; - } - } - } - - Component.onCompleted: wpCol.refresh() - - Process { id: setProc; command: [] } - Process { id: deleteProc; onExited: wpCol.refresh() } - - Text { - text: "Add images/videos to " + wpCol.wallpapersDir - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - wrapMode: Text.WordWrap - width: wpCol.width - } - - // -- Search -- - property string filterText: "" - property var filteredStatic: { - if (wpCol.filterText.length === 0) return wpCol.staticImages; - var q = wpCol.filterText.toLowerCase(); - return wpCol.staticImages.filter((p) => p.toLowerCase().indexOf(q) !== -1); - } - property var filteredAnimated: { - if (wpCol.filterText.length === 0) return wpCol.animatedImages; - var q = wpCol.filterText.toLowerCase(); - return wpCol.animatedImages.filter((p) => p.toLowerCase().indexOf(q) !== -1); - } - Rectangle { - width: wpCol.width - height: 30 - color: Theme.bgAlt - border.width: 1 - border.color: Theme.border - TextInput { - id: filterInput - anchors.fill: parent - anchors.margins: 8 - text: wpCol.filterText - onTextEdited: wpCol.filterText = text - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - clip: true - Text { - visible: filterInput.text.length === 0 - text: "Filter by filename..." - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - } - } - - // -- Settings -- - Column { - width: wpCol.width - spacing: 8 - - Text { - text: "SETTINGS" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - - Rectangle { - width: wpCol.width - height: 30 - color: shuffleMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.accent - Text { - anchors.centerIn: parent - text: "Shuffle" - color: Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - } - MouseArea { - id: shuffleMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - var all = wpCol.staticImages.concat(wpCol.animatedImages); - if (all.length === 0) return; - wpCol.apply(all[Math.floor(Math.random() * all.length)]); - } - } - } - - Text { - text: "Fit mode (static images)" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - } - Row { - spacing: 6 - Repeater { - model: ["cover", "contain"] - delegate: Rectangle { - property string mode: modelData - width: 76; height: 26 - color: wpSettings.fitMode === mode ? Theme.accent : (fitMa.containsMouse ? Theme.bgAlt : "transparent") - border.width: 1 - border.color: wpSettings.fitMode === mode ? Theme.accent : Theme.border - Text { - anchors.centerIn: parent - text: mode - color: wpSettings.fitMode === mode ? Theme.bg : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - MouseArea { - id: fitMa - anchors.fill: parent - hoverEnabled: true - onClicked: { - wpSettings.fitMode = mode; - wpSettings.save(); - if (wpCol.current !== "" && wpCol.staticImages.indexOf(wpCol.current) !== -1) wpCol.apply(wpCol.current); - } - } - } - } - } - - Text { - text: "Animated wallpaper playback" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - } - Row { - spacing: 6 - Rectangle { - width: 60; height: 26 - color: wpSettings.muted === "1" ? Theme.accent : (muteMa.containsMouse ? Theme.bgAlt : "transparent") - border.width: 1 - border.color: wpSettings.muted === "1" ? Theme.accent : Theme.border - Text { - anchors.centerIn: parent - text: "Muted" - color: wpSettings.muted === "1" ? Theme.bg : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - MouseArea { - id: muteMa - anchors.fill: parent - hoverEnabled: true - onClicked: { wpSettings.muted = wpSettings.muted === "1" ? "0" : "1"; wpSettings.save(); } - } - } - Rectangle { - width: 110; height: 26 - color: pauseMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: wpSettings.autoPause === "1" ? Theme.accent : Theme.border - Text { - anchors.centerIn: parent - text: "Auto-pause" - color: wpSettings.autoPause === "1" ? Theme.accent : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - MouseArea { - id: pauseMa - anchors.fill: parent - hoverEnabled: true - onClicked: { wpSettings.autoPause = wpSettings.autoPause === "1" ? "0" : "1"; wpSettings.save(); } - } - } - } - Row { - spacing: 6 - Text { - anchors.verticalCenter: parent.verticalCenter - text: "Speed: " + parseFloat(wpSettings.speed).toFixed(2) + "x" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - Rectangle { - width: 26; height: 26 - color: speedDownMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.border - Text { anchors.centerIn: parent; text: "-"; color: Theme.fg; font.pixelSize: 14 } - MouseArea { - id: speedDownMa - anchors.fill: parent - hoverEnabled: true - onClicked: { wpSettings.speed = String(Math.max(0.25, Math.round((parseFloat(wpSettings.speed) - 0.25) * 100) / 100)); wpSettings.save(); } - } - } - Rectangle { - width: 26; height: 26 - color: speedUpMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.border - Text { anchors.centerIn: parent; text: "+"; color: Theme.fg; font.pixelSize: 14 } - MouseArea { - id: speedUpMa - anchors.fill: parent - hoverEnabled: true - onClicked: { wpSettings.speed = String(Math.min(4.0, Math.round((parseFloat(wpSettings.speed) + 0.25) * 100) / 100)); wpSettings.save(); } - } - } - } - Text { - visible: wpSettings.muted !== "1" || parseFloat(wpSettings.speed) !== 1.0 || wpSettings.autoPause !== "1" - text: "Applies next time you pick an animated wallpaper (or re-click the current one)." - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - wrapMode: Text.WordWrap - width: wpCol.width - } - - Rectangle { width: wpCol.width; height: 1; color: Theme.border } - } - - Text { - visible: wpCol.staticImages.length === 0 && wpCol.animatedImages.length === 0 - text: "No wallpapers found" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - Text { - visible: (wpCol.staticImages.length > 0 || wpCol.animatedImages.length > 0) - && wpCol.filteredStatic.length === 0 && wpCol.filteredAnimated.length === 0 - text: "No wallpapers match \"" + wpCol.filterText + "\"" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - - Column { - visible: wpCol.filteredStatic.length > 0 - width: wpCol.width - spacing: 8 - - Text { - text: "STATIC" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - - Flow { - width: wpCol.width - spacing: 8 - - Repeater { - model: wpCol.filteredStatic - delegate: Rectangle { - id: staticTile - property string path: modelData - // 2 per row rather than the old fixed 92px (3 - // cramped columns) — bigger previews are the - // whole point of a wallpaper picker. - width: (wpCol.width - 8) / 2 - height: width - color: Theme.bgAlt - border.width: wpCol.current === path ? 2 : 1 - border.color: wpCol.current === path ? Theme.accent : Theme.borderDim - - HoverHandler { id: staticHover } - - Image { - anchors.fill: parent - anchors.margins: 2 - source: "file://" + path - fillMode: Image.PreserveAspectCrop - asynchronous: true - clip: true - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: wpCol.apply(path) - } - - DeleteTileButton { - visible: staticHover.hovered - onClicked: wpCol.deleteWallpaper(staticTile.path) - } - } - } - } - } - - Column { - visible: wpCol.filteredAnimated.length > 0 - width: wpCol.width - spacing: 8 - - Text { - text: "ANIMATED" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - - Flow { - width: wpCol.width - spacing: 8 - - Repeater { - model: wpCol.filteredAnimated - delegate: Rectangle { - id: animTile - property string path: modelData - property string fileName: path.split("/").pop() - property string thumb: wpCol.thumbMap[path] || "" - width: (wpCol.width - 8) / 2 - height: width - color: Theme.bgAlt - border.width: wpCol.current === path ? 2 : 1 - border.color: wpCol.current === path ? Theme.accent : Theme.borderDim - - HoverHandler { id: animHover } - - // Real first-frame preview once wallpaper-thumbs - // has generated one (see wpCol.thumbMap above); - // falls back to a play glyph + filename until - // then, or if ffmpeg couldn't read that file. - Image { - id: animThumb - anchors.fill: parent - anchors.margins: 2 - source: animTile.thumb.length > 0 ? ("file://" + animTile.thumb) : "" - fillMode: Image.PreserveAspectCrop - asynchronous: true - clip: true - visible: status === Image.Ready - } - Text { - visible: !animThumb.visible - anchors.centerIn: parent - text: "▶" - color: Theme.fgDim - font.pixelSize: 22 - anchors.verticalCenterOffset: -10 - } - // Small corner badge marking it as a video even - // once a real thumbnail is showing — otherwise - // it'd be indistinguishable from a static image. - Text { - visible: animThumb.visible - anchors.top: parent.top - anchors.right: parent.right - anchors.margins: 4 - text: "▶" - color: Theme.fg - font.pixelSize: 12 - style: Text.Outline - styleColor: Theme.bg - } - Text { - anchors.bottom: parent.bottom - anchors.bottomMargin: 6 - anchors.left: parent.left - anchors.right: parent.right - anchors.leftMargin: 4 - anchors.rightMargin: 4 - horizontalAlignment: Text.AlignHCenter - text: fileName - color: animThumb.visible ? Theme.fg : Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - elide: Text.ElideMiddle - style: animThumb.visible ? Text.Outline : Text.Normal - styleColor: Theme.bg - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: wpCol.apply(path) - } - - DeleteTileButton { - visible: animHover.hovered - onClicked: wpCol.deleteWallpaper(animTile.path) - } - } - } - } - } - } - } - - // --- Booru (Danbooru, e621, Yande.re, Konachan — Gelbooru/Rule34.xxx - // need a registered account + API key, skipped for that reason) ------- - // Defaults to Safe content rating; the Content rating tiers (Safe/ - // Moderate/Explicit) control how far that's relaxed. Clicking a result - // opens a bigger preview rather than downloading immediately — - // Download is an explicit separate action (saves to downloadsDir, not - // the Wallpapers folder), and "Set as Wallpaper" only appears once the - // file actually exists locally. - Component { - id: booruPanel - Column { - id: booruCol - width: loader.width - spacing: 10 - property string query: "" - // Real disk-backed persistence — PersistentProperties doesn't - // actually survive a Quickshell restart (verified: no backing - // file for it exists anywhere on disk), so this reads/writes - // its own small state file instead, same as wpSettings above. - Item { - id: booruSettings - // "safe" | "moderate" | "explicit" — moderate excludes only - // explicit-rated posts (allows questionable through), - // replacing the old binary lewds on/off which couldn't - // express that middle ground at all. - property string rating: "safe" - property string source: "danbooru" - // "newest" (site default/no tag) | "score" | "random" - property string sortOrder: "newest" - property string stateFile: Quickshell.stateDir + "/booru-settings.json" - - function save() { - var json = JSON.stringify({ - rating: booruSettings.rating, source: booruSettings.source, - sortOrder: booruSettings.sortOrder - }); - booruSaveProc.command = ["sh", "-c", - "mkdir -p \"$(dirname \"$2\")\" && printf '%s' \"$1\" > \"$2\"", - "--", json, booruSettings.stateFile]; - booruSaveProc.running = true; - } - - Process { id: booruSaveProc } - Process { - id: booruLoadProc - command: ["cat", booruSettings.stateFile] - stdout: StdioCollector { - onStreamFinished: { - try { - var d = JSON.parse(text); - // Migrate the old binary lewds flag for - // anyone whose state file predates the - // rating tiers — 1 mapped to "no filter at - // all" (explicit), 0 to the old default - // (safe), same behavior as before just - // expressed in the new setting. - if (d.rating) booruSettings.rating = d.rating; - else if (d.lewds !== undefined) booruSettings.rating = d.lewds === "1" ? "explicit" : "safe"; - if (d.source) booruSettings.source = d.source; - if (d.sortOrder) booruSettings.sortOrder = d.sortOrder; - // This load is async (cat running as a - // subprocess) while booruCol.doSearch() in - // Component.onCompleted below runs - // synchronously right on startup — that first - // search always fires before this file finishes - // loading, using the hardcoded defaults - // (Danbooru/Safe/Newest) rather than whatever - // was actually saved. Re-run it now that the - // real settings are in, or the panel opens - // showing the wrong source/rating/sort with no - // indication anything's off — as seen live: a - // "of 548508" Danbooru page count left over - // and displayed under a Konachan-labeled grid. - booruCol.doSearch(); - } catch (e) { /* no state file yet — defaults stand */ } - } - } - } - Component.onCompleted: booruLoadProc.running = true - } - - // -- Multi-source support -- - // Gelbooru and Rule34.xxx both now require a registered - // account + API key to use their APIs at all (verified live, - // both return an auth error with no key) — skipped for that - // reason, same call as Gelbooru earlier. These four don't - // need any key. - // - // ratingTags: verified live against each API — Danbooru and - // Yande.re/Konachan (moebooru) write rating tags as full words - // (rating:general/explicit), e621 uses single-letter codes - // (rating:s/e) even though the query GRAMMAR is otherwise the - // same "order:"/"-tag" syntax across all four (also verified - // live: `tags=order:score` and `tags=-rating:e` both work - // identically on Danbooru, e621, and moebooru sites). - readonly property var sources: ({ - danbooru: { - label: "Danbooru", hasCount: true, hasAutocomplete: true, - ratingTags: { safe: "rating:general", moderate: "-rating:explicit", explicit: "" } - }, - e621: { - label: "e621", hasCount: false, hasAutocomplete: false, - ratingTags: { safe: "rating:s", moderate: "-rating:e", explicit: "" } - }, - yandere: { - label: "Yande.re", hasCount: false, hasAutocomplete: false, - ratingTags: { safe: "rating:safe", moderate: "-rating:explicit", explicit: "" } - }, - konachan: { - label: "Konachan", hasCount: false, hasAutocomplete: false, - ratingTags: { safe: "rating:safe", moderate: "-rating:explicit", explicit: "" } - } - }) - readonly property var sourceNames: ["danbooru", "e621", "yandere", "konachan"] - readonly property string userAgent: "QuickshellBooruPanel/1.0 (by nexoniarz)" - - // Every source has a different JSON shape — this maps whatever - // came back into one common shape so the rest of the panel - // (grid, detail view, download) doesn't need to know which - // source it came from. - function normalizePost(raw, source) { - if (source === "danbooru") { - if (!raw.file_url || !raw.preview_file_url) return null; - return { - id: raw.id, - previewUrl: raw.preview_file_url, - largeUrl: raw.large_file_url || raw.file_url, - fileUrl: raw.file_url, - fileExt: raw.file_ext || "jpg", - rating: raw.rating || "g", - tagString: raw.tag_string || "", - width: raw.image_width || 0, - height: raw.image_height || 0 - }; - } - if (source === "e621") { - if (!raw.file || !raw.file.url || !raw.preview || !raw.preview.url) return null; - var tags = []; - if (raw.tags) { - for (var cat in raw.tags) tags = tags.concat(raw.tags[cat]); - } - var r1 = raw.rating === "e" ? "e" : (raw.rating === "q" ? "q" : "g"); - return { - id: raw.id, - previewUrl: raw.preview.url, - largeUrl: (raw.sample && raw.sample.has && raw.sample.url) ? raw.sample.url : raw.file.url, - fileUrl: raw.file.url, - fileExt: raw.file.ext || "jpg", - rating: r1, - tagString: tags.join(" "), - width: raw.file.width || 0, - height: raw.file.height || 0 - }; - } - if (source === "yandere" || source === "konachan") { - if (!raw.file_url || !raw.preview_url) return null; - var r2 = raw.rating === "e" ? "e" : (raw.rating === "q" ? "q" : "g"); - return { - id: raw.id, - previewUrl: raw.preview_url, - largeUrl: raw.sample_url || raw.file_url, - fileUrl: raw.file_url, - fileExt: raw.file_ext || "jpg", - rating: r2, - tagString: raw.tags || "", - width: raw.width || 0, - height: raw.height || 0 - }; - } - return null; - } - property var results: [] - property var selected: null - property string downloadedPath: "" - property bool downloading: false - property bool searching: false - property string downloadTarget: "" - property bool previewReady: false - // Video formats obviously can't decode as an Image — but this - // Qt build also can't decode webp (confirmed live: "Unsupported - // image format" even for a plain static one), so it gets the - // same "no preview, download to view" treatment. - readonly property bool selectedNoPreview: { - if (!booruCol.selected) return false; - var ext = (booruCol.selected.fileExt || "").toLowerCase(); - return ["mp4", "webm", "mov", "avi", "webp"].indexOf(ext) !== -1; - } - property string previewLocalPath: "" - property int page: 1 - // Whether the last fetch came back full — if so there's - // probably a next page; Danbooru doesn't expose a total count - // on this endpoint, so "did we get a full page" is the signal. - property bool hasNextPage: false - // Downloads land here, NOT directly in Wallpapers — previously - // every single Download (even ones never actually set as - // wallpaper) wrote straight into ~/Wallpapers, silently - // cluttering that picker's grid. "Set as Wallpaper" now copies - // from here into wallpapersDir explicitly, so only images the - // user actually chose show up there. - property string downloadsDir: Quickshell.env("HOME") + "/Booru" - property string wallpapersDir: Quickshell.env("HOME") + "/Wallpapers" - // cdn.donmai.us returns 403 to QML's Image networking (its - // default request looks bot-like to whatever's fronting the - // CDN) even though plain curl works fine — so thumbnails and - // previews are fetched via curl into a local cache dir first, - // and Image only ever points at the local file. - property string cacheDir: Quickshell.cacheDir + "/booru" - - // Sized to the actual screen instead of a fixed count, so a - // page of results never grows the flyout past the bottom of - // the screen (that was cutting images off entirely rather than - // just needing a scrollbar, since the popup has no scroll area). - // 2 columns (was 3, at a cramped fixed 92px) — bigger tiles, - // sized off the panel's real width instead of a hardcoded pixel - // value. - readonly property int columns: 2 - readonly property real tileSize: (booruCol.width - 8) / 2 - readonly property real tileSlot: booruCol.tileSize + 8 - property int visibleRows: { - var screenH = (root.screen && root.screen.height) ? root.screen.height : 1080; - // Reserve space for the taskbar, the flyout header, the - // search/rating/sort/source rows above the grid, and - // margins — bumped from 340 when the old single-row Lewds - // toggle became two full rating+sort rows with their own - // labels. - var reserved = 400; - return Math.max(2, Math.floor((screenH - reserved) / booruCol.tileSlot)); - } - property int pageSize: booruCol.columns * booruCol.visibleRows - // -1 = not known yet (count request still in flight / failed). - property int totalCount: -1 - property int totalPages: booruCol.totalCount >= 0 - ? Math.max(1, Math.ceil(booruCol.totalCount / booruCol.pageSize)) : -1 - - function buildTagsParam() { - var tagParts = booruCol.query.trim().length > 0 - ? booruCol.query.trim().split(/\s+/).map(encodeURIComponent) - : []; - var ratingTag = booruCol.sources[booruSettings.source].ratingTags[booruSettings.rating]; - if (ratingTag) tagParts.push(ratingTag); - // order: is a tag, not a URL param, on all four sources - // (verified live — a plain ?order= query param is silently - // ignored on the moebooru sites). Danbooru is the one - // exception: `order:random` as a TAG reliably times out - // server-side even scoped to a single rating tag (verified - // live — "ActiveRecord::QueryCanceled, database timed - // out"), which read as "Random doesn't work, only Score - // does" from the UI (empty results). Danbooru has a - // separate, fast `random=true` URL param specifically to - // avoid this — handled in fetchPage() instead, so it's - // deliberately skipped here for danbooru. - if (booruSettings.sortOrder === "score") tagParts.push("order:score"); - else if (booruSettings.sortOrder === "random" && booruSettings.source !== "danbooru") tagParts.push("order:random"); - return tagParts.join("+"); - } - - function doSearch() { - booruCol.page = 1; - booruCol.totalCount = -1; - var meta = booruCol.sources[booruSettings.source]; - if (meta.hasCount) { - var tagsParam = booruCol.buildTagsParam(); - countProc.command = ["curl", "-s", "-A", booruCol.userAgent, - "https://danbooru.donmai.us/counts/posts.json" + (tagsParam ? "?tags=" + tagsParam : "")]; - countProc.running = true; - } - booruCol.fetchPage(); - } - - // -- Tag autocomplete -- - // Suggests based on whatever's after the last space, so - // completing one tag doesn't disturb tags already typed - // before it. - property var suggestions: [] - - Timer { - id: autocompleteTimer - interval: 250 - repeat: false - onTriggered: booruCol.fetchSuggestions() - } - - function currentPartialTag() { - var text = booruCol.query; - var lastSpace = text.lastIndexOf(" "); - return (lastSpace >= 0 ? text.substring(lastSpace + 1) : text).trim(); - } - - function fetchSuggestions() { - if (!booruCol.sources[booruSettings.source].hasAutocomplete) { - booruCol.suggestions = []; - return; - } - var partial = booruCol.currentPartialTag(); - if (partial.length < 2) { - booruCol.suggestions = []; - return; - } - var url = "https://danbooru.donmai.us/autocomplete.json?search%5Bquery%5D=" - + encodeURIComponent(partial) + "&search%5Btype%5D=tag_query&limit=8"; - // If a previous keystroke's request is still in flight (curl - // hasn't returned yet), reassigning .command and setting - // .running = true on an already-running Process is a no-op - // — running:true -> true is not a change, so nothing - // restarts and the newer query silently never gets sent. - // That's the "sometimes works, sometimes not" flakiness: - // explicitly stop it first so the new request actually - // fires. - if (autocompleteProc.running) autocompleteProc.running = false; - autocompleteProc.requestedPartial = partial; - autocompleteProc.command = ["curl", "-s", "-A", booruCol.userAgent, url]; - autocompleteProc.running = true; - } - - function applySuggestion(value) { - var text = booruCol.query; - var lastSpace = text.lastIndexOf(" "); - var prefix = lastSpace >= 0 ? text.substring(0, lastSpace + 1) : ""; - booruCol.query = prefix + value + " "; - queryField.text = booruCol.query; - queryField.cursorPosition = queryField.text.length; - booruCol.suggestions = []; - queryField.forceActiveFocus(); - } - - function fetchPage() { - var tagsParam = booruCol.buildTagsParam(); - var tagsQuery = tagsParam ? "&tags=" + tagsParam : ""; - var url = ""; - switch (booruSettings.source) { - case "danbooru": - url = "https://danbooru.donmai.us/posts.json?limit=" + booruCol.pageSize + "&page=" + booruCol.page + tagsQuery; - // Fast path around the order:random timeout (see - // buildTagsParam) — Danbooru's own random=true redirects - // to a URL with real random ordering baked in, which is - // cheap; page doesn't really apply to "a random - // sample," so it's left out for this one request. - if (booruSettings.sortOrder === "random") url = "https://danbooru.donmai.us/posts.json?limit=" + booruCol.pageSize + "&random=true" + tagsQuery; - break; - case "e621": - url = "https://e621.net/posts.json?limit=" + booruCol.pageSize + "&page=" + booruCol.page + tagsQuery; - break; - case "yandere": - url = "https://yande.re/post.json?limit=" + booruCol.pageSize + "&page=" + booruCol.page + tagsQuery; - break; - case "konachan": - url = "https://konachan.com/post.json?limit=" + booruCol.pageSize + "&page=" + booruCol.page + tagsQuery; - break; - } - searchProc.requestedSource = booruSettings.source; - // -L: danbooru's random=true responds with a redirect — - // without following it, that request comes back as an - // empty body (verified live). - searchProc.command = ["curl", "-s", "-L", "-A", booruCol.userAgent, url]; - booruCol.searching = true; - booruCol.results = []; - searchProc.running = true; - } - - Process { id: mkdirProc; command: ["mkdir", "-p", booruCol.cacheDir, booruCol.downloadsDir] } - Component.onCompleted: { - mkdirProc.running = true; - booruCol.doSearch(); - } - - Process { - id: searchProc - property string requestedSource: "" - stdout: StdioCollector { - onStreamFinished: { - var rawPosts = []; - try { - var data = JSON.parse(text); - if (searchProc.requestedSource === "e621") { - rawPosts = (data && Array.isArray(data.posts)) ? data.posts : []; - } else { - rawPosts = Array.isArray(data) ? data : []; - } - } catch (e) { rawPosts = []; } - - var posts = []; - for (var i = 0; i < rawPosts.length; i++) { - var n = booruCol.normalizePost(rawPosts[i], searchProc.requestedSource); - if (n) posts.push(n); - } - - booruCol.hasNextPage = booruCol.totalPages >= 0 - ? booruCol.page < booruCol.totalPages - : posts.length >= booruCol.pageSize; - - if (posts.length === 0) { - booruCol.searching = false; - booruCol.results = []; - return; - } - - var cmd = ["curl", "-s"]; - for (var j = 0; j < posts.length; j++) { - posts[j].localThumb = booruCol.cacheDir + "/thumb_" + searchProc.requestedSource + "_" + posts[j].id + ".jpg"; - cmd.push(posts[j].previewUrl); - cmd.push("-o"); - cmd.push(posts[j].localThumb); - } - thumbProc.pendingResults = posts; - thumbProc.command = cmd; - thumbProc.running = true; - } - } - } - Process { - id: thumbProc - property var pendingResults: [] - onExited: { - booruCol.searching = false; - booruCol.results = thumbProc.pendingResults; - } - } - Process { - id: countProc - stdout: StdioCollector { - onStreamFinished: { - try { - var d = JSON.parse(text); - booruCol.totalCount = d.counts.posts; - } catch (e) { booruCol.totalCount = -1; } - } - } - } - Process { - id: autocompleteProc - property string requestedPartial: "" - stdout: StdioCollector { - onStreamFinished: { - // Drop stale responses: even with the running-guard - // above, a slow request can still resolve after a - // faster, newer one — without this check whichever - // curl happens to finish last wins, regardless of - // which one actually matches what's typed now. - if (autocompleteProc.requestedPartial !== booruCol.currentPartialTag()) return; - try { - var d = JSON.parse(text); - booruCol.suggestions = Array.isArray(d) ? d : []; - } catch (e) { booruCol.suggestions = []; } - } - } - } - - Process { - id: previewProc - onExited: (code) => { - if (code === 0) booruCol.previewReady = true; - } - } - - Process { - id: downloadProc - onExited: (code) => { - booruCol.downloading = false; - if (code === 0) booruCol.downloadedPath = booruCol.downloadTarget; - } - } - - property bool wallpaperSetDone: false - Process { - id: setWpProc - command: [] - onExited: (code) => { if (code === 0) booruCol.wallpaperSetDone = true; } - } - - // -- Search bar -- - Row { - width: booruCol.width - spacing: 6 - Rectangle { - width: parent.width - 66 - height: 30 - color: Theme.bgAlt - border.width: 1 - border.color: Theme.border - TextInput { - id: queryField - anchors.fill: parent - anchors.margins: 8 - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - text: booruCol.query - onTextChanged: { - booruCol.query = text; - autocompleteTimer.restart(); - } - Keys.onReturnPressed: { - booruCol.suggestions = []; - booruCol.doSearch(); - } - Keys.onEscapePressed: booruCol.suggestions = [] - } - } - Rectangle { - width: 60 - height: 30 - color: searchMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.accent - Text { - anchors.centerIn: parent - text: "Search" - color: Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - MouseArea { - id: searchMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { booruCol.suggestions = []; booruCol.doSearch(); } - } - } - } - - Column { - visible: booruCol.suggestions.length > 0 - width: booruCol.width - spacing: 2 - - Repeater { - model: booruCol.suggestions - delegate: Rectangle { - property var sug: modelData - width: booruCol.width - height: 24 - color: sugMa.containsMouse ? Theme.bgAlt : Theme.bgAlt2 - border.width: 1 - border.color: Theme.borderDim - Text { - anchors.left: parent.left - anchors.leftMargin: 8 - anchors.verticalCenter: parent.verticalCenter - text: sug.label - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - Text { - anchors.right: parent.right - anchors.rightMargin: 8 - anchors.verticalCenter: parent.verticalCenter - text: sug.post_count - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - MouseArea { - id: sugMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: booruCol.applySuggestion(sug.value) - } - } - } - } - - // Replaces the old binary Lewds on/off — "moderate" lets - // questionable-rated posts through while still excluding - // explicit, a middle ground the old toggle couldn't express. - Text { - text: "Content rating" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - Row { - spacing: 6 - Repeater { - model: [ - { v: "safe", label: "Safe" }, - { v: "moderate", label: "Moderate" }, - { v: "explicit", label: "Explicit" } - ] - delegate: Rectangle { - property string rv: modelData.v - readonly property bool active: booruSettings.rating === rv - width: (booruCol.width - 12) / 3; height: 26 - color: active ? (rv === "explicit" ? Theme.danger : Theme.accent) : (ratingMa.containsMouse ? Theme.bgAlt : "transparent") - border.width: 1 - border.color: active ? (rv === "explicit" ? Theme.danger : Theme.accent) : Theme.border - Text { - anchors.centerIn: parent - text: modelData.label - color: active ? Theme.bg : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - font.bold: true - } - MouseArea { - id: ratingMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (booruSettings.rating === rv) return; - // save() here, not just the property - // assignment: this rating setting not - // sticking on its own (only saving as a - // side effect of also switching source - // afterward) was a real persistence bug on - // the old Lewds toggle — keep saving - // explicitly so it can't regress the same way. - booruSettings.rating = rv; - booruSettings.save(); - booruCol.doSearch(); - } - } - } - } - } - - Text { - text: "Sort" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - Row { - spacing: 6 - Repeater { - model: [ - { v: "newest", label: "Newest" }, - { v: "score", label: "Score" }, - { v: "random", label: "Random" } - ] - delegate: Rectangle { - property string sv: modelData.v - readonly property bool active: booruSettings.sortOrder === sv - width: (booruCol.width - 12) / 3; height: 26 - color: active ? Theme.accent : (sortMa.containsMouse ? Theme.bgAlt : "transparent") - border.width: 1 - border.color: active ? Theme.accent : Theme.border - Text { - anchors.centerIn: parent - text: modelData.label - color: active ? Theme.bg : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - MouseArea { - id: sortMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (booruSettings.sortOrder === sv) return; - booruSettings.sortOrder = sv; - booruSettings.save(); - booruCol.doSearch(); - } - } - } - } - } - - Row { - spacing: 6 - Repeater { - model: booruCol.sourceNames - delegate: Rectangle { - property string src: modelData - width: 62; height: 22 - color: booruSettings.source === src ? Theme.accent : (srcMa.containsMouse ? Theme.bgAlt : "transparent") - border.width: 1 - border.color: booruSettings.source === src ? Theme.accent : Theme.border - Text { - anchors.centerIn: parent - text: booruCol.sources[src].label - color: booruSettings.source === src ? Theme.bg : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - } - MouseArea { - id: srcMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (booruSettings.source === src) return; - booruSettings.source = src; - booruSettings.save(); - booruCol.selected = null; - booruCol.suggestions = []; - booruCol.doSearch(); - } - } - } - } - } - - // -- Grid / detail -- - Loader { - width: booruCol.width - sourceComponent: booruCol.selected ? booruDetail : booruGrid - } - - Component { - id: booruGrid - Column { - width: booruCol.width - spacing: 8 - Text { - visible: booruCol.searching - text: "Searching..." - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - Text { - visible: !booruCol.searching && booruCol.results.length === 0 - text: "No results" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - Flow { - width: booruCol.width - spacing: 8 - Repeater { - model: booruCol.results - delegate: Rectangle { - property var post: modelData - width: booruCol.tileSize - height: booruCol.tileSize - color: Theme.bgAlt - border.width: 1 - border.color: (post.rating === "e" || post.rating === "q") ? Theme.danger : Theme.borderDim - Image { - anchors.fill: parent - anchors.margins: 2 - source: "file://" + post.localThumb - fillMode: Image.PreserveAspectCrop - asynchronous: true - clip: true - } - MouseArea { - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - booruCol.selected = post; - booruCol.downloadedPath = ""; - booruCol.wallpaperSetDone = false; - booruCol.previewReady = false; - var ext = (post.fileExt || "jpg").toLowerCase(); - // Image can't decode video files (largeUrl for a - // video post IS the video itself, not a still - // frame) or, on this Qt build, webp — so skip the - // preview fetch entirely for those rather than - // trying (and failing) to load it. - if (["mp4", "webm", "mov", "avi", "webp"].indexOf(ext) !== -1) { - booruCol.previewLocalPath = ""; - return; - } - var previewPath = booruCol.cacheDir + "/preview_" + booruSettings.source + "_" + post.id + "." + ext; - booruCol.previewLocalPath = previewPath; - previewProc.command = ["curl", "-s", post.largeUrl, "-o", previewPath]; - previewProc.running = true; - } - } - } - } - } - - // -- Pagination -- - // Flow, not Row: Row lays out children at their natural - // width regardless of the width assigned to the Row - // itself, so when Prev + "Page" + the page field + "of - // N" + Next added up to more than booruCol.width (which - // happened often enough — "of N" varies in width with - // page count, and it was already close to the edge) the - // Next button rendered past the flyout's right edge, - // off the visible screen. Flow wraps overflow onto a - // second line instead of spilling past the edge. - Flow { - // Danbooru's random=true (see fetchPage) is a fresh - // random sample every request, not a real page - // sequence — no &page= param is even sent for it, so - // "Page N"/Next wouldn't actually mean anything there. - readonly property bool isDanbooruRandom: booruSettings.source === "danbooru" && booruSettings.sortOrder === "random" - visible: !booruCol.searching && !isDanbooruRandom && (booruCol.page > 1 || booruCol.hasNextPage) - width: booruCol.width - spacing: 8 - Rectangle { - width: 70; height: 26 - opacity: booruCol.page > 1 ? 1 : 0.4 - color: prevMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.border - Text { - anchors.centerIn: parent - text: "< Prev" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - MouseArea { - id: prevMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (booruCol.page <= 1) return; - booruCol.page -= 1; - booruCol.fetchPage(); - } - } - } - // Flow does NOT support anchors on its direct children — - // Qt silently disables the ENTIRE Flow's layout the - // moment it sees one ("Flow will not function", logged - // as a warning), collapsing/overlapping every control - // in it. This is why pagination looked broken/missing: - // the three anchored items below were direct Flow - // children. Fix: push the anchor one level down into a - // plain Item wrapper, which Flow lays out normally. - Item { - width: pageLabelText.implicitWidth - height: 26 - Text { - id: pageLabelText - anchors.verticalCenter: parent.verticalCenter - text: "Page" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - } - Rectangle { - width: 40; height: 26 - color: Theme.bgAlt - border.width: 1 - border.color: Theme.border - TextInput { - id: pageField - anchors.fill: parent - anchors.margins: 4 - horizontalAlignment: TextInput.AlignHCenter - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - text: String(booruCol.page) - validator: IntValidator { bottom: 1; top: 999999 } - Keys.onReturnPressed: { - var n = parseInt(pageField.text); - if (isNaN(n) || n < 1) { pageField.text = String(booruCol.page); return; } - booruCol.page = booruCol.totalPages > 0 ? Math.min(n, booruCol.totalPages) : n; - booruCol.fetchPage(); - } - } - } - Item { - visible: booruCol.totalPages > 0 - width: ofPagesText.implicitWidth - height: 26 - Text { - id: ofPagesText - anchors.verticalCenter: parent.verticalCenter - text: booruCol.totalPages > 0 ? ("of " + booruCol.totalPages) : "" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - } - Rectangle { - width: 70; height: 26 - opacity: booruCol.hasNextPage ? 1 : 0.4 - color: nextMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.border - Text { - anchors.centerIn: parent - text: "Next >" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - MouseArea { - id: nextMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (!booruCol.hasNextPage) return; - booruCol.page += 1; - booruCol.fetchPage(); - } - } - } - } - } - } - - Component { - id: booruDetail - Column { - width: booruCol.width - spacing: 8 - - Rectangle { - width: 60 - height: 26 - color: backMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.border - Text { - anchors.centerIn: parent - text: "< Back" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - MouseArea { - id: backMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: booruCol.selected = null - } - } - - Rectangle { - width: booruCol.width - height: 240 - color: "#000000" - border.width: 1 - border.color: Theme.borderDim - clip: true - Image { - anchors.fill: parent - source: booruCol.previewReady ? ("file://" + booruCol.previewLocalPath) : "" - fillMode: Image.PreserveAspectFit - asynchronous: true - } - Text { - visible: !booruCol.previewReady && !booruCol.selectedNoPreview - anchors.centerIn: parent - text: "Loading preview..." - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - Text { - visible: booruCol.selectedNoPreview - anchors.centerIn: parent - text: "Video post — no preview.\nDownload to view it." - horizontalAlignment: Text.AlignHCenter - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - } - - Text { - visible: !!booruCol.selected - text: booruCol.selected - ? (booruCol.selected.width + "x" + booruCol.selected.height + " · rating: " + booruCol.selected.rating) - : "" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - Text { - visible: !!(booruCol.selected && booruCol.selected.tagString) - text: booruCol.selected ? booruCol.selected.tagString : "" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - wrapMode: Text.WordWrap - width: booruCol.width - maximumLineCount: 3 - elide: Text.ElideRight - } - - Rectangle { - width: booruCol.width - height: 30 - color: dlMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 2 - border.color: Theme.accent - opacity: booruCol.downloading ? 0.6 : 1 - Text { - anchors.centerIn: parent - text: booruCol.downloading ? "Downloading..." : (booruCol.downloadedPath !== "" ? "Downloaded" : "Download") - color: Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - } - MouseArea { - id: dlMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (booruCol.downloading || booruCol.downloadedPath !== "" || !booruCol.selected) return; - var post = booruCol.selected; - var ext = post.fileExt || "jpg"; - var target = booruCol.downloadsDir + "/" + booruSettings.source + "_" + post.id + "." + ext; - booruCol.downloadTarget = target; - downloadProc.command = ["curl", "-sL", post.fileUrl, "-o", target]; - booruCol.downloading = true; - downloadProc.running = true; - } - } - } - - Rectangle { - visible: booruCol.downloadedPath !== "" - width: booruCol.width - height: 30 - color: setMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.border - Text { - anchors.centerIn: parent - text: booruCol.wallpaperSetDone ? "Wallpaper set!" : "Set as Wallpaper" - color: booruCol.wallpaperSetDone ? Theme.accent : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - MouseArea { - id: setMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (!booruCol.selected) return; - var post = booruCol.selected; - var ext = post.fileExt || "jpg"; - // Downloaded files live in downloadsDir now, not - // wallpapersDir directly (see downloadsDir - // comment above) — copy into wallpapersDir only - // on this explicit action, then apply it. - var wpTarget = booruCol.wallpapersDir + "/" + booruSettings.source + "_" + post.id + "." + ext; - setWpProc.command = ["sh", "-c", - 'cp "$1" "$2" && wallpaper-set "$2"', - "--", booruCol.downloadedPath, wpTarget]; - setWpProc.running = true; - } - } - } - } - } - } - } - - // --- Cursor ------------------------------------------------------- - Component { - id: cursorPanel - Column { - id: cursorCol - spacing: 6 - width: loader.width - property var themes: [] - property bool previewsReady: false - // Xcursor files are a binary format, not something Image can - // load directly — cursor-preview-all renders a PNG per theme - // into this cache dir (skipping ones already rendered). - property string previewDir: Quickshell.cacheDir + "/cursors" - - Process { - id: listThemesProc - command: ["list-cursor-themes"] - stdout: StdioCollector { - onStreamFinished: cursorCol.themes = text.split("\n").filter((s) => s.length > 0) - } - } - Process { - id: previewGenProc - command: ["cursor-preview-all", cursorCol.previewDir] - onExited: cursorCol.previewsReady = true - } - Component.onCompleted: { - listThemesProc.running = true; - previewGenProc.running = true; - } - - Process { id: applyProc; command: [] } - - Text { - text: "Click a theme to apply" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - Text { - visible: cursorCol.themes.length === 0 - text: "No cursor themes found" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - - Repeater { - model: cursorCol.themes - delegate: Rectangle { - id: curTile - property string themeName: modelData - property string previewSrc: cursorCol.previewsReady - ? "file://" + cursorCol.previewDir + "/" + themeName + ".png" : "" - width: loader.width - height: 36 - color: curMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.borderDim - - IconImage { - id: curIcon - visible: curTile.previewSrc !== "" && status === Image.Ready - implicitSize: 24 - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter - source: curTile.previewSrc - } - Text { - anchors.left: curIcon.visible ? curIcon.right : parent.left - anchors.leftMargin: curIcon.visible ? 8 : 10 - anchors.verticalCenter: parent.verticalCenter - text: curTile.themeName - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - MouseArea { - id: curMa - anchors.fill: parent - hoverEnabled: true - onClicked: { applyProc.command = ["cursor-set", curTile.themeName]; applyProc.running = true; } - } - } - } - } - } -} diff --git a/dotfiles/quickshell/Meter.qml b/dotfiles/quickshell/Meter.qml deleted file mode 100644 index e2679b3..0000000 --- a/dotfiles/quickshell/Meter.qml +++ /dev/null @@ -1,26 +0,0 @@ -import QtQuick - -// Read-only horizontal usage bar — CPU/RAM/GPU/VRAM/disk meters in the -// System Monitor panel all need the same "filled fraction of a track" -// visual, just with a different value/color each time. -Item { - id: root - property real value: 0 // 0..1 - property color barColor: Theme.accent - implicitHeight: 8 - - Rectangle { - anchors.fill: parent - color: Theme.bgAlt - border.width: 1 - border.color: Theme.borderDim - } - Rectangle { - anchors.left: parent.left - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.margins: 1 - width: Math.max(0, (parent.width - 2) * Math.min(1, Math.max(0, root.value))) - color: root.barColor - } -} diff --git a/dotfiles/quickshell/Osd.qml b/dotfiles/quickshell/Osd.qml deleted file mode 100644 index c02fc3d..0000000 --- a/dotfiles/quickshell/Osd.qml +++ /dev/null @@ -1,199 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io -import Quickshell.Wayland -import Quickshell.Services.Pipewire - -// On-screen display: a themed top-center popup (same placement/layer-shell -// pattern as PolkitDialog and mako notifications) for volume, mic mute, -// brightness, and media changes — the transient overlay every other DE -// shows instead of making you open a panel to check. -// -// Volume/mic react directly to Pipewire, so they fire for ANY change -// (function keys, the RightBar sliders, pavucontrol, etc.), not just ones -// that went through our own keybinds. Brightness has no such live service -// to bind to (DDC/CI writes aren't broadcast anywhere), so its two -// hyprland.conf binds explicitly call `quickshell ipc call osd -// brightness ` after brightness-ctl. Media is driven by two -// long-running `playerctl --follow` processes (status, and metadata for -// track-change detection). -Item { - id: root - - property string osdText: "" - property bool showBar: false - property real barValue: 0 - property bool barDanger: false - - function present(text, bar, value, danger) { - root.osdText = text; - root.showBar = bar; - root.barValue = value || 0; - root.barDanger = !!danger; - osdWindow.visible = true; - hideTimer.restart(); - } - - IpcHandler { - target: "osd" - function brightness(pct: string): void { - var v = parseInt(pct); - if (isNaN(v)) return; - root.present("Brightness " + v + "%", true, v / 100, false); - } - } - - Timer { - id: hideTimer - interval: 1600 - onTriggered: osdWindow.visible = false - } - - // -- Volume / mic -- - PwObjectTracker { - objects: { - var arr = []; - if (Pipewire.defaultAudioSink) arr.push(Pipewire.defaultAudioSink); - if (Pipewire.defaultAudioSource) arr.push(Pipewire.defaultAudioSource); - return arr; - } - } - - // Pipewire reports the already-current volume/mute state once as soon - // as it connects — without these guards that fires an OSD on every - // Quickshell start/login, not just on real changes. - property bool sinkReady: false - property bool sourceReady: false - - Connections { - target: Pipewire.defaultAudioSink ? Pipewire.defaultAudioSink.audio : null - function onVolumesChanged() { - if (!root.sinkReady) { root.sinkReady = true; return; } - var sink = Pipewire.defaultAudioSink; - if (sink.audio.muted) return; // covered by onMutedChanged instead - root.present("Volume " + Math.round(sink.audio.volume * 100) + "%", true, sink.audio.volume, false); - } - function onMutedChanged() { - if (!root.sinkReady) { root.sinkReady = true; return; } - var sink = Pipewire.defaultAudioSink; - root.present(sink.audio.muted ? "Volume Muted" : "Volume " + Math.round(sink.audio.volume * 100) + "%", - !sink.audio.muted, sink.audio.volume, sink.audio.muted); - } - } - Connections { - target: Pipewire.defaultAudioSource ? Pipewire.defaultAudioSource.audio : null - function onMutedChanged() { - if (!root.sourceReady) { root.sourceReady = true; return; } - var source = Pipewire.defaultAudioSource; - root.present(source.audio.muted ? "Microphone Muted" : "Microphone Unmuted", - false, 0, source.audio.muted); - } - } - - // -- Media (MPRIS via playerctl) -- - property string lastTitle: "" - property double lastMetaAt: 0 - - Process { - id: statusFollow - command: ["playerctl", "--follow", "status"] - stdout: SplitParser { - splitMarker: "\n" - onRead: (data) => { - var status = data.trim(); - if (status === "Paused") { - root.present("⏸ Paused", false, 0, false); - } else if (status === "Stopped") { - root.present("⏹ Stopped", false, 0, false); - root.lastTitle = ""; - } else if (status === "Playing") { - // A track change fires both a metadata line and a - // Playing status line in short order — the metadata - // handler below already popped the OSD for that case, - // so only announce "Resumed" if nothing else just did. - if (Date.now() - root.lastMetaAt > 400) { - root.present("▶ Resumed", false, 0, false); - } - } - } - } - } - - Process { - id: metadataFollow - command: ["playerctl", "--follow", "metadata", "--format", "{{title}}\t{{artist}}"] - stdout: SplitParser { - splitMarker: "\n" - onRead: (data) => { - var parts = data.split("\t"); - var title = (parts[0] || "").trim(); - var artist = (parts[1] || "").trim(); - if (!title || title === root.lastTitle) return; - root.lastTitle = title; - root.lastMetaAt = Date.now(); - root.present("▶ " + title + (artist ? " — " + artist : ""), false, 0, false); - } - } - } - - Component.onCompleted: { - statusFollow.running = true; - metadataFollow.running = true; - } - - PanelWindow { - id: osdWindow - visible: false - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.namespace: "osd" - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - focusable: false - anchors { top: true } - margins.top: 12 - exclusiveZone: 0 - color: "transparent" - - implicitWidth: 300 - implicitHeight: osdContent.implicitHeight + 24 - - Rectangle { - anchors.fill: parent - color: Theme.bg - border.width: 2 - border.color: root.barDanger ? Theme.danger : Theme.accent - - Column { - id: osdContent - x: 14 - y: 12 - width: parent.width - 28 - spacing: 8 - - Text { - width: parent.width - text: root.osdText - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - elide: Text.ElideRight - } - - Rectangle { - visible: root.showBar - width: parent.width - height: 6 - color: Theme.bgAlt - border.width: 1 - border.color: Theme.border - - Rectangle { - width: parent.width * Math.max(0, Math.min(1, root.barValue)) - height: parent.height - color: root.barDanger ? Theme.danger : Theme.accent - } - } - } - } - } -} diff --git a/dotfiles/quickshell/PolkitDialog.qml b/dotfiles/quickshell/PolkitDialog.qml deleted file mode 100644 index cf6d4dc..0000000 --- a/dotfiles/quickshell/PolkitDialog.qml +++ /dev/null @@ -1,176 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Wayland -import Quickshell.Services.Polkit - -// Custom-themed polkit authentication agent, replacing hyprpolkitagent's -// default (unthemed, light) dialog. PolkitAgent/AuthFlow handle the actual -// D-Bus/PAM plumbing internally — this file is purely the UI around it. -// -// Rendered as a slim layer-shell bar anchored to the top-center of the -// screen (like mako's notification popups) rather than a normal toplevel -// window — this sidesteps Hyprland window placement entirely, so there's -// no need for float/center window rules. -Item { - id: root - - PolkitAgent { - id: agent - } - - PanelWindow { - id: dialogWindow - visible: agent.flow !== null - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.namespace: "polkit-dialog" - - anchors { top: true } - margins.top: 12 - exclusiveZone: 0 - focusable: visible - color: "transparent" - - implicitWidth: 420 - implicitHeight: content.implicitHeight + 24 - - onVisibleChanged: if (visible) responseField.forceActiveFocus() - - Rectangle { - anchors.fill: parent - color: Theme.bg - border.width: 2 - border.color: Theme.accent - - Row { - id: content - x: 12 - y: 12 - width: parent.width - 24 - spacing: 10 - - Rectangle { - width: 4 - height: infoCol.implicitHeight - color: Theme.accent - } - - Column { - id: infoCol - width: content.width - 4 - 10 - actionRow.width - 10 - spacing: 4 - - Text { - text: agent.flow ? agent.flow.message : "" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - elide: Text.ElideRight - width: parent.width - } - - Text { - visible: !!(agent.flow && agent.flow.supplementaryMessage) - text: agent.flow ? agent.flow.supplementaryMessage : "" - color: (agent.flow && agent.flow.supplementaryIsError) ? Theme.danger : Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - wrapMode: Text.WordWrap - width: parent.width - } - - Rectangle { - visible: !!(agent.flow && agent.flow.isResponseRequired) - width: parent.width - height: 26 - color: Theme.bgAlt - border.width: 1 - border.color: Theme.border - - Text { - visible: responseField.text.length === 0 - anchors.verticalCenter: parent.verticalCenter - x: 8 - text: agent.flow ? agent.flow.inputPrompt : "" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - - TextInput { - id: responseField - anchors.fill: parent - anchors.margins: 6 - echoMode: (agent.flow && agent.flow.responseVisible) ? TextInput.Normal : TextInput.Password - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - focus: true - - Keys.onReturnPressed: submitResponse() - Keys.onEnterPressed: submitResponse() - Keys.onEscapePressed: if (agent.flow) agent.flow.cancelAuthenticationRequest() - - function submitResponse() { - if (agent.flow) { - agent.flow.submit(text); - text = ""; - } - } - } - } - } - - Row { - id: actionRow - anchors.verticalCenter: infoCol.verticalCenter - spacing: 6 - - Rectangle { - width: 90 - height: 26 - color: authMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 2 - border.color: Theme.accent - Text { - anchors.centerIn: parent - text: "Authenticate" - color: Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - font.bold: true - } - MouseArea { - id: authMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: responseField.submitResponse() - } - } - Rectangle { - width: 70 - height: 26 - color: cancelMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.border - Text { - anchors.centerIn: parent - text: "Cancel" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - MouseArea { - id: cancelMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: if (agent.flow) agent.flow.cancelAuthenticationRequest() - } - } - } - } - } - } -} diff --git a/dotfiles/quickshell/RightBar.qml b/dotfiles/quickshell/RightBar.qml deleted file mode 100644 index 3d00e8d..0000000 --- a/dotfiles/quickshell/RightBar.qml +++ /dev/null @@ -1,240 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Services.Pipewire -import Quickshell.Services.SystemTray -import Quickshell.Widgets -import Quickshell.Wayland - -PanelWindow { - id: root - required property var screen - property string activePanel: "" - property bool expanded: false - - // Keeps the default sink's PwNode subscribed so audio properties - // (volume/muted) actually receive live updates. - PwObjectTracker { - objects: Pipewire.defaultAudioSink ? [Pipewire.defaultAudioSink] : [] - } - - anchors { top: true; right: true; bottom: true } - // Collapsed = zero reserved space; expand/collapse is triggered from - // the taskbar (BottomBar), not a button on this bar itself. - implicitWidth: expanded ? 220 : 0 - exclusiveZone: expanded ? 220 : 0 - color: Theme.bg - // Never take keyboard focus — this bar has no text input, and leaving - // it focusable was stealing the keyboard grab from things like slurp's - // screenshot selection overlay. - focusable: false - - // Only "network" needs the popup's keyboard grab (hotspot/wifi - // password fields). - function needsGrab(panel) { return panel === "network"; } - - function toggle(panel) { - if (root.activePanel === panel) { - root.activePanel = ""; - return; - } - if (root.activePanel !== "" && root.needsGrab(root.activePanel) !== root.needsGrab(panel)) { - // See LeftBar.qml's toggle() for why: an xdg_popup's grab - // can't be toggled on an already-mapped surface, only - // established fresh — so force a close+reopen when crossing - // that boundary instead of just changing the panel in place. - root.activePanel = ""; - Qt.callLater(function () { root.activePanel = panel; }); - return; - } - root.activePanel = panel; - } - - Column { - visible: root.expanded - anchors.top: parent.top - anchors.horizontalCenter: parent.horizontalCenter - anchors.topMargin: 14 - spacing: 14 - width: parent.width - 24 - - DigitalClock { - anchors.horizontalCenter: parent.horizontalCenter - } - - // System tray (StatusNotifierItem) — background/status apps like - // Steam, Discord, KDE Connect that register a tray icon. Sits - // right under the clock, separate from the categorized sections - // below the divider. - Row { - id: trayRow - visible: SystemTray.items.values.length > 0 - anchors.horizontalCenter: parent.horizontalCenter - spacing: 12 - - Repeater { - model: SystemTray.items.values - delegate: IconImage { - id: trayIcon - implicitSize: 18 - // Only resolve through the icon theme when this is - // actually a themed icon name (e.g. Steam's - // "steam_tray_mono"). Apps that only ever supply a raw - // IconPixmap over the SNI protocol and no IconName at - // all (confirmed via busctl: Vesktop's tray item has no - // IconName property) get an already-usable image:// URL - // back from modelData.icon instead — running that - // through iconPath(), a name/path resolver, mangled it - // into nothing, which is what rendered as a broken- - // texture checkerboard instead of the actual icon. - source: modelData.icon.includes("://") - ? modelData.icon - : Quickshell.iconPath(modelData.icon, "") - MouseArea { - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.RightButton - cursorShape: Qt.PointingHandCursor - onClicked: (mouse) => { - if (mouse.button === Qt.RightButton) { - if (modelData.hasMenu) { - var pos = trayIcon.mapToItem(null, 0, trayIcon.height); - modelData.display(root, pos.x, pos.y); - } else { - modelData.secondaryActivate(); - } - } else { - modelData.activate(); - } - } - } - } - } - } - - Rectangle { width: parent.width; height: 1; color: Theme.borderDim } - - Column { - width: parent.width - spacing: 6 - - Text { - text: "CONNECTIVITY" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - SidebarRow { - label: "Wi-Fi & Network" - active: root.activePanel === "network" - onClicked: root.toggle("network") - } - SidebarRow { - label: "Bluetooth" - active: root.activePanel === "bluetooth" - onClicked: root.toggle("bluetooth") - } - SidebarRow { - label: "Messages" - active: root.activePanel === "messages" - onClicked: root.toggle("messages") - } - } - - Column { - width: parent.width - spacing: 6 - - Text { - text: "SYSTEM" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - SidebarRow { - label: "Screenshot" - active: root.activePanel === "screenshot" - onClicked: root.toggle("screenshot") - } - SidebarRow { - label: (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.audio && Pipewire.defaultAudioSink.audio.muted) - ? "Volume (muted)" : "Volume" - active: root.activePanel === "volume" - onClicked: root.toggle("volume") - } - SidebarRow { - label: "Brightness" - active: root.activePanel === "brightness" - onClicked: root.toggle("brightness") - } - SidebarRow { - label: "Devices" - active: root.activePanel === "devices" - onClicked: root.toggle("devices") - } - SidebarRow { - label: "System Monitor" - active: root.activePanel === "system" - onClicked: root.toggle("system") - } - SidebarRow { - label: "Weather" - active: root.activePanel === "weather" - onClicked: root.toggle("weather") - } - } - } - - SidebarRow { - visible: root.expanded - anchors.bottom: parent.bottom - anchors.bottomMargin: 14 - anchors.horizontalCenter: parent.horizontalCenter - width: parent.width - 24 - label: "Power" - fg: Theme.danger - active: root.activePanel === "power" - onClicked: root.toggle("power") - } - - PanelWindow { - id: flyout - screen: root.screen - anchors { top: true; right: true } - // No margin here on purpose: Hyprland already insets layer-shell - // surfaces past other surfaces' exclusive zones on the same edge - // (confirmed via `hyprctl layers` — BottomBar gets the same - // treatment for free, spanning right up to x=1700 with zero - // margin of its own). A margin of 220 here was stacking on top - // of that automatic inset, pushing the flyout 220px further left - // than intended and leaving a ~220px gap between it and the - // sidebar. - visible: root.activePanel !== "" - implicitWidth: 320 - implicitHeight: content.implicitHeight - exclusiveZone: 0 - color: "transparent" - WlrLayershell.layer: WlrLayer.Overlay - // On-demand layer-shell focus, not an xdg_popup grab: this used to - // be a PopupWindow with grabFocus, but an xdg_popup grab gets - // dismissed by the compositor the instant anything else so much as - // touches focus/surface state elsewhere (confirmed: even an - // unrelated hyprpaper/mpvpaper surface change was enough), which - // silently closed the whole flyout AND dropped keyboard input — - // that's what let keystrokes fall through to whatever window was - // focused before the flyout opened instead of reaching the text - // field. OnDemand only claims focus when something inside is - // actually clicked, and isn't torn down by unrelated surfaces. - // Panels with no text input don't need it at all. - WlrLayershell.keyboardFocus: root.needsGrab(root.activePanel) - ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None - - RightFlyout { - id: content - anchors.fill: parent - activePanel: root.activePanel - onRequestClose: root.activePanel = "" - } - } -} diff --git a/dotfiles/quickshell/RightFlyout.qml b/dotfiles/quickshell/RightFlyout.qml deleted file mode 100644 index fa30709..0000000 --- a/dotfiles/quickshell/RightFlyout.qml +++ /dev/null @@ -1,2060 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io -import Quickshell.Services.Pipewire -import Quickshell.Bluetooth -import Quickshell.Networking - -Rectangle { - id: root - property string activePanel: "" - signal requestClose() - - width: 320 - implicitHeight: header.height + loader.item.implicitHeight + 16 - color: Theme.bg - border.width: 2 - border.color: Theme.border - - property var titles: ({ - network: "Network", - messages: "Messages", - bluetooth: "Bluetooth", - screenshot: "Screenshot", - volume: "Volume", - brightness: "Brightness", - devices: "Devices", - system: "System Monitor", - weather: "Weather", - power: "Power" - }) - - // Lives here, not inside messagesPanel's own Column — that Component - // gets destroyed every time the panel closes (the Loader tears it - // down), which was silently resetting this to [] on every reopen and - // making "cleared" messages reappear. `root` itself persists for the - // life of the popup, so this survives closing/reopening the panel - // (though not a full Quickshell restart — mako's own history is just - // as ephemeral, so there'd be nothing to hide-filter after one anyway). - property var hiddenMessageIds: [] - function hideMessageId(id) { - var ids = root.hiddenMessageIds.slice(); - if (ids.indexOf(id) === -1) ids.push(id); - root.hiddenMessageIds = ids; - } - function clearAllMessages(currentIds) { - var ids = root.hiddenMessageIds.slice(); - for (var i = 0; i < currentIds.length; i++) { - if (ids.indexOf(currentIds[i]) === -1) ids.push(currentIds[i]); - } - root.hiddenMessageIds = ids; - } - - Item { - id: header - width: parent.width - height: 40 - - Text { - anchors.left: parent.left - anchors.leftMargin: 14 - anchors.verticalCenter: parent.verticalCenter - text: root.titles[root.activePanel] || "" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 14 - font.bold: true - } - - CloseButton { - anchors.right: parent.right - anchors.rightMargin: 10 - anchors.verticalCenter: parent.verticalCenter - onClicked: root.requestClose() - } - } - Rectangle { anchors.top: header.bottom; width: parent.width; height: 1; color: Theme.border } - - Loader { - id: loader - y: header.height + 8 - x: 12 - width: parent.width - 24 - sourceComponent: { - switch (root.activePanel) { - case "network": return networkPanel; - case "messages": return messagesPanel; - case "bluetooth": return bluetoothPanel; - case "screenshot": return screenshotPanel; - case "volume": return volumePanel; - case "brightness": return brightnessPanel; - case "devices": return devicesPanel; - case "system": return systemPanel; - case "weather": return weatherPanel; - case "power": return powerPanel; - default: return emptyPanel; - } - } - } - - Component { - id: emptyPanel - Item { implicitHeight: 1 } - } - - // --- Network ------------------------------------------------------- - Component { - id: networkPanel - Column { - id: netCol - spacing: 10 - width: loader.width - property var selected: null // a Network, or a NetworkDevice (wired fallback) - property bool selectedIsDevice: netCol.selected !== null && netCol.selected.networks !== undefined - - // --- List view --------------------------------------------- - Column { - visible: netCol.selected === null - width: parent.width - spacing: 10 - - Item { - width: parent.width - height: 22 - Text { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: "Wi-Fi" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 13 - font.bold: true - } - ToggleSwitch { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - checked: Networking.wifiEnabled - onToggled: Networking.wifiEnabled = !Networking.wifiEnabled - } - } - - Text { - visible: Networking.devices.values.length === 0 - text: "No network devices" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - - // Hardware/rfkill state isn't reflected by wifiEnabled at all — without - // this, a soft- or hardware-killed adapter just looks identical to "no - // networks in range" with no way to tell the two apart. - Text { - visible: !Networking.wifiHardwareEnabled - text: "Wi-Fi is disabled in hardware (check airplane mode / rfkill switch)" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - wrapMode: Text.WordWrap - width: parent.width - } - - Repeater { - model: Networking.devices - delegate: Column { - width: loader.width - spacing: 4 - property var device: modelData - - // Wired devices report a single phantom "network" entry with no - // signal strength, so detect Wi-Fi by scannerEnabled (only WifiDevice - // has that property) instead of by networks-list emptiness. - property bool isWifi: device.scannerEnabled !== undefined - - // The plugin exposes no "scan in progress" signal, just the - // scannerEnabled bool — so this is a grace-period guess, not a - // real status readout. Good enough to stop "no networks yet" - // from looking identical to "scan came back empty", which is - // what made this look like a silent bug before. - property bool scanning: false - Timer { - id: scanGrace - interval: 4000 - onTriggered: scanning = false - } - - // Turn on active scanning while this panel is open so - // the network list shows more than just whatever we're - // already connected to. - Component.onCompleted: if (isWifi) { device.scannerEnabled = true; scanning = true; scanGrace.restart(); } - Component.onDestruction: if (isWifi) device.scannerEnabled = false; - - // NetworkManager throttles RequestScan, so a bare re-toggle right - // after the last one can be silently ignored — this is still just - // a best-effort nudge, not a guaranteed fresh scan. - function rescan() { - device.scannerEnabled = false; - scanning = true; - scanGrace.restart(); - rescanRestart.start(); - } - Timer { - id: rescanRestart - interval: 150 - onTriggered: device.scannerEnabled = true - } - - Text { - text: device.name - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - - Rectangle { - visible: isWifi - width: loader.width - height: 26 - color: rescanMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.borderDim - Text { - anchors.centerIn: parent - text: scanning ? "Scanning…" : "Rescan" - color: scanning ? Theme.accent : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - MouseArea { - id: rescanMa - anchors.fill: parent - hoverEnabled: true - onClicked: rescan() - } - } - - // Non-Wi-Fi devices — show link state directly instead of a list. - Rectangle { - visible: !isWifi - width: loader.width - height: 32 - color: wiredMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: device.connected ? Theme.accent : Theme.borderDim - Text { - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter - text: device.connected ? "Connected" : "No link" - color: device.connected ? Theme.accent : Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - MouseArea { - id: wiredMa - anchors.fill: parent - hoverEnabled: true - onClicked: netCol.selected = device - } - } - - Text { - visible: isWifi && (!device.networks || device.networks.values.length === 0) - text: scanning ? "Scanning…" : "No networks found" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - - Repeater { - model: (isWifi && device.networks) ? device.networks : [] - delegate: Rectangle { - width: loader.width - height: 40 - color: netMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: net.connected ? Theme.accent : Theme.borderDim - property var net: modelData - - Column { - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter - spacing: 1 - Text { - text: net.name - color: net.connected ? Theme.accent : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - Text { - text: (net.connected ? "Connected" : (net.known ? "Saved" : "Available")) - + " · " + net.signalStrength + "%" - + " · " + WifiSecurityType.toString(net.security) - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - } - - MouseArea { - id: netMa - anchors.fill: parent - hoverEnabled: true - onClicked: netCol.selected = net - } - } - } - } - } - - Rectangle { width: parent.width; height: 1; color: Theme.borderDim } - - Column { - id: hotspotCol - width: loader.width - spacing: 8 - property bool hotspotActive: false - property bool canStart: ssidInput.text.length > 0 && pskInput.text.length >= 8 - - // Persists SSID/password/security/band across Quickshell - // restarts, blank/default otherwise. - PersistentProperties { - id: hotspotState - property string ssid: "" - property string password: "" - property string security: "wpa2" - property string band: "auto" - } - - Text { - text: "Hotspot" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 13 - font.bold: true - } - - Process { id: hotspotProc; command: [] } - Process { - id: hotspotStatusProc - command: ["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show", "--active"] - stdout: StdioCollector { - onStreamFinished: hotspotCol.hotspotActive = text.indexOf("Hotspot") !== -1 - } - } - Component.onCompleted: hotspotStatusProc.running = true - - Rectangle { - width: loader.width - height: 30 - color: Theme.bgAlt - border.width: 1 - border.color: Theme.border - TextInput { - id: ssidInput - anchors.fill: parent - anchors.margins: 8 - text: hotspotState.ssid - onTextEdited: hotspotState.ssid = text - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - clip: true - - Text { - visible: ssidInput.text.length === 0 - text: "SSID" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - } - } - Rectangle { - width: loader.width - height: 30 - color: Theme.bgAlt - border.width: 1 - border.color: Theme.border - TextInput { - id: pskInput - anchors.fill: parent - anchors.margins: 8 - text: hotspotState.password - onTextEdited: hotspotState.password = text - echoMode: TextInput.Password - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - clip: true - - Text { - visible: pskInput.text.length === 0 - text: "Password" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - } - } - - Text { - text: "Security" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - Row { - spacing: 6 - Repeater { - model: [ - { key: "wpa2", label: "WPA2" }, - { key: "wpa3", label: "WPA3" } - ] - delegate: Rectangle { - width: (loader.width - 6) / 2 - height: 28 - color: modelData.key === hotspotState.security ? Theme.bgAlt : (secMa.containsMouse ? Theme.bgAlt : "transparent") - border.width: modelData.key === hotspotState.security ? 2 : 1 - border.color: modelData.key === hotspotState.security ? Theme.accent : Theme.borderDim - Text { - anchors.centerIn: parent - text: modelData.label - color: modelData.key === hotspotState.security ? Theme.accent : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - MouseArea { - id: secMa - anchors.fill: parent - hoverEnabled: true - onClicked: hotspotState.security = modelData.key - } - } - } - } - - Text { - text: "Band" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - Row { - spacing: 6 - Repeater { - model: [ - { key: "auto", label: "Auto" }, - { key: "bg", label: "2.4GHz" }, - { key: "a", label: "5GHz" } - ] - delegate: Rectangle { - width: (loader.width - 12) / 3 - height: 28 - color: modelData.key === hotspotState.band ? Theme.bgAlt : (bandMa.containsMouse ? Theme.bgAlt : "transparent") - border.width: modelData.key === hotspotState.band ? 2 : 1 - border.color: modelData.key === hotspotState.band ? Theme.accent : Theme.borderDim - Text { - anchors.centerIn: parent - text: modelData.label - color: modelData.key === hotspotState.band ? Theme.accent : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - MouseArea { - id: bandMa - anchors.fill: parent - hoverEnabled: true - onClicked: hotspotState.band = modelData.key - } - } - } - } - - Rectangle { - id: hsButton - width: loader.width - height: 32 - opacity: (hotspotCol.hotspotActive || hotspotCol.canStart) ? 1 : 0.4 - color: hsMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: hotspotCol.hotspotActive ? Theme.danger : Theme.accent - Text { - anchors.centerIn: parent - text: hotspotCol.hotspotActive ? "Stop Hotspot" : "Start Hotspot" - color: hotspotCol.hotspotActive ? Theme.danger : Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - } - MouseArea { - id: hsMa - anchors.fill: parent - hoverEnabled: true - onClicked: { - if (hotspotCol.hotspotActive) { - hotspotProc.command = ["nmcli", "connection", "down", "Hotspot"]; - hotspotProc.running = true; - hotspotCol.hotspotActive = false; - } else if (hotspotCol.canStart) { - hotspotProc.command = ["hotspot-start", ssidInput.text, pskInput.text, hotspotState.security, hotspotState.band]; - hotspotProc.running = true; - hotspotCol.hotspotActive = true; - } - } - } - } - - Text { - text: "Password needs at least 8 characters." - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - wrapMode: Text.WordWrap - width: loader.width - } - } - } - - // --- Detail view --------------------------------------------- - Column { - id: netDetail - visible: netCol.selected !== null - width: parent.width - spacing: 10 - - property var sel: netCol.selected - property bool needsPassword: !!(sel && !netCol.selectedIsDevice && !sel.known && !sel.connected) - - // NetworkDevice.address is actually the MAC address, not an IP — - // the real IP has to come from nmcli. - property string deviceName: sel ? (netCol.selectedIsDevice ? sel.name : (sel.device ? sel.device.name : "")) : "" - property string ip: "" - Process { - id: ipProc - command: netDetail.deviceName ? ["nmcli", "-g", "IP4.ADDRESS", "device", "show", netDetail.deviceName] : [] - stdout: StdioCollector { - onStreamFinished: netDetail.ip = text.split("/")[0].split("\n")[0].trim() - } - } - onDeviceNameChanged: { ip = ""; if (deviceName) ipProc.running = true; } - Component.onCompleted: if (deviceName) ipProc.running = true - - Process { id: netActionProc; command: [] } - - Rectangle { - width: parent.width - height: 26 - color: "transparent" - Text { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: "< Back" - color: Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - MouseArea { anchors.fill: parent; onClicked: netCol.selected = null } - } - - Text { - text: netCol.selected ? netCol.selected.name : "" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 15 - font.bold: true - } - - Column { - width: parent.width - spacing: 4 - - property var rows: { - if (!netCol.selected) return []; - if (netCol.selectedIsDevice) { - return [ - { label: "Status", value: netCol.selected.connected ? "Connected" : "No link" }, - { label: "IP Address", value: netDetail.ip || "—" }, - { label: "MAC Address", value: netCol.selected.address || "—" } - ]; - } - var r = [ - { label: "Status", value: netCol.selected.connected ? "Connected" : (netCol.selected.known ? "Saved" : "Available") }, - { label: "Signal", value: netCol.selected.signalStrength + "%" }, - { label: "Security", value: WifiSecurityType.toString(netCol.selected.security) } - ]; - if (netCol.selected.connected) - r.push({ label: "IP Address", value: netDetail.ip || "—" }); - if (netCol.selected.device && netCol.selected.device.address) - r.push({ label: "MAC Address", value: netCol.selected.device.address }); - return r; - } - - Repeater { - model: parent.rows - delegate: Item { - width: loader.width - height: 20 - Text { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: modelData.label - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - Text { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - text: modelData.value - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - } - } - } - - Rectangle { width: parent.width; height: 1; color: Theme.borderDim } - - Column { - visible: !netCol.selectedIsDevice && netDetail.needsPassword - width: parent.width - spacing: 8 - - Rectangle { - width: parent.width - height: 30 - color: Theme.bgAlt - border.width: 1 - border.color: Theme.border - TextInput { - id: detailPskField - anchors.fill: parent - anchors.margins: 8 - echoMode: TextInput.Password - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - clip: true - - Text { - visible: detailPskField.text.length === 0 - text: "Password" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - } - } - } - - Rectangle { - visible: !netCol.selectedIsDevice - width: parent.width - height: 34 - property bool canSubmit: !netDetail.needsPassword || detailPskField.text.length >= 8 - opacity: canSubmit ? 1 : 0.4 - color: connMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 2 - border.color: (netCol.selected && netCol.selected.connected) ? Theme.danger : Theme.accent - Text { - anchors.centerIn: parent - text: (netCol.selected && netCol.selected.connected) ? "Disconnect" - : (netDetail.needsPassword ? "Connect with password" : "Connect") - color: (netCol.selected && netCol.selected.connected) ? Theme.danger : Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - } - MouseArea { - id: connMa - anchors.fill: parent - hoverEnabled: true - onClicked: { - if (!netCol.selected) return; - if (netCol.selected.connected) netCol.selected.disconnect(); - else if (netDetail.needsPassword) { - if (detailPskField.text.length >= 8) netCol.selected.connectWithPsk(detailPskField.text); - } else netCol.selected.connect(); - } - } - } - - Text { - visible: !netCol.selectedIsDevice && netCol.selected && netCol.selected.known - text: "Forget this network" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - - MouseArea { - anchors.fill: parent - onClicked: { netCol.selected.forget(); netCol.selected = null; } - } - } - } - } - } - - // --- Bluetooth ------------------------------------------------------- - Component { - id: bluetoothPanel - Column { - id: btCol - spacing: 10 - width: loader.width - property var selected: null - - // Same reasoning as the Wi-Fi panel's per-device scannerEnabled - // toggle — auto-start scanning while the panel is open instead - // of requiring a manual click every time, then stop when it - // closes so the adapter isn't left discovering in the background. - // Qt.callLater, not a direct write: writing `discovering` in the - // same tick this Column is constructed was silently dropped - // (verified against BlueZ directly over D-Bus — Discovering - // stayed false) even though the identical write from the - // manual "Scan for devices" button works fine; deferring one - // event-loop tick gives Quickshell's Bluetooth adapter wrapper - // time to finish attaching before the write goes out. - Component.onCompleted: Qt.callLater(function () { - if (Bluetooth.defaultAdapter && Bluetooth.defaultAdapter.enabled) Bluetooth.defaultAdapter.discovering = true; - }); - Component.onDestruction: if (Bluetooth.defaultAdapter) Bluetooth.defaultAdapter.discovering = false; - - // --- List view --------------------------------------------- - Column { - visible: btCol.selected === null - width: parent.width - spacing: 10 - - Item { - width: parent.width - height: 22 - visible: Bluetooth.defaultAdapter !== null - Text { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: "Bluetooth" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 13 - font.bold: true - } - ToggleSwitch { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - checked: !!(Bluetooth.defaultAdapter && Bluetooth.defaultAdapter.enabled) - onToggled: if (Bluetooth.defaultAdapter) Bluetooth.defaultAdapter.enabled = !Bluetooth.defaultAdapter.enabled - } - } - - Text { - visible: Bluetooth.defaultAdapter === null - text: "No Bluetooth adapter found" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - - Rectangle { - visible: !!(Bluetooth.defaultAdapter && Bluetooth.defaultAdapter.enabled) - width: loader.width - height: 30 - color: scanMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.borderDim - Text { - anchors.centerIn: parent - text: (Bluetooth.defaultAdapter && Bluetooth.defaultAdapter.discovering) ? "Scanning…" : "Scan for devices" - color: (Bluetooth.defaultAdapter && Bluetooth.defaultAdapter.discovering) ? Theme.accent : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - MouseArea { - id: scanMa - anchors.fill: parent - hoverEnabled: true - onClicked: if (Bluetooth.defaultAdapter) Bluetooth.defaultAdapter.discovering = !Bluetooth.defaultAdapter.discovering - } - } - - Text { - text: "Paired & nearby devices" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - Text { - visible: Bluetooth.defaultAdapter !== null && Bluetooth.devices.values.length === 0 - text: "No devices" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - - Repeater { - model: Bluetooth.devices - delegate: Rectangle { - width: loader.width - height: 40 - color: btMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: dev.connected ? Theme.accent : Theme.borderDim - property var dev: modelData - - Column { - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter - spacing: 1 - Text { - text: dev.name - color: dev.connected ? Theme.accent : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - Text { - text: (dev.connected ? "Connected" : (dev.paired ? "Paired" : "Available")) - + (dev.batteryAvailable ? " · " + Math.round(dev.battery <= 1 ? dev.battery * 100 : dev.battery) + "%" : "") - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - } - - MouseArea { - id: btMa - anchors.fill: parent - hoverEnabled: true - onClicked: btCol.selected = dev - } - } - } - } - - // --- Detail view --------------------------------------------- - Column { - visible: btCol.selected !== null - width: parent.width - spacing: 10 - - Process { id: btActionProc; command: [] } - - Rectangle { - width: parent.width - height: 26 - color: "transparent" - Text { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: "< Back" - color: Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - MouseArea { anchors.fill: parent; onClicked: btCol.selected = null } - } - - Text { - text: btCol.selected ? btCol.selected.name : "" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 15 - font.bold: true - } - - Column { - width: parent.width - spacing: 4 - - property var rows: { - if (!btCol.selected) return []; - var d = btCol.selected; - var r = [ - { label: "Status", value: d.connected ? "Connected" : (d.paired ? "Paired" : "Available") }, - { label: "MAC Address", value: d.address || "—" }, - { label: "Type", value: d.icon || "unknown" }, - { label: "Trusted", value: d.trusted ? "Yes" : "No" } - ]; - if (d.batteryAvailable) - r.push({ label: "Battery", value: Math.round(d.battery <= 1 ? d.battery * 100 : d.battery) + "%" }); - return r; - } - - Repeater { - model: parent.rows - delegate: Item { - width: loader.width - height: 20 - Text { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: modelData.label - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - Text { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - text: modelData.value - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - } - } - } - - Rectangle { width: parent.width; height: 1; color: Theme.borderDim } - - Rectangle { - width: parent.width - height: 34 - color: btConnMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 2 - border.color: (btCol.selected && btCol.selected.connected) ? Theme.danger : Theme.accent - Text { - anchors.centerIn: parent - text: (btCol.selected && btCol.selected.connected) ? "Disconnect" - : (btCol.selected && btCol.selected.paired) ? "Connect" : "Pair" - color: (btCol.selected && btCol.selected.connected) ? Theme.danger : Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - } - MouseArea { - id: btConnMa - anchors.fill: parent - hoverEnabled: true - onClicked: { - if (!btCol.selected) return; - if (btCol.selected.connected) btCol.selected.disconnect(); - else if (btCol.selected.paired) btCol.selected.connect(); - else btCol.selected.pair(); - } - } - } - - Text { - visible: !!(btCol.selected && btCol.selected.paired) - text: "Forget this device" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - - MouseArea { - anchors.fill: parent - onClicked: { btCol.selected.forget(); btCol.selected = null; } - } - } - } - } - } - - // --- Messages (notification history, via mako) ----------------------- - // mako's IPC has no way to actually delete a history entry (only - // `dismiss` for currently-displayed notifications and a read-only - // `history` list) — so "clearing" is done client-side by remembering - // hidden ids across restarts and filtering them out of the list. The - // hidden ids naturally become irrelevant once mako's own history ring - // buffer rolls them out. - Component { - id: messagesPanel - Column { - id: msgCol - spacing: 6 - width: loader.width - property var history: [] - // The hidden-id list itself lives on `root` now (see top of - // file) — this Column gets destroyed every time the panel - // closes, so anything stored here alone would silently reset. - property var visibleHistory: { - var hidden = root.hiddenMessageIds; - var out = []; - for (var i = 0; i < msgCol.history.length; i++) { - if (hidden.indexOf(msgCol.history[i].id) === -1) out.push(msgCol.history[i]); - } - return out; - } - - function refresh() { historyProc.running = true; } - function hideId(id) { root.hideMessageId(id); } - function clearAll() { - var ids = []; - for (var i = 0; i < msgCol.history.length; i++) ids.push(msgCol.history[i].id); - root.clearAllMessages(ids); - } - - Process { - id: historyProc - command: ["makoctl", "history", "-j"] - stdout: StdioCollector { - onStreamFinished: { - try { - msgCol.history = JSON.parse(text); - } catch (e) { msgCol.history = []; } - } - } - } - Component.onCompleted: msgCol.refresh() - - Process { id: invokeProc; command: [] } - - Item { - width: parent.width - height: 22 - Text { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: "Recent notifications" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - Row { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 12 - Text { - visible: msgCol.visibleHistory.length > 0 - text: "Clear all" - color: Theme.danger - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - MouseArea { anchors.fill: parent; onClicked: msgCol.clearAll() } - } - Text { - text: "Refresh" - color: Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - MouseArea { anchors.fill: parent; onClicked: msgCol.refresh() } - } - } - } - - Text { - visible: msgCol.visibleHistory.length === 0 - text: "No notifications yet" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - - Repeater { - model: msgCol.visibleHistory - delegate: Rectangle { - id: msgRow - width: loader.width - height: bodyText.implicitHeight + summaryText.implicitHeight + 20 - color: rowHover.hovered ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.borderDim - property var entry: modelData - - HoverHandler { id: rowHover } - - Column { - anchors.left: parent.left - anchors.right: parent.right - anchors.leftMargin: 10 - anchors.rightMargin: 30 - anchors.verticalCenter: parent.verticalCenter - spacing: 2 - - Row { - width: parent.width - Text { - text: entry.app_name || "unknown" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - } - Text { - id: summaryText - width: parent.width - text: entry.summary || "" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - elide: Text.ElideRight - } - Text { - id: bodyText - width: parent.width - text: entry.body || "" - color: Theme.fgDim2 - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - wrapMode: Text.WordWrap - maximumLineCount: 3 - elide: Text.ElideRight - } - } - - MouseArea { - id: msgMa - anchors.fill: parent - onClicked: { - invokeProc.command = ["makoctl", "invoke", "-n", String(entry.id)]; - invokeProc.running = true; - } - } - - Rectangle { - id: closeBtn - z: 1 - visible: rowHover.hovered - width: 18 - height: 18 - anchors.right: parent.right - anchors.rightMargin: 6 - anchors.top: parent.top - anchors.topMargin: 6 - color: closeMa.containsMouse ? Theme.danger : "transparent" - border.width: 1 - border.color: Theme.danger - Text { - anchors.centerIn: parent - text: "×" - color: closeMa.containsMouse ? Theme.bg : Theme.danger - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - } - MouseArea { - id: closeMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: msgCol.hideId(entry.id) - } - } - } - } - } - } - - // --- Screenshot ------------------------------------------------------- - Component { - id: screenshotPanel - Column { - id: ssCol - spacing: 14 - width: loader.width - property string mode: "region" - property string action: "both" - - property var modes: [ - { key: "region", label: "Region (drag to select)" }, - { key: "full", label: "Full screen" }, - { key: "window", label: "Active window" } - ] - property var actions: [ - { key: "both", label: "Save file + copy" }, - { key: "file", label: "Save file only" }, - { key: "clipboard", label: "Copy only" } - ] - - Text { - text: "Capture" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - Column { - width: parent.width - spacing: 4 - Repeater { - model: ssCol.modes - delegate: Rectangle { - width: loader.width - height: 32 - color: modelData.key === ssCol.mode ? Theme.bgAlt : (modeMa.containsMouse ? Theme.bgAlt : "transparent") - border.width: modelData.key === ssCol.mode ? 2 : 1 - border.color: modelData.key === ssCol.mode ? Theme.accent : Theme.borderDim - Text { - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter - text: modelData.label - color: modelData.key === ssCol.mode ? Theme.accent : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - MouseArea { - id: modeMa - anchors.fill: parent - hoverEnabled: true - onClicked: ssCol.mode = modelData.key - } - } - } - } - - Text { - text: "Save" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - Column { - width: parent.width - spacing: 4 - Repeater { - model: ssCol.actions - delegate: Rectangle { - width: loader.width - height: 32 - color: modelData.key === ssCol.action ? Theme.bgAlt : (actionMa.containsMouse ? Theme.bgAlt : "transparent") - border.width: modelData.key === ssCol.action ? 2 : 1 - border.color: modelData.key === ssCol.action ? Theme.accent : Theme.borderDim - Text { - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter - text: modelData.label - color: modelData.key === ssCol.action ? Theme.accent : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - MouseArea { - id: actionMa - anchors.fill: parent - hoverEnabled: true - onClicked: ssCol.action = modelData.key - } - } - } - } - - Rectangle { - width: parent.width - height: 36 - color: captureMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 2 - border.color: Theme.accent - Text { - anchors.centerIn: parent - text: "Capture" - color: Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 13 - font.bold: true - } - MouseArea { - id: captureMa - anchors.fill: parent - hoverEnabled: true - onClicked: { - // execDetached, not a local Process: closing the panel - // right after this swaps the Loader's sourceComponent, - // which destroys this whole Component (and any Process - // owned by it) before it would ever get to actually run. - Quickshell.execDetached(["screenshot", ssCol.mode, ssCol.action]); - root.requestClose(); - } - } - } - - Text { - text: "Saves to ~/Screenshots" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - } - } - } - - // --- Volume ------------------------------------------------------- - Component { - id: volumePanel - Column { - spacing: 12 - width: loader.width - - Item { - width: parent.width - height: 18 - Text { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - anchors.right: pct.left - anchors.rightMargin: 8 - text: Pipewire.defaultAudioSink ? Pipewire.defaultAudioSink.description : "No output" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - elide: Text.ElideRight - } - Text { - id: pct - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - text: (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.audio) - ? Math.round(Pipewire.defaultAudioSink.audio.volume * 100) + "%" : "--" - color: Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - } - } - - SquareSlider { - width: parent.width - value: (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.audio) ? Pipewire.defaultAudioSink.audio.volume : 0 - onMoved: (v) => { - if (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.audio) - Pipewire.defaultAudioSink.audio.volume = v; - } - } - - Item { - width: parent.width - height: 22 - Text { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: "Mute" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - ToggleSwitch { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - onLabel: "Muted" - offLabel: "Unmuted" - checked: !!(Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.audio && Pipewire.defaultAudioSink.audio.muted) - onToggled: if (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.audio) - Pipewire.defaultAudioSink.audio.muted = !Pipewire.defaultAudioSink.audio.muted - } - } - - Rectangle { width: parent.width; height: 1; color: Theme.borderDim } - - Text { - text: "Output device" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - - Repeater { - model: Pipewire.nodes - delegate: Rectangle { - property var node: modelData - visible: node.isSink && !node.isStream - width: loader.width - height: 32 - color: outMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.id === node.id) ? Theme.accent : Theme.borderDim - - Text { - anchors.left: parent.left - anchors.leftMargin: 8 - anchors.right: parent.right - anchors.rightMargin: 8 - anchors.verticalCenter: parent.verticalCenter - text: node.description - color: (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.id === node.id) ? Theme.accent : Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - elide: Text.ElideRight - } - MouseArea { - id: outMa - anchors.fill: parent - hoverEnabled: true - onClicked: Pipewire.preferredDefaultAudioSink = node - } - } - } - } - } - - // --- Brightness ------------------------------------------------------- - Component { - id: brightnessPanel - Column { - spacing: 10 - width: loader.width - - property int pct: 50 - - Process { - id: getProc - command: ["brightness-ctl", "get"] - stdout: StdioCollector { - onStreamFinished: { - var n = parseInt(text.trim(), 10); - if (!isNaN(n)) slider.value = n / 100; - } - } - } - Process { - id: setProc - command: [] - } - - Component.onCompleted: getProc.running = true - - Text { - text: "Monitor brightness" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - SquareSlider { - id: slider - width: parent.width - onMoved: (v) => { - setProc.command = ["brightness-ctl", "set", String(Math.round(v * 100))]; - setProc.running = true; - } - } - } - } - - // --- Devices (removable drives) ------------------------------------- - Component { - id: devicesPanel - Column { - id: devCol - spacing: 6 - width: loader.width - property var drives: [] - - function refresh() { lsblkProc.running = true; } - - Process { - id: lsblkProc - command: ["lsblk", "-J", "-o", "NAME,LABEL,SIZE,RM,TYPE,MOUNTPOINT,PATH,FSTYPE"] - stdout: StdioCollector { - onStreamFinished: { - try { - var data = JSON.parse(text); - var out = []; - function walk(devs) { - for (var i = 0; i < devs.length; i++) { - var d = devs[i]; - // Any data partition that isn't the running system - // itself — not just literally-removable (RM) ones, - // since internal secondary drives count too. - if (d.type === "part" && d.fstype !== "swap" - && d.mountpoint !== "/" && d.mountpoint !== "/boot") { - out.push(d); - } - if (d.children) walk(d.children); - } - } - walk(data.blockdevices || []); - devCol.drives = out; - } catch (e) { devCol.drives = []; } - } - } - } - Component.onCompleted: devCol.refresh() - - Text { - visible: devCol.drives.length === 0 - text: "No extra drives found" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - - Repeater { - model: devCol.drives - delegate: Rectangle { - width: loader.width - height: 44 - color: "transparent" - property var drive: modelData - - Process { - id: mountProc - command: [] - onExited: devCol.refresh() - } - - Column { - anchors.verticalCenter: parent.verticalCenter - spacing: 2 - Text { - text: (drive.label || drive.name) + " " + drive.size - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - Row { - spacing: 10 - Text { - text: drive.mountpoint ? "Unmount" : "Mount" - color: Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - MouseArea { - anchors.fill: parent - onClicked: { - mountProc.command = drive.mountpoint - ? ["udisksctl", "unmount", "-b", drive.path] - : ["udisksctl", "mount", "-b", drive.path]; - mountProc.running = true; - } - } - } - Text { - visible: !!drive.mountpoint - text: "Open" - color: Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - MouseArea { - anchors.fill: parent - onClicked: { - mountProc.command = ["thunar", drive.mountpoint]; - mountProc.running = true; - } - } - } - } - } - } - } - } - } - - // --- System Monitor ------------------------------------------------------- - // One process invocation per poll (system-telemetry, modules/scripts/ - // system-telemetry.sh) instead of separate Process objects per metric — - // it reads /proc/stat twice 200ms apart internally to compute CPU%, - // /proc/meminfo for RAM, nvidia-smi or the AMD sysfs counters for GPU/ - // VRAM (whichever's actually present), and `df -h` for / and /home — - // and returns it all as one JSON line. - Component { - id: systemPanel - Column { - id: sysCol - width: loader.width - spacing: 14 - property var stats: ({}) - property bool haveData: Object.keys(sysCol.stats).length > 0 - - Timer { - interval: 2000 - running: true - repeat: true - triggeredOnStart: true - onTriggered: if (!telemetryProc.running) telemetryProc.running = true - } - Process { - id: telemetryProc - command: ["system-telemetry"] - stdout: StdioCollector { - onStreamFinished: { - try { sysCol.stats = JSON.parse(text); } catch (e) { /* keep last good reading */ } - } - } - } - - Text { - visible: !sysCol.haveData - text: "Reading system stats..." - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - - // -- CPU -- - Column { - visible: sysCol.haveData - width: sysCol.width - spacing: 4 - Item { - width: parent.width - height: 16 - Text { - anchors.left: parent.left - text: "CPU" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - } - Text { - anchors.right: parent.right - text: (sysCol.stats.cpuPct !== undefined ? sysCol.stats.cpuPct : 0) + "%" - color: Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - } - Meter { width: parent.width; value: (sysCol.stats.cpuPct || 0) / 100 } - } - - // -- RAM -- - Column { - visible: sysCol.haveData - width: sysCol.width - spacing: 4 - Item { - width: parent.width - height: 16 - Text { - anchors.left: parent.left - text: "Memory" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - } - Text { - anchors.right: parent.right - text: ((sysCol.stats.memUsedKb || 0) / 1048576).toFixed(1) + " / " - + ((sysCol.stats.memTotalKb || 0) / 1048576).toFixed(1) + " GB" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - } - Meter { - width: parent.width - value: sysCol.stats.memTotalKb > 0 ? sysCol.stats.memUsedKb / sysCol.stats.memTotalKb : 0 - } - } - - // -- GPU / VRAM -- - Text { - visible: sysCol.haveData && (sysCol.stats.gpuPct === undefined || sysCol.stats.gpuPct < 0) - text: "No GPU metrics available" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - Column { - visible: sysCol.haveData && sysCol.stats.gpuPct >= 0 - width: sysCol.width - spacing: 4 - Item { - width: parent.width - height: 16 - Text { - anchors.left: parent.left - text: "GPU" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - } - Text { - anchors.right: parent.right - text: (sysCol.stats.gpuPct !== undefined ? sysCol.stats.gpuPct : 0) + "%" - color: Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - } - Meter { width: parent.width; value: (sysCol.stats.gpuPct || 0) / 100 } - } - Column { - visible: sysCol.haveData && sysCol.stats.vramTotalMb > 0 - width: sysCol.width - spacing: 4 - Item { - width: parent.width - height: 16 - Text { - anchors.left: parent.left - text: "VRAM" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: true - } - Text { - anchors.right: parent.right - text: (sysCol.stats.vramUsedMb || 0) + " / " + (sysCol.stats.vramTotalMb || 0) + " MB" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - } - Meter { - width: parent.width - value: sysCol.stats.vramTotalMb > 0 ? sysCol.stats.vramUsedMb / sysCol.stats.vramTotalMb : 0 - } - } - - // -- Disks -- - Column { - visible: sysCol.haveData && sysCol.stats.disks && sysCol.stats.disks.length > 0 - width: sysCol.width - spacing: 8 - Text { - text: "STORAGE" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - Repeater { - model: sysCol.stats.disks || [] - delegate: Column { - width: parent.width - spacing: 4 - property var disk: modelData - Item { - width: parent.width - height: 16 - Text { - anchors.left: parent.left - text: disk.mount - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - Text { - anchors.right: parent.right - text: disk.used + " / " + disk.size - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - } - Meter { width: parent.width; value: (parseFloat(disk.pct) || 0) / 100 } - } - } - } - } - } - - // --- Weather --------------------------------------------------------- - // weather-fetch (modules/scripts/weather-fetch.sh) does the actual - // network work — IP-geolocates via ip-api.com by default (or geocodes - // an explicitly typed city via Open-Meteo's own geocoding endpoint), - // then pulls current + hourly conditions from Open-Meteo. Both are - // free and keyless, matching curl everywhere else in this file rather - // than QML's own networking (Image loads already had to route around - // 403s from a CDN that didn't like Qt's request fingerprint — curl - // has been the reliable path for every other external fetch here). - Component { - id: weatherPanel - Column { - id: wxCol - width: loader.width - spacing: 12 - property var forecast: ({}) - property bool haveData: !!wxCol.forecast.current - property bool hasError: !!wxCol.forecast.error - - Item { - id: wxSettings - property string city: "" - property string stateFile: Quickshell.stateDir + "/weather-settings.json" - function save() { - wxSaveProc.command = ["sh", "-c", - "mkdir -p \"$(dirname \"$2\")\" && printf '%s' \"$1\" > \"$2\"", - "--", JSON.stringify({ city: wxSettings.city }), wxSettings.stateFile]; - wxSaveProc.running = true; - } - Process { id: wxSaveProc } - Process { - id: wxLoadProc - command: ["cat", wxSettings.stateFile] - stdout: StdioCollector { - onStreamFinished: { - try { - var d = JSON.parse(text); - if (d.city !== undefined) wxSettings.city = d.city; - } catch (e) { /* no state file yet — defaults stand */ } - wxCol.refresh(); - } - } - } - Component.onCompleted: wxLoadProc.running = true - } - - function refresh() { - fetchProc.command = wxSettings.city.length > 0 - ? ["weather-fetch", wxSettings.city] : ["weather-fetch"]; - fetchProc.running = true; - } - Process { - id: fetchProc - stdout: StdioCollector { - onStreamFinished: { - try { wxCol.forecast = JSON.parse(text); } catch (e) { wxCol.forecast = { error: "bad response" }; } - } - } - } - // Open-Meteo's current reading updates roughly every 15 - // minutes on their end — polling faster would just repeat the - // same numbers. - Timer { interval: 900000; running: true; repeat: true; onTriggered: wxCol.refresh() } - - // WMO weather codes -> a short label + a plain Unicode symbol - // (not a Nerd Font glyph — those need exact codepoints from - // the specific icon set and guessing wrong renders as a - // tofu box; these are plain emoji, safe under any font with - // emoji fallback, which every font on this system has). - function describeCode(code) { - if (code === 0) return { label: "Clear sky", icon: "☀" }; - if (code === 1) return { label: "Mostly clear", icon: "🌤" }; - if (code === 2) return { label: "Partly cloudy", icon: "⛅" }; - if (code === 3) return { label: "Overcast", icon: "☁" }; - if (code === 45 || code === 48) return { label: "Fog", icon: "🌫" }; - if (code >= 51 && code <= 57) return { label: "Drizzle", icon: "🌦" }; - if (code >= 61 && code <= 67) return { label: "Rain", icon: "🌧" }; - if (code >= 71 && code <= 77) return { label: "Snow", icon: "❄" }; - if (code >= 80 && code <= 82) return { label: "Showers", icon: "🌧" }; - if (code === 85 || code === 86) return { label: "Snow showers", icon: "🌨" }; - if (code >= 95) return { label: "Thunderstorm", icon: "⛈" }; - return { label: "Unknown", icon: "?" }; - } - - // -- City search (blank = auto IP-geolocate) -- - Row { - width: wxCol.width - spacing: 6 - Rectangle { - width: parent.width - 66 - height: 30 - color: Theme.bgAlt - border.width: 1 - border.color: Theme.border - TextInput { - id: cityInput - anchors.fill: parent - anchors.margins: 8 - text: wxSettings.city - onTextEdited: wxSettings.city = text - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - clip: true - Keys.onReturnPressed: { wxSettings.save(); wxCol.forecast = {}; wxCol.refresh(); } - Text { - visible: cityInput.text.length === 0 - text: "City (blank = auto-detect)" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - } - } - Rectangle { - width: 60 - height: 30 - color: wxSearchMa.containsMouse ? Theme.bgAlt : "transparent" - border.width: 1 - border.color: Theme.accent - Text { - anchors.centerIn: parent - text: "Set" - color: Theme.accent - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - MouseArea { - id: wxSearchMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { wxSettings.save(); wxCol.forecast = {}; wxCol.refresh(); } - } - } - } - - Text { - visible: !wxCol.haveData && !wxCol.hasError - text: "Loading weather..." - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - } - Text { - visible: wxCol.hasError - text: "Couldn't load weather — check the city name or try again later." - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - wrapMode: Text.WordWrap - width: wxCol.width - } - - // -- Current conditions -- - Column { - visible: wxCol.haveData - width: wxCol.width - spacing: 4 - - Text { - text: [wxCol.forecast.place, wxCol.forecast.country].filter((s) => s && s.length > 0).join(", ") - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - - Row { - spacing: 12 - Text { - text: wxCol.haveData ? wxCol.describeCode(wxCol.forecast.current.weather_code).icon : "" - font.pixelSize: 40 - } - Column { - anchors.verticalCenter: parent.verticalCenter - spacing: 2 - Text { - text: wxCol.haveData ? Math.round(wxCol.forecast.current.temperature_2m) + "°C" : "" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 24 - font.bold: true - } - Text { - text: wxCol.haveData ? wxCol.describeCode(wxCol.forecast.current.weather_code).label : "" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } - } - } - - Row { - spacing: 16 - Text { - text: wxCol.haveData ? ("Wind: " + wxCol.forecast.current.wind_speed_10m + " km/h") : "" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - Text { - text: wxCol.haveData ? ("Precip: " + wxCol.forecast.current.precipitation + " mm") : "" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 10 - } - } - } - - Rectangle { visible: wxCol.haveData; width: wxCol.width; height: 1; color: Theme.borderDim } - - // -- Hourly forecast timeline -- - Column { - visible: wxCol.haveData && !!wxCol.forecast.hourly - width: wxCol.width - spacing: 8 - Text { - text: "NEXT HOURS" - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - font.bold: true - } - Flow { - width: wxCol.width - spacing: 6 - Repeater { - // Only future hours from "now" — Open-Meteo's hourly - // array starts at local midnight, not the current - // hour, so without this the timeline would open - // showing most of a day that's already passed. - model: { - if (!wxCol.haveData || !wxCol.forecast.hourly) return []; - var nowIso = wxCol.forecast.current.time.substring(0, 13); - var times = wxCol.forecast.hourly.time; - var startIdx = 0; - for (var i = 0; i < times.length; i++) { - if (times[i].substring(0, 13) >= nowIso) { startIdx = i; break; } - } - var out = []; - for (var j = startIdx; j < Math.min(startIdx + 8, times.length); j++) out.push(j); - return out; - } - delegate: Rectangle { - property int idx: modelData - width: (wxCol.width - 30) / 4 - height: 64 - color: Theme.bgAlt - border.width: 1 - border.color: Theme.borderDim - Column { - anchors.centerIn: parent - spacing: 2 - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: wxCol.forecast.hourly.time[idx].substring(11, 16) - color: Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 9 - } - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: wxCol.describeCode(wxCol.forecast.hourly.weather_code[idx]).icon - font.pixelSize: 16 - } - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: Math.round(wxCol.forecast.hourly.temperature_2m[idx]) + "°" - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - font.bold: true - } - } - } - } - } - } - } - } - - // --- Power ------------------------------------------------------- - Component { - id: powerPanel - Column { - spacing: 4 - width: loader.width - - Process { id: powerProc; command: [] } - - property var actions: [ - { label: " Lock", cmd: ["hyprlock"] }, - { label: " Suspend", cmd: ["systemctl", "suspend"] }, - { label: " Reboot", cmd: ["systemctl", "reboot"] }, - { label: " Shutdown", cmd: ["systemctl", "poweroff"] }, - { label: " Log out", cmd: ["hyprctl", "dispatch", "exit"] } - ] - - Repeater { - model: parent.actions - delegate: Rectangle { - width: loader.width - height: 32 - color: pma.containsMouse ? Theme.bgAlt : "transparent" - Text { - anchors.verticalCenter: parent.verticalCenter - x: 8 - text: modelData.label - color: Theme.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 13 - } - MouseArea { - id: pma - anchors.fill: parent - hoverEnabled: true - onClicked: { powerProc.command = modelData.cmd; powerProc.running = true; } - } - } - } - } - } -} diff --git a/dotfiles/quickshell/SidebarRow.qml b/dotfiles/quickshell/SidebarRow.qml deleted file mode 100644 index 4dc424b..0000000 --- a/dotfiles/quickshell/SidebarRow.qml +++ /dev/null @@ -1,36 +0,0 @@ -import QtQuick - -Rectangle { - id: root - property string label: "" - property bool active: false - property color fg: Theme.fg - property color accent: Theme.accent - signal clicked() - - width: parent ? parent.width : 200 - height: 40 - color: mouseArea.containsMouse ? Theme.bgAlt : "transparent" - border.width: active ? 2 : 1 - border.color: active ? accent : Theme.borderDim - radius: 0 - - Text { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: root.label - color: root.active ? root.accent : root.fg - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 12 - font.bold: root.active - } - - MouseArea { - id: mouseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.clicked() - } -} diff --git a/dotfiles/quickshell/SquareSlider.qml b/dotfiles/quickshell/SquareSlider.qml deleted file mode 100644 index ddc3211..0000000 --- a/dotfiles/quickshell/SquareSlider.qml +++ /dev/null @@ -1,48 +0,0 @@ -import QtQuick - -// Reusable 0..1 drag slider with a visible handle, used by the volume and -// brightness panels. -Item { - id: root - property real value: 0 - signal moved(real value) - height: 28 - - Rectangle { - id: track - anchors.verticalCenter: parent.verticalCenter - width: parent.width - height: 6 - color: Theme.bgAlt - border.width: 1 - border.color: Theme.border - - Rectangle { - width: track.width * Math.max(0, Math.min(1, root.value)) - height: parent.height - color: Theme.accent - } - } - - Rectangle { - id: handle - width: 14 - height: 20 - anchors.verticalCenter: parent.verticalCenter - x: Math.max(0, Math.min(track.width - width, track.width * Math.max(0, Math.min(1, root.value)) - width / 2)) - color: Theme.fg - border.width: 1 - border.color: Theme.border - } - - MouseArea { - anchors.fill: parent - onPositionChanged: (mouse) => { if (pressed) update(mouse.x) } - onPressed: (mouse) => update(mouse.x) - function update(x) { - var v = Math.max(0, Math.min(1, x / width)); - root.value = v; - root.moved(v); - } - } -} diff --git a/dotfiles/quickshell/Theme.qml b/dotfiles/quickshell/Theme.qml deleted file mode 100644 index f06537a..0000000 --- a/dotfiles/quickshell/Theme.qml +++ /dev/null @@ -1,148 +0,0 @@ -pragma Singleton -import QtQuick -import Quickshell -import Quickshell.Io - -// Single source of truth for every color used across the shell — swapping -// accent color or dark/light mode here changes it everywhere, instead of -// hunting down hardcoded hex values in 14 different files (which is what -// this replaced). Persisted to disk (same pattern as the wallpaper/booru -// settings) since PersistentProperties doesn't survive a real restart. -// -// Plain strings, not `color`-typed properties — QML still accepts them -// fine anywhere a `color:` binding is expected (auto-converts at the -// point of use), but keeping them as strings means they can also be -// handed straight to hypr-theme-set/gtk-theme-set as clean "#rrggbb" -// text without a color-to-string round trip appending an alpha suffix. -Item { - id: theme - - property bool dark: true - property string accentName: "teal" - - readonly property var accentPalette: ({ - teal: "#8fbcbb", - blue: "#61afef", - green: "#98c379", - purple: "#c678dd", - orange: "#d19a66", - pink: "#ff79c6", - yellow: "#e5c07b" - }) - readonly property var accentNames: ["teal", "blue", "green", "purple", "orange", "pink", "yellow"] - - readonly property string accent: theme.accentPalette[theme.accentName] || theme.accentPalette.teal - readonly property string danger: "#e06c75" - - // Dark palette (the original, and still the default) - readonly property string bgDark: "#0d0d0d" - readonly property string bgAltDark: "#1a1a1a" - readonly property string bgAlt2Dark: "#131313" - readonly property string fgDark: "#e0e0e0" - readonly property string fgDimDark: "#666666" - readonly property string fgDim2Dark: "#999999" - readonly property string borderDark: "#333333" - readonly property string borderDimDark: "#262626" - - // Light palette - readonly property string bgLight: "#f0f0f0" - readonly property string bgAltLight: "#e2e2e2" - readonly property string bgAlt2Light: "#d5d5d5" - readonly property string fgLight: "#1a1a1a" - readonly property string fgDimLight: "#666666" - readonly property string fgDim2Light: "#888888" - readonly property string borderLight: "#c4c4c4" - readonly property string borderDimLight: "#d8d8d8" - - readonly property string bg: theme.dark ? theme.bgDark : theme.bgLight - readonly property string bgAlt: theme.dark ? theme.bgAltDark : theme.bgAltLight - readonly property string bgAlt2: theme.dark ? theme.bgAlt2Dark : theme.bgAlt2Light - readonly property string fg: theme.dark ? theme.fgDark : theme.fgLight - readonly property string fgDim: theme.dark ? theme.fgDimDark : theme.fgDimLight - readonly property string fgDim2: theme.dark ? theme.fgDim2Dark : theme.fgDim2Light - readonly property string border: theme.dark ? theme.borderDark : theme.borderLight - readonly property string borderDim: theme.dark ? theme.borderDimDark : theme.borderDimLight - - property string stateFile: Quickshell.stateDir + "/theme-settings.json" - - function setAccent(name) { - if (theme.accentPalette[name] === undefined) return; - theme.accentName = name; - theme.save(); - theme.syncSystem(); - } - function setDark(isDark) { - theme.dark = isDark; - theme.save(); - theme.syncSystem(); - } - function randomAccent() { - var names = theme.accentNames; - var pick = names[Math.floor(Math.random() * names.length)]; - theme.setAccent(pick); - } - - function save() { - var json = JSON.stringify({ dark: theme.dark, accentName: theme.accentName }); - saveProc.command = ["sh", "-c", - "mkdir -p \"$(dirname \"$2\")\" && printf '%s' \"$1\" > \"$2\"", - "--", json, theme.stateFile]; - saveProc.running = true; - } - - // Pushes the current palette out to the two things that don't read - // this file directly: Hyprland's window border colors (hardcoded in - // hyprland.conf otherwise) and GTK apps like Thunar (which used to be - // a static Nix-rebuild-only CSS file). - function syncSystem() { - hyprSyncProc.command = ["hypr-theme-set", - theme.accent.replace("#", ""), theme.border.replace("#", "")]; - hyprSyncProc.running = true; - - gtkSyncProc.command = ["gtk-theme-set", - theme.accent, theme.bg, theme.bgAlt, theme.fg, theme.fgDim, theme.border, - theme.dark ? "1" : "0"]; - gtkSyncProc.running = true; - - qtSyncProc.command = ["qt-theme-set", - theme.accent, theme.bg, theme.bgAlt, theme.fg, theme.fgDim, theme.border, - theme.dark ? "1" : "0"]; - qtSyncProc.running = true; - - kittySyncProc.command = ["kitty-theme-set", - theme.accent, theme.bg, theme.bgAlt, theme.fg, theme.fgDim, theme.border]; - kittySyncProc.running = true; - - rofiSyncProc.command = ["rofi-theme-set", - theme.accent, theme.bg, theme.bgAlt, theme.fg, theme.fgDim, theme.border, - theme.dark ? "1" : "0"]; - rofiSyncProc.running = true; - } - - Process { id: saveProc } - Process { id: hyprSyncProc } - Process { id: gtkSyncProc } - Process { id: qtSyncProc } - Process { id: kittySyncProc } - Process { id: rofiSyncProc } - Process { - id: loadProc - command: ["cat", theme.stateFile] - stdout: StdioCollector { - onStreamFinished: { - try { - var d = JSON.parse(text); - if (typeof d.dark === "boolean") theme.dark = d.dark; - if (d.accentName) theme.accentName = d.accentName; - } catch (e) { /* no state file yet — defaults stand */ } - // Always sync on load, even if the state file didn't - // exist — Hyprland's border colors and GTK's CSS are - // otherwise still whatever was last written (or the - // hardcoded hyprland.conf default), independent of - // whether Quickshell itself just restarted. - theme.syncSystem(); - } - } - } - Component.onCompleted: loadProc.running = true -} diff --git a/dotfiles/quickshell/ToggleSwitch.qml b/dotfiles/quickshell/ToggleSwitch.qml deleted file mode 100644 index 84da678..0000000 --- a/dotfiles/quickshell/ToggleSwitch.qml +++ /dev/null @@ -1,41 +0,0 @@ -import QtQuick - -Row { - id: root - property bool checked: false - property string onLabel: "On" - property string offLabel: "Off" - signal toggled() - spacing: 8 - - Rectangle { - width: 44 - height: 22 - anchors.verticalCenter: parent.verticalCenter - color: root.checked ? Theme.accent : Theme.bgAlt - border.width: 1 - border.color: Theme.border - - Rectangle { - width: 18 - height: 18 - y: 2 - x: root.checked ? parent.width - width - 2 : 2 - color: root.checked ? Theme.bg : Theme.fgDim - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: root.toggled() - } - } - - Text { - anchors.verticalCenter: parent.verticalCenter - text: root.checked ? root.onLabel : root.offLabel - color: root.checked ? Theme.accent : Theme.fgDim - font.family: "JetBrainsMono Nerd Font" - font.pixelSize: 11 - } -} diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml deleted file mode 100644 index ceb178d..0000000 --- a/dotfiles/quickshell/shell.qml +++ /dev/null @@ -1,45 +0,0 @@ -//@ pragma UseQApplication -// Quickshell entry point. -// Deployed as the whole directory -> ~/.config/quickshell via -// systemd.tmpfiles in modules/desktop.nix (relative imports need the full -// directory present, not just this file). -// UseQApplication is required for SystemTray items' native right-click -// menu (item.display()) — without it, Quickshell logs "Cannot display -// PlatformMenuEntry ... was not started in QApplication mode" and the -// menu silently does nothing. -import QtQuick -import Quickshell -import Quickshell.Io - -ShellRoot { - PolkitDialog {} - Osd {} - - Variants { - model: Quickshell.screens - - Item { - required property var modelData - - LeftBar { id: leftBarInstance; screen: modelData } - RightBar { id: rightBarInstance; screen: modelData } - BottomBar { screen: modelData; leftBar: leftBarInstance; rightBar: rightBarInstance } - - // Lets Hyprland keybinds (Super+Shift+Z/X, see hyprland.conf) - // toggle the sidebars via `quickshell ipc call sidebars - // toggleLeft/toggleRight` — same open/close behavior as - // clicking the arrows on BottomBar. - IpcHandler { - target: "sidebars" - function toggleLeft(): void { - leftBarInstance.expanded = !leftBarInstance.expanded; - if (!leftBarInstance.expanded) leftBarInstance.activePanel = ""; - } - function toggleRight(): void { - rightBarInstance.expanded = !rightBarInstance.expanded; - if (!rightBarInstance.expanded) rightBarInstance.activePanel = ""; - } - } - } - } -} diff --git a/dotfiles/rofi/config.rasi b/dotfiles/rofi/config.rasi deleted file mode 100644 index 33599b6..0000000 --- a/dotfiles/rofi/config.rasi +++ /dev/null @@ -1,115 +0,0 @@ -/* Rofi launcher configuration - * Deployed to ~/.config/rofi/config.rasi via systemd.tmpfiles in - * modules/desktop.nix. Squared, sharp, no rounded corners. - * - * Colors live in theme.rasi (imported below), regenerated live by - * rofi-theme-set from the Quickshell Theme picker (dotfiles/quickshell/ - * Theme.qml) — same accent/bg/fg/border values Hyprland, GTK, and Kitty - * all sync to. Rofi has no config watcher, but it's launched on demand - * rather than staying resident, so the next open just picks up whatever - * is on disk — no reload step needed. - */ -@import "theme.rasi" - -configuration { - modi: "drun,run,window"; - show-icons: true; - /* icon-theme comes from theme.rasi (imported above), set live by - rofi-theme-set to track dark/light mode. */ - display-drun: ""; - display-run: ""; - display-window: ""; - drun-display-format: "{name}"; - font: "JetBrainsMono Nerd Font 11"; -} - -* { - background-color: @bg; - text-color: @fg; - margin: 0px; - padding: 0px; - spacing: 0px; - - /* Rofi's built-in "default" theme (always loaded first, see - `rofi -dump-theme`) defines its own named vars — background, - foreground, lightbg, lightfg, blue, red — and alternate/active/ - urgent row states read THOSE, not our bg/fg/accent. Explicit - `element.normal.alternate{...}` overrides below turned out not - to reliably win against them, so redefine the base theme's own - variables directly instead (same fix shape as GTK's - @define-color trick) — this is what actually stopped every - other row rendering in stock Solarized cream. */ - background: @bg; - foreground: @fg; - lightbg: @bg-alt; - lightfg: @fg; - blue: @accent; - red: @accent; -} - -window { - width: 600px; - border: 2px; - border-color: @border-color; - border-radius: 0px; - background-color: @bg; -} - -mainbox { - children: [ "inputbar", "listview" ]; -} - -inputbar { - padding: 12px; - background-color: @bg-alt; - border: 0px 0px 2px 0px; - border-color: @border-color; - border-radius: 0px; - children: [ "prompt", "entry" ]; -} - -prompt { - text-color: @accent; - padding: 0px 8px 0px 0px; -} - -entry { - placeholder: "Search..."; - placeholder-color: @placeholder; - text-color: @fg; -} - -listview { - lines: 8; - scrollbar: false; - padding: 8px; -} - -element { - padding: 8px 12px; - border-radius: 0px; - spacing: 10px; -} - -/* State selectors are dot-joined with no space (element.normal.normal, - not "element normal.normal") — the latter silently matches nothing, - which is why unselected rows never actually picked up @bg/@fg before - and fell back to rofi's built-in cream default on every other row. */ -element.normal.normal, element.normal.alternate { - background-color: @bg; - text-color: @fg; -} - -element.selected.normal, element.selected.alternate { - background-color: @accent; - text-color: @bg; -} - -element-icon { - size: 20px; -} - -element-text { - text-color: inherit; - vertical-align: 0.5; -} diff --git a/hyprland/hyprland.conf b/hyprland/hyprland.conf deleted file mode 100644 index 8d9c604..0000000 --- a/hyprland/hyprland.conf +++ /dev/null @@ -1,219 +0,0 @@ -# Hyprland configuration -# Deployed to ~/.config/hypr/hyprland.conf via systemd.tmpfiles in -# modules/desktop.nix. Edit this file, not the symlink target. - -# --- Monitors ----------------------------------------------------------- -# Wildcard fallback for any monitor without its own line below. -monitor = , preferred, auto, 1 -# DP-1 (AOC 27G2G8) explicitly at its real 144Hz spec: its EDID lists a -# bogus "239.96Hz" mode, and "preferred" trusts the EDID's preferred-timing -# flag — which picked that bogus mode here, silently running the monitor -# at a refresh rate it doesn't actually support. display.conf below -# normally overrides this anyway, but this is the safe cold-boot default -# if that file is ever missing/reset. -monitor = DP-1, 1920x1080@144.00Hz, auto, 1 - -# User-editable cursor theme (set via the Quickshell cursor picker); empty -# by default, created by systemd.tmpfiles so this source never fails. -source = ~/.config/hypr/cursor.conf - -# User-set display mode/scale/transform (set via the Quickshell display -# picker) — overrides the generic "preferred" fallback above for whatever -# monitors have an explicit line in here. Empty by default. -source = ~/.config/hypr/display.conf - -# --- Autostart ------------------------------------------------------------ -# Polkit auth is handled by our own themed PolkitDialog inside quickshell -# (dotfiles/quickshell/PolkitDialog.qml) — hyprpolkitagent stays installed -# but unstarted as a manual emergency fallback ("hyprpolkitagent &" from a -# TTY) since only one agent can be registered at a time. -exec-once = mako -exec-once = hyprpaper -# Reapplies whatever wallpaper was last set — hyprpaper/mpvpaper start every -# session with nothing loaded, and wallpaper-set never persisted anything -# on its own (see modules/scripts/wallpaper-set.sh, wallpaper-restore.sh). -exec-once = wallpaper-restore -exec-once = quickshell - -# GTK/Qt apps native-Wayland where possible, X11 fallback otherwise -env = XDG_CURRENT_DESKTOP,Hyprland -env = XDG_SESSION_TYPE,wayland -env = XDG_SESSION_DESKTOP,Hyprland -env = GDK_BACKEND,wayland,x11 -env = QT_QPA_PLATFORM,wayland;xcb -env = QT_QPA_PLATFORMTHEME,qt6ct -# Kirigami-based KDE apps (confirmed live: Gwenview's toolbar/statusbar) -# draw their chrome in QML, not QWidget — outside a real Plasma session -# they default to their own hardcoded light "Basic" QQC2 style instead of -# reading qt6ct or kdeglobals at all. org.kde.desktop is the QQC2 style -# that actually pulls from KColorScheme/kdeglobals like the rest of the -# theming pipeline here does. -env = QT_QUICK_CONTROLS_STYLE,org.kde.desktop - -# --- Input ------------------------------------------------------------ -input { - kb_layout = pl - follow_mouse = 1 - touchpad { - natural_scroll = true - } - sensitivity = 0 -} - -# --- General: sharp, square, minimal ----------------------------------- -general { - gaps_in = 0 - gaps_out = 0 - border_size = 2 - col.active_border = rgba(8fbcbbff) - col.inactive_border = rgba(333333ff) - layout = dwindle - resize_on_border = true -} - -# Border colors set via the Quickshell Theme picker (dotfiles/quickshell/ -# Theme.qml) — overrides the general{} defaults above for whatever's been -# picked. Empty by default; hypr-theme-set writes to it live. -source = ~/.config/hypr/theme.conf - -# Zero rounding, no shadows/blur: sharp squared look everywhere -decoration { - rounding = 0 - - shadow { - enabled = false - } - - blur { - enabled = false - } -} - -animations { - enabled = false -} - -dwindle { - preserve_split = true -} - -master { - new_status = master -} - -# Don't warp the cursor to whatever window gets focused (e.g. clicking a -# taskbar entry) — only move it when the user actually moves the mouse. -cursor { - no_warps = true -} - -# --- Window rules --------------------------------------------------------- -# (none needed currently — the polkit dialog is a layer-shell surface via -# Quickshell's PanelWindow, not a normal toplevel, so it doesn't need a -# float/center window rule at all.) - -misc { - disable_hyprland_logo = true - disable_splash_rendering = true - force_default_wallpaper = 0 -} - -# --- Keybinds ------------------------------------------------------------ -$mod = SUPER -$terminal = kitty -$fileManager = thunar -$launcher = rofi -show drun -$imageViewer = imv - -bind = $mod, Return, exec, $terminal -bind = $mod, T, exec, $terminal -bind = $mod, E, exec, $fileManager -bind = $mod, A, exec, $launcher -bind = $mod, D, exec, show-desktop-toggle - -# Sidebar toggles — mirrors clicking the arrows on BottomBar, via the -# IpcHandler registered in shell.qml. -bind = $mod SHIFT, Z, exec, quickshell ipc call sidebars toggleLeft -bind = $mod SHIFT, X, exec, quickshell ipc call sidebars toggleRight -bind = $mod, Q, killactive, -bind = $mod SHIFT, Q, exit, -bind = $mod, V, togglefloating, -bind = $mod, F, fullscreen, -bind = $mod, P, pseudo, -bind = $mod, S, layoutmsg, togglesplit - -# Focus movement (vim-style, plus arrow keys as the same thing) -bind = $mod, H, movefocus, l -bind = $mod, L, movefocus, r -bind = $mod, K, movefocus, u -bind = $mod, J, movefocus, d -bind = $mod, Left, movefocus, l -bind = $mod, Right, movefocus, r -bind = $mod, Up, movefocus, u -bind = $mod, Down, movefocus, d - -# Move windows -bind = $mod SHIFT, H, movewindow, l -bind = $mod SHIFT, L, movewindow, r -bind = $mod SHIFT, K, movewindow, u -bind = $mod SHIFT, J, movewindow, d -bind = $mod SHIFT, Left, movewindow, l -bind = $mod SHIFT, Right, movewindow, r -bind = $mod SHIFT, Up, movewindow, u -bind = $mod SHIFT, Down, movewindow, d - -# Workspaces 1-10 -bind = $mod, 1, workspace, 1 -bind = $mod, 2, workspace, 2 -bind = $mod, 3, workspace, 3 -bind = $mod, 4, workspace, 4 -bind = $mod, 5, workspace, 5 -bind = $mod, 6, workspace, 6 -bind = $mod, 7, workspace, 7 -bind = $mod, 8, workspace, 8 -bind = $mod, 9, workspace, 9 -bind = $mod, 0, workspace, 10 - -bind = $mod SHIFT, 1, movetoworkspace, 1 -bind = $mod SHIFT, 2, movetoworkspace, 2 -bind = $mod SHIFT, 3, movetoworkspace, 3 -bind = $mod SHIFT, 4, movetoworkspace, 4 -bind = $mod SHIFT, 5, movetoworkspace, 5 -bind = $mod SHIFT, 6, movetoworkspace, 6 -bind = $mod SHIFT, 7, movetoworkspace, 7 -bind = $mod SHIFT, 8, movetoworkspace, 8 -bind = $mod SHIFT, 9, movetoworkspace, 9 -bind = $mod SHIFT, 0, movetoworkspace, 10 - -# Function keys — media/volume/brightness. bindl (not bind) so these still -# work with the screen locked; bindel additionally repeats while held, for -# the ones you'd actually want to hold down (volume/brightness ramps). -bindel = , XF86AudioRaiseVolume, exec, wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ 5%+ -bindel = , XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%- -bindl = , XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle -bindl = , XF86AudioMicMute, exec, wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle -# Chained with an OSD call: unlike volume/mic, brightness has no live -# service Quickshell can react to (DDC/CI writes aren't broadcast -# anywhere), so the keybind has to tell the OSD explicitly. -bindel = , XF86MonBrightnessUp, exec, brightness-ctl up && quickshell ipc call osd brightness "$(brightness-ctl get)" -bindel = , XF86MonBrightnessDown, exec, brightness-ctl down && quickshell ipc call osd brightness "$(brightness-ctl get)" -bindl = , XF86AudioPlay, exec, playerctl play-pause -bindl = , XF86AudioPause, exec, playerctl play-pause -bindl = , XF86AudioStop, exec, playerctl stop -bindl = , XF86AudioNext, exec, playerctl next -bindl = , XF86AudioPrev, exec, playerctl previous - -# Mouse: drag/resize floating windows -bindm = $mod, mouse:272, movewindow -bindm = $mod, mouse:273, resizewindow - -# Screenshots — the "screenshot" script (region|full|window, clipboard|file|both). -# Super+Shift+S is the primary bind (this keyboard has no physical Print key) — -# it saves a file AND copies to clipboard, so it's never a silent no-op. -bind = , Print, exec, screenshot full clipboard -bind = $mod, Print, exec, screenshot region clipboard -bind = $mod SHIFT, Print, exec, screenshot region both -bind = $mod SHIFT, S, exec, screenshot region both - -# Image viewer quick-launch on the screenshots folder -bind = $mod, I, exec, $imageViewer ~/Screenshots diff --git a/hyprland/hyprpaper.conf b/hyprland/hyprpaper.conf deleted file mode 100644 index 51f7050..0000000 --- a/hyprland/hyprpaper.conf +++ /dev/null @@ -1,7 +0,0 @@ -# Hyprpaper wallpaper daemon config. -# Deployed to ~/.config/hypr/hyprpaper.conf via systemd.tmpfiles in -# modules/desktop.nix. No static preload list here on purpose — wallpapers -# are set at runtime via IPC (`wallpaper-set `) from the Quickshell -# wallpaper picker (dotfiles/quickshell/LeftFlyout.qml). -splash = false -ipc = true diff --git a/modules/apps.nix b/modules/apps.nix index a2b5f60..73daade 100644 --- a/modules/apps.nix +++ b/modules/apps.nix @@ -1,41 +1,17 @@ { config, pkgs, ... }: -let - # hyprpolkitagent only ships its binary under libexec/, not bin/, so it - # never lands on $PATH — wrap it so `exec-once = hyprpolkitagent` in - # hyprland.conf can find it. - hyprpolkitagentBin = pkgs.writeShellScriptBin "hyprpolkitagent" - "exec ${pkgs.hyprpolkitagent}/libexec/hyprpolkitagent"; -in { # Every installed package — GUI apps, CLI tools, and dev toolchains alike. # If it's "install X", it goes here. users.users."nexoniarz".packages = with pkgs; [ - # Hyprland desktop toolchain (Wayland-native, lightweight, squared theme) - kitty # Terminal - thunar # File manager - thunar-archive-plugin - thunar-volman - tumbler # Thumbnails (paired with services.tumbler.enable) - rofi # Launcher (Wayland support built in since 2.0) - quickshell # Bar / UI shell + tumbler # Thumbnails (paired with services.tumbler.enable; also used by Nemo) + gnome-screenshot # Screenshot capture (X11) imv # Image viewer - mako # Notification daemon - grim # Screenshot capture - slurp # Screenshot region select - wl-clipboard # Wayland clipboard - hyprpolkitagentBin # Polkit authentication agent (wrapped, see `let` above) - hyprlock # Screen locker (power menu) brightnessctl # Backlight brightness (laptop panels) ddcutil # DDC/CI brightness (external monitors) lm_sensors # sensors-detect helps ddcutil find I2C buses - hyprpaper # Wallpaper daemon, driven by the Quickshell picker (static images) - mpvpaper # Wallpaper daemon for video-loop (animated) wallpapers networkmanager # nmcli, for the Wi-Fi hotspot toggle xdg-user-dirs # Resolves localized folder names (Obrazy, not Pictures, on pl_PL) - bibata-cursors # Cursor theme(s) for the cursor picker - xcur2png # Renders Xcursor files to PNG for the cursor picker's previews - imagemagick # `identify`, used to pick the right cursor-preview size playerctl # MPRIS media control, for the function-key media binds # Apps @@ -48,6 +24,14 @@ in vlc filezilla chromium + obs-studio + easyeffects # Real-time mic/output effects (pitch, EQ, ...) — auto-creates a + # selectable "Easy Effects Source" virtual mic once an Input effect + # is added, no manual PipeWire routing needed + qpwgraph # Patchbay GUI — wires EasyEffects'/Soundux's output into the + # standalone "Virtual Microphone Sink" (see system.nix) + # Soundux (soundboard) isn't packaged in this nixpkgs channel anymore — + # installed via Flatpak instead (io.github.Soundux). # Games prismlauncher @@ -79,6 +63,13 @@ in unzip unrar kdePackages.gwenview - kdePackages. kate + kdePackages.kate + kdePackages.qqc2-desktop-style + linphonePackages.linphone-desktop + kdePackages.kcalc # Calculator (nothing filled this role before) + kdePackages.okular # Document/PDF viewer (nothing filled this role before) + kdePackages.elisa # Audio player (vlc/ffmpeg cover video already; nothing dedicated for audio) + kdePackages.konsole # Terminal (replaces kitty) + kdePackages.dolphin # File manager (replaces Thunar, alongside Budgie's default Nemo) ]; } diff --git a/modules/default-apps.nix b/modules/default-apps.nix index 0ef6b29..44b0f05 100644 --- a/modules/default-apps.nix +++ b/modules/default-apps.nix @@ -42,24 +42,19 @@ "application/x-7z-compressed" "application/vnd.rar" "application/zip" "application/x-java-archive" "application/x-arj" ]; + # Taken from Okular's own .desktop file, same as the other two lists. + okularMimes = [ + "application/pdf" "application/x-pdf" "image/vnd.djvu" "application/epub+zip" + "application/x-fictionbook+xml" "application/x-mobipocket-ebook" + "image/x-portable-document" "application/x-cbr" "application/x-cbz" + "application/x-cbt" "application/x-cb7" "application/postscript" + ]; line = desktop: mime: "${mime}=${desktop}"; in '' [Default Applications] ${lib.concatMapStringsSep "\n" (line "org.kde.gwenview.desktop") gwenviewMimes} ${lib.concatMapStringsSep "\n" (line "org.kde.ark.desktop") arkMimes} + ${lib.concatMapStringsSep "\n" (line "org.kde.okular.desktop") okularMimes} ''; - - # Thunar's own "Open Terminal Here" custom action (Edit > Configure - # Custom Actions) shipped pointing at `exo-open --launch - # TerminalEmulator` — exo isn't installed on this system at all (no - # XFCE session, so its helpers.rc mechanism has nothing to resolve - # against), so the action silently did nothing. `f`, not `w`: only - # creates this baseline for a fresh account — once it exists, further - # edits made through Thunar's own UI (adding more custom actions) are - # never overwritten by a rebuild. - systemd.tmpfiles.rules = [ - "d /home/nexoniarz/.config/Thunar 0755 nexoniarz users -" - ''f /home/nexoniarz/.config/Thunar/uca.xml 0644 nexoniarz users - utilities-terminalOpen Terminal Here1788014825097143-1kitty --working-directory %fOpen a terminal in this folder*'' - ]; } diff --git a/modules/desktop.nix b/modules/desktop.nix index d4c3cf3..24c4d32 100644 --- a/modules/desktop.nix +++ b/modules/desktop.nix @@ -1,101 +1,41 @@ { config, pkgs, ... }: { - # --- Hyprland: Wayland compositor --- - programs.hyprland = { - enable = true; - xwayland.enable = true; - }; - - # --- greetd + tuigreet: minimal TUI login manager, launches Hyprland directly --- - services.greetd = { - enable = true; - settings.default_session = { - # start-hyprland, not the raw Hyprland binary: it's Hyprland's own - # watchdog-wrapped launcher (ships alongside Hyprland in the same - # package) that also auto-restarts the compositor if it hangs. - # Launching the raw binary directly works but logs a startup warning - # ("Hyprland is being launched without start-hyprland. This is highly - # advised against.") and skips that recovery. - command = "${pkgs.tuigreet}/bin/tuigreet --time --remember --remember-session --cmd start-hyprland"; - user = "greeter"; - }; - }; - - # --- XDG portals: screen share, file pickers, etc. --- - xdg.portal = { - enable = true; - extraPortals = [ pkgs.xdg-desktop-portal-hyprland pkgs.xdg-desktop-portal-gtk ]; - config.common.default = [ "hyprland" "gtk" ]; - }; + # --- Budgie: X11 desktop environment, lightdm login manager --- + services.xserver.enable = true; + services.xserver.displayManager.lightdm.enable = true; + services.desktopManager.budgie.enable = true; security.polkit.enable = true; - # Keyboard layout for console/XWayland/greetd; Hyprland's own input block - # in hyprland/hyprland.conf mirrors this. + # Keyboard layout for console/X11/lightdm services.xserver.xkb = { layout = "pl"; variant = ""; }; - # Thunar: thumbnails, trash/volume mount integration, settings storage + # Thumbnails + trash/volume mount integration for Dolphin and Budgie's + # default Nemo file manager alike. services.tumbler.enable = true; services.gvfs.enable = true; - programs.xfconf.enable = true; - # Native Wayland backend for Electron/Chromium-based apps - environment.sessionVariables.NIXOS_OZONE_WL = "1"; + # Drop the xterm fallback terminal xserver.nix installs unconditionally — + # Konsole is the real terminal here. + services.xserver.excludePackages = [ pkgs.xterm ]; - # --- Dotfiles: symlink tracked configs into the user's home --- - # Editing /etc/nixos/hyprland or /etc/nixos/dotfiles takes effect on the - # next reload of the respective app (no rebuild needed for config-only - # tweaks; a rebuild re-runs the symlink if the target path itself changes). - systemd.tmpfiles.rules = [ - "d /home/nexoniarz/.config/hypr 0755 nexoniarz users -" - "L+ /home/nexoniarz/.config/hypr/hyprland.conf - - - - /etc/nixos/hyprland/hyprland.conf" - "L+ /home/nexoniarz/.config/hypr/hyprpaper.conf - - - - /etc/nixos/hyprland/hyprpaper.conf" - - "d /home/nexoniarz/.config/kitty 0755 nexoniarz users -" - "L+ /home/nexoniarz/.config/kitty/kitty.conf - - - - /etc/nixos/dotfiles/kitty/kitty.conf" - # Colors set live via the Quickshell Theme picker — see kitty.conf's - # `include theme.conf`. Empty by default; kitty-theme-set writes to it. - "f /home/nexoniarz/.config/kitty/theme.conf 0644 nexoniarz users - -" - - "d /home/nexoniarz/.config/rofi 0755 nexoniarz users -" - "L+ /home/nexoniarz/.config/rofi/config.rasi - - - - /etc/nixos/dotfiles/rofi/config.rasi" - # Colors set live via the Quickshell Theme picker — see config.rasi's - # `@import "theme.rasi"`. Empty by default; rofi-theme-set writes to it. - "f /home/nexoniarz/.config/rofi/theme.rasi 0644 nexoniarz users - -" - - "d /home/nexoniarz/.config/mako 0755 nexoniarz users -" - "L+ /home/nexoniarz/.config/mako/config - - - - /etc/nixos/dotfiles/mako/config" - - # Quickshell is multi-file with relative imports, so the whole directory - # is symlinked rather than a single file. - "L+ /home/nexoniarz/.config/quickshell - - - - /etc/nixos/dotfiles/quickshell" - - # $HOME/Wallpapers, not $HOME/Pictures/Wallpapers — this system's locale - # (pl_PL) means the real XDG Pictures dir is ~/Obrazy, not ~/Pictures. - # Living directly under $HOME sidesteps that entirely. - "d /home/nexoniarz/Wallpapers 0755 nexoniarz users -" - "d /home/nexoniarz/Screenshots 0755 nexoniarz users -" - - # User-editable cursor theme choice (set live via the Quickshell cursor - # picker) — created empty if missing, never overwritten if it already - # has content, since it's runtime state rather than tracked config. - "f /home/nexoniarz/.config/hypr/cursor.conf 0644 nexoniarz users - -" - - # Same deal for display settings (mode/scale/transform per monitor, - # set via the Quickshell display picker) — without this file backing - # them, they only ever applied live via hyprctl and got silently - # reset by hyprland.conf's generic fallback monitor rule any time - # Hyprland re-evaluated monitor state. - "f /home/nexoniarz/.config/hypr/display.conf 0644 nexoniarz users - -" - - # Same deal for window border colors (accent/border, set live via the - # Quickshell Theme picker) — without this file, they only ever applied - # via hyprctl and would fall back to hyprland.conf's own general{} - # defaults any time Hyprland re-evaluated its config. - "f /home/nexoniarz/.config/hypr/theme.conf 0644 nexoniarz users - -" + # GTK/Qt themselves stay at their stock defaults (no forced dark mode, no + # custom CSS) — only the icon and cursor theme are picked explicitly, via + # Budgie's own GSettings-override hook rather than a custom script. + # (extraGSettingsOverridePackages is for packages that ship their own + # gsettings-schema overrides, e.g. plugins — not theme packages, which + # just need to be on the system path for their icons/cursors to resolve.) + environment.systemPackages = [ + pkgs.gruvbox-plus-icons + pkgs.bibata-cursors ]; + services.desktopManager.budgie.extraGSettingsOverrides = '' + [org.gnome.desktop.interface] + icon-theme='Gruvbox-Plus-Dark' + cursor-theme='Bibata-Modern-Classic' + ''; } diff --git a/modules/drivers.nix b/modules/drivers.nix index bced531..e130a93 100644 --- a/modules/drivers.nix +++ b/modules/drivers.nix @@ -10,14 +10,9 @@ #OpenTabletDriver hardware.opentabletdriver.enable = true; hardware.uinput.enable = true; - boot.kernelModules = [ "uinput" "v4l2loopback" ]; + boot.kernelModules = [ "uinput" ]; - # Virtual webcam device so ffmpeg (fed by the GoPro's stream) can appear - # as a normal camera to apps like Discord. exclusive_caps=1 is required - # for Chromium/WebRTC-based apps to recognize it as a real capture device. - boot.extraModulePackages = [ config.boot.kernelPackages.v4l2loopback ]; boot.extraModprobeConfig = '' - options v4l2loopback devices=1 video_nr=10 card_label="GoPro Webcam" exclusive_caps=1 # PS4/PS5 controllers (e.g. DualSense) connect over Bluetooth then # immediately disconnect — a well-known Linux Bluetooth stack issue # where many adapters' Enhanced Retransmission Mode implementation is diff --git a/modules/scripts.nix b/modules/scripts.nix index 574ee4e..a6a61d8 100644 --- a/modules/scripts.nix +++ b/modules/scripts.nix @@ -5,21 +5,6 @@ let vaultToggle = pkgs.writeShellScriptBin "vault-toggle" (builtins.readFile ./scripts/vault-toggle.sh); stripMetadata = pkgs.writeShellScriptBin "strip-metadata" (builtins.readFile ./scripts/strip-metadata.sh); brightnessCtl = pkgs.writeShellScriptBin "brightness-ctl" (builtins.readFile ./scripts/brightness-ctl.sh); - showDesktopToggle = pkgs.writeShellScriptBin "show-desktop-toggle" (builtins.readFile ./scripts/show-desktop-toggle.sh); - wallpaperSet = pkgs.writeShellScriptBin "wallpaper-set" (builtins.readFile ./scripts/wallpaper-set.sh); - wallpaperDelete = pkgs.writeShellScriptBin "wallpaper-delete" (builtins.readFile ./scripts/wallpaper-delete.sh); - wallpaperRestore = pkgs.writeShellScriptBin "wallpaper-restore" (builtins.readFile ./scripts/wallpaper-restore.sh); - wallpaperThumbs = pkgs.writeShellScriptBin "wallpaper-thumbs" (builtins.readFile ./scripts/wallpaper-thumbs.sh); - displaySet = pkgs.writeShellScriptBin "display-set" (builtins.readFile ./scripts/display-set.sh); - hyprThemeSet = pkgs.writeShellScriptBin "hypr-theme-set" (builtins.readFile ./scripts/hypr-theme-set.sh); - gtkThemeSet = pkgs.writeShellScriptBin "gtk-theme-set" (builtins.readFile ./scripts/gtk-theme-set.sh); - qtThemeSet = pkgs.writeShellScriptBin "qt-theme-set" (builtins.readFile ./scripts/qt-theme-set.sh); - kittyThemeSet = pkgs.writeShellScriptBin "kitty-theme-set" (builtins.readFile ./scripts/kitty-theme-set.sh); - rofiThemeSet = pkgs.writeShellScriptBin "rofi-theme-set" (builtins.readFile ./scripts/rofi-theme-set.sh); - listCursorThemes = pkgs.writeShellScriptBin "list-cursor-themes" (builtins.readFile ./scripts/list-cursor-themes.sh); - cursorPreviewAll = pkgs.writeShellScriptBin "cursor-preview-all" (builtins.readFile ./scripts/cursor-preview-all.sh); - cursorSet = pkgs.writeShellScriptBin "cursor-set" (builtins.readFile ./scripts/cursor-set.sh); - screenshot = pkgs.writeShellScriptBin "screenshot" (builtins.readFile ./scripts/screenshot.sh); hotspotStart = pkgs.writeShellScriptBin "hotspot-start" (builtins.readFile ./scripts/hotspot-start.sh); systemTelemetry = pkgs.writeShellScriptBin "system-telemetry" (builtins.readFile ./scripts/system-telemetry.sh); weatherFetch = pkgs.writeShellScriptBin "weather-fetch" (builtins.readFile ./scripts/weather-fetch.sh); @@ -33,23 +18,8 @@ in vaultToggle stripMetadata brightnessCtl - showDesktopToggle - wallpaperSet - wallpaperDelete - wallpaperRestore - wallpaperThumbs - displaySet - hyprThemeSet - gtkThemeSet - qtThemeSet - kittyThemeSet - rofiThemeSet - listCursorThemes - cursorPreviewAll - cursorSet - screenshot hotspotStart systemTelemetry weatherFetch ]; -} \ No newline at end of file +} diff --git a/modules/scripts/cursor-preview-all.sh b/modules/scripts/cursor-preview-all.sh deleted file mode 100644 index d4da2c0..0000000 --- a/modules/scripts/cursor-preview-all.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash -# Extracts a small preview PNG (closest to 32px) for every installed -# cursor theme's "left_ptr" cursor into a cache directory, one per theme -# named ".png". Xcursor files are a binary format QML's Image -# element can't decode directly, so this is a one-time render step — -# already-cached themes are skipped. -# -# usage: cursor-preview-all -set -euo pipefail - -cache_dir="${1:?usage: cursor-preview-all }" -mkdir -p "$cache_dir" - -dirs=( - "$HOME/.icons" - "$HOME/.local/share/icons" - "/run/current-system/sw/share/icons" - "/etc/profiles/per-user/$USER/share/icons" -) - -render_one() { - local theme="$1" - local src="$2" - local out="$cache_dir/$theme.png" - [ -e "$out" ] && return 0 - - local tmp - tmp=$(mktemp -d) - trap 'rm -rf "$tmp"' RETURN - - xcur2png -q -d "$tmp" -c "$tmp/conf" -i 1 "$src" > /dev/null 2>&1 || return 0 - - local best="" - local best_diff=999999 - for png in "$tmp"/*.png; do - [ -e "$png" ] || continue - local w - w=$(identify -format "%w" "$png" 2>/dev/null || echo 0) - local diff=$(( w > 32 ? w - 32 : 32 - w )) - if [ "$diff" -lt "$best_diff" ]; then - best_diff=$diff - best="$png" - fi - done - [ -n "$best" ] && cp "$best" "$out" -} - -for d in "${dirs[@]}"; do - [ -d "$d" ] || continue - for theme_dir in "$d"/*/; do - [ -f "${theme_dir}cursors/left_ptr" ] || continue - render_one "$(basename "$theme_dir")" "${theme_dir}cursors/left_ptr" - done -done diff --git a/modules/scripts/cursor-set.sh b/modules/scripts/cursor-set.sh deleted file mode 100755 index a99d5f3..0000000 --- a/modules/scripts/cursor-set.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -# Applies a cursor theme immediately (hyprctl) and persists it across -# restarts by writing env vars to a file hyprland.conf sources on startup. -set -euo pipefail - -theme="${1:?usage: cursor-set [size]}" -size="${2:-24}" - -hyprctl setcursor "$theme" "$size" > /dev/null - -mkdir -p "$HOME/.config/hypr" -cat > "$HOME/.config/hypr/cursor.conf" </dev/null; then - # mktemp in the same directory as $conf, not the default /tmp: /tmp is - # a different filesystem here, which makes `mv` below a non-atomic - # copy+unlink instead of a rename — Hyprland's file watcher (it - # watches every `source=`d file, including this one) could catch the - # destination mid-replace and its `source=` glob would find nothing, - # throwing "globbing error: found no match" (observed live after an - # orientation change, which always hits this branch since DP-1 - # already has an existing line by then). - tmp=$(mktemp "$conf.XXXXXX") - awk -v prefix="monitor = $mon," -v newline="$line" ' - index($0, prefix) == 1 { print newline; next } { print } - ' "$conf" > "$tmp" - mv "$tmp" "$conf" -else - echo "$line" >> "$conf" -fi - -hyprctl keyword monitor "$descriptor" > /dev/null diff --git a/modules/scripts/gtk-theme-set.sh b/modules/scripts/gtk-theme-set.sh deleted file mode 100644 index efa4034..0000000 --- a/modules/scripts/gtk-theme-set.sh +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env bash -# Regenerates GTK3/4 CSS + settings.ini to match the current Quickshell -# theme (accent color + dark/light mode). These used to be static, -# Nix-rebuild-only files (symlinked straight into the store) — this makes -# them live-editable instead, the same way cursor.conf/display.conf work, -# so the Theme picker can actually reach GTK apps like Thunar. -set -euo pipefail - -accent="${1:?usage: gtk-theme-set }" -bg="${2:?bg required}" -bg_alt="${3:?bgAlt required}" -fg="${4:?fg required}" -fg_dim="${5:?fgDim required}" -border="${6:?border required}" -dark="${7:?dark required}" - -dir3="$HOME/.config/gtk-3.0" -dir4="$HOME/.config/gtk-4.0" -mkdir -p "$dir3" "$dir4" - -css_file=$(mktemp) -cat > "$css_file" < stack, scrolledwindow, viewport { - background-color: $bg; - color: $fg; -} -iconview.cell, iconview text { - background-color: $bg; - color: $fg; -} -treeview.view:selected, row:selected, iconview:selected { - background-color: $accent; - color: $bg; -} -headerbar, .titlebar { - background-color: $bg_alt; - color: $fg; - border-color: $border; - box-shadow: none; -} -button { - border-color: $border; -} -button:hover { - background-color: $bg_alt; -} -selection, *:selected { - background-color: $accent; - color: $bg; -} -EOF - -cp "$css_file" "$dir3/gtk.css" -cp "$css_file" "$dir4/gtk.css" -rm -f "$css_file" - -prefer_dark="false" -theme_name="Adwaita" -icon_theme="Gruvbox-Plus-Light" -if [ "$dark" = "1" ]; then - prefer_dark="true" - theme_name="Adwaita-dark" - icon_theme="Gruvbox-Plus-Dark" -fi - -ini_file=$(mktemp) -cat > "$ini_file" </dev/null 2>&1; then - color_scheme="default" - [ "$dark" = "1" ] && color_scheme="prefer-dark" - dconf write /org/gnome/desktop/interface/gtk-theme "'$theme_name'" - dconf write /org/gnome/desktop/interface/icon-theme "'$icon_theme'" - dconf write /org/gnome/desktop/interface/color-scheme "'$color_scheme'" -fi - -# GTK2 compat: separate config format (~/.gtkrc-2.0, not settings.ini), -# and no live theme-switching mechanism at all — GTK2 apps only read this -# at their own startup. Only icon theme and font actually follow the -# current theme; gnome-themes-extra's bundled GTK2 Adwaita engine has no -# dark variant to switch to (GTK2 predates that concept), so gtk-theme- -# name here is always the one real option, not a broken attempt at dark -# mode. -cat > "$HOME/.gtkrc-2.0" < /dev/null -hyprctl keyword general:col.inactive_border "rgba(${border}ff)" > /dev/null - -conf="$HOME/.config/hypr/theme.conf" -mkdir -p "$(dirname "$conf")" -cat > "$conf" < "$conf" <.packages (as opposed to - # environment.systemPackages) land here, not under - # /run/current-system/sw — this is where bibata-cursors actually is. - "/etc/profiles/per-user/$USER/share/icons" -) - -for d in "${dirs[@]}"; do - [ -d "$d" ] || continue - for theme in "$d"/*/; do - [ -d "${theme}cursors" ] || continue - basename "$theme" - done -done | sort -u diff --git a/modules/scripts/qt-theme-set.sh b/modules/scripts/qt-theme-set.sh deleted file mode 100644 index a10af4b..0000000 --- a/modules/scripts/qt-theme-set.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env bash -# Regenerates qt6ct's config + color scheme to match the current Quickshell -# theme, the Qt-side equivalent of gtk-theme-set. Qt apps (Gwenview, Ark, -# Kate, ...) had no working platform theme plugin at all before this — the -# QT_QPA_PLATFORMTHEME=qt6ct env var (hyprland.conf) was pointing at a -# plugin that wasn't even installed, so every Qt app fell back to -# whatever default style/palette it shipped with, ignoring the rest of -# the desktop entirely. style=Fusion (not a KDE/Breeze style) matters -# specifically: Fusion paints straight from QPalette, so a custom color -# scheme here is guaranteed to actually show — Breeze-family styles -# prefer reading colors from kdeglobals instead, which is a separate, -# static, unrelated leftover file on this machine. -set -euo pipefail - -accent="${1:?usage: qt-theme-set }" -bg="${2:?bg required}" -bg_alt="${3:?bgAlt required}" -fg="${4:?fg required}" -fg_dim="${5:?fgDim required}" -border="${6:?border required}" -dark="${7:?dark required}" - -dir="$HOME/.config/qt6ct" -mkdir -p "$dir/colors" - -# qt6ct color scheme files store QPalette::ColorRole values as a fixed- -# order, comma-separated #aarrggbb list — order and format verified -# against the example schemes qt6ct itself ships -# (share/qt6ct/colors/darker.conf etc.), not guessed: WindowText, Button, -# Light, Midlight, Dark, Mid, Text, BrightText, ButtonText, Base, Window, -# Shadow, Highlight, HighlightedText, Link, LinkVisited, AlternateBase, -# NoRole, ToolTipBase, ToolTipText, PlaceholderText. -a() { printf '#ff%s' "${1#\#}"; } # opaque -t() { printf '#80%s' "${1#\#}"; } # translucent, for PlaceholderText - -active="$(a "$fg"), $(a "$bg_alt"), $(a "$border"), $(a "$bg_alt"), $(a "$bg"), $(a "$border"), $(a "$fg"), $(a "$fg"), $(a "$fg"), $(a "$bg"), $(a "$bg"), $(a "$border"), $(a "$accent"), $(a "$bg"), $(a "$accent"), $(a "$accent"), $(a "$bg_alt"), $(a "$bg"), $(a "$bg_alt"), $(a "$fg"), $(t "$fg_dim")" -disabled="$(a "$fg_dim"), $(a "$bg_alt"), $(a "$border"), $(a "$bg_alt"), $(a "$bg"), $(a "$border"), $(a "$fg_dim"), $(a "$fg_dim"), $(a "$fg_dim"), $(a "$bg"), $(a "$bg"), $(a "$border"), $(a "$accent"), $(a "$fg_dim"), $(a "$accent"), $(a "$accent"), $(a "$bg_alt"), $(a "$bg"), $(a "$bg_alt"), $(a "$fg_dim"), $(t "$fg_dim")" - -color_file="$dir/colors/quickshell-theme.conf" -cat > "$color_file" < "$dir/qt6ct.conf" < "$HOME/.config/kdeglobals" < "$conf" < -set -euo pipefail - -mode="${1:-region}" -action="${2:-both}" - -geom=() -case "$mode" in - region) - sel="$(slurp)" || exit 0 - [ -z "$sel" ] && exit 0 - geom=(-g "$sel") - ;; - window) - win="$(hyprctl activewindow -j)" - at_x=$(echo "$win" | jq -r '.at[0]') - at_y=$(echo "$win" | jq -r '.at[1]') - w=$(echo "$win" | jq -r '.size[0]') - h=$(echo "$win" | jq -r '.size[1]') - geom=(-g "${at_x},${at_y} ${w}x${h}") - ;; - full) - geom=() - ;; - *) - echo "unknown mode: $mode (expected region|full|window)" >&2 - exit 1 - ;; -esac - -mkdir -p "$HOME/Screenshots" -file="$HOME/Screenshots/$(date +%Y-%m-%d_%H-%M-%S).png" - -case "$action" in - clipboard) - grim "${geom[@]}" - | wl-copy - ;; - file) - grim "${geom[@]}" "$file" - ;; - both) - grim "${geom[@]}" "$file" - wl-copy < "$file" - ;; - *) - echo "unknown action: $action (expected clipboard|file|both)" >&2 - exit 1 - ;; -esac diff --git a/modules/scripts/show-desktop-toggle.sh b/modules/scripts/show-desktop-toggle.sh deleted file mode 100755 index 03254e5..0000000 --- a/modules/scripts/show-desktop-toggle.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -# "Show desktop" for Hyprland: stashes every window on the active workspace -# into a hidden special workspace, and restores each one to the workspace -# it came from on the next press — regardless of which workspace you're on -# when you press it again (a single global state file, not one keyed by -# "current workspace", which broke the moment you switched workspaces -# in between). -set -euo pipefail - -state_file="${XDG_RUNTIME_DIR:-/tmp}/hypr-show-desktop.json" - -if [ -s "$state_file" ]; then - jq -c '.[]' "$state_file" | while IFS= read -r entry; do - addr=$(echo "$entry" | jq -r '.address') - ws=$(echo "$entry" | jq -r '.workspace') - hyprctl dispatch movetoworkspacesilent "$ws,address:$addr" > /dev/null - done - rm -f "$state_file" -else - ws_id=$(hyprctl activeworkspace -j | jq -r '.id') - hyprctl clients -j | jq -c --argjson ws "$ws_id" \ - '[.[] | select(.workspace.id == $ws) | {address: .address, workspace: $ws}]' > "$state_file" - jq -r '.[].address' "$state_file" | while IFS= read -r addr; do - [ -n "$addr" ] && hyprctl dispatch movetoworkspacesilent "special:showdesktop,address:$addr" > /dev/null - done -fi diff --git a/modules/scripts/wallpaper-delete.sh b/modules/scripts/wallpaper-delete.sh deleted file mode 100644 index 7011128..0000000 --- a/modules/scripts/wallpaper-delete.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -# Removes a wallpaper by moving it to the freedesktop.org trash -# (~/.local/share/Trash), the same mechanism Thunar's own delete uses — -# so it shows up in Thunar's Trash and can be restored from there, -# instead of `rm` permanently destroying it with no way back. -set -euo pipefail - -path="${1:?usage: wallpaper-delete }" -[ -e "$path" ] || exit 0 - -trash_dir="$HOME/.local/share/Trash" -mkdir -p "$trash_dir/files" "$trash_dir/info" - -name="$(basename "$path")" -dest="$trash_dir/files/$name" -info="$trash_dir/info/$name.trashinfo" -n=1 -while [ -e "$dest" ] || [ -e "$info" ]; do - dest="$trash_dir/files/${name}.$n" - info="$trash_dir/info/${name}.$n.trashinfo" - n=$((n + 1)) -done - -cat > "$info" < /dev/null 2>&1 && break - sleep 0.1 -done - -bash "$state_file" diff --git a/modules/scripts/wallpaper-set.sh b/modules/scripts/wallpaper-set.sh deleted file mode 100755 index b5e6a4d..0000000 --- a/modules/scripts/wallpaper-set.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -# Sets the desktop wallpaper — static images go through hyprpaper's IPC, -# video files loop via mpvpaper. Only one backend should ever be driving -# the background at a time, so switching to one always tears the other -# down first. -# -# usage: wallpaper-set [fit_mode] [muted] [speed] [auto_pause] -# fit_mode: cover|contain (static images only, default cover) -# muted: 1|0 (animated only, default 1) -# speed: playback speed (animated only, default 1.0) -# auto_pause: 1|0 (animated only — mpvpaper -p, pauses -# playback while the wallpaper is hidden -# behind a fullscreen window, default 1) -set -euo pipefail - -image="${1:?usage: wallpaper-set [fit_mode] [muted] [speed] [auto_pause]}" -fit_mode="${2:-cover}" -muted="${3:-1}" -speed="${4:-1.0}" -auto_pause="${5:-1}" - -case "${image,,}" in - *.mp4|*.webm|*.mkv|*.mov|*.gif) - # -9: plain SIGTERM doesn't reliably kill mpvpaper (verified live — - # it survives pkill -f without -9), which would leave the old - # instance still holding the layer-shell surface underneath a new - # one. The short sleep gives it a moment to actually release that - # surface before a replacement tries to claim it. - pkill -9 -f mpvpaper > /dev/null 2>&1 || true - sleep 0.3 - - mpv_opts="loop-playlist=inf speed=$speed" - [ "$muted" = "1" ] && mpv_opts="no-audio $mpv_opts" - - mpvpaper_flags=() - [ "$auto_pause" = "1" ] && mpvpaper_flags+=("-p") - - # setsid + disown so mpvpaper (which runs forever, looping the - # video) survives after this script exits. - setsid mpvpaper "${mpvpaper_flags[@]}" -o "$mpv_opts" '*' "$image" \ - > /dev/null 2>&1 < /dev/null & - disown - ;; - *) - # -9: plain SIGTERM doesn't reliably kill mpvpaper (verified live — - # it survives pkill -f without -9), which would leave the old - # instance still holding the layer-shell surface underneath a new - # one. The short sleep gives it a moment to actually release that - # surface before a replacement tries to claim it. - pkill -9 -f mpvpaper > /dev/null 2>&1 || true - sleep 0.3 - # This hyprpaper version's IPC only exposes a single `wallpaper` - # request (confirmed via `hyprctl hyprpaper --help`) — it loads - # and swaps the image in one call. preload/unload/listloaded - # requests it used to need don't exist anymore and error out with - # "invalid hyprpaper request", which — combined with `set -e` — - # was aborting this script before it ever got here. - hyprctl hyprpaper wallpaper ",$image,$fit_mode" > /dev/null - ;; -esac - -# Persist so the wallpaper survives a reboot/relogin. hyprpaper/mpvpaper -# both start every session with nothing loaded — this was previously only -# ever set at runtime via IPC, so the desktop silently reverted to blank -# every fresh login. wallpaper-restore (exec-once in hyprland.conf, after -# hyprpaper starts) re-runs the exact command saved here. -state_file="$HOME/.config/hypr/wallpaper.conf" -mkdir -p "$(dirname "$state_file")" -printf 'wallpaper-set %q %q %q %q %q\n' "$image" "$fit_mode" "$muted" "$speed" "$auto_pause" > "$state_file" diff --git a/modules/scripts/wallpaper-thumbs.sh b/modules/scripts/wallpaper-thumbs.sh deleted file mode 100644 index 38897fc..0000000 --- a/modules/scripts/wallpaper-thumbs.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -# Generates (or reuses) a cached preview JPEG for each animated wallpaper -# — first frame ~1s in, to skip a common black/fade intro on most clips — -# so the Wallpapers panel can show a real thumbnail instead of a generic -# play icon. Prints "\t" per file on stdout -# once it's ready, so the caller (LeftFlyout.qml) never has to -# independently reconstruct the cache filename — it's a hash of the -# source path, chosen only to keep filenames short/safe, not something -# QML needs to know how to derive. -set -euo pipefail - -cache_dir="${1:?usage: wallpaper-thumbs }" -shift -mkdir -p "$cache_dir" - -for file in "$@"; do - [ -f "$file" ] || continue - name="$(printf '%s' "$file" | sha1sum | cut -d' ' -f1)" - thumb="$cache_dir/$name.jpg" - if [ ! -f "$thumb" ] || [ "$file" -nt "$thumb" ]; then - ffmpeg -y -ss 00:00:01 -i "$file" -frames:v 1 -vf "scale=320:-1" "$thumb" \ - > /dev/null 2>&1 || true - fi - [ -f "$thumb" ] && printf '%s\t%s\n' "$file" "$thumb" -done diff --git a/modules/system.nix b/modules/system.nix index 2ac35c5..97ff804 100644 --- a/modules/system.nix +++ b/modules/system.nix @@ -58,6 +58,36 @@ alsa.enable = true; alsa.support32Bit = true; pulse.enable = true; + + # Standalone "Virtual Microphone" device, fully independent of the real + # hardware mic — always present regardless of what's running, so apps + # like Discord keep defaulting to the real mic untouched, and only pick + # this up if explicitly selected. Feed anything into "Virtual + # Microphone Sink" (e.g. via qpwgraph/helvum, routing EasyEffects' or + # Soundux's output to it) and it comes out the other side as "Virtual + # Microphone", selectable like any other input device. + extraConfig.pipewire."99-virtual-microphone" = { + "context.modules" = [ + { + name = "libpipewire-module-loopback"; + args = { + "node.description" = "Virtual Microphone"; + "capture.props" = { + "node.name" = "virtual_mic_sink"; + "node.description" = "Virtual Microphone Sink"; + "media.class" = "Audio/Sink"; + "audio.position" = "FL,FR"; + }; + "playback.props" = { + "node.name" = "virtual_mic_source"; + "node.description" = "Virtual Microphone"; + "media.class" = "Audio/Source"; + "audio.position" = "FL,FR"; + }; + }; + } + ]; + }; }; hardware.bluetooth = { @@ -168,6 +198,15 @@ "net.ipv6.conf.default.accept_redirects" = false; "net.ipv4.conf.all.send_redirects" = false; "net.ipv4.conf.default.send_redirects" = false; + # Restrict ptrace to a process's own children — blocks a common local + # credential-scraping/process-injection vector between unrelated + # processes. Only bites ad-hoc `gdb -p `-style attaches to a + # process that isn't your debugger's own child. + "kernel.yama.ptrace_scope" = 1; + # Drop the kernel-uptime-derived TCP timestamp from outgoing packets + # (a passive fingerprinting/uptime-leak vector). Negligible throughput + # impact on a normal home connection. + "net.ipv4.tcp_timestamps" = 0; }; # Crash dumps can contain decrypted memory contents, passwords, keys — @@ -201,4 +240,16 @@ networking.networkmanager.wifi.macAddress = "random"; networking.networkmanager.wifi.scanRandMacAddress = true; networking.networkmanager.ethernet.macAddress = "stable"; + + # Disable NetworkManager's periodic external connectivity probe (normally + # pings a canary URL to detect captive portals) — one less routine + # external phone-home, no functional loss on a directly-connected desktop. + networking.networkmanager.settings.connectivity = { + uri = ""; + interval = 0; + }; + + # Budgie's module turns this on by default (for Budgie Control Center's + # Location Services panel) — nothing in this config needs location data. + services.geoclue2.enable = false; } diff --git a/modules/theme.nix b/modules/theme.nix deleted file mode 100644 index 2219b37..0000000 --- a/modules/theme.nix +++ /dev/null @@ -1,50 +0,0 @@ -{ config, pkgs, lib, ... }: - -{ - # Dark GTK theme (built into GTK3/4's Adwaita since 3.20) for Thunar and - # other GTK apps, as a starting point. The actual palette (accent color, - # dark/light) is no longer static here — it's generated at runtime by - # gtk-theme-set (modules/scripts/gtk-theme-set.sh), called from the - # Quickshell Theme picker (dotfiles/quickshell/Theme.qml) every time the - # theme changes, so switching accent/mode there actually reaches GTK - # apps instead of requiring a rebuild. - environment.systemPackages = with pkgs; [ - gnome-themes-extra - adwaita-icon-theme - gruvbox-plus-icons - dconf - # Qt's side of the same live-theming pipeline as GTK above — see - # qt-theme-set (modules/scripts/qt-theme-set.sh). Without this - # installed, QT_QPA_PLATFORMTHEME=qt6ct (hyprland.conf) pointed at a - # platform theme plugin that didn't exist, so every Qt app (Gwenview, - # Ark, Kate, ...) silently fell back to its own default style/palette - # instead of anything resembling the rest of the desktop. - qt6Packages.qt6ct - kdePackages.qqc2-desktop-style # QQC2 style needed for QT_QUICK_CONTROLS_STYLE= - # org.kde.desktop (hyprland.conf) to actually - # theme Kirigami-based apps' chrome - ]; - - # No environment.sessionVariables.GTK_THEME here on purpose: GTK_THEME - # takes priority over settings.ini's gtk-theme-name for GTK3 apps, and - # as a session env var it's fixed at login — it can't be changed live. - # A leftover "Adwaita:dark" override here was silently overriding - # whatever gtk-theme-set wrote to settings.ini, permanently forcing - # dark mode and blocking the Theme picker from ever reaching Thunar. - - systemd.tmpfiles.rules = [ - "d /home/nexoniarz/.config/gtk-3.0 0755 nexoniarz users -" - "d /home/nexoniarz/.config/gtk-4.0 0755 nexoniarz users -" - "d /home/nexoniarz/.config/qt6ct 0755 nexoniarz users -" - "d /home/nexoniarz/.config/qt6ct/colors 0755 nexoniarz users -" - # `f`, not `L+`: created empty only if missing, never overwritten on - # rebuild — gtk-theme-set/qt-theme-set own the actual content from - # here on. - "f /home/nexoniarz/.config/gtk-3.0/gtk.css 0644 nexoniarz users - -" - "f /home/nexoniarz/.config/gtk-4.0/gtk.css 0644 nexoniarz users - -" - "f /home/nexoniarz/.config/gtk-3.0/settings.ini 0644 nexoniarz users - -" - "f /home/nexoniarz/.config/gtk-4.0/settings.ini 0644 nexoniarz users - -" - "f /home/nexoniarz/.gtkrc-2.0 0644 nexoniarz users - -" - "f /home/nexoniarz/.config/qt6ct/qt6ct.conf 0644 nexoniarz users - -" - ]; -}