Nix-Vibe public snapshot (squashed history)

This commit is contained in:
2026-09-19 13:56:12 +01:00
commit aee8fb1e9b
119 changed files with 18895 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
{ pkgs, lib, ... }:
{
imports = [
./gnome.nix
./kitty.nix
./firefox.nix
./tools/just.nix
];
home.activation.linkOnlyOfficeFonts = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
FONT_DIR="/run/current-system/sw/share/X11/fonts"
USER_FONTS="$HOME/.local/share/fonts"
NATIVE_TARGET="$HOME/.local/share/onlyoffice/desktopeditors/data/fonts"
FLATPAK_TARGET="$HOME/.var/app/org.onlyoffice.desktopeditors/data/onlyoffice/desktopeditors/data/fonts"
if [ -d "$FONT_DIR" ]; then
for TARGET_DIR in "$USER_FONTS" "$NATIVE_TARGET" "$FLATPAK_TARGET"; do
$DRY_RUN_CMD mkdir -p "$TARGET_DIR"
$DRY_RUN_CMD rm -f "$TARGET_DIR/AllFonts.js"* "$TARGET_DIR/fonts_thumbnail"* "$TARGET_DIR/font_selection.bin" "$TARGET_DIR/fonts.log"
$DRY_RUN_CMD cp -Lf "$FONT_DIR"/*.ttf "$FONT_DIR"/*.otf "$FONT_DIR"/*.ttc "$TARGET_DIR/" 2>/dev/null || true
done
fi
'';
programs.vscode = {
enable = true;
package = pkgs.vscode;
profiles.default = {
extensions =
with pkgs.vscode-extensions;
[
bbenoist.nix
ms-python.python
dart-code.flutter
]
++ [
# OpenCode Go: Copilot Provider - pinned to specific version for reproducibility.
# This extension is not in nixpkgs, only available from VSCode Marketplace.
(pkgs.vscode-utils.extensionFromVscodeMarketplace {
name = "opencode-go-for-copilot";
publisher = "DenizhanDaklr";
version = "0.1.18";
sha256 = "0igdkgv6dra3zpd80lhwymdh216dqn8m8kqwsxh258wyk9fzgfk0";
})
];
};
};
fonts.fontconfig.enable = true;
}
+39
View File
@@ -0,0 +1,39 @@
{
"$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json",
"modules": [
"title",
"separator",
"os",
"host",
"kernel",
"uptime",
"packages",
"shell",
"display",
"de",
"wm",
"wmtheme",
"theme",
"icons",
"font",
"cursor",
"terminal",
"terminalfont",
"cpu",
"gpu",
"memory",
"swap",
"disk",
"localip",
"battery",
"poweradapter",
"locale",
"break",
{
"type": "command",
"key": "Age Public Key",
"text": "cat /tmp/age-public-key.txt 2>/dev/null || echo 'Not generated yet'"
},
"colors"
]
}
+19
View File
@@ -0,0 +1,19 @@
{ pkgs, ... }:
{
programs.firefox = {
enable = true;
configPath = ".mozilla/firefox";
profiles.petere = {
isDefault = true;
settings = {
"ui.systemUsesDarkTheme" = 1;
"browser.in-content.dark-mode" = true;
"extensions.activeThemeID" = "firefox-compact-dark@mozilla.org";
"browser.newtabpage.enabled" = false;
"browser.startup.homepage" = "about:blank";
"browser.aboutConfig.showWarning" = false;
};
};
};
}
+20
View File
@@ -0,0 +1,20 @@
{ pkgs, ... }:
# Shared GNOME extensions: AppIndicator, User Themes, Caffeine
{
dconf.settings = {
"org/gnome/shell" = {
enabled-extensions = [
"user-theme@gnome-shell-extensions.gcampax.github.com"
"appindicatorsupport@rgcjonas.gmail.com"
"caffeine@patapon.info"
];
};
};
home.packages = with pkgs; [
gnomeExtensions.appindicator
gnomeExtensions.user-themes
gnomeExtensions.caffeine
];
}
+169
View File
@@ -0,0 +1,169 @@
{ config
, pkgs
, lib
, ...
}:
{
dconf.settings = {
# Desktop interface settings
"org/gnome/desktop/interface" = {
color-scheme = "prefer-dark";
enable-hot-corners = false;
clock-show-weekday = true;
show-battery-percentage = true;
icon-theme = "Papirus-Dark";
cursor-theme = "Bibata-Modern-Classic";
};
"org/gnome/desktop/input-sources" = {
sources = [
(lib.gvariant.mkTuple [
"xkb"
"gb"
])
];
xkb-options = [ "terminate:ctrl_alt_bksp" ];
};
# Window manager preferences
"org/gnome/desktop/wm/preferences" = {
button-layout = "appmenu:minimize,maximize,close";
num-workspaces = 4;
};
# Keybindings
"org/gnome/desktop/wm/keybindings" = {
close = [ "<Super>q" ];
switch-to-workspace-1 = [ "<Super>1" ];
switch-to-workspace-2 = [ "<Super>2" ];
switch-to-workspace-3 = [ "<Super>3" ];
switch-to-workspace-4 = [ "<Super>4" ];
move-to-workspace-1 = [ "<Super><Shift>1" ];
move-to-workspace-2 = [ "<Super><Shift>2" ];
move-to-workspace-3 = [ "<Super><Shift>3" ];
move-to-workspace-4 = [ "<Super><Shift>4" ];
};
# Shell settings
"org/gnome/shell" = {
favorite-apps = [
"firefox.desktop"
"kitty.desktop"
"org.gnome.Nautilus.desktop"
"thunderbird.desktop"
"chromium-browser.desktop"
];
enabled-extensions = [
"user-theme@gnome-shell-extensions.gcampax.github.com"
"appindicatorsupport@rgcjonas.gmail.com"
"caffeine@patapon.info"
"screen-rotate@shyzus.github.io"
"gsconnect@andyholmes.github.io"
];
};
# Mutter (window manager) settings
"org/gnome/mutter" = {
dynamic-workspaces = false;
edge-tiling = true;
workspaces-only-on-primary = true;
};
# File manager (Nautilus) settings
"org/gnome/nautilus/preferences" = {
default-folder-viewer = "list-view";
search-filter-time-type = "last_modified";
show-hidden-files = true;
};
# Power settings
"org/gnome/settings-daemon/plugins/power" = {
sleep-inactive-ac-type = "nothing";
sleep-inactive-battery-timeout = 1800;
};
# Privacy settings
"org/gnome/desktop/privacy" = {
remember-recent-files = true;
remove-old-temp-files = true;
remove-old-trash-files = true;
};
# Session settings
"org/gnome/desktop/session" = {
idle-delay = lib.gvariant.mkUint32 900; # 15 minutes
};
# Screensaver settings
"org/gnome/desktop/screensaver" = {
lock-enabled = true;
lock-delay = lib.gvariant.mkUint32 0;
};
# Caffeine extension settings
"org/gnome/shell/extensions/caffeine" = {
enable-fullscreen = true;
restore-state = true;
show-indicator = "always";
show-notifications = false;
};
};
# GNOME-specific packages
home.packages = with pkgs; [
gnome-tweaks
gnomeExtensions.appindicator
gnomeExtensions.user-themes
gnomeExtensions.caffeine
gnomeExtensions.gsconnect
dconf-editor
catppuccin-gtk
papirus-icon-theme
bibata-cursors
];
# GTK theme configuration
gtk = {
enable = true;
gtk4.theme = config.gtk.theme;
theme = {
name = "catppuccin-frappe-blue-standard";
package = pkgs.catppuccin-gtk;
};
iconTheme = {
name = "Papirus-Dark";
package = pkgs.papirus-icon-theme;
};
cursorTheme = {
name = "Bibata-Modern-Classic";
package = pkgs.bibata-cursors;
};
};
# Home Manager cursor configuration
home.pointerCursor = {
enable = true;
name = "Bibata-Modern-Classic";
package = pkgs.bibata-cursors;
gtk.enable = true;
x11.enable = true;
};
home.file."Pictures/wallpapers/tokyo-night.png".source = ../../assets/wallpapers/tokyo-night.png;
# Set initial default wallpaper on first install if user hasn't configured a custom wallpaper yet.
# If the user changes their wallpaper via GNOME Settings, dconf read detects it and leaves it untouched across reboots/updates.
home.activation.setInitialWallpaper = lib.hm.dag.entryAfter [ "dconf" ] ''
WALLPAPER="${config.home.homeDirectory}/Pictures/wallpapers/tokyo-night.png"
VAL="$(${pkgs.dconf}/bin/dconf read /org/gnome/desktop/background/picture-uri 2>/dev/null || true)"
if [ -z "$VAL" ]; then
$DRY_RUN_CMD ${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-uri "'file://$WALLPAPER'"
$DRY_RUN_CMD ${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-uri-dark "'file://$WALLPAPER'"
$DRY_RUN_CMD ${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-options "'zoom'"
$DRY_RUN_CMD ${pkgs.dconf}/bin/dconf write /org/gnome/desktop/screensaver/picture-uri "'file://$WALLPAPER'"
$DRY_RUN_CMD ${pkgs.dconf}/bin/dconf write /org/gnome/desktop/screensaver/picture-options "'zoom'"
fi
'';
}
@@ -0,0 +1,459 @@
#!/usr/bin/env python3
# hyprland-tablet-daemon.py — Hyprland tablet-mode rotation + on-screen keyboard.
#
# Wired to the ThinkPad X1 Yoga Gen 6 (Intel HID switches, /dev/input/event17):
# * SW_TABLET_MODE tells us the LCD is folded into tablet mode.
# * "Accelerometer orientation changed: <orient>" lines are parsed from
# `monitor-sensor` (a thin client of iio-sensor-proxy, which runs as a
# system service on this host). iio-sensor-proxy merges the accel axes +
# mount matrix into a single compass-style orientation string.
#
# While in tablet mode the eDP-1 monitor transform (and matching per-device
# transforms for touch inputs) follow the accelerometer. The on-screen
# keyboard (wvkbd) follows text focus instead of Hyprland's input-method v2
# protocol (which Hyprland advertises but never fires):
# * We watch `.socket2.sock` "activewindow>>class,title" events. If the
# focused window class is in the text-capable allowlist the keyboard is
# shown (SIGUSR2), otherwise hidden (SIGUSR1). This gives reliable
# auto-hide when there is nothing to type into.
# * Manual control (super+crtl+k / the bar chip) flips wvkbd directly via
# SIGRTMIN. The daemon re-applies focus state on the next focus change,
# so a manual dismissal lasts until the user switches windows.
# Leaving tablet mode snaps transform back to 0 and kills wvkbd.
#
# State is mirrored to a small JSON file ($XDG_RUNTIME_DIR/hyprland-tablet)
# that the QuickShell bar chip reads via `hyprland-tablet status`:
# {"tablet": bool, "osk": visible-or-not}
#
# Control commands arrive over a Unix datagram socket
# ($XDG_RUNTIME_DIR/hyprland-tablet.ctl): "toggle" | "show" | "hide".
import json
import os
import re
import signal
import socket
import struct
import subprocess
import sys
import threading
import time
# Orientation string (from iio-sensor-proxy) -> Hyprland monitor transform.
# Hyprland transform: 0 normal, 1 90° CCW, 2 180°, 3 270° CCW.
# If the screen rotates the wrong way on hardware, swap the 1 and 3 below.
ORIENTATION_TRANSFORM = {
"normal": 0,
"left-up": 1,
"bottom-up": 2,
"right-up": 3,
}
SW_TABLET_MODE = 0x01 # input event code for SW_TABLET_MODE
# Paths resolved at build time by the Nix module (wrapped with pkgs.python3).
# The placeholder strings below are in at-sign-delimited form because
# pkgs.replaceVarsWith substitutes exactly that syntax.
EVDEV_DEVICE = "@EVDEV_DEVICE@"
WVKBD_PATH = "@WVKBD_PATH@"
WVKBD_ARGS = @WVKBD_ARGS@ # replaced with a JSON array (valid Python list)
MONITOR_SENSOR = "@MONITOR_SENSOR@"
HYPRCTL = "@HYPRCTL@"
TEXT_APPS = @TEXT_APPS_JSON@ # replaced with a JSON array (valid Python list)
# State file lives in $XDG_RUNTIME_DIR (set by the user systemd service); the
# QuickShell bar chip + CLI read the same path.
STATE_FILE = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "hyprland-tablet")
CONTROL_SOCKET = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "hyprland-tablet.ctl")
DEBUG = os.environ.get("HYPRLAND_TABLET_DEBUG") == "1"
def log(msg: str) -> None:
if DEBUG:
print(f"[hyprland-tablet] {msg}", file=sys.stderr, flush=True)
_state_lock = threading.Lock()
def write_state(tablet: bool, osk: bool) -> None:
try:
with open(STATE_FILE, "w") as f:
json.dump({"tablet": tablet, "osk": osk}, f)
except OSError:
pass
def hyprctl_transform(monitor: str, transform: int) -> None:
"""Apply a transform to the monitor and every touch/tablet device."""
# hyprland.nix uses the Lua config parser (0.55+), so the legacy
# `monitor NAME,transform,N` / input keyword lines don't apply at runtime
# ("keyword can't work with non-legacy parsers"). The equivalent Lua API:
# hl.monitor({ output = "NAME", mode = ..., scale = ..., transform = N })
# hl.config({ input = { touchdevice/tablet = { transform = N } } })
# scale MUST be passed explicitly: omitting it makes Hyprland re-derive
# HiDPI zoom (1.5 here), blowing up the bar on rotation.
tx = [HYPRCTL, "eval", f'hl.monitor({{ output = "{monitor}", mode = "preferred", position = "auto", scale = 1, transform = {transform} }})']
subprocess.run(tx, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
# touchdevice transform follows the display so touch coordinates track the
# rotated framebuffer. input:touchdevice:transform is a global input option
# (not per-device device:touchdevice:transform which doesn't exist).
subprocess.run(
[HYPRCTL, "eval", f'hl.config({{ input = {{ touchdevice = {{ transform = {transform} }} }} }})'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
# same for the tablet (pen) — this Yoga presents the Wacom digitizer as a
# tablet device, and without this the pen coordinates stay in screen space.
subprocess.run(
[HYPRCTL, "eval", f'hl.config({{ input = {{ tablet = {{ transform = {transform} }} }} }})'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
def transform_for(orientation: str) -> int:
return ORIENTATION_TRANSFORM.get(orientation, 0)
def _hypr_socket(name: str) -> str:
"""Locate a Hyprland IPC socket (".socket.sock" / ".socket2.sock")."""
runtime = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
inst = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE")
if inst:
candidate = os.path.join(runtime, "hypr", inst, name)
if os.path.exists(candidate):
return candidate
base = os.path.join(runtime, "hypr")
try:
for entry in os.listdir(base):
candidate = os.path.join(base, entry, name)
if os.path.exists(candidate):
return candidate
except OSError:
pass
return ""
class Daemon:
def __init__(self, monitor: str) -> None:
self.monitor = monitor
self.tablet = False
self.orientation = "normal"
self.current_transform = 0
self.osk_proc = None
self.osk_visible = False
self.dev = None
self.sensor = None
self.last_focus_class = ""
# -------------------------------------------------------------
# evdev switch watcher (thread)
# -------------------------------------------------------------
def open_evdev(self) -> bool:
try:
import evdev
import evdev.ecodes as ecodes
self.dev = evdev.InputDevice(EVDEV_DEVICE)
except Exception as e:
log(f"cannot open {EVDEV_DEVICE}: {e}")
return False
# Seed the current switch state via EVIOCGSW so the daemon is correct
# if it starts mid-tablet.
EVIOCGSW = (2 << 30) | (ord("E") << 8) | 0x1B | (64 << 16)
buf = bytearray(64)
try:
import fcntl
fcntl.ioctl(self.dev.fd, EVIOCGSW, buf)
vals = struct.unpack("16i", buf)
self.tablet = bool(vals[0] >> SW_TABLET_MODE & 1)
except OSError:
self.tablet = False
log(f"opened {self.dev.name}, starting tablet={self.tablet}")
return True
def evdev_thread(self) -> None:
import evdev.ecodes as ecodes
for event in self.dev.read_loop():
if event.type != ecodes.EV_SW or event.code != SW_TABLET_MODE:
continue
new = event.value == 1
if new != self.tablet:
self.tablet = new
log(f"SW_TABLET_MODE -> {new}")
self.on_tablet_changed(new)
# -------------------------------------------------------------
# orientation watcher (thread)
# -------------------------------------------------------------
def _start_sensor(self) -> bool:
try:
self.sensor = subprocess.Popen(
[MONITOR_SENSOR],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
bufsize=1,
)
except FileNotFoundError:
log("monitor-sensor not found")
return False
return self.sensor.stdout is not None
def sensor_thread(self) -> None:
# monitor-sensor (a thin client of iio-sensor-proxy) only emits
# "Accelerometer orientation changed:" on *changes*; the initial
# orientation arrives as "Has accelerometer (orientation: X, ...)".
# Match both, and restart the client if it ever exits so orientation
# tracking survives.
first = True
while True:
if not self._start_sensor():
return
if self.sensor is None or self.sensor.stdout is None:
return
if first:
log("monitor-sensor started")
first = False
for line in self.sensor.stdout:
m = re.search(r"Accelerometer orientation changed:\s*(\S+)", line.strip())
if m:
self.on_orientation(m.group(1))
continue
m = re.search(r"Has accelerometer \(orientation:\s*(\S+)", line.strip())
if m:
# value is "normal," / "left-up," — strip the separator
self.on_orientation(m.group(1).rstrip(","))
# monitor-sensor exited: reopen it (iio-sensor-proxy may have
# dropped our client if a second one connected).
log("monitor-sensor exited, restarting")
time.sleep(1)
# -------------------------------------------------------------
# Hyprland focus watcher (thread) — drives auto show/hide
# -------------------------------------------------------------
def focus_thread(self) -> None:
path = _hypr_socket(".socket2.sock")
if not path:
log("hyprland .socket2.sock not found; focus auto-hide disabled")
return
log(f"watching focus events on {path}")
while True:
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect(path)
f = s.makefile("r")
for line in f:
line = line.strip()
if not line.startswith("activewindow>>"):
continue
payload = line.split(">>", 1)[1].split(",", 1)
self.on_focus_changed(payload[0].strip())
except OSError as e:
log(f"focus socket error: {e}")
time.sleep(2)
def _is_text_app(self, window_class: str) -> bool:
if not window_class:
return False
lowered = window_class.lower()
for spec in TEXT_APPS:
if lowered == spec or lowered.startswith(spec):
return True
return False
def on_focus_changed(self, window_class: str) -> None:
self.last_focus_class = window_class
if not self.tablet:
return # in laptop mode the OSK duty is entirely manual
want = self._is_text_app(window_class)
log(f"focus={window_class!r} text_app={want}")
self.set_osk_visible(want)
# -------------------------------------------------------------
# control socket (thread) — manual show/hide/toggle
# -------------------------------------------------------------
def control_thread(self) -> None:
try:
os.unlink(CONTROL_SOCKET)
except OSError:
pass
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
s.bind(CONTROL_SOCKET)
s.settimeout(1.0)
except OSError as e:
log(f"cannot bind control socket: {e}")
return
log(f"control socket ready at {CONTROL_SOCKET}")
while True:
try:
data, _ = s.recvfrom(128)
except socket.timeout:
continue
except OSError:
break
cmd = data.decode("ascii", "replace").strip()
log(f"control command: {cmd}")
if cmd == "toggle":
self.toggle_osk()
elif cmd == "show":
self.set_osk_visible(True)
elif cmd == "hide":
self.set_osk_visible(False)
# -------------------------------------------------------------
# actions
# -------------------------------------------------------------
def ensure_osk(self) -> None:
with _state_lock:
if self.osk_proc is not None and self.osk_proc.poll() is None:
return
try:
# Start hidden: focus state decides whether to SIGUSR2 it.
self.osk_proc = subprocess.Popen([WVKBD_PATH, *WVKBD_ARGS, "--hidden"])
self.osk_visible = False
log(f"wvkbd started (hidden, args={WVKBD_ARGS!r})")
except FileNotFoundError:
log("wvkbd not found")
write_state(self.tablet, self.osk_visible)
def kill_osk(self) -> None:
with _state_lock:
if self.osk_proc is not None and self.osk_proc.poll() is None:
self.osk_proc.terminate()
try:
self.osk_proc.wait(timeout=3)
except subprocess.TimeoutExpired:
self.osk_proc.kill()
self.osk_proc.wait()
log("wvkbd stopped")
self.osk_proc = None
self.osk_visible = False
write_state(self.tablet, False)
def _signal_osk(self, sig: int) -> None:
if self.osk_proc is not None and self.osk_proc.poll() is None:
try:
os.kill(self.osk_proc.pid, sig)
except OSError:
pass
def set_osk_visible(self, visible: bool) -> None:
if visible == self.osk_visible:
return
if visible:
if self.osk_proc is None or self.osk_proc.poll() is not None:
self.ensure_osk()
if self.osk_proc is None:
return
self._signal_osk(signal.SIGUSR2)
self.osk_visible = True
log("wvkbd show")
else:
self._signal_osk(signal.SIGUSR1)
self.osk_visible = False
log("wvkbd hide")
write_state(self.tablet, self.osk_visible)
def toggle_osk(self) -> None:
with _state_lock:
if self.osk_proc is None or self.osk_proc.poll() is not None:
self.osk_proc = subprocess.Popen([WVKBD_PATH, *WVKBD_ARGS, "--hidden"])
self.osk_visible = False
log("wvkbd started (hidden, manual toggle)")
write_state(self.tablet, self.osk_visible)
return
# wvkbd SIGRTMIN toggles visibility; we shadow its state here.
self.osk_visible = not self.osk_visible
self._signal_osk(signal.SIGRTMIN)
log(f"wvkbd toggled -> visible={self.osk_visible}")
write_state(self.tablet, self.osk_visible)
def apply_transform(self, t: int) -> None:
if t != self.current_transform:
hyprctl_transform(self.monitor, t)
self.current_transform = t
log(f"transform -> {t}")
# -------------------------------------------------------------
# event handlers
# -------------------------------------------------------------
def on_tablet_changed(self, new: bool) -> None:
if new:
self.apply_transform(transform_for(self.orientation))
self.ensure_osk()
else:
self.apply_transform(0)
self.kill_osk()
write_state(self.tablet, self.osk_visible)
def on_orientation(self, orient: str) -> None:
self.orientation = orient
if self.tablet:
self.apply_transform(transform_for(orient))
# laptop mode: never rotate (gated by the switch above)
# -------------------------------------------------------------
def run(self) -> int:
have_evdev = self.open_evdev()
# If we started mid-tablet, bring Hyprland to the current state right
# away instead of waiting for the next SW_TABLET_MODE edge. The OSK
# duty for the currently-focused window is applied below.
if self.tablet:
self.apply_transform(transform_for(self.orientation))
self.ensure_osk()
cls = self.cur_focus_class()
if cls:
self.on_focus_changed(cls)
write_state(self.tablet, self.osk_visible)
threads = []
if have_evdev:
t = threading.Thread(target=self.evdev_thread, daemon=True)
t.start()
threads.append(t)
t = threading.Thread(target=self.sensor_thread, daemon=True)
t.start()
threads.append(t)
t = threading.Thread(target=self.focus_thread, daemon=True)
t.start()
threads.append(t)
t = threading.Thread(target=self.control_thread, daemon=True)
t.start()
threads.append(t)
try:
while True:
time.sleep(3600)
except KeyboardInterrupt:
return 0
def cur_focus_class(self) -> str:
try:
out = subprocess.run(
[HYPRCTL, "activewindow", "-j"],
capture_output=True,
text=True,
timeout=3,
)
info = json.loads(out.stdout)
return info.get("class") or ""
except Exception:
return ""
def main() -> int:
monitor = os.environ.get("HYPRLAND_TABLET_MONITOR", "eDP-1")
d = Daemon(monitor)
return d.run()
if __name__ == "__main__":
raise SystemExit(main())
+219
View File
@@ -0,0 +1,219 @@
{
config,
pkgs,
...
}:
let
palette = import ./palette.nix;
evdevPython = pkgs.python3.withPackages (ps: [ ps.evdev ]);
# Text-capable window classes. wvkbd's own --auto relies on Hyprland's
# input-method-v2 protocol, which Hyprland advertises but never actually
# fires — so auto show/hide is driven here by watching activewindow events.
# Add classes (lowercase prefixes are allowed) for anything you type into.
textApps = [
"kitty"
"alacritty"
"wezterm"
"konsole"
"com.mitchellh.ghostty"
"ghostty"
"xterm"
"firefox"
"librewolf"
"chromium"
"chromium-browser"
"google-chrome"
"microsoft-edge"
"brave-browser"
"thunderbird"
"com.github.xournalpp.xournalpp"
"code"
"code-url-handler"
"zen"
];
# Shell-escaped arg list for the CLI fallback (same content as wvkbdFlags
# but space-joined for shell expansion). The daemon receives the JSON form.
# The font spec is double-quoted so it survives shell word-splitting.
wvkbdFlagsShell = pkgs.lib.concatStringsSep " " [
"-l"
"simple"
"-H"
"300"
"-L"
"200"
"--fn"
"\"DejaVu Sans 24\""
"--alpha"
"235"
"--bg"
"${palette.wvkbdBg}"
"--fg"
"${palette.surface}"
"--fg-sp"
"${palette.wvkbdFgSp}"
"--press"
"${palette.neon}"
"--press-sp"
"${palette.violet}"
"--text"
"${palette.text}"
"--text-sp"
"${palette.muted}"
"--text-press"
"${palette.ink}"
"--text-press-sp"
"${palette.ink}"
"--swipe"
"${palette.cyan}"
"--swipe-sp"
"${palette.violet}"
"--text-swipe"
"${palette.ink}"
"--text-swipe-sp"
"${palette.ink}"
];
# wvkbd argv as a JSON array. The daemon splices this into place as a bare
# Python expression — a JSON array of strings IS valid Python (list of
# double-quoted strings), so no quote-escaping headaches at build time.
wvkbdArgs = [
"-l"
"simple"
"-H"
"300"
"-L"
"200"
"--fn"
"DejaVu Sans 24"
"--alpha"
"235"
"--bg"
"${palette.wvkbdBg}"
"--fg"
"${palette.surface}"
"--fg-sp"
"${palette.wvkbdFgSp}"
"--press"
"${palette.neon}"
"--press-sp"
"${palette.violet}"
"--text"
"${palette.text}"
"--text-sp"
"${palette.muted}"
"--text-press"
"${palette.ink}"
"--text-press-sp"
"${palette.ink}"
"--swipe"
"${palette.cyan}"
"--swipe-sp"
"${palette.violet}"
"--text-swipe"
"${palette.ink}"
"--text-swipe-sp"
"${palette.ink}"
];
wvkbdArgsJson = builtins.toJSON wvkbdArgs;
# Text-app allowlist as a JSON array, spliced the same way (valid Python
# list literal after substitution).
textAppsJson = builtins.toJSON textApps;
# Place build-time paths into the daemon (replaceVars honours the
# @VAR@ placeholders) and run it with the evdev-enabled python.
daemon = pkgs.replaceVarsWith {
name = "hyprland-tablet-daemon";
src = pkgs.writeScript "hyprland-tablet-daemon-src" ''
#!${evdevPython}/bin/python3
${builtins.readFile ./hyprland-tablet-daemon.py}
'';
isExecutable = true;
replacements = {
EVDEV_DEVICE = "/dev/input/event17";
WVKBD_PATH = "${pkgs.wvkbd}/bin/wvkbd-mobintl";
WVKBD_ARGS = wvkbdArgsJson;
TEXT_APPS_JSON = textAppsJson;
MONITOR_SENSOR = "${pkgs.iio-sensor-proxy}/bin/monitor-sensor";
HYPRCTL = "${pkgs.hyprland}/bin/hyprctl";
};
};
# Toggle/show/hide are forwarded to the daemon over a Unix datagram socket
# so manual control and focus-driven auto-hide share one visibility state.
cli = pkgs.writeShellScriptBin "hyprland-tablet" ''
set -eu
state_file="$XDG_RUNTIME_DIR/hyprland-tablet"
ctl_sock="$XDG_RUNTIME_DIR/hyprland-tablet.ctl"
ctl_send() {
if [ -S "$ctl_sock" ]; then
${pkgs.python3}/bin/python3 -c '
import os, socket, sys
path = os.environ.get("XDG_RUNTIME_DIR", "/tmp") + "/hyprland-tablet.ctl"
s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
s.sendto(sys.argv[1].encode(), path)
' "$1"
return 0
fi
return 1
}
osk_pid() {
pgrep -f 'wvkbd-mobintl' | head -n1 || true
}
case "''${1:-}" in
status)
if [ -f "$state_file" ]; then
cat "$state_file"
else
echo '{"tablet":false,"osk":false}'
fi
;;
# Toggle on-screen keyboard visibility. Manual control wins over the
# daemon's focus-derived state until the next activewindow change.
keyboard-toggle|osk-toggle)
ctl_send toggle || {
pid="$(osk_pid)"
if [ -z "$pid" ]; then
nohup ${pkgs.wvkbd}/bin/wvkbd-mobintl ${wvkbdFlagsShell} >/dev/null 2>&1 &
else
kill -SIGRTMIN "$pid"
fi
}
;;
osk-show)
ctl_send show || { pid="$(osk_pid)"; [ -n "$pid" ] && kill -SIGUSR2 "$pid"; }
;;
osk-hide)
ctl_send hide || { pid="$(osk_pid)"; [ -n "$pid" ] && kill -SIGUSR1 "$pid"; }
;;
*)
echo "usage: hyprland-tablet {status|keyboard-toggle|osk-show|osk-hide}" >&2
exit 2
;;
esac
'';
in
{
home.packages = [ cli ];
systemd.user.services.hyprland-tablet = {
Unit = {
Description = "Hyprland tablet-mode auto-rotation + on-screen keyboard";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
};
Service = {
Type = "simple";
ExecStart = daemon;
Restart = "on-failure";
};
Install = {
WantedBy = [ "graphical-session.target" ];
};
};
}
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
{ pkgs, ... }:
let
palette = import ./palette.nix;
in
{
programs.kitty = {
enable = true;
font = {
name = "FiraCode Nerd Font";
size = 12;
};
settings = {
enabled_layouts = "tall:bias=50;full_size=1;mirrored=false,fat,grid,stack";
scrollback_lines = 10000;
enable_audio_bell = false;
update_check_interval = 0;
shell_integration = "enabled";
# Performance
repaint_delay = 10;
input_delay = 3;
sync_to_monitor = "yes";
# Window layout
window_padding_width = 10;
confirm_os_window_close = 0;
background_opacity = "0.85";
dynamic_background_opacity = "yes";
# Tabs (colors live in colors.conf so `qs-theme` can override them)
tab_bar_style = "powerline";
};
keybindings = {
"ctrl+shift+c" = "copy_to_clipboard";
"ctrl+shift+v" = "paste_from_clipboard";
"ctrl+shift+enter" = "new_window";
"ctrl+shift+]" = "next_window";
"ctrl+shift+[" = "previous_window";
"ctrl+shift+l" = "next_layout";
};
# Colors live in an include so a wallpaper-driven palette (`qs-theme`,
# via matugen) can overwrite them without touching the rest of kitty.conf.
extraConfig = "include colors.conf";
};
xdg.configFile."kitty/colors.conf".text = ''
# Color palette (Tokyo Night inspired) declared in home-manager.
# Regenerated by `~/.local/bin/qs-theme <wallpaper>` (matugen) at runtime.
background ${palette.hash palette.bgDark}
foreground ${palette.hash palette.muted}
selection_background ${palette.hash palette.selection}
selection_foreground ${palette.hash palette.text}
url_color ${palette.hash palette.teal}
cursor ${palette.hash palette.text}
cursor_text_color ${palette.hash palette.bgDark}
active_tab_background ${palette.hash palette.blue}
active_tab_foreground ${palette.hash palette.tabFgBright}
inactive_tab_background ${palette.hash palette.tabBg}
inactive_tab_foreground ${palette.hash palette.tabFg}
# ANSI 16
color0 ${palette.hash palette.color0}
color1 ${palette.hash palette.danger}
color2 ${palette.hash palette.green}
color3 ${palette.hash palette.yellow}
color4 ${palette.hash palette.blue}
color5 ${palette.hash palette.purple}
color6 ${palette.hash palette.cyan}
color7 ${palette.hash palette.muted}
color8 ${palette.hash palette.color8}
color9 ${palette.hash palette.danger}
color10 ${palette.hash palette.green}
color11 ${palette.hash palette.yellow}
color12 ${palette.hash palette.blue}
color13 ${palette.hash palette.purple}
color14 ${palette.hash palette.cyan}
color15 ${palette.hash palette.text}
'';
}
@@ -0,0 +1,33 @@
background {{colors.surface.dark.hex}}
foreground {{colors.on_surface.dark.hex}}
selection_background {{colors.primary.dark.hex}}
selection_foreground {{colors.on_primary.dark.hex}}
url_color {{colors.tertiary.dark.hex}}
cursor {{colors.on_surface.dark.hex}}
cursor_text_color {{colors.surface.dark.hex}}
active_tab_background {{colors.primary.dark.hex}}
active_tab_foreground {{colors.on_primary.dark.hex}}
inactive_tab_background {{colors.surface_container_low.dark.hex}}
inactive_tab_foreground {{colors.on_surface_variant.dark.hex}}
# ANSI 16 — mapped to Material-You roles (NOT base16: matugen's base16 dark
# accents collapse to near-black, making colored terminal text unreadable).
# Material roles are the dark-mode light-tinted variants, all >10:1 contrast
# on the surface, so both text and UI stay legible.
color0 {{colors.surface.dark.hex}}
color1 {{colors.error.dark.hex}}
color2 {{colors.tertiary.dark.hex}}
color3 {{colors.secondary.dark.hex}}
color4 {{colors.primary.dark.hex}}
color5 {{colors.tertiary.dark.hex}}
color6 {{colors.secondary.dark.hex}}
color7 {{colors.on_surface.dark.hex}}
color8 {{colors.outline.dark.hex}}
color9 {{colors.error.dark.hex}}
color10 {{colors.tertiary.dark.hex}}
color11 {{colors.secondary.dark.hex}}
color12 {{colors.primary.dark.hex}}
color13 {{colors.tertiary.dark.hex}}
color14 {{colors.secondary.dark.hex}}
color15 {{colors.on_surface.dark.hex}}
@@ -0,0 +1,11 @@
{
"surface": "{{colors.surface.dark.hex}}",
"ink": "{{colors.background.dark.hex}}",
"text": "{{colors.on_surface.dark.hex}}",
"muted": "{{colors.on_surface_variant.dark.hex}}",
"line": "{{colors.outline.dark.hex}}",
"neon": "{{colors.primary.dark.hex}}",
"violet": "{{colors.tertiary.dark.hex}}",
"magenta": "{{colors.secondary.dark.hex}}",
"danger": "{{colors.error.dark.hex}}"
}
+55
View File
@@ -0,0 +1,55 @@
# Wallpaper-driven Material-You theming (matugen) for the QuickShell shell.
# `qs-theme <wallpaper>` regenerates:
# - ~/.cache/quickshell/theme.json → live-repaints the bar (FileView watcher)
# - ~/.config/kitty/colors.conf → kitty palette + ANSI 16
# The Nix-declared colors.conf symlink is restored on the next home-manager
# rebuild; until then the generated palette wins (see the rm in qs-theme).
{
config,
pkgs,
...
}:
{
home.packages = [ pkgs.matugen ];
# Default matugen config — renders our two templates on every run.
xdg.configFile."matugen/config.toml".text = ''
[config]
[templates.quick-shell]
input_path = "${config.home.homeDirectory}/.config/matugen/themes/theme.json.template"
output_path = "${config.home.homeDirectory}/.cache/quickshell/theme.json"
[templates.kitty]
input_path = "${config.home.homeDirectory}/.config/matugen/themes/kitty-colors.conf.template"
output_path = "${config.home.homeDirectory}/.config/kitty/colors.conf"
'';
# Template sources live in the repo so matugen can read them at runtime.
xdg.configFile."matugen/themes/theme.json.template".text =
builtins.readFile ./matugen-themes/theme.json.template;
xdg.configFile."matugen/themes/kitty-colors.conf.template".text =
builtins.readFile ./matugen-themes/kitty-colors.conf.template;
# qs-theme — regenerate the wallpaper palette, then repaint the live bar.
home.file.".local/bin/qs-theme" = {
executable = true;
text = ''
#!/bin/sh
# Regenerate the Material-You palette (QuickShell bar + kitty) from a
# wallpaper. The bar picks it up live via its theme.json watcher.
set -e
if [ "$#" -lt 1 ]; then
echo "usage: qs-theme <wallpaper>" >&2
exit 1
fi
# matugen refuses to overwrite the Nix-declared symlink; unlink it first
# (home-manager restores it on the next rebuild).
rm -f "$HOME/.config/kitty/colors.conf"
exec ${pkgs.matugen}/bin/matugen image "$1" -m dark --prefer darkness
'';
};
}
+31
View File
@@ -0,0 +1,31 @@
{ pkgs, ... }:
{
# nil (Nix language server) for x1carbon only — wired into VS Code via the
# nix-community Nix IDE extension and into opencode (see opencode.nix).
home.packages = [ pkgs.nil ];
programs.vscode = {
enable = true;
profiles.default = {
extensions = [ pkgs.vscode-extensions.jnoortheen.nix-ide ];
userSettings = {
"nix.enableLanguageServer" = true;
"nix.serverPath" = "${pkgs.nil}/bin/nil";
# All nil LSP settings nest under a "nil" key. Formatting shells out
# to nixfmt (the flake formatter), reading stdin / writing stdout.
"nix.serverSettings" = {
nil = {
formatting = {
command = [
"nixfmt"
"-"
];
};
};
};
};
};
};
}
+80
View File
@@ -0,0 +1,80 @@
{ pkgs, ... }:
{
# mcp-nixos gives opencode + VS Code real NixOS package/option data instead of
# hallucinated package names. Only installed on x1carbon (this module is only
# imported from hosts/x1carbon), so it does not leak to other machines.
home.packages = [
pkgs.mcp-nixos
# Gitea MCP server: browse/manage Gitea repos, issues, PRs from opencode.
pkgs.gitea-mcp-server
];
xdg.configFile."opencode/opencode.json".text = builtins.toJSON {
"$schema" = "https://opencode.ai/config.json";
provider = {
# lmstudio = {
# npm = "@ai-sdk/openai-compatible";
# name = "LM Studio (local)";
# options = {
# baseURL = "http://127.0.0.1:1234/v1";
# };
# models = {
# "google/gemma-4-e4b" = {
# name = "Gemma 4 E4B (LM Studio)";
# limit = {
# context = 65536;
# output = 32768;
# };
# };
# };
# };
opencode-go = { };
};
model = "opencode-go/kimi-k2.6";
lsp = {
# nil (Nix language server, installed by nix-lsp.nix) as a custom LSP
# server so the agent gets Nix diagnostics. Formatter: nixfmt (stdin).
nil = {
command = [ "${pkgs.nil}/bin/nil" ];
extensions = [ ".nix" ];
initialization = {
nil = {
formatting = {
command = [
"nixfmt"
"-"
];
};
};
};
};
};
mcp = {
# NixOS packages/options MCP server (stdlib CLI; binary provided by
# pkgs.mcp-nixos). Use "the nix tool" to look up real package names,
# options and versions while editing flake/host configs.
nixos = {
type = "local";
command = [ "mcp-nixos" ];
enabled = true;
};
# Gitea MCP server for the self-hosted instance (gitea.edley.me).
# Token comes from the SOPS secret via $GITEA_ACCESS_TOKEN (exported in
# zsh.nix) so it is never committed to the repo.
gitea = {
type = "local";
command = [
"gitea-mcp"
"-t"
"stdio"
"-H"
"https://gitea.edley.me"
"-T"
"{env:GITEA_ACCESS_TOKEN}"
];
enabled = true;
};
};
};
}
+89
View File
@@ -0,0 +1,89 @@
# Shared color palette for the "sci-fi dark glass + vivid neon" desktop.
# Single source of truth: every consumer (QuickShell QML theme, kitty,
# wvkbd, wofi, hyprlock, Hyprland borders/shadows, matugen fallback) reads
# its colors from here instead of hardcoding hex literals.
#
# Colors are stored as raw hex WITHOUT the leading '#'. Use the helpers below
# to format for each consumer:
# palette.hash "#rrggbb" (QML, kitty, wofi borders)
# palette.cssRgba "rgba(r, g, b, a)" (wofi)
# palette.lockRgba "rgba(r, g, b, a)" (hyprlock)
# palette.hyprRgba "rgba(rrggbbaa)" (Hyprland, compact hex+alpha)
# palette.rgb [ r g b ] decimals
rec {
# ---- Surfaces (translucent over the Hyprland layer blur) ----
# glass/glassPanel are 8-digit AARRGGBB for QML only.
glass = "8c0f111c";
glassPanel = "c2141627";
surface = "24283b";
panel = "141627"; # wofi / hyprlock solid panel
line = "394b70";
# ---- Text ----
text = "c0caf5";
muted = "a9b1d6";
ink = "0b0e14";
# ---- Neon accents ----
neon = "00e5ff";
magenta = "ff2e97";
violet = "7c4dff";
cyan = "7dcfff";
blue = "7aa2f7";
green = "9ece6a";
yellow = "e0af68";
purple = "bb9af7";
teal = "73daca"; # kitty url_color
danger = "f7768e";
# ---- kitty extras (Tokyo Night inspired) ----
bgDark = "1a1b26";
color0 = "15161e";
color8 = "414868";
selection = "33467c";
tabBg = "292e42";
tabFg = "545c7e";
tabFgBright = "16161e";
# ---- wvkbd ----
wvkbdBg = "1b1e2d";
wvkbdFgSp = "1f2740";
# ---- helpers ----
hexToInt =
h:
let
m = {
"0" = 0;
"1" = 1;
"2" = 2;
"3" = 3;
"4" = 4;
"5" = 5;
"6" = 6;
"7" = 7;
"8" = 8;
"9" = 9;
a = 10;
b = 11;
c = 12;
d = 13;
e = 14;
f = 15;
};
len = builtins.stringLength h;
chars = builtins.genList (i: builtins.substring i 1 h) len;
in
builtins.foldl' (acc: c: acc * 16 + m.${c}) 0 chars;
rgb = h: [
(hexToInt (builtins.substring 0 2 h))
(hexToInt (builtins.substring 2 2 h))
(hexToInt (builtins.substring 4 2 h))
];
hash = c: "#${c}";
cssRgba = c: a: "rgba(${builtins.concatStringsSep ", " (map toString (rgb c))}, ${a})";
lockRgba = c: a: "rgba(${builtins.concatStringsSep ", " (map toString (rgb c))}, ${a})";
hyprRgba = c: a: "rgba(${c}${a})";
}
+73
View File
@@ -0,0 +1,73 @@
{
config,
pkgs,
lib,
...
}:
# QuickShell autostart app manager: lets the user pick from the gear
# quick-settings panel which installed apps start at login. The candidate
# *pool* is declared here (services.quickshell-apps.apps) and seeded into
# ~/.config/quickshell/autostartseed.json; runtime on/off state and any apps
# added from the UI live in ~/.cache/quickshell/autostart.json. The Hyprland
# login autostart runs `qs-apps run` (see home-manager/modules/hyprland.nix)
# to launch whatever is enabled.
{
options.services.quickshell-apps = {
enable = lib.mkEnableOption "QuickShell autostart app manager (runtime toggle from the gear panel)";
apps = lib.mkOption {
type = lib.types.listOf (
lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Display name shown in the gear panel.";
};
cmd = lib.mkOption {
type = lib.types.str;
description = "Command to launch the app (shell-style, argv 0 must name the binary).";
};
};
}
);
default = [ ];
description = "Candidate pool the user can toggle/start from the shell.";
};
};
config = lib.mkIf config.services.quickshell-apps.enable {
# Store the manager in the Nix store with the procps tools baked in (the
# Hyprland login autostart PATH is not guaranteed to have pgrep/pkill).
xdg.configFile."quickshell/qs-apps.py".text =
builtins.replaceStrings
[
"@pgrep@"
"@pkill@"
]
[
"${pkgs.procps}/bin/pgrep"
"${pkgs.procps}/bin/pkill"
]
(builtins.readFile ./quickshell-apps.py);
# Nix-declared pool. The runtime state file is authoritative for on/off
# and for UI-added apps; the manager merges this in on every load, adding
# new entries as disabled.
xdg.configFile."quickshell/autostartseed.json".text = builtins.toJSON (
map (app: {
name = app.name;
cmd = app.cmd;
}) config.services.quickshell-apps.apps
);
home.file.".local/bin/qs-apps" = {
executable = true;
text = ''
#!/bin/sh
exec ${pkgs.python3}/bin/python3 \
${config.home.homeDirectory}/.config/quickshell/qs-apps.py "$@"
'';
};
};
}
+287
View File
@@ -0,0 +1,287 @@
#!/usr/bin/env python3
"""QuickShell autostart app manager.
Lets the user pick which installed apps start at login, toggled live from the
gear quick-settings panel (no rebuild needed for preference changes).
Model
-----
The *pool* of candidates is declared in Nix (host config) and written to
~/.config/quickshell/autostartseed.json. The runtime *state* lives in
~/.cache/quickshell/autostart.json and is authoritative for on/off and for
apps the user adds from the UI (source = "user"). On every `list`/`run` the
seed is merged in: new seed entries are added disabled and, for existing seed
entries, the Nix command is re-applied while keeping the enabled flag. A
user-added app named like a seed entry wins (the seed never re-enables it).
Commands
--------
list -> JSON {apps: [{name, cmd, enabled, source}]} (after merge)
toggle <name> -> flip enabled; start/stop the app now, then persist
add <name> <cmd> -> create a user entry (enabled, starts now); if the name
already exists, just enable it
remove <name> -> stop (best-effort) and forget the entry
run -> login autostart: launch every enabled app (no dupes)
"""
import json
import os
import shlex
import subprocess
import sys
HOME = os.path.expanduser("~")
CACHE_DIR = os.path.join(HOME, ".cache", "quickshell")
STATE_FILE = os.path.join(CACHE_DIR, "autostart.json")
SEED_FILE = os.path.join(HOME, ".config", "quickshell", "autostartseed.json")
PGREP = "/run/current-system/sw/bin/pgrep"
PKILL = "/run/current-system/sw/bin/pkill"
def ensure_cache_dir():
os.makedirs(CACHE_DIR, exist_ok=True)
def load_state():
try:
with open(STATE_FILE) as fh:
data = json.load(fh)
except (OSError, ValueError):
data = {"apps": []}
if not isinstance(data, dict) or not isinstance(data.get("apps"), list):
data = {"apps": []}
return data
def save_state(data):
ensure_cache_dir()
with open(STATE_FILE, "w") as fh:
json.dump(data, fh, indent=2)
def load_seed():
try:
with open(SEED_FILE) as fh:
seed = json.load(fh)
except (OSError, ValueError):
return []
if not isinstance(seed, list):
return []
out = []
for entry in seed:
if isinstance(entry, dict) and entry.get("name") and entry.get("cmd"):
out.append({"name": str(entry["name"]), "cmd": str(entry["cmd"])})
return out
def merge_seed(data):
"""Apply the Nix-declared pool to the runtime state (add-missing only)."""
seed = load_seed()
for s in seed:
found = next(
(a for a in data["apps"] if a.get("name", "").lower() == s["name"].lower()),
None,
)
if found is None:
data["apps"].append(
{"name": s["name"], "cmd": s["cmd"], "enabled": False, "source": "seed"}
)
elif found.get("source") == "seed":
found["cmd"] = s["cmd"]
return data
def find_app(data, name):
return next(
(a for a in data["apps"] if a.get("name", "").lower() == name.lower()), None
)
def binary_of(cmd):
try:
toks = shlex.split(cmd)
except ValueError:
return None
return os.path.basename(toks[0]) if toks else None
def pattern_of(cmd):
"""pgrep/pkill -f pattern for the app's executable.
Electron apps (element-desktop, bitwarden-desktop) run with comm=electron,
so comm (-x) matching misses them and we match the full command line
instead. The bracket trick 'e[e]lement-desktop' still matches
element-desktop but never the pgrep/pkill command itself (its own cmdline
contains the literal bracketed form) — avoiding the classic -f self-match.
"""
binary = binary_of(cmd)
if not binary:
return None
if len(binary) < 2:
return binary
return binary[:1] + "[" + binary[1:2] + "]" + binary[2:]
def _self_and_ancestors():
"""PIDs of this process and its parent chain.
The -f guard can be fooled by the *invoking* process: `qs-apps add X "cmd"`
passes the command as an argv element, so our own cmdline (and ancestors
like a `sh -c 'qs-apps add X cmd'` wrapper) contains the app's binary name
and pgrep -f would match it instead of the real app. Excluding the whole
ancestor chain makes the guard see only genuinely running apps.
"""
pids = {os.getpid()}
ppid = os.getppid()
seen = set()
while ppid > 1 and ppid not in seen:
seen.add(ppid)
pids.add(ppid)
try:
with open("/proc/%d/stat" % ppid) as fh:
parts = fh.read().split()
ppid = int(parts[3])
except (OSError, IndexError, ValueError):
break
return pids
def is_running(pattern):
if not pattern:
return False
try:
r = subprocess.run([PGREP, "-f", pattern], capture_output=True, text=True)
except OSError:
return False
if r.returncode != 0:
return False
self_pids = _self_and_ancestors()
for line in r.stdout.splitlines():
try:
pid = int(line.strip())
except ValueError:
continue
if pid not in self_pids:
return True
return False
def start_app(cmd):
"""Launch cmd detached. Returns True if it is (or already is) running."""
try:
toks = shlex.split(cmd)
except ValueError:
return False
if not toks:
return False
pattern = pattern_of(cmd)
if pattern and is_running(pattern):
return True
try:
subprocess.Popen(
toks,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
return True
except OSError:
return False
def stop_app(cmd):
"""Best-effort kill by -f pattern (comm is 'electron' for Electron apps)."""
pattern = pattern_of(cmd)
if not pattern:
return
try:
subprocess.run([PKILL, "-f", pattern], capture_output=True)
except OSError:
pass
def cmd_toggle(name):
data = merge_seed(load_state())
app = find_app(data, name)
if app is None:
return {"ok": False, "error": "no app named " + name}
app["enabled"] = not app["enabled"]
if app["enabled"]:
started = start_app(app["cmd"])
else:
stop_app(app["cmd"])
started = True
save_state(data)
return {"ok": started, "enabled": app["enabled"]}
def cmd_add(name, cmd):
data = merge_seed(load_state())
app = find_app(data, name)
if app is None:
data["apps"].append(
{"name": name, "cmd": cmd, "enabled": True, "source": "user"}
)
app = data["apps"][-1]
else:
app["enabled"] = True
if app.get("source") == "user":
app["cmd"] = cmd
started = start_app(app["cmd"])
save_state(data)
return {"ok": started}
def cmd_remove(name):
data = merge_seed(load_state())
app = find_app(data, name)
if app is None:
return {"ok": True}
stop_app(app["cmd"])
data["apps"] = [a for a in data["apps"] if a is not app]
save_state(data)
return {"ok": True}
def cmd_run():
data = merge_seed(load_state())
for app in data["apps"]:
if app.get("enabled"):
start_app(app["cmd"])
save_state(data)
return {"ok": True}
def cmd_list():
data = merge_seed(load_state())
save_state(data)
return {"apps": data["apps"]}
def main():
args = sys.argv[1:]
cmd = args[0] if args else "list"
if cmd == "list":
out = cmd_list()
print(json.dumps(out))
elif cmd == "run":
print(json.dumps(cmd_run()))
elif cmd == "toggle":
name = args[1] if len(args) > 1 else ""
print(json.dumps(cmd_toggle(name)))
elif cmd == "add":
name = args[1] if len(args) > 1 else ""
command = args[2] if len(args) > 2 else ""
print(json.dumps(cmd_add(name, command)))
elif cmd == "remove":
name = args[1] if len(args) > 1 else ""
print(json.dumps(cmd_remove(name)))
else:
print(json.dumps({"ok": False, "error": "unknown command: " + cmd}))
if __name__ == "__main__":
main()
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""QuickShell bluetooth manager backend.
Talks to bluez via `bluetoothctl` (text/shell parsing — the tool has no JSON
mode). State is read live from the adapter (`bluetoothctl show`) and per device
(`bluetoothctl info <mac>`), so it reflects reality even after pairing from
another app. The `qs-bt` wrapper (home-manager hyprland module) passes the
absolute bluetoothctl path in $BLUECTL.
Commands
--------
status -> JSON adapter + devices
{powered,name,alias,pairable,discoverable,
discovering,devices:[{mac,name,icon,paired,
trusted,connected,battery}]}
power on|off -> toggle adapter power -> JSON ok
scan -> discovery for SCAN_SECONDS, then stop; JSON
list of {mac,name} found during the scan
connect <mac> / disconnect <mac>
pair <mac> -> pair (bounded; pairing prompts end it)
trust <mac> / untrust <mac>
remove <mac> -> unpair / forget
All non-status commands return {"ok": bool, "error": "..."}.
"""
import json
import os
import re
import subprocess
import sys
BLUECTL = os.environ.get("BLUECTL", "bluetoothctl")
SCAN_SECONDS = 8
PAIR_TIMEOUT = 30
CONNECT_TIMEOUT = 12
WAIT_TIMEOUT = 10
def run(args, timeout=WAIT_TIMEOUT):
try:
p = subprocess.run(
[BLUECTL] + args,
capture_output=True,
text=True,
timeout=timeout,
)
return p.returncode, p.stdout, p.stderr
except subprocess.TimeoutExpired as exc:
tail = (exc.stdout or exc.stderr or b"")
if isinstance(tail, bytes):
tail = tail.decode(errors="replace")
return -1, tail, "command timed out"
except OSError as exc:
return -2, "", str(exc)
def field(line, key):
low = line.lstrip()
return low.split(":", 1)[1].strip() if low.startswith(key + ":") else None
def parse_adapter(out):
"""Adapter sections from `bluetoothctl show`; prefer the [default] one."""
sections = []
cur = None
for line in out.splitlines():
if line.startswith("Controller "):
bits = line.split()
cur = {"mac": bits[1], "default": "[default]" in line}
sections.append(cur)
elif cur is not None:
v = field(line, "Powered")
if v:
cur["powered"] = v == "yes"
v = field(line, "Discovering")
if v:
cur["discovering"] = v == "yes"
v = field(line, "Pairable")
if v:
cur["pairable"] = v == "yes"
v = field(line, "Discoverable")
if v:
cur["discoverable"] = v == "yes"
v = field(line, "Name")
if v:
cur["name"] = v
v = field(line, "Alias")
if v:
cur["alias"] = v
for s in sections:
if s.get("default"):
return s
return sections[0] if sections else None
def parse_device(mac):
rc, out, _ = run(["info", mac], timeout=WAIT_TIMEOUT)
dev = {
"mac": mac,
"name": "",
"icon": "",
"paired": False,
"trusted": False,
"connected": False,
"battery": None,
}
if rc != 0:
return dev
for line in out.splitlines():
v = field(line, "Name")
if v:
dev["name"] = v
v = field(line, "Icon")
if v:
dev["icon"] = v
v = field(line, "Paired")
if v:
dev["paired"] = v == "yes"
v = field(line, "Trusted")
if v:
dev["trusted"] = v == "yes"
v = field(line, "Connected")
if v:
dev["connected"] = v == "yes"
m = re.search(r"Battery Percentage:\s*0x[0-9A-Fa-f]+\s*\((\d+)\)", line)
if m:
dev["battery"] = int(m.group(1))
return dev
def list_devices():
rc, out, _ = run(["devices"], timeout=WAIT_TIMEOUT)
macs = []
if rc == 0:
for line in out.splitlines():
bits = line.split()
if len(bits) >= 2 and bits[0] == "Device":
mac = bits[1]
if mac not in macs:
macs.append(mac)
return [parse_device(m) for m in macs]
def status():
rc, out, _ = run(["show"], timeout=WAIT_TIMEOUT)
adapter = parse_adapter(out) if rc == 0 else None
devs = list_devices()
# connected first, then alphabetical — the stable order the popup renders.
devs.sort(key=lambda d: (not d["connected"], (d["name"] or d["mac"]).lower()))
return {
"powered": bool(adapter and adapter.get("powered")),
"name": adapter.get("name") if adapter else "",
"alias": adapter.get("alias") if adapter else "",
"pairable": bool(adapter and adapter.get("pairable")),
"discoverable": bool(adapter and adapter.get("discoverable")),
"discovering": bool(adapter and adapter.get("discovering")),
"devices": devs,
}
def scan():
"""Discovery for SCAN_SECONDS; bluetoothctl parses NEW/CHG device lines."""
rc, out, _ = run(
["--timeout", str(SCAN_SECONDS), "scan", "on"],
timeout=SCAN_SECONDS + 8,
)
found = {}
for line in out.splitlines():
if line.startswith("[NEW] Device"):
mac, _, name = line.split()[2:5]
found[mac] = name
elif line.startswith("[CHG] Device"):
bits = line.split()
if len(bits) >= 5 and bits[3] == "Name:":
found[bits[2]] = line.split("Name:", 1)[1].strip()
return [{"mac": m, "name": n} for m, n in sorted(found.items())]
def action(args):
rc, out, err = run(args, timeout=PAIR_TIMEOUT if args and args[0] == "pair" else CONNECT_TIMEOUT)
if rc == 0:
return {"ok": True, "error": ""}
# bluetoothctl prints failures like "Failed to connect: ..." to stdout.
detail = ""
for line in (out or "").splitlines():
if "Failed" in line or "not" in line.lower() or "error" in line.lower():
detail = line.strip()
break
return {"ok": False, "error": detail or (err or "command failed")}
def main():
args = sys.argv[1:]
cmd = args[0] if args else "status"
if cmd == "status":
print(json.dumps(status()))
elif cmd == "power":
val = args[1] if len(args) > 1 else ""
print(json.dumps(action(["power", val]) if val in ("on", "off") else {"ok": False, "error": "power on|off"}))
elif cmd == "scan":
print(json.dumps(scan()))
elif cmd in ("pair", "connect", "disconnect", "trust", "untrust", "remove"):
mac = args[1] if len(args) > 1 else ""
if not mac:
print(json.dumps({"ok": False, "error": "missing device address"}))
else:
print(json.dumps(action([cmd, mac])))
else:
print(json.dumps({"ok": False, "error": "unknown command: " + cmd}))
if __name__ == "__main__":
main()
+136
View File
@@ -0,0 +1,136 @@
{
config,
pkgs,
lib,
...
}:
let
# Python with the ICS parsing stack baked in: icalendar + recurring
# recurrence expansion for the reminder scanner.
python = pkgs.python3.withPackages (ps: [
ps.icalendar
ps.recurring-ical-events
]);
in
{
options.services.quickshell-cal = {
enable = lib.mkEnableOption "QuickShell calendar + weather sync (Nextcloud CalDAV via vdirsyncer/khal, Open-Meteo)";
eventDays = lib.mkOption {
type = lib.types.int;
default = 31;
description = "How many days ahead of today khal should expand/return events for the shell popup.";
};
};
config = lib.mkIf config.services.quickshell-cal.enable {
# The one Nextcloud fact the sync pipeline needs beyond the secret: the
# base user-principal CalDAV URL (vdirsyncer discovers every calendar
# under it). The username + password live in the SOPS secret
# "hp-laptop/nextcloud-cal-env".
home.packages = [
python
pkgs.vdirsyncer
pkgs.pipewire
pkgs.libnotify
];
# ---------- sync script ----------
# Store the python in the Nix store with tool paths baked in (user units
# have an unpredictable PATH), then expose it on the QuickShell PATH.
# Substitutions keep the source tree readable.
xdg.configFile."quickshell-cal/qs-cal-sync.py".text =
builtins.replaceStrings
[
"@vdirsyncer@"
"@libnotify@"
"@pipewire@"
]
[
"${pkgs.vdirsyncer}"
"${pkgs.libnotify}"
"${pkgs.pipewire}"
]
(builtins.readFile ./quickshell-cal.py);
home.file.".local/bin/qs-cal-sync" = {
executable = true;
text = ''
#!/bin/sh
exec ${python}/bin/python3 \
${config.home.homeDirectory}/.config/quickshell-cal/qs-cal-sync.py "$@"
'';
};
# ---------- khal config ----------
# Generated at runtime by the sync script from the vdirsyncer discovery:
# one [[calendar]] section per subdir of ~/.local/share/vdirsyncer (khal
# cannot glob, and the calendar set only changes when Nextcloud does).
# Removing this file from HM keeps ~/.config/khal/config a plain user
# file the script may rewrite on every sync.
# ---------- periodic sync ----------
# Runs as the user so caches are written to ~/.cache without privilege
# games, and the SOPS env file is readable because the host config sets
# owner petere / group users / mode 0440 on it.
systemd.user.services.qs-cal-sync = {
Unit = {
Description = "Sync Nextcloud calendar + weather for the QuickShell popup";
After = [ "network-online.target" ];
};
Service = {
Type = "oneshot";
ExecStart = "${config.home.homeDirectory}/.local/bin/qs-cal-sync sync";
};
};
systemd.user.timers.qs-cal-sync = {
Unit = {
Description = "Periodic Nextcloud calendar + weather sync";
After = [ "network-online.target" ];
};
Install = {
WantedBy = [ "timers.target" ];
};
Timer = {
OnBootSec = "1min";
OnUnitActiveSec = "20min";
Persistent = true;
};
};
# ---------- reminders ----------
# Fires VALARM due-tones once per minute: plays the cached chime and
# sends a desktop notification that lands in the QuickShell notification
# center. Reads only the local .ics stores (no network), so it is cheap
# enough to run every 60s.
systemd.user.services.qs-cal-remind = {
Unit = {
Description = "Fire QuickShell calendar reminders";
};
Service = {
Type = "oneshot";
ExecStart = "${config.home.homeDirectory}/.local/bin/qs-cal-sync remind";
};
};
systemd.user.timers.qs-cal-remind = {
Unit = {
Description = "Per-minute QuickShell calendar reminder check";
};
Install = {
WantedBy = [ "timers.target" ];
};
Timer = {
OnBootSec = "1min";
OnUnitActiveSec = "30s";
# default AccuracySec is a whole minute, which (plus polling phase)
# lets alerts land up to ~2min late; 1s keeps current-skew tiny.
AccuracySec = "1s";
Persistent = true;
};
};
};
}
File diff suppressed because it is too large Load Diff
+393
View File
@@ -0,0 +1,393 @@
#!/usr/bin/env python3
"""qs-launch — wofi-based launcher modes: emoji pick, calculator, DuckDuckGo search.
Subcommands:
emoji feed a curated emoji list into wofi --dmenu, copy the picked emoji
calc type an expression in wofi, evaluate with qalc, copy + toast result
search type a query in wofi, open DuckDuckGo in the default browser
All binary paths are injected via $QS_* env vars by the .local/bin/qs-launch
wrapper so execution works regardless of PATH (same pattern as qs-bt's
$BLUECTL). When no DISPLAY/WAYLAND_DISPLAY is present, wofi can't open, so each
mode exits silently with code 1.
"""
import os
import subprocess
import sys
import urllib.parse
# Curated emoji list: "<char> <shortcode>". Searchable by name, output splits
# on the first whitespace so only the emoji character gets copied.
EMOJI = """😀 grin
😃 smiley
😁 beam
😂 joy
🤣 rofl
😊 blush
🙂 slight smile
😉 wink
😍 heart eyes
😘 kiss
🤩 star struck
🥳 party
😎 cool
🤓 nerd
😇 halo
😏 smirk
😜 winky tongue
🥺 plead
😬 grimace
🤔 think
🤯 mind blown
😴 sleepy
😭 cry
😢 sad
😞 disappointed
😠 angry
😤 triumph
🤬 swear
🙄 roll eyes
😐 neutral
😑 expressionless
🙂🙃 upside down
😱 scream
🤒 sick
🤕 hurt
💀 skull
👻 ghost
👽 alien
🤖 robot
🎃 pumpkin
💩 poo
🙌 yes
👏 clap
🙏 pray
🤝 handshake
👍 thumbs up
👎 thumbs down
👊 fist
✊ raised fist
🤞 crossed fingers
✌️ peace
👋 hello wave
🖐️ hand
✍️ writing
💪 muscle
🫶 heart hands
🦾 bionic arm
🧠 brain
👀 eyes
👁️ eye
👄 lips
👅 taste tongue
🧛 vampire
🧟 zombie
❤️ heart
🧡 orange heart
💛 yellow heart
💚 green heart
💙 blue heart
💜 purple heart
🖤 black heart
🤍 white heart
🤎 brown heart
💔 broken heart
❤️‍🔥 heart fire
💯 hundred
💥 boom
💫 dizzy
✨ sparkles
🔥 fire
🌟 star
⭐ star white
☀️ sun
🌙 moon
🌑 new moon
🌈 rainbow
☁️ cloud
⛈️ storm
❄️ snow
🌊 ocean wave
🌍 earth
🌺 hibiscus
🌸 cherry blossom
🌹 rose
🌷 tulip
🌵 cactus
🌴 palm
🍀 clover
🍄 mushroom
🐶 dog
🐱 cat
🦊 fox
🐻 bear
🐼 panda
🐨 koala
🦁 lion
🐯 tiger
🐸 frog
🐵 monkey
🐧 penguin
🦄 unicorn
🐝 bee
🦋 butterfly
🐢 turtle
🐙 octopus
🦈 shark
🐬 dolphin
🍍 pineapple
🥭 mango
🍒 cherries
🍓 strawberry
🍑 peach
🥑 avocado
🍕 pizza
🍔 burger
🍟 fries
🌭 hotdog
🍿 popcorn
🌮 taco
🍰 cake
🍩 donut
🥐 croissant
☕ coffee
🍵 tea
🍺 beer
🍻 cheers
⚽ football
🏀 basketball
🎾 tennis
⚾ baseball
🏈 american football
🎮 game
🎲 dice
🎯 target
🎵 music
🎶 notes
🎤 mic
🎧 headphones
📻 radio
🎬 movie
📺 tv
💻 laptop
📱 phone
🖥️ computer
🖱️ mouse
💾 floppy
💿 disc
🔋 battery
🔌 plug
💡 bulb
🔦 flashlight
🗑️ trash
🔒 lock
🔓 unlocked
🔑 key
✂️ scissors
📎 paperclip
📌 pin
📍 location
🧲 magnet
🛠️ tools
⚙️ gear
🔧 wrench
✏️ pencil
🖊️ pen
📖 book
📚 books
📄 doc
📁 folder
📦 package
✉️ mail
📝 memo
📊 chart
💰 money
💳 card
🧾 receipt
🏆 trophy
🥇 gold
🥈 silver
🎁 gift
🎈 balloon
🎉 party popper
🥂 toast
🎊 confetti
🧹 clean
🧽 sponge
⏰ alarm
⏱️ stopwatch
🕰️ clock
📅 calendar
📆 date
🔔 bell
🔕 bell off
❌ cross
✅ check
⚠️ warning
🚫 no entry
plus
minus
➗ divide
✖️ multiply
🔄 refresh
⬆️ up
⬇️ down
⬅️ left
➡️ right
↩️ return
🔙 back
🔜 soon
🌐 globe
🚀 rocket
🛸 saucer
✈️ plane
🚗 car
🚕 taxi
🚌 bus
🚂 train
🚲 bike
🏍️ motorcycle
👻 spooky
☠️ skull crossbones
💣 bomb
🚁 chopper
🚢 ship
🚄 bullet train
🚦 traffic light
⛽ fuel
🅿️ parking
🚧 roadwork
🌉 bridge
🏰 castle
⛺ camp
🌋 volcano
🗼 tower
🏯 pagoda
🌆 city dusk
🌃 city night
🏙️ skyline
🖼️ frame
🎨 palette
🖌️ brush
🧶 yarn
🧵 thread
💍 ring
👟 sneaker
👠 heel
🧢 cap
🎓 graduate
👒 hat
🧣 scarf
🧤 gloves
🧥 coat
👔 tie
👕 shirt
👖 jeans
💭 thought
💬 speech
🗯️ anger
⌨️ keyboard
🖨️ printer
🧮 abacus
🕹️ joystick
🎳 bowling
🏓 ping pong
🏸 badminton
🥊 boxing
🥋 martial arts
🎽 running shirt
🚴 cyclist
🧗 climber
🏊 swimmer
🎪 circus
🎠 carousel
🗿 moai
🏛️ classical
🏟️ stadium
🏫 school
🏥 hospital
🏦 bank
🏪 store
🏭 factory
🏝️ island
🏖️ beach
⛱️ umbrella
🧭 compass"""
def run(command, **kwargs):
return subprocess.run(command, capture_output=True, text=True, **kwargs)
def wofi(entries, prompt, exec_search=False):
cmd = [os.environ.get("QS_WOFI", "wofi"), "--dmenu", "--prompt", prompt]
if exec_search:
cmd.append("--exec-search")
result = run(cmd, input=entries)
if result.returncode != 0:
return None
return result.stdout.rstrip("\n")
def cmd_mode():
if "WAYLAND_DISPLAY" not in os.environ and "DISPLAY" not in os.environ:
return 1
sub = sys.argv[1] if len(sys.argv) > 1 else ""
if sub == "emoji":
return emoji_mode()
if sub == "calc":
return calc_mode()
if sub == "search":
return search_mode()
print(__doc__, file=sys.stderr)
return 2
def emoji_mode():
picked = wofi(EMOJI, "\U0001f600 Emoji")
if not picked:
return 0
char = picked.split(" ", 1)[0]
if not char:
return 0
wl_copy = os.environ.get("QS_WLCOPY", "wl-copy")
run([wl_copy, char])
return 0
def calc_mode():
expr = wofi("", "=? \u2014 1+2 then Enter", exec_search=True)
if not expr:
return 0
qalc = os.environ.get("QS_QALC", "qalc")
result = run([qalc, "-t", expr])
if result.returncode != 0:
return 0
answer = result.stdout.strip()
wl_copy = os.environ.get("QS_WLCOPY", "wl-copy")
run([wl_copy, answer])
notify(os.environ.get("QS_NOTIFY", "notify-send"),
"\U0001f4d0 " + expr, "= " + answer + " (copied)")
return 0
def search_mode():
query = wofi("", "Search the web", exec_search=True)
if not query:
return 0
url = "https://duckduckgo.com/?q=" + urllib.parse.quote_plus(query)
xdg_open = os.environ.get("QS_XDGO", "xdg-open")
subprocess.Popen([xdg_open, url])
return 0
def notify(sender, title, body):
try:
run([sender, "-a", "qs-launch", title, body])
except OSError:
pass
if __name__ == "__main__":
raise SystemExit(cmd_mode())
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""qs-shot — screenshot capture with a QuickShell toast preview.
Subcommands:
pick show a wofi chooser (select area / full screen) then capture
area interactively select a region (grimblast)
screen capture the current output (grimblast)
open <path> open a saved screenshot in the default viewer (toast action)
copy <path> re-push a saved screenshot to the clipboard (toast action)
The capture is copied to the clipboard AND saved to a temp file. A
notification toast is posted with the image preview and Open / Copy
actions; the shell intercepts those in calNotifAction (identifier
payload carries the saved file path).
All binary paths are injected via $QS_* env vars by the .local/bin/qs-shot
wrapper so execution works regardless of PATH. When no DISPLAY/WAYLAND_DISPLAY
is present, wofi can't open, so the picker exits silently with code 1.
"""
import os
import subprocess
import sys
import time
TARGETS = {
"area": "Select area",
"screen": "Full screen",
}
def run(command, **kwargs):
return subprocess.run(command, capture_output=True, text=True, **kwargs)
def wofi(entries, prompt):
cmd = [os.environ.get("QS_WOFI", "wofi"), "--dmenu", "--prompt", prompt]
result = run(cmd, input=entries)
if result.returncode != 0:
return None
return result.stdout.rstrip("\n")
def _output_path():
return os.path.join(
os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")),
"qs-shot",
"shot-%s.png" % time.strftime("%Y%m%d-%H%M%S"),
)
def _grab(target, out):
"""grimblast save <target> <out> — file only, no clipboard."""
grimblast = os.environ.get("QS_GRIMBLAST", "grimblast")
return subprocess.run(
[grimblast, "save", target, out],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode == 0
def _copy_to_clip(out):
"""wl-copy < out — fire-and-forget, mirrors the `copy` subcommand."""
wl_copy = os.environ.get("QS_WLCOPY", "wl-copy")
subprocess.Popen(["sh", "-c",
'exec "$1" --type image/png < "$2"', "--",
wl_copy, out],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def _notify(out, target):
summary = "Screenshot" if target == "screen" else "Screenshot (area)"
body = "\u2702 + \U0001f4be " + out
notify = os.environ.get("QS_NOTIFY", "notify-send")
# fire-and-forget: notify-send with --action stays alive as the D-Bus action
# sender waiting for ActionInvoked; quickshell handles the shot-* actions by
# identifier in QML instead, so the sender can exit immediately.
subprocess.Popen([
notify,
"-a", "Screenshot",
"-t", "8000",
"--hint=string:image-path:" + out,
"--action=shot-open:%s=OPEN" % out,
"--action=shot-copy:%s=COPY" % out,
summary,
body,
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def pick_mode():
if "WAYLAND_DISPLAY" not in os.environ and "DISPLAY" not in os.environ:
return 1
out = _output_path()
os.makedirs(os.path.dirname(out), exist_ok=True)
_prune_old_shots(os.path.dirname(out), keep=40)
# Capture BEFORE the chooser opens. wofi's layer surface lingers as a
# composited zombie after it exits (Hyprland keeps it in hyprctl layers even
# with a dead pid), so any post-exit wait races teardown and can bake the
# menu into the shot. A pre-capture makes that impossible for full screen.
if not _grab("screen", out):
return 1
label = wofi("\n".join(TARGETS.values()), "\U0001f4f7 Screenshot")
if not label:
try:
os.unlink(out)
except OSError:
pass
return 0
target = None
for t, entry in TARGETS.items():
if label == entry:
target = t
break
if target is None:
try:
os.unlink(out)
except OSError:
pass
return 0
if target == "area":
if not _grab("area", out):
return 1
_copy_to_clip(out)
_notify(out, target)
return 0
def _prune_old_shots(directory, keep=40):
try:
files = sorted(
os.path.join(directory, f) for f in os.listdir(directory)
if f.startswith("shot-") and f.endswith(".png")
)
for old in files[:-keep]:
os.unlink(old)
except OSError:
pass
def capture(target):
out = _output_path()
os.makedirs(os.path.dirname(out), exist_ok=True)
_prune_old_shots(os.path.dirname(out), keep=40)
if not _grab(target, out):
return 1
_copy_to_clip(out)
_notify(out, target)
return 0
def main():
sub = sys.argv[1] if len(sys.argv) > 1 else "pick"
if sub == "area" or sub == "screen":
return capture(sub)
if sub == "pick":
return pick_mode()
if sub == "open" and len(sys.argv) > 2:
xdg_open = os.environ.get("QS_XDGO", "xdg-open")
subprocess.Popen([xdg_open, sys.argv[2]])
return 0
if sub == "copy" and len(sys.argv) > 2:
wl_copy = os.environ.get("QS_WLCOPY", "wl-copy")
subprocess.Popen(["sh", "-c",
'exec "$1" --type image/png < "$2"', "--",
wl_copy, sys.argv[2]])
return 0
print(__doc__, file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,81 @@
// SciSlider.qml — themed slider for the quick-settings panel.
// Imperative two-way use: set `.value` from the outside, read `.moved(v)`.
// (Avoids QML binding loops with live PipeWire/brightness state.)
import QtQuick
Item {
id: slider
implicitHeight: 24
property real value: 0.5 // 0..1
property color accent: "#00e5ff"
property color trackColor: "#24283b"
readonly property bool pressed: mouseArea.pressed
signal moved(real v)
function setFromMouse(mx) {
if (width <= 0)
return;
let v = mx / width;
if (v < 0)
v = 0;
if (v > 1)
v = 1;
if (v !== slider.value) {
slider.value = v;
slider.moved(v);
}
}
// track
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 6
radius: 3
color: slider.trackColor
border.color: "#394b70"
border.width: 1
}
// filled portion
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: slider.value * parent.width
height: 6
radius: 3
color: slider.accent
}
// knob
Rectangle {
anchors.verticalCenter: parent.verticalCenter
x: slider.value * (parent.width - width)
width: 14
height: 14
radius: 7
color: slider.pressed ? "#ffffff" : slider.accent
border.color: "#0b0e14"
border.width: 1
Behavior on color { ColorAnimation { duration: 120 } }
}
MouseArea {
id: mouseArea
anchors {
fill: parent
topMargin: -6
bottomMargin: -6
}
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onPressed: mouse => slider.setFromMouse(mouse.x)
onPositionChanged: mouse => {
if (pressed)
slider.setFromMouse(mouse.x);
}
}
}
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""qs-stats: one-shot system stats probe for the QuickShell stats popup.
Emits a single JSON object on stdout:
{
"ts": 1730000000,
"cpu": 12.3,
"ram": {"used": 8.2, "total": 31.9, "pct": 25.7},
"swap": {"used": 0.0, "total": 8.0, "pct": 0.0},
"disk": {"used": 156.4, "total": 934.1, "pct": 16.7},
"load": [2.01, 2.29, 2.10],
"uptime": 7861.0,
"temps": [{"name": "TCPU", "temp": 58.0}, ...],
"freq": 3.8
}
CPU% is computed from two /proc/stat samples ~250ms apart (mostly an idle
sleep: ~free on battery). Memory/swap/disk come from /proc/meminfo +
os.statvfs. Temperatures come from /sys/class/thermal (kernel hwmon zones).
The bar runs this on a slow 15s timer; the stats popup refreshes every 3s
while open.
"""
import json
import os
import sys
import time
CPU_SAMPLE_MS = 250
def cpu_usage():
def sample():
with open("/proc/stat") as f:
parts = f.readline().split()
# cpu user nice system idle iowait irq softirq steal guest guest_nice
vals = list(map(int, parts[1:]))
idle = vals[3] + vals[4]
total = sum(vals)
return idle, total
idle0, total0 = sample()
time.sleep(CPU_SAMPLE_MS / 1000.0)
idle1, total1 = sample()
d_idle = idle1 - idle0
d_total = total1 - total0
if d_total <= 0:
return 0.0
return round(100.0 * (1.0 - d_idle / d_total), 1)
def meminfo_gi():
mem = {}
try:
with open("/proc/meminfo") as f:
for line in f:
key, _, rest = line.partition(":")
val = rest.strip().split()[0]
mem[key] = int(val) # kB
except OSError:
return {}
def gi(kb):
return round(kb / 1024.0 / 1024.0, 1)
ram = {
"total": gi(mem.get("MemTotal", 0)),
# MemAvailable is the honest "swappable minus thrash" number
"available": gi(mem.get("MemAvailable", mem.get("MemFree", 0))),
}
ram["used"] = round(ram["total"] - ram["available"], 1)
ram["pct"] = (
round(100.0 * ram["used"] / ram["total"], 1) if ram["total"] > 0 else 0.0
)
swap = {
"total": gi(mem.get("SwapTotal", 0)),
"free": gi(mem.get("SwapFree", 0)),
}
swap["used"] = round(swap["total"] - swap["free"], 1)
swap["pct"] = (
round(100.0 * swap["used"] / swap["total"], 1) if swap["total"] > 0 else 0.0
)
return ram, swap
def disk_pct(path="/"):
try:
st = os.statvfs(path)
except OSError:
return {"used": 0.0, "total": 0.0, "pct": 0.0}
total = st.f_blocks * st.f_frsize
free = st.f_bavail * st.f_frsize
used = total - free
return {
"used": round(used / 1024.0**3, 1),
"total": round(total / 1024.0**3, 1),
"pct": round(100.0 * used / total, 1) if total > 0 else 0.0,
}
def load_avg():
try:
return [round(float(x), 2) for x in os.getloadavg()]
except OSError:
return []
def uptime():
try:
with open("/proc/uptime") as f:
return round(float(f.read().split()[0]), 1)
except OSError:
return 0.0
def temps():
out = []
base = "/sys/class/thermal"
try:
zones = sorted(os.listdir(base))
except OSError:
return out
for z in zones:
if not z.startswith("thermal_zone"):
continue
tpath = os.path.join(base, z)
typefile = os.path.join(tpath, "type")
tempfile = os.path.join(tpath, "temp")
try:
with open(typefile) as f:
name = f.read().strip()
with open(tempfile) as f:
millideg = int(f.read().strip())
except (OSError, ValueError):
continue
# Skip the ACPI "INT3400 Thermal" umbrella zone (always ~20C, noise)
if "INT3400" in name:
continue
out.append({"name": name, "temp": round(millideg / 1000.0, 0)})
# Keep a sane display order: CPU forward, wifi last
def rank(n):
n = n.lower()
if "cpu" in n or "tctl" in n or "pkg" in n:
return 0
if "sen" in n:
return 1
return 2
out.sort(key=lambda t: (rank(t["name"]), t["name"]))
return out
def scaled_vout():
# Current CPU P-state / GHz (x86-package-temp zone present ⇒ has cpufreq).
try:
with open("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq") as f:
khz = int(f.read().strip())
return round(khz / 1_000_000.0, 2)
except OSError:
return 0.0
def main():
ram, swap = meminfo_gi()
payload = {
"ts": int(time.time()),
"cpu": cpu_usage(),
"ram": ram,
"swap": swap,
"disk": disk_pct(),
"load": load_avg(),
"uptime": uptime(),
"temps": temps(),
"freq": scaled_vout(),
}
sys.stdout.write(json.dumps(payload))
sys.stdout.write("\n")
if __name__ == "__main__":
main()
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""QuickShell wallpaper picker backend.
Wallpapers are the image files in ~/Pictures/wallpapers (hyprpaper reads that
dir as the initial set; the picker surfaces whatever is there at runtime).
Commands
--------
list -> JSON array of {name, path, active} (active = matches the state file)
set <p> -> persist <p> to ~/.cache/quickshell/wallpaper, then apply it now
apply -> re-apply the persisted choice (login autostart; retries while
hyprpaper's control socket comes up)
path -> print the persisted path (empty if never picked)
Apply mechanism
---------------
hyprpaper >= 0.8 ships a new control protocol: the legacy `preload`/`reload`
IPC commands are gone, and `hyprctl hyprpaper wallpaper <monitor>,<path>`
takes a real output name (no `*`). We query `hyprctl monitors -j` for the
current output names and apply to each. Persisting to a cache file (not
hyprpaper.conf) keeps the Nix-generated config authoritative; the login
autostart line in hyprland.nix re-applies the choice after hyprpaper starts,
defaulting to the config wallpaper until the user picks something.
"""
import json
import os
import subprocess
import sys
import time
HOME = os.path.expanduser("~")
WALL_DIR = os.path.join(HOME, "Pictures", "wallpapers")
CACHE_DIR = os.path.join(HOME, ".cache", "quickshell")
STATE_FILE = os.path.join(CACHE_DIR, "wallpaper")
IMAGE_EXT = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif")
HYPRCTL = "hyprctl"
def ensure_cache_dir():
os.makedirs(CACHE_DIR, exist_ok=True)
def scan_names():
if not os.path.isdir(WALL_DIR):
return []
try:
names = os.listdir(WALL_DIR)
except OSError:
return []
return sorted(n for n in names if n.lower().endswith(IMAGE_EXT))
def read_state():
try:
with open(STATE_FILE) as fh:
path = fh.read().strip()
return path if os.path.isfile(path) else ""
except OSError:
return ""
def list_walls():
current = read_state()
entries = []
for name in scan_names():
path = os.path.join(WALL_DIR, name)
entries.append({"name": name, "path": path, "active": path == current})
return entries
def monitors():
"""Current Hyprland output names (e.g. eDP-1), empty if unavailable."""
try:
out = subprocess.run(
[HYPRCTL, "monitors", "-j"],
capture_output=True,
text=True,
)
except OSError:
return []
if out.returncode != 0:
return []
try:
data = json.loads(out.stdout)
return [m["name"] for m in data if "name" in m]
except (ValueError, KeyError, TypeError):
return []
def apply_path(path, retries=10, delay=0.5):
"""Apply via hyprctl hyprpaper, absorbing the login socket race."""
if not os.path.isfile(path):
return False, "no such file: %s" % path
last = ""
for _ in range(retries):
mons = monitors()
if not mons:
last = "no monitors available"
time.sleep(delay)
continue
try:
errs = []
ok = True
for mon in mons:
setw = subprocess.run(
[HYPRCTL, "hyprpaper", "wallpaper", mon + "," + path],
capture_output=True,
text=True,
)
if setw.returncode != 0:
ok = False
errs.append(setw.stderr.strip())
except OSError as exc:
return False, "hyprctl unavailable: %s" % exc
if ok:
return True, ""
last = "; ".join(errs) or "hyprctl hyprpaper wallpaper failed"
time.sleep(delay)
return False, last
def set_wall(path):
if not os.path.isfile(path):
return {"ok": False, "error": "no such file: %s" % path}
ensure_cache_dir()
with open(STATE_FILE, "w") as fh:
fh.write(path + "\n")
ok, err = apply_path(path)
return {"ok": ok, "stderr": err}
def main():
args = sys.argv[1:]
cmd = args[0] if args else "list"
if cmd == "list":
print(json.dumps(list_walls()))
elif cmd == "set":
print(json.dumps(set_wall(args[1] if len(args) > 1 else "")))
elif cmd == "apply":
current = read_state()
print(json.dumps(set_wall(current) if current else {"ok": True, "stderr": ""}))
elif cmd == "path":
print(read_state())
else:
print(json.dumps({"ok": False, "error": "unknown command: " + cmd}))
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
{ lib, pkgs, ... }:
let
justfileContent = builtins.readFile ./justfile;
in
{
home.packages = [ pkgs.just ];
home.file.".config/just/justfile".text = justfileContent;
}
+53
View File
@@ -0,0 +1,53 @@
nix_flake := "/home/petere/Nix-Vibe"
hostname := `hostname`
# list available recipes
default:
@just --list --justfile {{justfile()}} 2>/dev/null || echo "Run: just -g --list"
# rebuild current system
[group('NixOS')]
update:
cd {{nix_flake}} && sudo nixos-rebuild switch --flake .#{{hostname}}
# remote deploy: just deploy x470
[group('NixOS')]
deploy target:
cd {{nix_flake}} && nixos-rebuild switch --target-host petere@{{target}} --flake .#{{target}} --sudo
# update flake.lock
[group('NixOS')]
flake-lock:
cd {{nix_flake}} && nix flake update
# update flake + rebuild
[group('NixOS')]
upgrade:
cd {{nix_flake}} && nix flake update && sudo nixos-rebuild switch --flake .#{{hostname}}
# build without switching (test config)
[group('NixOS')]
build:
cd {{nix_flake}} && nixos-rebuild build --flake .#{{hostname}}
# dry activation (check if config is valid)
[group('NixOS')]
check:
cd {{nix_flake}} && nixos-rebuild dry-activate --flake .#{{hostname}}
# switch home-manager config only
[group('NixOS')]
hm-switch:
cd {{nix_flake}} && home-manager switch --flake .#{{hostname}}
# garbage collect nix store
[group('System')]
gc:
nix-collect-garbage -d
sudo nix-collect-garbage -d
# remove old generations
[group('System')]
clean:
sudo nix-env --delete-generations old -p /nix/var/nix/profiles/system
sudo nix-collect-garbage -d
+59
View File
@@ -0,0 +1,59 @@
{ pkgs, ... }:
{
# Configure Zsh
programs.zsh = {
enable = true;
enableCompletion = true;
autosuggestion.enable = true;
syntaxHighlighting.enable = true;
autocd = true;
oh-my-zsh = {
enable = true;
plugins = [
"git"
"eza"
"vscode"
"sudo"
];
theme = "lukerandall";
};
# Use initContent to add custom Zsh configuration after Home Manager's setup
initContent = ''
# Load Gemini API Key from SOPS
if [ -f "/run/secrets/gemini-api-key" ]; then
export GOOGLE_GENERATIVE_AI_API_KEY=$(cat "/run/secrets/gemini-api-key")
export GEMINI_API_KEY="$GOOGLE_GENERATIVE_AI_API_KEY"
fi
# Load OpenCode API Key from SOPS
if [ -f "/run/secrets/opencode-api-key" ]; then
export OPENCODE_API_KEY=$(cat "/run/secrets/opencode-api-key")
fi
# Load Gitea MCP access token from SOPS (used by opencode's gitea MCP server)
if [ -f "/run/secrets/x1carbon/gitea-mcp-token" ]; then
export GITEA_ACCESS_TOKEN=$(cat "/run/secrets/x1carbon/gitea-mcp-token")
fi
# Fastfetch check
if command -v fastfetch >/dev/null 2>&1; then
fastfetch --config ${./fastfetch.json}
fi
'';
};
programs.zoxide.enable = true;
programs.eza = {
enable = true;
extraOptions = [
"-l"
"--icons"
"--git"
"-a"
];
};
}
+42
View File
@@ -0,0 +1,42 @@
{ config
, pkgs
, lib
, inputs
, ...
}:
{
# Home Manager configuration for caitlin
home-manager.users.caitlin = {
home.username = "caitlin";
home.homeDirectory = "/home/caitlin";
home.stateVersion = "23.11"; # Set to your NixOS release version
home.file.".config/containers/containers.conf".text = ''
[service_destinations]
[service_destinations.local]
uri = "unix:///run/user/1000/podman/podman.sock"
[engine]
active_service = "local"
'';
programs.home-manager.enable = true;
programs.git = {
enable = true;
settings.user = {
name = "Caitlin";
email = "caitlin@example.com"; # Placeholder email
};
};
programs.direnv = {
enable = true;
nix-direnv.enable = true;
};
systemd.user.startServices = "sd-switch";
home.packages = with pkgs; [
fastfetch
];
};
}
+22
View File
@@ -0,0 +1,22 @@
{ config
, pkgs
, lib
, ...
}:
{
# User definition moved to host NixOS config (x470)
# Home Manager configuration for guest
home-manager.users.guest = {
home.username = "guest";
home.homeDirectory = "/home/guest";
home.stateVersion = "23.11"; # Set to your NixOS release version
programs.home-manager.enable = true;
home.packages = with pkgs; [
fastfetch
];
};
}
+42
View File
@@ -0,0 +1,42 @@
{ config
, pkgs
, lib
, inputs
, ...
}:
{
# Home Manager configuration for mary
home-manager.users.mary = {
home.username = "mary";
home.homeDirectory = "/home/mary";
home.stateVersion = "24.11";
home.file.".config/containers/containers.conf".text = ''
[service_destinations]
[service_destinations.local]
uri = "unix:///run/user/1000/podman/podman.sock"
[engine]
active_service = "local"
'';
programs.home-manager.enable = true;
programs.git = {
enable = true;
settings.user = {
name = "Mary";
email = "mary@example.com";
};
};
programs.direnv = {
enable = true;
nix-direnv.enable = true;
};
systemd.user.startServices = "sd-switch";
home.packages = with pkgs; [
fastfetch
];
};
}
+49
View File
@@ -0,0 +1,49 @@
{ config
, pkgs
, lib
, inputs
, ...
}:
{
# User password is set in NixOS host configuration
users.users.petere.extraGroups = [
"optical"
"cdrom"
];
# Home Manager configuration for petere
home-manager.users.petere = {
home.username = "petere";
home.homeDirectory = "/home/petere";
home.stateVersion = "23.11"; # Set to your NixOS release version
home.file.".config/containers/containers.conf".text = ''
[service_destinations]
[service_destinations.local]
uri = "unix:///run/user/1000/podman/podman.sock"
[engine]
active_service = "local"
'';
programs.home-manager.enable = true;
programs.git = {
enable = true;
settings.user = {
name = "Peter Edley";
email = "peter@edleyit.com";
};
};
programs.direnv = {
enable = true;
nix-direnv.enable = true;
};
systemd.user.startServices = "sd-switch";
home.packages = with pkgs; [
fastfetch
];
};
}