Nix-Vibe public snapshot (squashed history)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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
@@ -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}}"
|
||||
}
|
||||
@@ -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
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
"-"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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})";
|
||||
}
|
||||
@@ -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 "$@"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,9 @@
|
||||
{ lib, pkgs, ... }:
|
||||
let
|
||||
justfileContent = builtins.readFile ./justfile;
|
||||
in
|
||||
{
|
||||
home.packages = [ pkgs.just ];
|
||||
|
||||
home.file.".config/just/justfile".text = justfileContent;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
];
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user