commit 5dc9fb020ebd543a090c970dd9727d58fc0bcbb5 Author: Nexoniarz Date: Sun Aug 30 01:26:52 2026 +0200 nixos config diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b07f545 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +hardware-configuration.nix +.claude +result +result-* diff --git a/README.md b/README.md new file mode 100644 index 0000000..aee7ada --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# nexoniarz-config-nixos +# nexoniarz-config-nixos +# nexoniarz-config-nixos diff --git a/configuration.nix b/configuration.nix new file mode 100644 index 0000000..9f3a031 --- /dev/null +++ b/configuration.nix @@ -0,0 +1,19 @@ +# Main NixOS configuration file importing modular configurations. + +{ config, pkgs, ... }: + +{ + imports = [ + ./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/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 + ]; + + # Do not change this value. Read documentation before updating. + system.stateVersion = "26.05"; +} diff --git a/dotfiles/kitty/kitty.conf b/dotfiles/kitty/kitty.conf new file mode 100644 index 0000000..3bd540c --- /dev/null +++ b/dotfiles/kitty/kitty.conf @@ -0,0 +1,60 @@ +# 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 new file mode 100644 index 0000000..6373415 --- /dev/null +++ b/dotfiles/mako/config @@ -0,0 +1,30 @@ +# 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 new file mode 100644 index 0000000..3016ec5 --- /dev/null +++ b/dotfiles/quickshell/BottomBar.qml @@ -0,0 +1,352 @@ +import QtQuick +import Quickshell +import Quickshell.Hyprland +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 + } + + // --- 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: rightToggle.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 new file mode 100644 index 0000000..0c1c1f2 --- /dev/null +++ b/dotfiles/quickshell/CloseButton.qml @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..c435444 --- /dev/null +++ b/dotfiles/quickshell/DeleteTileButton.qml @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..509ccaa --- /dev/null +++ b/dotfiles/quickshell/DigitalClock.qml @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..b2034a8 --- /dev/null +++ b/dotfiles/quickshell/IconButton.qml @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..ac683bb --- /dev/null +++ b/dotfiles/quickshell/LeftBar.qml @@ -0,0 +1,162 @@ +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 new file mode 100644 index 0000000..37b3f15 --- /dev/null +++ b/dotfiles/quickshell/LeftFlyout.qml @@ -0,0 +1,1799 @@ +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 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. + Component { + id: settingsPanel + Column { + width: loader.width + spacing: 16 + + Text { + visible: Hyprland.monitors.values.length === 0 + text: "No monitor data" + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 12 + } + + 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 || [] + // 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 + + 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 + } + } + } + } + + // -- 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: { + applyProc.command = ["display-set", monDelegate.mon.name, + monDelegate.selectedMode, String(monDelegate.mon.x), String(monDelegate.mon.y), + monDelegate.selectedScale.toFixed(2), String(monDelegate.selectedTransform)]; + 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; + } + + 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); + } + } + } + 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 + } + + // -- 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 + } + + Column { + visible: wpCol.staticImages.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.staticImages + delegate: Rectangle { + id: staticTile + property string path: modelData + width: 92 + height: 92 + 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.animatedImages.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.animatedImages + delegate: Rectangle { + id: animTile + property string path: modelData + property string fileName: path.split("/").pop() + width: 92 + height: 92 + color: Theme.bgAlt + border.width: wpCol.current === path ? 2 : 1 + border.color: wpCol.current === path ? Theme.accent : Theme.borderDim + + HoverHandler { id: animHover } + + // No live thumbnail for video files — a play + // glyph + filename instead of the expense/ + // complexity of extracting a preview frame. + Text { + anchors.centerIn: parent + text: "▶" + color: Theme.fgDim + font.pixelSize: 22 + anchors.verticalCenterOffset: -10 + } + 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: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 9 + elide: Text.ElideMiddle + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: wpCol.apply(path) + } + + DeleteTileButton { + visible: animHover.hovered + onClicked: wpCol.deleteWallpaper(animTile.path) + } + } + } + } + } + } + } + + // --- Booru (Danbooru only for now — Gelbooru's API needs an account + // API key which hasn't been set up) ------------------------------------ + // Defaults to safe content (rating:general appended to the tag query); + // the Lewds toggle removes that restriction. Clicking a result opens a + // bigger preview rather than downloading immediately — Download is an + // explicit separate action, 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 + property string lewds: "0" + property string source: "danbooru" + property string stateFile: Quickshell.stateDir + "/booru-settings.json" + + function save() { + var json = JSON.stringify({ lewds: booruSettings.lewds, source: booruSettings.source }); + 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); + if (d.lewds) booruSettings.lewds = d.lewds; + if (d.source) booruSettings.source = d.source; + } catch (e) { /* no state file yet — defaults stand */ } + } + } + } + Component.onCompleted: booruLoadProc.running = true + } + readonly property bool lewds: booruSettings.lewds === "1" + + // -- 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. + readonly property var sources: ({ + danbooru: { label: "Danbooru", safeTag: "rating:general", hasCount: true, hasAutocomplete: true }, + e621: { label: "e621", safeTag: "rating:s", hasCount: false, hasAutocomplete: false }, + yandere: { label: "Yande.re", safeTag: "rating:safe", hasCount: false, hasAutocomplete: false }, + konachan: { label: "Konachan", safeTag: "rating:safe", hasCount: false, hasAutocomplete: false } + }) + 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 + // $HOME/Wallpapers, same folder the Wallpapers panel reads from + // — a downloaded post shows up there automatically. + 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). + readonly property int columns: 3 + readonly property int tileSlot: 100 // 92px tile + 8px spacing + property int visibleRows: { + var screenH = (root.screen && root.screen.height) ? root.screen.height : 1080; + // Reserve space for the taskbar, the flyout header, the + // search/lewds/source rows above the grid, and margins. + var reserved = 340; + 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) + : []; + if (!booruCol.lewds) tagParts.push(booruCol.sources[booruSettings.source].safeTag); + 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"; + 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; + 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; + searchProc.command = ["curl", "-s", "-A", booruCol.userAgent, url]; + booruCol.searching = true; + booruCol.results = []; + searchProc.running = true; + } + + Process { id: mkdirProc; command: ["mkdir", "-p", booruCol.cacheDir] } + 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 + stdout: StdioCollector { + onStreamFinished: { + 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; + } + } + + Process { id: setWpProc; command: [] } + + // -- 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) + } + } + } + } + + Row { + spacing: 8 + Rectangle { + width: 80 + height: 26 + color: booruCol.lewds ? Theme.danger : (lewdMa.containsMouse ? Theme.bgAlt : "transparent") + border.width: 1 + border.color: Theme.danger + Text { + anchors.centerIn: parent + text: booruCol.lewds ? "Lewds: ON" : "Lewds" + color: booruCol.lewds ? Theme.bg : Theme.danger + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 11 + font.bold: true + } + MouseArea { + id: lewdMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { booruSettings.lewds = booruCol.lewds ? "0" : "1"; 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: 92 + height: 92 + 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.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 -- + Row { + visible: !booruCol.searching && (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(); + } + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Page" + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 11 + } + Rectangle { + anchors.verticalCenter: parent.verticalCenter + 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(); + } + } + } + Text { + 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.wallpapersDir + "/" + 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: "Set as Wallpaper" + color: Theme.fg + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 12 + } + MouseArea { + id: setMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + setWpProc.command = ["wallpaper-set", booruCol.downloadedPath]; + 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/PolkitDialog.qml b/dotfiles/quickshell/PolkitDialog.qml new file mode 100644 index 0000000..cf6d4dc --- /dev/null +++ b/dotfiles/quickshell/PolkitDialog.qml @@ -0,0 +1,176 @@ +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 new file mode 100644 index 0000000..58008cc --- /dev/null +++ b/dotfiles/quickshell/RightBar.qml @@ -0,0 +1,218 @@ +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 + source: 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 { + 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 new file mode 100644 index 0000000..a274570 --- /dev/null +++ b/dotfiles/quickshell/RightFlyout.qml @@ -0,0 +1,1485 @@ +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", + 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 "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 + } + + Repeater { + model: Networking.devices + delegate: Column { + width: loader.width + spacing: 4 + property var device: modelData + + // 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 (device.scannerEnabled !== undefined) device.scannerEnabled = true; + Component.onDestruction: if (device.scannerEnabled !== undefined) device.scannerEnabled = false; + + Text { + text: device.name + color: Theme.fgDim + font.family: "JetBrainsMono Nerd Font" + font.pixelSize: 10 + } + + // 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 + + // 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 + } + } + + 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 + + // --- 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; + } + } + } + } + } + } + } + } + } + + // --- 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 new file mode 100644 index 0000000..4dc424b --- /dev/null +++ b/dotfiles/quickshell/SidebarRow.qml @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..ddc3211 --- /dev/null +++ b/dotfiles/quickshell/SquareSlider.qml @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..5de1909 --- /dev/null +++ b/dotfiles/quickshell/Theme.qml @@ -0,0 +1,142 @@ +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; + + 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: 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 new file mode 100644 index 0000000..84da678 --- /dev/null +++ b/dotfiles/quickshell/ToggleSwitch.qml @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..c83c16a --- /dev/null +++ b/dotfiles/quickshell/shell.qml @@ -0,0 +1,27 @@ +//@ 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 + +ShellRoot { + PolkitDialog {} + + 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 } + } + } +} diff --git a/dotfiles/rofi/config.rasi b/dotfiles/rofi/config.rasi new file mode 100644 index 0000000..33599b6 --- /dev/null +++ b/dotfiles/rofi/config.rasi @@ -0,0 +1,115 @@ +/* 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 new file mode 100644 index 0000000..90fa09d --- /dev/null +++ b/hyprland/hyprland.conf @@ -0,0 +1,185 @@ +# 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 +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 + +# --- 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 +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 + +# 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 new file mode 100644 index 0000000..51f7050 --- /dev/null +++ b/hyprland/hyprpaper.conf @@ -0,0 +1,7 @@ +# 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 new file mode 100644 index 0000000..3d57558 --- /dev/null +++ b/modules/apps.nix @@ -0,0 +1,76 @@ +{ 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 + 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 + + # Apps + blender + gimp + inkscape + vesktop + tor-browser + ffmpeg-full + vlc + filezilla + chromium + + # Games + prismlauncher + pkgs.osu-lazer-bin + + # Dev: compilers, runtimes, languages + glibc + musl + cmake + gnumake # Provides the 'make' command + gcc + python3 + openjdk25 + claude-code # AI Developer Assistant + ]; + + # Global command-line utilities available everywhere + environment.systemPackages = with pkgs; [ + git + vim + fastfetch + gparted + firejail + gnupg + lmstudio + ]; +} diff --git a/modules/desktop.nix b/modules/desktop.nix new file mode 100644 index 0000000..107911d --- /dev/null +++ b/modules/desktop.nix @@ -0,0 +1,95 @@ +{ 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 = { + command = "${pkgs.tuigreet}/bin/tuigreet --time --remember --remember-session --cmd 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" ]; + }; + + security.polkit.enable = true; + + # Keyboard layout for console/XWayland/greetd; Hyprland's own input block + # in hyprland/hyprland.conf mirrors this. + services.xserver.xkb = { + layout = "pl"; + variant = ""; + }; + + # Thunar: thumbnails, trash/volume mount integration, settings storage + 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"; + + # --- 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 - -" + ]; +} diff --git a/modules/drivers.nix b/modules/drivers.nix new file mode 100644 index 0000000..bced531 --- /dev/null +++ b/modules/drivers.nix @@ -0,0 +1,48 @@ +{ config, lib, pkgs, ... }: + +{ + # Enable graphics driver support and 32-bit acceleration (for Steam/gaming) + hardware.graphics = { + enable = true; + enable32Bit = true; + }; + + #OpenTabletDriver + hardware.opentabletdriver.enable = true; + hardware.uinput.enable = true; + boot.kernelModules = [ "uinput" "v4l2loopback" ]; + + # 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 + # broken. Disabling ERTM is the standard, widely-documented fix. + options bluetooth disable_ertm=1 + ''; + + # I2C bus, for DDC/CI monitor control (brightness) via ddcutil — this + # machine has no laptop backlight, only external DisplayPort monitors. + hardware.i2c.enable = true; + + # Load NVIDIA driver for X11 and Wayland + services.xserver.videoDrivers = [ "nvidia" ]; + + hardware.nvidia = { + # Required settings + modesetting.enable = true; + + # Enable NVIDIA Open GPU Kernel Modules (Turing architecture or newer) + open = true; + + # Enable the NVIDIA settings menu + nvidiaSettings = true; + + # Use the stable driver package matching current kernel + package = config.boot.kernelPackages.nvidiaPackages.stable; + }; +} diff --git a/modules/scripts.nix b/modules/scripts.nix new file mode 100644 index 0000000..1a02d8c --- /dev/null +++ b/modules/scripts.nix @@ -0,0 +1,45 @@ +{ config, pkgs, ... }: + +let + # Wrap external shell scripts into system executables + 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); + 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); + 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); +in +{ + # Install CLI dependencies required by the scripts + environment.systemPackages = with pkgs; [ + gocryptfs + exiftool + jq + vaultToggle + stripMetadata + brightnessCtl + showDesktopToggle + wallpaperSet + wallpaperDelete + displaySet + hyprThemeSet + gtkThemeSet + kittyThemeSet + rofiThemeSet + listCursorThemes + cursorPreviewAll + cursorSet + screenshot + hotspotStart + ]; +} \ No newline at end of file diff --git a/modules/scripts/brightness-ctl.sh b/modules/scripts/brightness-ctl.sh new file mode 100755 index 0000000..85a4f27 --- /dev/null +++ b/modules/scripts/brightness-ctl.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Brightness control abstraction: prefers the kernel backlight class (laptop +# panels, via brightnessctl); falls back to DDC/CI over I2C (external +# monitors, via ddcutil) when no backlight device exists. Keeps the rest of +# the config (Quickshell's brightness popup) hardware-agnostic. +set -euo pipefail + +has_backlight() { + [ -n "$(ls -A /sys/class/backlight 2>/dev/null)" ] +} + +case "${1:-get}" in + get) + if has_backlight; then + brightnessctl -m | cut -d, -f4 | tr -d '%' + else + ddcutil getvcp 10 --brief 2>/dev/null | awk '{print $4}' + fi + ;; + set) + pct="${2:?usage: brightness-ctl set <0-100>}" + if has_backlight; then + brightnessctl set "${pct}%" > /dev/null + else + ddcutil setvcp 10 "$pct" > /dev/null + fi + ;; + *) + echo "usage: brightness-ctl [get|set <0-100>]" >&2 + exit 1 + ;; +esac diff --git a/modules/scripts/cursor-preview-all.sh b/modules/scripts/cursor-preview-all.sh new file mode 100644 index 0000000..d4da2c0 --- /dev/null +++ b/modules/scripts/cursor-preview-all.sh @@ -0,0 +1,54 @@ +#!/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 new file mode 100755 index 0000000..a99d5f3 --- /dev/null +++ b/modules/scripts/cursor-set.sh @@ -0,0 +1,16 @@ +#!/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 + tmp=$(mktemp) + 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 new file mode 100644 index 0000000..d656beb --- /dev/null +++ b/modules/scripts/gtk-theme-set.sh @@ -0,0 +1,119 @@ +#!/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 diff --git a/modules/scripts/hotspot-start.sh b/modules/scripts/hotspot-start.sh new file mode 100755 index 0000000..5d78ba5 --- /dev/null +++ b/modules/scripts/hotspot-start.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Starts a Wi-Fi hotspot with explicit security/band control — the simple +# `nmcli device wifi hotspot` one-liner doesn't expose those. +# hotspot-start +set -euo pipefail + +ssid="${1:?usage: hotspot-start }" +password="${2:?password required}" +security="${3:-wpa2}" +band="${4:-auto}" + +iface=$(nmcli -t -f DEVICE,TYPE device | awk -F: '$2=="wifi"{print $1; exit}') +if [ -z "$iface" ]; then + echo "No Wi-Fi device found" >&2 + exit 1 +fi + +# Idempotent: drop any previous Hotspot profile from an earlier run first. +nmcli connection delete Hotspot >/dev/null 2>&1 || true + +key_mgmt="wpa-psk" +[ "$security" = "wpa3" ] && key_mgmt="sae" + +nmcli connection add type wifi ifname "$iface" con-name Hotspot autoconnect no ssid "$ssid" > /dev/null +nmcli connection modify Hotspot 802-11-wireless.mode ap +if [ "$band" != "auto" ]; then + nmcli connection modify Hotspot 802-11-wireless.band "$band" +fi +nmcli connection modify Hotspot wifi-sec.key-mgmt "$key_mgmt" +nmcli connection modify Hotspot wifi-sec.psk "$password" +nmcli connection modify Hotspot ipv4.method shared + +nmcli connection up Hotspot diff --git a/modules/scripts/hypr-theme-set.sh b/modules/scripts/hypr-theme-set.sh new file mode 100644 index 0000000..e227353 --- /dev/null +++ b/modules/scripts/hypr-theme-set.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Applies Hyprland's active/inactive window border colors immediately AND +# persists them into ~/.config/hypr/display.conf's sibling, theme.conf +# (sourced by hyprland.conf after the static general{} block, so it +# overrides those hardcoded defaults) — without that file, a config +# reload/restart would silently revert to whatever's hardcoded in +# hyprland.conf, same class of bug as the display-settings one. +set -euo pipefail + +accent="${1:?usage: hypr-theme-set }" +border="${2:?border required}" + +hyprctl keyword general:col.active_border "rgba(${accent}ff)" > /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/rofi-theme-set.sh b/modules/scripts/rofi-theme-set.sh new file mode 100644 index 0000000..1b107ac --- /dev/null +++ b/modules/scripts/rofi-theme-set.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Regenerates ~/.config/rofi/theme.rasi (imported by config.rasi) to match +# the current Quickshell theme. Rofi has no config-file watcher — unlike +# kitty, it's launched on demand rather than staying resident, so the next +# time it's opened it just reads whatever is on disk. No reload step needed. +set -euo pipefail + +accent="${1:?usage: rofi-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}" + +icon_theme="Gruvbox-Plus-Light" +if [ "$dark" = "1" ]; then + icon_theme="Gruvbox-Plus-Dark" +fi + +conf="$HOME/.config/rofi/theme.rasi" +mkdir -p "$(dirname "$conf")" + +cat > "$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 new file mode 100755 index 0000000..03254e5 --- /dev/null +++ b/modules/scripts/show-desktop-toggle.sh @@ -0,0 +1,26 @@ +#!/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/strip-metadata.sh b/modules/scripts/strip-metadata.sh new file mode 100755 index 0000000..2756091 --- /dev/null +++ b/modules/scripts/strip-metadata.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +if [ $# -eq 0 ]; then + echo "Usage: strip-metadata " + exit 1 +fi + +TARGET="$1" + +echo "[*] Stripping metadata from: $TARGET" + +# Remove all embedded metadata while keeping original filenames +if command -v exiftool &> /dev/null; then + exiftool -all= -overwrite_original -r "$TARGET" + echo "[+] Exif metadata removed." +else + echo "[!] Warning: exiftool is not installed." +fi + +# Reset timestamps (atime and mtime) to Epoch (1970-01-01 00:00:00) +echo "[*] Resetting timestamps..." +find "$TARGET" -exec touch -t 197001010000.00 {} + + +echo "[+] Operation completed successfully." \ No newline at end of file diff --git a/modules/scripts/vault-toggle.sh b/modules/scripts/vault-toggle.sh new file mode 100755 index 0000000..fda6ad2 --- /dev/null +++ b/modules/scripts/vault-toggle.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +CIPHER_DIR="/run/media/nexoniarz/Lukas/.vault_cipher" +MOUNT_DIR="$HOME/Vault" + +mkdir -p "$CIPHER_DIR" "$MOUNT_DIR" + +if mountpoint -q "$MOUNT_DIR"; then + echo "[*] Synchronizing buffers and unmounting Vault..." + sync + + if fusermount -u "$MOUNT_DIR"; then + echo "[+] Vault unmounted successfully." + else + echo "[!] Failed to unmount Vault. Check if any application is using $MOUNT_DIR" + exit 1 + fi +else + echo "[*] Mounting Vault..." + if [ ! -f "$CIPHER_DIR/gocryptfs.conf" ]; then + echo "[!] Container not initialized. Initializing new gocryptfs vault..." + gocryptfs -init "$CIPHER_DIR" || exit 1 + fi + if gocryptfs -allow_other "$CIPHER_DIR" "$MOUNT_DIR"; then + echo "[+] Vault mounted successfully at $MOUNT_DIR" + else + echo "[!] Mount failed." + exit 1 + fi +fi diff --git a/modules/scripts/wallpaper-delete.sh b/modules/scripts/wallpaper-delete.sh new file mode 100644 index 0000000..7011128 --- /dev/null +++ b/modules/scripts/wallpaper-delete.sh @@ -0,0 +1,30 @@ +#!/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" < [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 diff --git a/modules/system.nix b/modules/system.nix new file mode 100644 index 0000000..2ac35c5 --- /dev/null +++ b/modules/system.nix @@ -0,0 +1,204 @@ +{ config, lib, pkgs, ... }: + +{ + # Bootloader & Kernel + boot.loader.systemd-boot.enable = true; + boot.loader.efi.canTouchEfiVariables = true; + boot.loader.systemd-boot.configurationLimit = 5; + boot.kernelPackages = pkgs.linuxPackages; + boot.supportedFilesystems = [ "fuse" ]; + + # Networking + networking.hostName = "nixos"; + networking.networkmanager.enable = true; + + # Firewall Configuration + networking.firewall = { + enable = true; + + # Allow local ping diagnostic queries + allowPing = true; + + allowedTCPPortRanges = [ { from = 1714; to = 1764; } ]; + allowedUDPPortRanges = [ { from = 1714; to = 1764; } ]; + allowedTCPPorts = [ 80 443 ]; + # allowedUDPPorts = [ ]; + }; + + # Time zone and Internationalization + time.timeZone = "Europe/Warsaw"; + i18n.defaultLocale = "pl_PL.UTF-8"; + i18n.extraLocaleSettings = { + LC_ADDRESS = "pl_PL.UTF-8"; + LC_IDENTIFICATION = "pl_PL.UTF-8"; + LC_MEASUREMENT = "pl_PL.UTF-8"; + LC_MONETARY = "pl_PL.UTF-8"; + LC_NAME = "pl_PL.UTF-8"; + LC_NUMERIC = "pl_PL.UTF-8"; + LC_PAPER = "pl_PL.UTF-8"; + LC_TELEPHONE = "pl_PL.UTF-8"; + LC_TIME = "pl_PL.UTF-8"; + }; + + nix.settings.auto-optimise-store = true; + nix.gc = { + automatic = true; + dates = "weekly"; + options = "--delete-older-than 7d"; + }; + + # Console keymap + console.keyMap = "pl2"; + + # Sound and Audio (Pipewire) + security.rtkit.enable = true; + services.pulseaudio.enable = false; + services.pipewire = { + enable = true; + alsa.enable = true; + alsa.support32Bit = true; + pulse.enable = true; + }; + + hardware.bluetooth = { + enable = true; + powerOnBoot = true; + settings = { + General = { + # Shows battery charge of connected devices on supported + # Bluetooth adapters. Defaults to 'false'. + Experimental = true; + # When enabled other devices can connect faster to us, however + # the tradeoff is increased power consumption. Defaults to + # 'false'. + FastConnectable = true; + }; + Policy = { + # Enable all controllers when they are found. This includes + # adapters present on start as well as adapters that are plugged + # in later on. Defaults to 'true'. + AutoEnable = true; + }; + }; + }; + + services.blueman.enable = true; + + #Firejail + programs.firejail = { + enable = true; + }; + + #FUSE shit + programs.fuse.userAllowOther = true; + + # Enable GnuPG Agent with GUI pinentry support + programs.gnupg.agent = { + enable = true; + enableSSHSupport = true; + pinentryPackage = pkgs.pinentry-rofi; # Reuses the Rofi theme, no extra toolkit + }; + + security.sudo.extraConfig = '' + Defaults pwfeedback + ''; + + # Printing services + services.printing.enable = true; + + nixpkgs.config.allowUnfree = true; + + fonts.packages = with pkgs; [ + corefonts + dejavu_fonts + noto-fonts + noto-fonts-cjk-sans + noto-fonts-color-emoji + nerd-fonts.fira-code + nerd-fonts.jetbrains-mono + nerd-fonts.hack + inter + liberation_ttf + roboto + ]; + + # --- Loader shim for running non-Nix-packaged binaries (e.g. the official + # Tor Browser bundle) that expect a standard FHS dynamic linker/libraries. --- + programs.nix-ld.enable = true; + programs.nix-ld.libraries = with pkgs; [ + glib gtk3 gdk-pixbuf pango cairo atk + dbus dbus-glib fontconfig freetype + libx11 libxext libxrender libxtst + libxi libxcomposite libxdamage libxfixes + libxrandr libxcursor libxcb libxkbcommon + alsa-lib mesa zlib stdenv.cc.cc + ]; + environment.systemPackages = [ pkgs.file ]; + + # --- Hardening: swap, kernel sysctls, DNS, MAC randomization --- + + # Swap: replace disk swap with compressed in-RAM swap. Nothing sensitive + # (decrypted vault contents, browser memory, GPG session data) ever gets + # written to disk in plaintext via swap. Also faster than disk swap under + # memory pressure. + zramSwap.enable = true; + swapDevices = lib.mkForce [ ]; + # systemd's gpt-auto-generator independently re-discovers and activates any + # GPT partition tagged as Linux swap, regardless of the swapDevices list + # above — this disables that auto-discovery so the old disk swap partition + # actually stays off. Doesn't affect /, /home, /nix, /boot: those are + # already explicitly declared in hardware-configuration.nix, not relying on + # gpt-auto. Takes effect on next reboot. + boot.kernelParams = [ "systemd.gpt_auto=0" ]; + + # Kernel/system hardening sysctls + boot.kernel.sysctl = { + # Hide kernel pointers from unprivileged users (blocks a common + # local info-leak used in exploit chains). + "kernel.kptr_restrict" = 2; + # Restrict dmesg access to root. + "kernel.dmesg_restrict" = 1; + # Reverse-path filtering: drop packets with spoofed source addresses. + "net.ipv4.conf.all.rp_filter" = 1; + "net.ipv4.conf.default.rp_filter" = 1; + # Don't accept/send ICMP redirects (blocks a classic local MITM trick). + "net.ipv4.conf.all.accept_redirects" = false; + "net.ipv4.conf.default.accept_redirects" = false; + "net.ipv6.conf.all.accept_redirects" = false; + "net.ipv6.conf.default.accept_redirects" = false; + "net.ipv4.conf.all.send_redirects" = false; + "net.ipv4.conf.default.send_redirects" = false; + }; + + # Crash dumps can contain decrypted memory contents, passwords, keys — + # never write them to disk. + systemd.coredump.enable = false; + + # Blocks kexec-based kernel image tampering. + security.protectKernelImage = true; + + # DNS-over-TLS system-wide (Tor Browser already routes its own DNS + # through Tor, so this only affects everything else). Cloudflare + Quad9 + # both support DoT; "allow-downgrade" avoids hard failures on domains + # with broken DNSSEC deployment. + networking.nameservers = [ "1.1.1.1#cloudflare-dns.com" "9.9.9.9#dns.quad9.net" ]; + services.resolved = { + enable = true; + settings.Resolve = { + DNSSEC = "allow-downgrade"; + DNSOverTLS = "true"; + }; + }; + networking.networkmanager.dns = "systemd-resolved"; + + # MAC address randomization. + # WiFi: fully random per connection (you're on ethernet now, but this + # protects you if you ever use WiFi). + # Ethernet: "stable" — a consistent pseudonymous MAC per connection + # profile rather than your real hardware MAC, so it won't break any + # static DHCP reservation/MAC filtering on your OpenWRT router, while + # still not exposing your real hardware identity. + networking.networkmanager.wifi.macAddress = "random"; + networking.networkmanager.wifi.scanRandMacAddress = true; + networking.networkmanager.ethernet.macAddress = "stable"; +} diff --git a/modules/theme.nix b/modules/theme.nix new file mode 100644 index 0000000..2daebb3 --- /dev/null +++ b/modules/theme.nix @@ -0,0 +1,35 @@ +{ 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 + ]; + + # 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 -" + # `f`, not `L+`: created empty only if missing, never overwritten on + # rebuild — gtk-theme-set owns 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 - -" + ]; +} diff --git a/modules/users.nix b/modules/users.nix new file mode 100644 index 0000000..fe92cf7 --- /dev/null +++ b/modules/users.nix @@ -0,0 +1,27 @@ +{ config, pkgs, ... }: + +{ + # Primary user account configuration (Access and settings only) + users.users."nexoniarz" = { + isNormalUser = true; + description = "Nexoniarz"; + extraGroups = [ "networkmanager" "wheel" "fuse" "i2c" "video" ]; + shell = pkgs.zsh; + }; + + # Default shell (ZSH) + programs.zsh.enable = true; + + # Web browser configuration + programs.firefox.enable = true; + + # Enable Steam with optimized system configurations + programs.steam = { + enable = true; + remotePlay.openFirewall = true; + dedicatedServer.openFirewall = true; + }; + + # Enable Flatpak support + services.flatpak.enable = true; +}