diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..dd739b4
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,245 @@
+name: Brain Shell CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ # ─────────────────────────────────────────────
+ # Arch Linux — pacman + AUR dry-run
+ # ─────────────────────────────────────────────
+ arch-validate:
+ name: Arch Linux — Dependency & Lint Check
+ runs-on: ubuntu-latest
+ container:
+ image: archlinux:latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Update pacman & install base tools
+ run: |
+ pacman -Syu --noconfirm
+ pacman -S --needed --noconfirm git base-devel python
+
+ - name: Validate pacman dependencies exist in repos
+ run: |
+ PACMAN_DEPS=(
+ hyprland qt6-base qt6-declarative qt6-multimedia
+ qt6-5compat qt6ct pipewire pipewire-pulse wireplumber
+ networkmanager bluez bluez-utils brightnessctl upower
+ libnotify polkit python wl-clipboard slurp xdg-user-dirs
+ wf-recorder cava imagemagick wtype lm_sensors util-linux
+ hyprsunset hyprlock hyprpolkitagent hypridle
+ xdg-desktop-portal-hyprland ttf-jetbrains-mono-nerd
+ ttf-nerd-fonts-symbols-common hyprshutdown cliphist
+ )
+ FAILED=()
+ for pkg in "${PACMAN_DEPS[@]}"; do
+ if pacman -Si "$pkg" &>/dev/null; then
+ echo "✓ $pkg"
+ else
+ echo "✗ $pkg (not found in repos)"
+ FAILED+=("$pkg")
+ fi
+ done
+ if [ ${#FAILED[@]} -gt 0 ]; then
+ echo ""
+ echo "Missing packages: ${FAILED[*]}"
+ exit 1
+ fi
+
+ - name: Validate AUR packages exist on AUR RPC
+ run: |
+ AUR_DEPS=(
+ quickshell-git
+ awww-git
+ matugen-bin
+ envycontrol
+ auto-cpufreq
+ nbfc-linux
+ grimblast-git
+ )
+ FAILED=()
+ for pkg in "${AUR_DEPS[@]}"; do
+ STATUS=$(curl -s "https://aur.archlinux.org/rpc/v5/info?arg[]=${pkg}" \
+ | python3 -c "import json,sys; d=json.load(sys.stdin); print('ok' if d.get('resultcount',0)>0 else 'missing')")
+ if [[ "$STATUS" == "ok" ]]; then
+ echo "✓ $pkg (AUR)"
+ else
+ echo "✗ $pkg not found on AUR"
+ FAILED+=("$pkg")
+ fi
+ done
+ if [ ${#FAILED[@]} -gt 0 ]; then
+ echo ""
+ echo "Missing AUR packages: ${FAILED[*]}"
+ exit 1
+ fi
+
+ - name: Lint install.sh
+ run: |
+ pacman -S --needed --noconfirm bash
+ bash -n install.sh
+ echo "✓ install.sh syntax OK"
+
+ - name: Lint dots-extra/install-arch.sh
+ run: |
+ bash -n dots-extra/install-arch.sh
+ echo "✓ install-arch.sh syntax OK"
+
+ - name: Lint dots-extra/validate-install.sh
+ run: |
+ bash -n dots-extra/validate-install.sh
+ echo "✓ validate-install.sh syntax OK"
+
+ - name: Lint shell scripts in src/scripts/
+ run: |
+ bash -n src/scripts/GfxSwitch.sh
+ bash -n src/scripts/PowerControl.sh
+ echo "✓ src/scripts/ syntax OK"
+
+ - name: Validate QML files exist and are non-empty
+ run: |
+ FAILED=()
+ while IFS= read -r -d '' f; do
+ if [ ! -s "$f" ]; then
+ echo "✗ Empty file: $f"
+ FAILED+=("$f")
+ else
+ echo "✓ $f"
+ fi
+ done < <(find src -name "*.qml" -print0)
+ if [ ${#FAILED[@]} -gt 0 ]; then
+ echo "Empty QML files found!"
+ exit 1
+ fi
+
+ - name: Validate qmldir files
+ run: |
+ for f in $(find src -name "qmldir"); do
+ echo "✓ Found qmldir: $f"
+ cat "$f"
+ done
+
+ # ─────────────────────────────────────────────
+ # NixOS — flake check
+ # ─────────────────────────────────────────────
+ nix-validate:
+ name: NixOS — Flake & Package Check
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install Nix
+ uses: cachix/install-nix-action@v27
+ with:
+ nix_path: nixpkgs=channel:nixos-unstable
+ extra_nix_config: |
+ experimental-features = nix-command flakes
+ accept-flake-config = true
+
+ - name: Validate flake.nix syntax
+ run: |
+ # Check file exists and is valid UTF-8 text
+ cat flake.nix > /dev/null
+ # Use nix-instantiate for pure syntax check (no network, no path access)
+ nix-instantiate --parse flake.nix > /dev/null
+ echo "✓ flake.nix syntax OK"
+
+ - name: Validate flake.lock structure
+ run: |
+ python3 -c "
+ import json, sys
+ with open('flake.lock') as f:
+ lock = json.load(f)
+ assert lock.get('version') == 7, 'Expected lock version 7'
+ nodes = lock.get('nodes', {})
+ print(f'Lock version: {lock[\"version\"]}')
+ print(f'Nodes: {list(nodes.keys())}')
+ for name, node in nodes.items():
+ if 'locked' in node:
+ rev = node['locked'].get('rev', 'N/A')[:10]
+ print(f' ✓ {name}: {rev}')
+ "
+
+ - name: Validate flake.nix declares required outputs
+ run: |
+ python3 -c "
+ with open('flake.nix') as f:
+ content = f.read()
+ checks = {
+ 'packages': 'packages' in content,
+ 'devShells': 'devShells' in content,
+ 'nixosModules': 'nixosModules' in content,
+ 'description': 'description' in content,
+ 'brain-shell': 'brain-shell' in content,
+ }
+ failed = []
+ for key, ok in checks.items():
+ if ok:
+ print(f' ✓ {key} declared')
+ else:
+ print(f' ✗ {key} MISSING')
+ failed.append(key)
+ if failed:
+ raise SystemExit(f'Missing from flake.nix: {failed}')
+ "
+
+ # ─────────────────────────────────────────────
+ # Structure Sanity Check (both runners agree)
+ # ─────────────────────────────────────────────
+ structure-check:
+ name: Repo Structure Sanity
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Check required files exist
+ run: |
+ REQUIRED=(
+ "shell.qml"
+ "flake.nix"
+ "flake.lock"
+ "install.sh"
+ "dots-extra/install-arch.sh"
+ "dots-extra/validate-install.sh"
+ "src/config/brain-shell-colors.json.example"
+ "src/theme/Theme.qml"
+ "src/theme/ColorLoader.qml"
+ "src/state/ShellState.qml"
+ "src/state/Popups.qml"
+ "src/windows/TopBar.qml"
+ "src/popups/Dashboard.qml"
+ )
+ FAILED=()
+ for f in "${REQUIRED[@]}"; do
+ if [[ -f "$f" ]]; then
+ echo "✓ $f"
+ else
+ echo "✗ MISSING: $f"
+ FAILED+=("$f")
+ fi
+ done
+ if [ ${#FAILED[@]} -gt 0 ]; then
+ echo ""
+ echo "Missing required files: ${FAILED[*]}"
+ exit 1
+ fi
+
+ - name: Count QML files per module
+ run: |
+ echo "QML file counts by directory:"
+ find src -name "*.qml" | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn
+
+ - name: Check install.sh is executable or has correct shebang
+ run: |
+ head -1 install.sh | grep -q "^#!/" && echo "✓ shebang OK" || (echo "✗ Missing shebang in install.sh" && exit 1)
+ head -1 dots-extra/install-arch.sh | grep -q "^#!/" && echo "✓ shebang OK (arch)" || (echo "✗ Missing shebang in install-arch.sh" && exit 1)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..60a2ba2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+src/config/colors.conf
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..91f43bc
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Venkat Saahit Kamu (Brainitech)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fdc0f3e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,255 @@
+
Brain_Shell
+
+
+ A dynamic, highly modular Wayland desktop shell built with Quickshell and QML, tailored for Hyprland.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+Showcase
+
+
+
+
+
+---
+
+Features
+
+- **Modular Setup** — Unintrusive setup
+- **Material You Integration** — Dynamic colors via Matugen
+- **Lua-Based Config** — Hyprland v0.55+ compatible
+- **System Dashboard** — Monitor CPU, RAM, battery, temps, and more
+- **Kanban/Tasks** — To Do, Ongoing and Competed lists with Prioiry and Deadlines
+- **App Launcher** — Dropdown App Launcher
+- **Keybinds** — Set your own keybinds for each popup
+- **Theming Engine** — Live wallpaper-synced color updates
+- **Network Manager** — WiFi, Bluetooth, VPN integration
+- **Notifications** — DBus Notifcations via libnotify
+- **Audio Control** — PipeWire volume & device management
+- **Screen Recorder** — Built-in recording with wf-recorder
+- **Clipboard Manager** — Cliphist integration for history management
+- **Highly Customizable** — QML-based UI, easily extended
+
+> **Note:** Brain Shell is currently in its `v0.1.0` release. While the core architecture and theming pipeline are feature-complete, you may encounter bugs. Please report them on our [Discord](https://discord.gg/BV8UduvABx) or via GitHub Issues!
+
+---
+
+
+ Installation
+
+
+### One line installer
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/Brainitech/Brain_Shell/refs/heads/main/install.sh | bash
+```
+
+### Manual installation
+
+```bash
+git clone https://github.com/Brainitech/Brain_Shell.git
+cd Brain_Shell
+chmod +x install.sh
+./install.sh
+```
+
+The installer automatically:
+
+- ✓ Detects your Linux distribution
+- ✓ Detects your Window Manager and Hyprland Config
+- ✓ Backs up your entire `~/.config`
+- ✓ Installs all required dependencies
+- ✓ Clones the repository to `~/.local/src/Brain_Shell`
+- ✓ Updates your Hyprland config
+- ✓ Creates configuration directories
+
+**After installation, restart Hyprland for changes to take effect.**
+
+---
+
+
+ Requirements
+
+
+> [!IMPORTANT]
+> **Matugen is required** for dynamic color generation. Brain Shell will not function correctly without it.
+
+### Core Dependencies
+
+
+Runtime & Rendering
+
+- **Hyprland** v0.55+ – Wayland compositor
+- **Quickshell** – QML shell framework
+- **Qt6** – Qt6 libraries and QML engine
+- **qt6ct** – Qt6 theme configuration
+
+
+
+
+System Tools
+
+- **PipeWire** – Audio server (pipewire, pipewire-pulse, wireplumber)
+- **NetworkManager** – Network management
+- **BlueZ** – Bluetooth stack (bluez, bluez-utils)
+- **Brightnessctl** – Backlight control
+- **Mpris** – Media Retrival
+- **Playerctl** – Player controls
+- **UPower** – Battery and power info
+- **libnotify** – Desktop notifications
+- **Polkit** – Privilege escalation
+- **wl-clipboard** – Wayland clipboard (wl-copy/wl-paste)
+
+
+
+
+Theming & Wallpaper
+
+- **Matugen** – Material You color generation **(REQUIRED)**
+- **awww** – Wallpaper daemon (Wayland)
+- **ImageMagick** – Image manipulation
+
+
+
+
+Recording & Utilities
+
+- **wf-recorder** – Screen recording (Wayland)
+- **cava** – Audio visualizer
+- **slurp** – Region/window selection
+- **wtype** – Keyboard input emulation
+- **cliphist** – Clipboard history manager
+
+
+
+
+Hardware Management
+
+- **lm_sensors** – CPU temperature & fan monitoring
+- **rfkill** – Airplane mode control
+- **envycontrol** – GPU switching (NVIDIA/Intel)
+- **auto-cpufreq** – CPU frequency scaling
+- **nbfc-linux** – Laptop fan control
+
+
+
+
+Hyprland Integration
+
+- **hyprlock** – Lock screen
+- **hypridle** – Idle management daemon
+- **hyprsunset** – Blue light filter
+- **hyprshutdown** – Graceful shutdown
+- **xdg-desktop-portal-hyprland** – Portal backend
+
+
+
+
+Fonts
+
+- **ttf-jetbrains-mono-nerd** – Primary font (Nerd Font variant)
+- **ttf-noto-nerd** – Emoji and CJK support
+
+
+
+---
+
+
+ Roadmap
+
+
+### Current (v0.1.0)
+
+- [x] Core shell framework
+- [x] System monitoring dashboard
+- [x] Keybind editor with live conflict detection
+- [x] Network management (WiFi, Bluetooth, VPN)
+- [x] Audio control panel
+- [x] Screen recording integration
+- [x] Clipboard manager
+- [x] Material You color integration
+- [x] Lua config generation
+- [x] Professional installer (Arch/NixOS)
+- [x] Auto-update mechanism
+
+### Upcoming (Post-v0.1.0)
+
+- [ ] Scaling on Different Screen-Sizes
+- [ ] Config Pages for Shell Customization
+- [ ] Multi-Monitor Support
+- [ ] Additional theme options
+- [ ] App launcher enhancements (pinned/recent)
+- [ ] Unified popup configuration layer
+- [ ] Extended documentation
+- [ ] Community themes
+- [ ] CLI
+- [ ] More Linux distribution support
+
+---
+
+
+Known Issues
+
+
+- **Multi-Monitor Scaling:** Global scaling across mixed-resolution monitors (e.g., 4K paired with 1080p) is currently inconsistent. UI elements may appear misproportioned or poorly sized on non-1080p screens.
+
+- **Input Focus Delays:** The App Launcher and Wallpaper popups occasionally fail to capture keyboard focus immediately upon opening. A slight mouse movement is currently required to force focus activation.
+
+- **Top Bar Clipping:** Elements within the right notch may become visually clipped if the system tray is expanded and contains an excessive number of active items.
+
+- **Shutdown Menu (Hyprshutdown) State:** Canceling a shutdown or logout action can sometimes leave the Hyprland session in an empty state with most applications unintentionally closed. It may also occasionally struggle to terminate all running apps smoothly.
+
+---
+
+
+ Contributing
+
+
+Brain Shell is actively developed and welcomes contributions!
+
+- Found a bug? → [Open an issue](https://github.com/Brainitech/Brain_Shell/issues)
+- Have an idea? → [Start a discussion](https://github.com/Brainitech/Brain_Shell/discussions)
+- Want to contribute? → Fork, branch, and submit a pull request
+
+---
+
+
+ Special Thanks
+
+
+- **[Hyprland Community](https://github.com/hyprwm)** – For creating an exceptional Wayland compositor and fostering an amazing community
+- **[Quickshell Contributors](https://github.com/quickshell/quickshell)** – For the powerful QML framework that powers this shell
+- **[Matugen Team](https://github.com/InioX/matugen)** – For Material You color generation technology
+- **[Wayland Project](https://wayland.freedesktop.org)** – For the modern display protocol foundation
+- **[Celestial Shell](https://github.com/caelestia-dots/shell)** & **[AX-Shell](https://github.com/Axenide/ax-shell)** — For the inspiration
+- **[NotCandy001](https://github.com/notcandy001)** — For the installer
+- **All the Testers & Contributors** — For their time put into testing and suggesting fixes.
+
+---
+
+
+ License
+
+
+This project is licensed under the MIT License – see the [LICENSE](LICENSE) file for details.
diff --git a/cli.sh b/cli.sh
new file mode 100755
index 0000000..8447a25
--- /dev/null
+++ b/cli.sh
@@ -0,0 +1,771 @@
+#!/usr/bin/env bash
+# ═══════════════════════════════════════════════════════════════════════════════
+# Brain Shell CLI — Standalone launcher & manager
+# github.com/Brainitech/Brain_Shell
+# ═══════════════════════════════════════════════════════════════════════════════
+set -euo pipefail
+
+_script_src="${BASH_SOURCE[0]}"
+# Resolve symlinks to get the real script directory (for global brain-shell command)
+if [[ -L "$_script_src" ]]; then
+ _script_src=$(readlink -f "$_script_src" 2>/dev/null || readlink "$_script_src")
+fi
+SCRIPT_DIR="${_script_src%/*}"
+if [[ "$SCRIPT_DIR" == "$_script_src" ]] && [[ ! -f "$_script_src" ]]; then
+ SCRIPT_DIR="$PWD"
+fi
+if [[ "$SCRIPT_DIR" != /* ]]; then
+ SCRIPT_DIR="$PWD/$SCRIPT_DIR"
+fi
+unset _script_src
+
+QS_BIN="${BRAIN_SHELL_QS:-qs}"
+NIXGL_BIN="${BRAIN_SHELL_NIXGL:-}"
+if [[ -n "${QML2_IMPORT_PATH:-}" ]] && [[ -z "${QML_IMPORT_PATH:-}" ]]; then
+ export QML_IMPORT_PATH="$QML2_IMPORT_PATH"
+fi
+
+SHELL_QML="${SCRIPT_DIR}/shell.qml"
+CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/Brain_Shell"
+DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/Brain_Shell"
+PID_FILE="/tmp/brain_shell.pid"
+PIPE="/tmp/brain_shell_ipc.pipe"
+VERSION_FILE="${SCRIPT_DIR}/version"
+BRIGHTNESS_SAVE_FILE="/tmp/brain_shell_brightness_saved.txt"
+
+if [[ -f "$VERSION_FILE" ]]; then
+ VERSION=$(<"$VERSION_FILE")
+else
+ VERSION="0.1.0"
+fi
+
+show_help() {
+ cat <<'EOF'
+Brain Shell CLI — Desktop Environment Control
+
+Usage: brain-shell [COMMAND]
+
+Commands:
+ (none) Launch Brain_Shell
+ update Update Brain_Shell
+ reload Restart Brain_Shell
+ sync Regenerate hyprland binds
+ quit Stop Brain_Shell
+ lock Activate lockscreen
+ screen [on|off] Turn screen on/off
+ suspend Suspend the system
+
+ brightness [monitor] Set brightness (0-100)
+ brightness +/- [monitor] Adjust brightness relatively
+ brightness -s [monitor] Save current brightness
+ brightness -r [monitor] Restore saved brightness
+ brightness -l List monitors
+
+ volume-up Increase volume
+ volume-down Decrease volume
+ volume-mute Toggle volume mute
+ mic-mute Toggle microphone mute
+ caffeine Toggle caffeine (idle inhibition)
+ gamemode Toggle game mode (reduced motion)
+ nightlight Toggle night light (blue light filter)
+
+ run Send IPC command to running shell
+ Available: dashboard, dashboard-home, dashboard-stats, dashboard-kanban,
+ dashboard-launcher, dashboard-config, launcher,
+ audio, network, notifications, clipboard, wallpaper,
+ arch-menu, screenshot, color-picker, focus, close-all
+
+ help, -h, --help Show this help message
+ version, -v, --version Show Brain_Shell version
+ goodbye Uninstall Brain_Shell :(
+ install Install compositor integration
+ brain-shell install hyprland Add to Hyprland autostart (.conf)
+ brain-shell install hyprland --lua Add to Hyprland autostart (Lua)
+ brain-shell install hyprland --conf Add to Hyprland autostart (safe)
+ remove Remove compositor integration
+ brain-shell remove hyprland Remove from Hyprland config
+
+Examples:
+ brain-shell brightness 75 Set all monitors to 75%
+ brain-shell brightness 50 HDMI-A-1 Set HDMI-A-1 to 50%
+ brain-shell brightness +10 Increase brightness by 10%
+ brain-shell brightness -s Save current brightness
+ brain-shell brightness -r Restore saved brightness
+ brain-shell run dashboard-home Open dashboard on Home tab
+ brain-shell run launcher Open app launcher
+ brain-shell run screenshot Take a region screenshot
+ brain-shell install hyprland Auto-start with Hyprland
+ brain-shell reload Quick restart after edits
+
+EOF
+}
+
+# ── Config bootstrap ──────────────────────────────────────────────────────────
+ensure_config_files() {
+ local cfg_dir="${CONFIG_DIR}/src/user_data"
+ mkdir -p "$cfg_dir"
+
+ local provider_json="${cfg_dir}/config_Provider.json"
+ if [[ ! -f "$provider_json" ]]; then
+ printf '{"configProvider":"lua"}' > "$provider_json"
+ fi
+
+ local shell_json="${cfg_dir}/shell_config.json"
+ if [[ ! -f "$shell_json" ]]; then
+ printf '{\n "animationSpeed": 1.0,\n "barEnabled": true,\n "dashboardWidth": 900,\n "dashboardHeight": 520,\n "focusModeOnStartup": false,\n "autoUpdateCheck": true\n}\n' > "$shell_json"
+ fi
+
+ local permon_json="${cfg_dir}/per_monitor.json"
+ if [[ ! -f "$permon_json" ]]; then
+ printf '{}' > "$permon_json"
+ fi
+
+ mkdir -p "${XDG_CACHE_HOME:-$HOME/.cache}/Brain_Shell"
+}
+
+# ── Process management ────────────────────────────────────────────────────────
+find_brain_shell_pid() {
+ local pid
+ pid=$(pgrep -f "qs.*${SCRIPT_DIR}/shell.qml" 2>/dev/null | head -1)
+ if [[ -z "$pid" ]]; then
+ pid=$(pgrep -f "quickshell.*${SCRIPT_DIR}/shell.qml" 2>/dev/null | head -1)
+ fi
+ if [[ -z "$pid" ]]; then
+ pid=$(pgrep -f "qs.*shell.qml" 2>/dev/null | head -1)
+ fi
+ if [[ -z "$pid" ]]; then
+ pid=$(pgrep -f "quickshell.*shell.qml" 2>/dev/null | head -1)
+ fi
+ if [[ -z "$pid" ]]; then
+ pid=$(pgrep -a "qs" 2>/dev/null | grep -F "$SCRIPT_DIR" | awk '{print $1}' | head -1)
+ fi
+ if [[ -z "$pid" ]]; then
+ pid=$(pgrep -a quickshell 2>/dev/null | grep -F "$SCRIPT_DIR" | awk '{print $1}' | head -1)
+ fi
+ echo "$pid"
+}
+
+find_brain_shell_pid_cached() {
+ local pid
+ if [[ -f "$PID_FILE" ]]; then
+ pid=$(<"$PID_FILE" 2>/dev/null)
+ if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
+ echo "$pid"
+ return 0
+ fi
+ rm -f "$PID_FILE"
+ fi
+ pid=$(find_brain_shell_pid)
+ echo "$pid"
+}
+
+launch_shell() {
+ local pid
+ pid=$(find_brain_shell_pid)
+ if [[ -n "$pid" ]]; then
+ echo "Brain_Shell is already running (PID $pid)"
+ return 0
+ fi
+
+ ensure_config_files
+
+ echo "Starting Brain_Shell v${VERSION}..."
+
+ local launch_cmd=("$QS_BIN" -p "$SHELL_QML")
+ if [[ -n "$NIXGL_BIN" ]] && command -v "$NIXGL_BIN" &>/dev/null; then
+ launch_cmd=("$NIXGL_BIN" "${launch_cmd[@]}")
+ fi
+
+ nohup "${launch_cmd[@]}" > /dev/null 2>&1 &
+ local new_pid=$!
+ echo "$new_pid" > "$PID_FILE"
+
+ # Wait for shell to initialize (Quickshell takes 2-4 seconds)
+ sleep 3
+ if ! kill -0 "$new_pid" 2>/dev/null; then
+ echo "Error: Brain_Shell failed to start. Check logs with: qs -p ${SHELL_QML}"
+ rm -f "$PID_FILE"
+ exit 1
+ fi
+
+ echo "Brain_Shell started (PID $new_pid)"
+}
+
+restart_shell() {
+ local pid
+ pid=$(find_brain_shell_pid_cached)
+
+ if [[ -n "$pid" ]]; then
+ echo "Stopping Brain_Shell (PID $pid)..."
+ kill "$pid" 2>/dev/null || true
+ local waited=0
+ while kill -0 "$pid" 2>/dev/null && [[ $waited -lt 30 ]]; do
+ sleep 0.1
+ waited=$((waited + 1))
+ done
+ if kill -0 "$pid" 2>/dev/null; then
+ kill -9 "$pid" 2>/dev/null || true
+ fi
+ rm -f "$PID_FILE"
+ fi
+
+ launch_shell
+}
+
+quit_shell() {
+ local pid
+ pid=$(find_brain_shell_pid_cached)
+
+ if [[ -n "$pid" ]]; then
+ echo "Stopping Brain_Shell (PID $pid)..."
+ kill "$pid" 2>/dev/null || true
+ sleep 0.3
+ if kill -0 "$pid" 2>/dev/null; then
+ kill -9 "$pid" 2>/dev/null || true
+ fi
+ rm -f "$PID_FILE"
+ echo "Brain_Shell stopped."
+ else
+ echo "Brain_Shell is not running."
+ fi
+}
+
+lock_screen() {
+ if [[ -p "$PIPE" ]]; then
+ echo "lockscreen" > "$PIPE" &
+ exit 0
+ fi
+
+ local pid
+ pid=$(find_brain_shell_pid_cached)
+ if [[ -n "$pid" ]] && command -v qs &>/dev/null; then
+ qs ipc --pid "$pid" call brain-shell run lockscreen 2>/dev/null && exit 0
+ fi
+
+ if command -v hyprlock &>/dev/null; then
+ hyprlock &
+ elif command -v loginctl &>/dev/null; then
+ loginctl lock-session
+ else
+ echo "Error: Could not lock screen"
+ exit 1
+ fi
+}
+
+# ── IPC ───────────────────────────────────────────────────────────────────────
+# ── IPC target mapping (friendly name → original IpcManager target) ──────────
+# Translates brain-shell run to the correct qs ipc call toggle
+declare -A IPC_MAP=(
+ # Dashboard
+ ["dashboard"]="dashboard-home"
+ ["dashboard-home"]="dashboard-home"
+ ["dashboard-stats"]="dashboard-stats"
+ ["dashboard-kanban"]="dashboard-kanban"
+ ["dashboard-launcher"]="dashboard-launcher"
+ ["dashboard-config"]="dashboard-config"
+ ["launcher"]="dashboard-launcher"
+ # Popups
+ ["audio"]="audioOut-toggle"
+ ["network"]="wifi-toggle"
+ ["notifications"]="notification-toggle"
+ ["notification-toggle"]="notification-toggle"
+ ["clipboard"]="clipboard-toggle"
+ ["wallpaper"]="wallpaper-toggle"
+ ["arch-menu"]="PowerMenu-toggle"
+ # Network tabs
+ ["wifi-toggle"]="wifi-toggle"
+ ["bluetooth-toggle"]="bluetooth-toggle"
+ ["vpn-toggle"]="vpn-toggle"
+ ["hotspot-toggle"]="hotspot-toggle"
+ # Audio tabs
+ ["audioOut-toggle"]="audioOut-toggle"
+ ["audioIn-toggle"]="audioIn-toggle"
+ ["audioMix-toggle"]="audioMix-toggle"
+ # Tools
+ ["focus"]="focus-toggle"
+ ["screen-record"]="screenrec-on"
+ ["close-all"]="focus-toggle"
+)
+
+send_ipc() {
+ local cmd="$1"
+
+ # Primary: qs ipc (most reliable)
+ local pid
+ pid=$(find_brain_shell_pid_cached)
+ if [[ -n "$pid" ]]; then
+ local target="${IPC_MAP[$cmd]:-$cmd}"
+ qs ipc --pid "$pid" call "$target" toggle 2>/dev/null && return 0
+ qs ipc --pid "$pid" call brain-shell "$cmd" 2>/dev/null && return 0
+ fi
+
+ # Fallback: write to named pipe (zero-latency path)
+ if [[ -p "$PIPE" ]]; then
+ echo "$cmd" > "$PIPE" 2>/dev/null &
+ return 0
+ fi
+
+ return 1
+}
+
+# ── Brightness ────────────────────────────────────────────────────────────────
+brightness_list_monitors() {
+ echo "Monitors:"
+ if command -v hyprctl &>/dev/null; then
+ hyprctl monitors -j 2>/dev/null | jq -r '.[] | " \(.name)"' 2>/dev/null || {
+ echo "Error: Could not list monitors (jq required)"
+ exit 1
+ }
+ else
+ echo "Error: hyprctl not found"
+ exit 1
+ fi
+}
+
+brightness_get_current() {
+ local monitor="${1:-}"
+ if command -v brightnessctl &>/dev/null; then
+ if [[ -z "$monitor" ]]; then
+ brightnessctl -m 2>/dev/null | while IFS=, read -r dev _ cur max; do
+ dev="${dev// /}"
+ local pct=$(( cur * 100 / max ))
+ echo "${dev}:${pct}"
+ done
+ else
+ local info
+ info=$(brightnessctl -d "$monitor" -m 2>/dev/null) || return 1
+ IFS=, read -r _ _ cur max <<< "$info"
+ echo $(( cur * 100 / max ))
+ fi
+ else
+ echo "Warning: brightnessctl not found"
+ return 1
+ fi
+}
+
+brightness_save() {
+ local monitor="${1:-}"
+ if [[ -z "$monitor" ]]; then
+ brightness_get_current > "$BRIGHTNESS_SAVE_FILE" 2>/dev/null || {
+ echo "Warning: Could not query current brightness"
+ }
+ if [[ -s "$BRIGHTNESS_SAVE_FILE" ]]; then
+ echo "Saved current brightness for all monitors"
+ fi
+ else
+ local cur
+ cur=$(brightness_get_current "$monitor" 2>/dev/null) || {
+ echo "Error: Monitor $monitor not found"
+ exit 1
+ }
+ if [[ -f "$BRIGHTNESS_SAVE_FILE" ]]; then
+ grep -v "^${monitor}:" "$BRIGHTNESS_SAVE_FILE" > "${BRIGHTNESS_SAVE_FILE}.tmp" 2>/dev/null || true
+ echo "${monitor}:${cur}" >> "${BRIGHTNESS_SAVE_FILE}.tmp"
+ mv "${BRIGHTNESS_SAVE_FILE}.tmp" "$BRIGHTNESS_SAVE_FILE"
+ else
+ echo "${monitor}:${cur}" > "$BRIGHTNESS_SAVE_FILE"
+ fi
+ echo "Saved current brightness for $monitor (${cur}%)"
+ fi
+}
+
+brightness_restore() {
+ local monitor="${1:-}"
+ if [[ ! -f "$BRIGHTNESS_SAVE_FILE" ]]; then
+ echo "Error: No saved brightness found. Use -s to save first."
+ exit 1
+ fi
+
+ if [[ -z "$monitor" ]]; then
+ while IFS=: read -r name value; do
+ [[ -n "$name" && -n "$value" ]] || continue
+ brightnessctl -d "$name" set "${value}%" 2>/dev/null || echo "Warning: Could not restore brightness for $name"
+ done < "$BRIGHTNESS_SAVE_FILE"
+ echo "Restored brightness for all monitors"
+ else
+ local value
+ value=$(grep "^${monitor}:" "$BRIGHTNESS_SAVE_FILE" | cut -d: -f2)
+ if [[ -z "$value" ]]; then
+ echo "Error: No saved brightness for monitor $monitor"
+ exit 1
+ fi
+ brightnessctl -d "$monitor" set "${value}%" 2>/dev/null || {
+ echo "Error: Could not restore brightness for $monitor"
+ exit 1
+ }
+ echo "Restored brightness for $monitor to ${value}%"
+ fi
+}
+
+brightness_set() {
+ local value="$1"
+ local monitor="${2:-}"
+ local save_flag="$3"
+
+ if [[ "$value" -lt 0 ]] || [[ "$value" -gt 100 ]]; then
+ echo "Error: Brightness must be between 0 and 100"
+ exit 1
+ fi
+
+ if [[ "$save_flag" == "true" ]]; then
+ brightness_save "$monitor"
+ fi
+
+ if [[ -z "$monitor" ]]; then
+ brightnessctl set "${value}%" 2>/dev/null || {
+ echo "Error: Could not set brightness"
+ exit 1
+ }
+ echo "Set brightness to ${value}% for all monitors"
+ else
+ brightnessctl -d "$monitor" set "${value}%" 2>/dev/null || {
+ echo "Error: Could not set brightness for $monitor"
+ exit 1
+ }
+ echo "Set brightness to ${value}% for $monitor"
+ fi
+}
+
+brightness_adjust() {
+ local delta="$1"
+ local monitor="${2:-}"
+
+ if [[ -z "$monitor" ]]; then
+ brightnessctl set "${delta}%" 2>/dev/null || {
+ echo "Error: Could not adjust brightness"
+ exit 1
+ }
+ echo "Adjusted brightness by ${delta}% for all monitors"
+ else
+ brightnessctl -d "$monitor" set "${delta}%" 2>/dev/null || {
+ echo "Error: Could not adjust brightness for $monitor"
+ exit 1
+ }
+ echo "Adjusted brightness by ${delta}% for $monitor"
+ fi
+}
+
+# ── Volume ────────────────────────────────────────────────────────────────────
+_vol_ctl() {
+ if command -v wpctl &>/dev/null; then echo "wpctl"; else echo "pactl"; fi
+}
+
+volume_up() {
+ case "$(_vol_ctl)" in
+ wpctl) wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+ 2>/dev/null ;;
+ pactl) pactl set-sink-volume @DEFAULT_SINK@ +5% 2>/dev/null ;;
+ esac
+}
+volume_down() {
+ case "$(_vol_ctl)" in
+ wpctl) wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%- 2>/dev/null ;;
+ pactl) pactl set-sink-volume @DEFAULT_SINK@ -5% 2>/dev/null ;;
+ esac
+}
+volume_mute() {
+ case "$(_vol_ctl)" in
+ wpctl) wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle 2>/dev/null ;;
+ pactl) pactl set-sink-mute @DEFAULT_SINK@ toggle 2>/dev/null ;;
+ esac
+}
+mic_mute() {
+ case "$(_vol_ctl)" in
+ wpctl) wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle 2>/dev/null ;;
+ pactl) pactl set-source-mute @DEFAULT_SOURCE@ toggle 2>/dev/null ;;
+ esac
+}
+
+caffeine_toggle() {
+ if pgrep -f "systemd-inhibit.*BrainShell.*Caffeine" &>/dev/null; then
+ pkill -f "systemd-inhibit.*BrainShell.*Caffeine" 2>/dev/null || true
+ echo "Caffeine: OFF"
+ else
+ systemd-inhibit --what=idle:sleep --who="Brain Shell" --why="Caffeine mode" sleep infinity &
+ echo "Caffeine: ON"
+ fi
+}
+
+# ── Hyprland integration ──────────────────────────────────────────────────────
+HYPR_DIR="$HOME/.config/hypr"
+
+BRAIN_SHELL_CONF_BLOCK='# Brain_Shell
+source = ~/.local/share/Brain_Shell/hyprland.conf
+
+# OVERRIDES
+# Add your own Hyprland settings below. They will override Brain_Shell defaults.'
+
+BRAIN_SHELL_LUA_BLOCK='-- Brain_Shell
+loadfile(os.getenv("HOME") .. "/.local/share/Brain_Shell/hyprland.lua")()
+
+-- OVERRIDES
+-- Add your own Hyprland settings below. They will override Brain_Shell defaults.'
+
+append_hyprland_block() {
+ local conf="$1"
+ local source="$2"
+ local block="$3"
+
+ if [[ -f "$conf" ]] && grep -qF "$source" "$conf" 2>/dev/null; then
+ echo "Brain_Shell is already integrated in $conf"
+ return 0
+ fi
+
+ if [[ -f "$conf" ]] && [[ -s "$conf" ]]; then
+ printf "\n%s\n" "$block" >> "$conf"
+ else
+ printf "%s\n" "$block" > "$conf"
+ fi
+
+ echo "Brain_Shell integrated into $conf"
+}
+
+remove_hyprland_block() {
+ local conf="$1"
+ local source="$2"
+
+ if [[ ! -f "$conf" ]]; then
+ return 0
+ fi
+
+ awk '
+ /^# Brain_Shell$/ { skip=1; next }
+ /^-- Brain_Shell$/ { skip=1; next }
+ /^source =.*Brain_Shell/ { next }
+ /^loadfile.*Brain_Shell/ { next }
+ /^exec-once = brain-shell/ { next }
+ /^# OVERRIDES$/ { if(skip) next }
+ /^-- OVERRIDES$/ { if(skip) next }
+ /^# Add your own/ { if(skip) { skip=0; next } }
+ /^-- Add your own/ { if(skip) { skip=0; next } }
+ { if(!skip) print }
+ ' "$conf" > "${conf}.tmp" && mv "${conf}.tmp" "$conf"
+
+ echo "Brain_Shell removed from $conf"
+}
+
+detect_hyprland_config() {
+ if [[ -f "${HYPR_DIR}/hyprland.lua" ]]; then echo "lua"
+ elif [[ -f "${HYPR_DIR}/hyprland.conf" ]]; then echo "conf"
+ else echo "conf"; fi
+}
+
+generate_hyprland_configs() {
+ mkdir -p "$DATA_DIR"
+
+ cat > "${DATA_DIR}/hyprland.conf" <<'HYPRCONF'
+# Brain_Shell — generated by HyprlandSyncService
+# Regenerated on keybind changes. Do not edit manually.
+
+$mainMod = SUPER
+
+exec-once = brain-shell
+
+windowrule = no_blur on, match:class ^(quickshell)$
+windowrule = border_size 0, match:class ^(quickshell)$
+windowrule = no_anim on, match:class ^(quickshell)$
+windowrule = rounding 0, match:class ^(quickshell)$
+windowrule = stay_focused on, match:class ^(quickshell)$
+windowrule = no_max_size on, match:class ^(quickshell)$
+
+layerrule = blur on, match:namespace ^(quickshell)$
+layerrule = ignore_alpha 0, match:namespace ^(quickshell)$
+layerrule = no_anim on, match:namespace ^(quickshell)$
+HYPRCONF
+
+ cat > "${DATA_DIR}/hyprland.lua" <<'HYPRLUA'
+-- Brain_Shell — generated by HyprlandSyncService
+-- This file is regenerated automatically when keybinds change.
+-- Do not edit manually.
+
+-- ── Autostart ─────────────────────────────────────────────────────────────
+hl.on("hyprland.start", function ()
+ hl.exec_cmd("brain-shell")
+end)
+
+-- ── Window rules (quickshell integration) ────────────────────────────────
+hl.window_rule({ match = { class = "^(quickshell)$" }, no_blur = true })
+hl.window_rule({ match = { class = "^(quickshell)$" }, border_size = 0 })
+hl.window_rule({ match = { class = "^(quickshell)$" }, no_anim = true })
+hl.window_rule({ match = { class = "^(quickshell)$" }, rounding = 0 })
+hl.window_rule({ match = { class = "^(quickshell)$" }, stay_focused = true })
+hl.window_rule({ match = { class = "^(quickshell)$" }, no_max_size = true })
+
+-- ── Layer rules (quickshell integration) ──────────────────────────────────
+hl.layer_rule({ match = { namespace = "^(quickshell)$" }, blur = true })
+hl.layer_rule({ match = { namespace = "^(quickshell)$" }, ignore_alpha = 0 })
+hl.layer_rule({ match = { namespace = "^(quickshell)$" }, no_anim = true })
+
+-- ── Submap for keybind interception ────────────────────────────────────────
+hl.define_submap("BrainShell_clean", function()
+ hl.bind("Escape", hl.dsp.submap("reset"))
+end)
+HYPRLUA
+}
+
+install_hyprland() {
+ local mode="${1:-auto}"
+ if [[ "$mode" == "auto" ]]; then mode=$(detect_hyprland_config); fi
+
+ mkdir -p "$HYPR_DIR" "$DATA_DIR"
+ generate_hyprland_configs
+
+ if [[ "$mode" == "lua" ]]; then
+ local lua_source='loadfile(os.getenv("HOME") .. "/.local/share/Brain_Shell/hyprland.lua")()'
+ append_hyprland_block "${HYPR_DIR}/hyprland.lua" "$lua_source" "$BRAIN_SHELL_LUA_BLOCK"
+ remove_hyprland_block "${HYPR_DIR}/hyprland.conf" "source = ${DATA_DIR}/hyprland.conf" 2>/dev/null || true
+ else
+ local conf_source="source = ${DATA_DIR}/hyprland.conf"
+ append_hyprland_block "${HYPR_DIR}/hyprland.conf" "$conf_source" "$BRAIN_SHELL_CONF_BLOCK"
+ remove_hyprland_block "${HYPR_DIR}/hyprland.lua" 'loadfile(os.getenv("HOME") .. "/.local/share/Brain_Shell/hyprland.lua")()' 2>/dev/null || true
+ fi
+
+ echo ""
+ echo " Restart Hyprland or run: brain-shell"
+}
+
+remove_hyprland() {
+ remove_hyprland_block "${HYPR_DIR}/hyprland.conf" "source = ${DATA_DIR}/hyprland.conf" 2>/dev/null || true
+ remove_hyprland_block "${HYPR_DIR}/hyprland.lua" 'loadfile(os.getenv("HOME") .. "/.local/share/Brain_Shell/hyprland.lua")()' 2>/dev/null || true
+ rm -f "${DATA_DIR}/hyprland.conf" "${DATA_DIR}/hyprland.lua"
+}
+
+# ── Update ────────────────────────────────────────────────────────────────────
+update_shell() {
+ echo "Updating Brain_Shell..."
+ if [[ -d "${SCRIPT_DIR}/.git" ]]; then
+ cd "$SCRIPT_DIR"
+ git pull --ff-only || { echo "Error: git pull failed."; exit 1; }
+ echo "Brain_Shell updated."
+ else
+ echo "Error: Not a git repository."
+ exit 1
+ fi
+
+ local pid
+ pid=$(find_brain_shell_pid)
+ if [[ -n "$pid" ]]; then
+ echo "Restarting..."
+ restart_shell
+ fi
+}
+
+# ── Goodbye ───────────────────────────────────────────────────────────────────
+goodbye() {
+ echo "This will remove Brain_Shell from your system."
+ echo "Your configs will be preserved at: $CONFIG_DIR"
+ echo ""
+ read -rp "Continue? [y/N] " confirm
+ if [[ ! "$confirm" =~ ^[Yy] ]]; then
+ echo "Cancelled."
+ exit 0
+ fi
+
+ quit_shell 2>/dev/null || true
+ remove_hyprland
+
+ if [[ -L /usr/local/bin/brain-shell ]]; then
+ sudo rm -f /usr/local/bin/brain-shell 2>/dev/null || echo " Please remove /usr/local/bin/brain-shell manually"
+ fi
+
+ echo ""
+ echo "Brain_Shell removed."
+ echo " Configs: $CONFIG_DIR"
+ echo " Full wipe: rm -rf $CONFIG_DIR ${DATA_DIR} $SCRIPT_DIR"
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# MAIN
+# ═══════════════════════════════════════════════════════════════════════════════
+case "${1:-}" in
+ ""|launch) launch_shell ;;
+ help|-h|--help) show_help ;;
+ version|-v|--version) echo "Brain_Shell v${VERSION}" ;;
+ update) update_shell ;;
+ reload|restart) restart_shell ;;
+ sync) send_ipc "sync-binds" 2>/dev/null || echo "Brain_Shell not running — binds will sync on next start" ;;
+ quit|stop|exit) quit_shell ;;
+ lock|lockscreen) lock_screen ;;
+
+ screen)
+ case "${2:-}" in
+ off) hyprctl dispatch dpms off 2>/dev/null || echo "Error: hyprctl not found" ;;
+ on) hyprctl dispatch dpms on 2>/dev/null || echo "Error: hyprctl not found" ;;
+ *) echo "Usage: brain-shell screen [on|off]"; exit 1 ;;
+ esac ;;
+
+ suspend)
+ if command -v systemctl &>/dev/null; then systemctl suspend
+ elif command -v loginctl &>/dev/null; then loginctl suspend
+ else dbus-send --system --print-reply --dest=org.freedesktop.login1 /org/freedesktop/login1 org.freedesktop.login1.Manager.Suspend boolean:true
+ fi ;;
+
+ brightness)
+ ARG2="${2:-}"; ARG3="${3:-}"; ARG4="${4:-}"
+ case "$ARG2" in
+ -l|--list) brightness_list_monitors ;;
+ -r|--restore) brightness_restore "${ARG3:-}" ;;
+ -s|--save) brightness_save "${ARG3:-}" ;;
+ [+-][0-9]*)
+ MONITOR=""
+ [[ -n "$ARG3" && "$ARG3" != "-s" && "$ARG3" != "--save" ]] && MONITOR="$ARG3"
+ brightness_adjust "$ARG2" "$MONITOR" ;;
+ [0-9]*)
+ VALUE="$ARG2"; MONITOR=""; SAVE="false"
+ [[ "$ARG3" == "-s" || "$ARG3" == "--save" ]] && { SAVE="true"; }
+ [[ -n "$ARG3" && "$ARG3" != "-s" && "$ARG3" != "--save" ]] && { MONITOR="$ARG3"; [[ "${ARG4:-}" == "-s" || "${ARG4:-}" == "--save" ]] && SAVE="true"; }
+ brightness_set "$VALUE" "$MONITOR" "$SAVE" ;;
+ *) echo "Error: Invalid brightness argument '$ARG2'."; echo "Usage: brain-shell brightness <0-100|+/-delta|-s|-r|-l> [monitor]"; exit 1 ;;
+ esac ;;
+
+ volume-up) volume_up ;;
+ volume-down) volume_down ;;
+ volume-mute) volume_mute ;;
+ mic-mute) mic_mute ;;
+ caffeine) caffeine_toggle ;;
+ gamemode) send_ipc "gamemode" 2>/dev/null || echo "Error: Brain_Shell is not running" ;;
+ nightlight)
+ if command -v hyprsunset &>/dev/null; then
+ if pgrep -x hyprsunset &>/dev/null; then
+ pkill -x hyprsunset 2>/dev/null && echo "Night light: OFF"
+ else
+ hyprsunset -t 4000 &>/dev/null &
+ echo "Night light: ON (4000K)"
+ fi
+ else
+ echo "Error: hyprsunset not found. Install hyprsunset for night light."
+ fi
+ ;;
+
+ run)
+ CMD="${2:-}"
+ if [[ -z "$CMD" ]]; then
+ echo "Error: No command specified."
+ echo "Run 'brain-shell help' for available commands."
+ exit 1
+ fi
+ send_ipc "$CMD" ;;
+
+ install)
+ TARGET="${2:-}"
+ if [[ "$TARGET" == "hyprland" ]]; then
+ MODE="auto"
+ for arg in "${@:3}"; do
+ case "$arg" in --lua) MODE="lua" ;; --conf) MODE="conf" ;; esac
+ done
+ install_hyprland "$MODE"
+ else
+ echo "Error: Unknown install target '$TARGET'."
+ echo "Usage: brain-shell install hyprland [--lua|--conf]"
+ exit 1
+ fi ;;
+
+ remove)
+ TARGET="${2:-}"
+ if [[ "$TARGET" == "hyprland" ]]; then remove_hyprland
+ else echo "Error: Unknown remove target '$TARGET'."; echo "Usage: brain-shell remove hyprland"; exit 1
+ fi ;;
+
+ goodbye) goodbye ;;
+
+ *) echo "Error: Unknown command '$1'."; echo "Run 'brain-shell help' for usage information."; exit 1 ;;
+esac
diff --git a/dots-extra/install-arch.sh b/dots-extra/install-arch.sh
new file mode 100644
index 0000000..fba2290
--- /dev/null
+++ b/dots-extra/install-arch.sh
@@ -0,0 +1,460 @@
+#!/bin/bash
+# ─────────────────────────────────────────────────────────────────────────────
+# Brain Shell — Arch Linux Installer
+# Invoked by install.sh: $1=HYPRLAND_CONF $2=BACKUP_DIR $3=CONFIG_TYPE
+# ─────────────────────────────────────────────────────────────────────────────
+
+set -eo pipefail
+
+# ── Arguments (validated up-front) ───────────────────────────────────────────
+HYPRLAND_CONF="${1:?Missing arg: HYPRLAND_CONF path}"
+BACKUP_DIR="${2:?Missing arg: BACKUP_DIR}"
+CONFIG_TYPE="${3:?Missing arg: CONFIG_TYPE (conf|lua)}"
+REPO_DIR="$HOME/.local/src/Brain_Shell"
+
+# ── Colors ────────────────────────────────────────────────────────────────────
+RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
+BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'
+DIM='\033[2m'; NC='\033[0m'
+
+# ── Logging ───────────────────────────────────────────────────────────────────
+log_info() { echo -e " ${BLUE}·${NC} $1"; }
+log_ok() { echo -e " ${GREEN}✓${NC} $1"; }
+log_warn() { echo -e " ${YELLOW}⚠${NC} $1"; }
+log_error() { echo -e " ${RED}✗${NC} $1" >&2; }
+die() { echo ""; log_error "$1"; exit 1; }
+
+TOTAL_STEPS=5
+step() {
+ echo ""
+ echo -e "${BOLD}${CYAN} [$1/$TOTAL_STEPS] $2${NC}"
+ echo -e " ${DIM}$(printf '%.0s─' {1..50})${NC}"
+}
+
+# ── Failure Tracking ──────────────────────────────────────────────────────────
+# Packages that couldn't be installed are collected here and shown in the
+# final summary with manual fix commands instead of aborting the whole install.
+declare -a FAILED_PKGS=()
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# PACKAGE HELPERS
+# ══════════════════════════════════════════════════════════════════════════════
+
+# pacman_install [ ...]
+#
+# Strategy (three attempts, most to least aggressive):
+#
+# 1. Bulk install — fastest; skips already-installed packages via --needed.
+#
+# 2. Per-package retry — if the bulk transaction fails because ONE package
+# has a conflict, the entire batch is rejected. Retrying individually
+# isolates which package is actually broken so the rest can still install.
+#
+# 3. --overwrite='*' per package — resolves FILE-OWNERSHIP conflicts, where
+# two packages both claim the same path. Safe in practice: the new package
+# just wins the ownership. This does NOT help with hard PKGBUILD conflicts
+# (ConflictsWith). Those need manual resolution (see summary output).
+#
+pacman_install() {
+ local -a pkgs=("$@")
+ local total=${#pkgs[@]}
+
+ log_info "Installing $total packages via pacman..."
+
+ # Attempt 1 — bulk
+ if sudo pacman -S --needed --noconfirm "${pkgs[@]}" 2>/dev/null; then
+ log_ok "All $total packages installed."
+ return 0
+ fi
+
+ # Bulk failed — at least one conflict. Fall back to one-by-one.
+ echo ""
+ log_warn "Bulk install hit a conflict. Retrying individually..."
+ echo ""
+
+ local installed=0 already=0
+ local -a failed=()
+
+ for pkg in "${pkgs[@]}"; do
+ # Skip silently if already present
+ if pacman -Qi "$pkg" &>/dev/null; then
+ already=$(( already + 1 ))
+ continue
+ fi
+
+ printf " ${DIM}%-32s${NC} " "$pkg"
+
+ # Attempt 2 — standard per-package
+ if sudo pacman -S --needed --noconfirm "$pkg" &>/dev/null; then
+ echo -e "${GREEN}✓${NC}"
+ installed=$(( installed + 1 ))
+ continue
+ fi
+
+ # Attempt 3 — overwrite (file-ownership conflicts)
+ if sudo pacman -S --needed --noconfirm --overwrite='*' "$pkg" &>/dev/null; then
+ echo -e "${YELLOW}✓ overwrite${NC}"
+ installed=$(( installed + 1 ))
+ continue
+ fi
+
+ # Hard conflict — needs manual resolution
+ echo -e "${RED}✗ conflict${NC}"
+ failed+=("$pkg")
+ done
+
+ echo ""
+ log_ok "$installed installed, $already already present"
+
+ if [[ ${#failed[@]} -gt 0 ]]; then
+ log_warn "${#failed[@]} package(s) failed (hard conflict — see summary):"
+ for pkg in "${failed[@]}"; do
+ log_warn " $pkg"
+ FAILED_PKGS+=("pacman:$pkg")
+ done
+ fi
+}
+
+# aur_install [ ...]
+#
+# Installs packages individually via yay/paru. AUR helpers build from source,
+# so they don't support the same --overwrite shortcut. Failures are tracked but
+# non-fatal; quickshell is checked explicitly afterwards.
+#
+aur_install() {
+ local helper="$1"; shift
+ local -a pkgs=("$@")
+ local -a failed=()
+
+ echo ""
+ for pkg in "${pkgs[@]}"; do
+ printf " ${DIM}%-32s${NC} " "$pkg"
+
+ if $helper -Q "$pkg" &>/dev/null; then
+ echo -e "${GREEN}✓ already installed${NC}"
+ continue
+ fi
+
+ if $helper -S --noconfirm "$pkg" &>/dev/null; then
+ echo -e "${GREEN}✓${NC}"
+ else
+ echo -e "${RED}✗${NC}"
+ failed+=("$pkg")
+ fi
+ done
+
+ echo ""
+
+ if [[ ${#failed[@]} -gt 0 ]]; then
+ log_warn "${#failed[@]} AUR package(s) failed (see summary):"
+ for pkg in "${failed[@]}"; do
+ log_warn " $pkg"
+ FAILED_PKGS+=("aur:$pkg")
+ done
+ else
+ log_ok "All AUR packages installed."
+ fi
+}
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# STEP 1 — AUR Helper
+# ══════════════════════════════════════════════════════════════════════════════
+step 1 "AUR Helper"
+
+AUR_HELPER=""
+
+if command -v yay &>/dev/null; then
+ AUR_HELPER="yay"
+ log_ok "yay detected"
+elif command -v paru &>/dev/null; then
+ AUR_HELPER="paru"
+ log_ok "paru detected"
+else
+ log_warn "No AUR helper found (yay / paru)."
+ echo ""
+ echo -e " ${BOLD}Select one to install:${NC}"
+ echo " 1) yay — more interactive, widely used"
+ echo " 2) paru — faster builds, more features"
+ echo " 3) Skip — pacman-only (quickshell will be missing)"
+ echo ""
+ read -rp " Choice [1/2/3]: " _aur_choice < /dev/tty
+
+ _bootstrap_aur_helper() {
+ local name="$1"
+ log_info "Bootstrapping $name from AUR..."
+ sudo pacman -S --needed --noconfirm git base-devel
+ local tmp; tmp=$(mktemp -d)
+ git clone "https://aur.archlinux.org/${name}.git" "$tmp/$name"
+ ( cd "$tmp/$name" && makepkg -si --noconfirm )
+ rm -rf "$tmp"
+ log_ok "$name installed."
+ }
+
+ case "$_aur_choice" in
+ 1) _bootstrap_aur_helper yay; AUR_HELPER="yay" ;;
+ 2) _bootstrap_aur_helper paru; AUR_HELPER="paru" ;;
+ 3)
+ log_warn "Skipping AUR helper."
+ log_warn "quickshell is required — install yay or paru later and re-run."
+ AUR_HELPER="none"
+ ;;
+ *) die "Invalid choice." ;;
+ esac
+fi
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# STEP 2 — Pacman Packages
+# ══════════════════════════════════════════════════════════════════════════════
+step 2 "Pacman Packages"
+
+PACMAN_DEPS=(
+ # Qt6 runtime
+ qt6-base qt6-declarative qt6-multimedia qt6-5compat qt6ct
+
+ # Audio / PipeWire
+ pipewire pipewire-pulse wireplumber
+
+ # Media & player control
+ playerctl mpv-mpris mpd-mpris
+
+ # Network / Bluetooth
+ networkmanager bluez bluez-utils
+
+ # System services
+ brightnessctl upower libnotify polkit
+ python wl-clipboard slurp xdg-user-dirs
+
+ # Screen recording
+ wf-recorder cava
+
+ # Wallpaper / theming
+ imagemagick
+
+ # Input simulation
+ wtype
+
+ # Hardware sensors
+ lm_sensors rfkill
+
+ # Hyprland ecosystem (hyprshutdown is AUR-only — kept out of here)
+ hyprland hyprsunset hyprlock hyprpolkitagent hypridle
+ xdg-desktop-portal-hyprland
+
+ # Fonts
+ ttf-jetbrains-mono-nerd ttf-nerd-fonts-symbols-common
+)
+
+log_info "Syncing package database..."
+sudo pacman -Syu --noconfirm 2>/dev/null || {
+ log_warn "System update failed — continuing with current DB. Some packages may be stale."
+}
+
+pacman_install "${PACMAN_DEPS[@]}"
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# STEP 3 — AUR Packages
+# ══════════════════════════════════════════════════════════════════════════════
+step 3 "AUR Packages"
+
+AUR_DEPS=(
+ quickshell # REQUIRED — the shell runtime
+ matugen # Material You color generation
+ envycontrol # GPU switching
+ auto-cpufreq # CPU power management
+ nbfc-linux # fan control
+ cliphist # clipboard history
+ hyprshutdown # power menu backend
+ grimblast-git # screenshot tool
+)
+
+if [[ "$AUR_HELPER" == "none" ]]; then
+ log_warn "No AUR helper — skipping all AUR packages."
+ for pkg in "${AUR_DEPS[@]}"; do
+ FAILED_PKGS+=("aur:$pkg (no helper)")
+ done
+else
+ log_info "Using: $AUR_HELPER"
+ aur_install "$AUR_HELPER" "${AUR_DEPS[@]}"
+fi
+
+# quickshell is non-negotiable
+if ! "$AUR_HELPER" -Q quickshell &>/dev/null 2>&1; then
+ die "quickshell failed to install. Brain Shell cannot run without it."
+fi
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# STEP 4 — Systemd Services
+# ══════════════════════════════════════════════════════════════════════════════
+step 4 "Systemd Services"
+
+_svc_system() {
+ sudo systemctl enable --now "$1" 2>/dev/null \
+ && log_ok "system: $1" \
+ || log_warn "system: $1 (failed to enable — may not apply to your setup)"
+}
+_svc_user() {
+ systemctl --user enable --now "$1" 2>/dev/null \
+ && log_ok "user: $1" \
+ || log_warn "user: $1 (failed to enable)"
+}
+
+_svc_system NetworkManager
+_svc_system bluetooth
+_svc_system upower
+_svc_user pipewire
+_svc_user pipewire-pulse
+_svc_user wireplumber
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# STEP 5 — Brain Shell Config & Keybind Check
+# ══════════════════════════════════════════════════════════════════════════════
+step 5 "Brain Shell Config"
+
+USER_DATA="$HOME/.config/Brain_Shell/src/user_data"
+
+mkdir -p "$USER_DATA" \
+ "$HOME/.config/hypr/shaders" \
+ "$HOME/.config/matugen/templates"
+
+# Copy hypridle config; -n = do not overwrite if already customised
+if cp -n "$REPO_DIR/src/config/hypridle.conf" "$HOME/.config/hypr/" 2>/dev/null; then
+ log_ok "hypridle.conf → ~/.config/hypr/"
+else
+ log_info "hypridle.conf already exists — not overwritten"
+fi
+
+printf '{"configProvider": "%s"}\n' "$CONFIG_TYPE" > "$USER_DATA/config_Provider.json"
+printf '{}\n' > "$USER_DATA/keybinds.json"
+
+log_ok "Config dirs created"
+log_ok "config_Provider.json → $CONFIG_TYPE"
+
+log_info "Initializing cache directories..."
+mkdir -p "$HOME/.cache/brain-shell"
+touch "$HOME/.cache/brain-shell/colors.json"
+mkdir -p "$HOME/Pictures/Wallpapers"
+cp -n -r "$REPO_DIR/src/assets/wallpapers"/* "$HOME/Pictures/Wallpapers/" 2>/dev/null || true
+
+log_ok "Cache directories initialized"
+
+# ── Keybind Conflict Detection ────────────────────────────────────────────────
+echo ""
+log_info "Checking keybind conflicts against active Hyprland session..."
+
+python3 << 'PYEOF' || log_warn "Keybind check skipped (Python error or no Hyprland session)."
+import subprocess, json, os, sys
+
+DEFAULTS = {
+ "dashboard-home": {"mods": "SUPER", "key": "D", "label": "Dashboard: System"},
+ "dashboard-stats": {"mods": "CTRL + SHIFT", "key": "ESCAPE", "label": "Dashboard: Home"},
+ "dashboard-kanban": {"mods": "SUPER", "key": "Z", "label": "Dashboard: Tasks"},
+ "dashboard-launcher": {"mods": "SUPER", "key": "Q", "label": "Dashboard: Apps"},
+ "dashboard-config": {"mods": "SUPER", "key": "C", "label": "Dashboard: Config"},
+ "PowerMenu-toggle": {"mods": "SUPER", "key": "ESCAPE", "label": "Power Menu"},
+ "notification-toggle": {"mods": "SUPER", "key": "N", "label": "Notifications"},
+ "wallpaper-toggle": {"mods": "SUPER", "key": "W", "label": "Wallpaper"},
+ "clipboard-toggle": {"mods": "SUPER", "key": "V", "label": "Clipboard"},
+ "wifi-toggle": {"mods": "SUPER + ALT", "key": "W", "label": "Network: Wi-Fi"},
+ "bluetooth-toggle": {"mods": "SUPER + ALT", "key": "B", "label": "Network: Bluetooth"},
+ "vpn-toggle": {"mods": "SUPER + ALT", "key": "G", "label": "Network: VPN"},
+ "hotspot-toggle": {"mods": "SUPER + ALT", "key": "H", "label": "Network: Hotspot"},
+ "audioOut-toggle": {"mods": "SUPER", "key": "A", "label": "Audio: Output"},
+ "audioIn-toggle": {"mods": "SUPER + ALT", "key": "I", "label": "Audio: Input"},
+ "audioMix-toggle": {"mods": "SUPER", "key": "M", "label": "Audio: Mixer"},
+ "focus-toggle": {"mods": "SUPER", "key": "B", "label": "Focus Mode"},
+ "screenrec-on": {"mods": "ALT", "key": "F9", "label": "Screen Record"},
+}
+
+MOD_BITS = {"SHIFT": 1, "CTRL": 4, "ALT": 8, "SUPER": 64}
+
+def mods_to_mask(mods_str):
+ mask = 0
+ for part in mods_str.upper().split("+"):
+ mask |= MOD_BITS.get(part.strip(), 0)
+ return mask
+
+try:
+ raw = subprocess.check_output(["hyprctl", "binds", "-j"], stderr=subprocess.DEVNULL).decode()
+ hypr_binds = json.loads(raw)
+except Exception:
+ print(" \033[2m(not inside Hyprland — skipping live conflict check)\033[0m")
+ sys.exit(0)
+
+conflicts = {}
+for action, data in DEFAULTS.items():
+ mask = mods_to_mask(data["mods"])
+ key = data["key"].lower()
+ for hb in hypr_binds:
+ if hb.get("submap", "") or hb.get("mouse"):
+ continue
+ if hb.get("modmask") == mask and str(hb.get("key", "")).lower() == key:
+ desc = hb.get("dispatcher", "")
+ arg = hb.get("arg", "")
+ conflicts[action] = {
+ "bind": f"{data['mods']} + {data['key']}",
+ "label": data["label"],
+ "used_by": f"{desc} {arg}".strip(),
+ }
+ break
+
+if not conflicts:
+ print(" \033[0;32m✓\033[0m No keybind conflicts detected.")
+ sys.exit(0)
+
+print(f"\n \033[0;31m✗\033[0m {len(conflicts)} conflict(s) found:\n")
+unbound = {}
+for action, info in conflicts.items():
+ print(f" \033[1m{info['bind']:<24}\033[0m {info['label']}")
+ print(f" {'':24} already used by: {info['used_by']}\n")
+ unbound[action] = {"mods": "", "key": ""}
+
+config_path = os.path.expanduser("~/.config/Brain_Shell/src/user_data/keybinds.json")
+with open(config_path, "w") as f:
+ json.dump(unbound, f, indent=2)
+
+print(" \033[1;33m⚠\033[0m Conflicting binds left unbound in Brain Shell.")
+print(" Re-assign them: Dashboard → Config → Keybinds\n")
+PYEOF
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# SUMMARY
+# ══════════════════════════════════════════════════════════════════════════════
+echo ""
+echo -e " ${DIM}$(printf '%.0s─' {1..50})${NC}"
+
+if [[ ${#FAILED_PKGS[@]} -eq 0 ]]; then
+ log_ok "Arch installation complete — no failures."
+ echo ""
+else
+ log_warn "Installation finished with ${#FAILED_PKGS[@]} unresolved package(s)."
+ echo ""
+ echo -e " ${BOLD}Retry commands:${NC}"
+
+ for entry in "${FAILED_PKGS[@]}"; do
+ _src="${entry%%:*}"
+ _pkg="${entry#*:}"
+ # Strip any parenthetical note before displaying the install command
+ _pkg_name="${_pkg%% (*}"
+ if [[ "$_src" == "pacman" ]]; then
+ log_info "sudo pacman -S $_pkg_name"
+ else
+ log_info "$AUR_HELPER -S $_pkg_name"
+ fi
+ done
+
+ echo ""
+ echo -e " ${BOLD}Resolving hard package conflicts (ConflictsWith):${NC}"
+ log_info "Find what conflicts: pacman -Si | grep Conflicts"
+ log_info "Remove the old one: sudo pacman -Rdd "
+ log_info "Then retry: sudo pacman -S "
+ echo ""
+fi
+
+exit 0
diff --git a/dots-extra/validate-install.sh b/dots-extra/validate-install.sh
new file mode 100644
index 0000000..ec4c64a
--- /dev/null
+++ b/dots-extra/validate-install.sh
@@ -0,0 +1,210 @@
+#!/bin/bash
+
+# Brain Shell — Post-Installation Validator
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+BOLD='\033[1m'
+NC='\033[0m'
+
+INSTALLED=0
+MISSING=0
+OPTIONAL_MISSING=0
+
+log_installed() {
+ echo -e "${GREEN}[✓]${NC} $1"
+ ((INSTALLED++))
+}
+
+log_missing() {
+ echo -e "${RED}[✗]${NC} $1 ${YELLOW}(MISSING)${NC}"
+ ((MISSING++))
+}
+
+log_optional() {
+ echo -e "${YELLOW}[○]${NC} $1 ${YELLOW}(optional)${NC}"
+ ((OPTIONAL_MISSING++))
+}
+
+log_info() {
+ echo -e "${BLUE}[INFO]${NC} $1"
+}
+
+check_command() {
+ if command -v "$1" &> /dev/null; then
+ log_installed "$1"
+ else
+ log_missing "$1"
+ fi
+}
+
+check_optional() {
+ if command -v "$1" &> /dev/null; then
+ log_installed "$1 (optional)"
+ else
+ log_optional "$1"
+ fi
+}
+
+check_package() {
+ local pkg="$1"
+ local cmd="$2"
+ [[ -z "$cmd" ]] && cmd="$pkg"
+
+ if command -v "$cmd" &> /dev/null; then
+ log_installed "$pkg"
+ else
+ log_missing "$pkg"
+ fi
+}
+
+clear
+echo "Brain Shell — Post-Installation Validator"
+echo "Verify all dependencies are installed"
+echo ""
+
+if [[ -f /etc/os-release ]]; then
+ . /etc/os-release
+ DISTRO="$ID"
+else
+ DISTRO="unknown"
+fi
+
+log_info "Detected distribution: $DISTRO"
+echo ""
+
+echo "# CORE RUNTIME"
+check_command "quickshell"
+check_command "hyprland"
+check_command "hyprctl"
+
+echo ""
+echo "# QT6 & RENDERING"
+check_package "qt6-base" "qdbus"
+check_command "qt6ct"
+
+echo ""
+echo "# SYSTEM TOOLS"
+check_command "pactl" || check_command "pacmd"
+check_command "bluetoothctl"
+check_command "brightnessctl"
+check_command "upower"
+check_command "notify-send"
+check_command "pkexec"
+check_command "python"
+check_command "wl-copy"
+check_command "slurp"
+
+echo ""
+echo "# SCREEN RECORDING"
+check_command "wf-recorder"
+check_command "cava"
+
+echo ""
+echo "# WALLPAPER & THEMING"
+check_command "magick"
+check_optional "matugen"
+
+echo ""
+echo "# CLIPBOARD"
+check_command "wtype"
+check_optional "cliphist"
+
+echo ""
+echo "# POWER & HARDWARE"
+check_optional "envycontrol"
+check_optional "auto-cpufreq"
+check_command "sensors"
+check_optional "nbfc"
+check_command "rfkill"
+
+echo ""
+echo "# HYPRLAND ECOSYSTEM"
+check_command "hyprsunset"
+check_command "hyprlock"
+check_command "hypridle"
+check_optional "hyprshutdown"
+
+echo ""
+echo "# FONTS"
+if fc-list | grep -q "JetBrains Mono"; then
+ log_installed "JetBrains Mono Nerd Font"
+else
+ log_missing "JetBrains Mono Nerd Font"
+fi
+
+echo ""
+echo "# CONFIGURATION FILES"
+
+if [[ -f "$HOME/.config/hypr/hyprland.conf" ]]; then
+ log_installed "Hyprland config"
+
+ if grep -q "quickshell.*-c.*Brain_Shell" "$HOME/.config/hypr/hyprland.conf"; then
+ log_installed "Brain Shell exec-once in hyprland.conf"
+ else
+ log_missing "Brain Shell exec-once in hyprland.conf"
+ fi
+else
+ log_missing "Hyprland config"
+fi
+
+if [[ -f "$HOME/.config/hypr/hyprland.lua" ]]; then
+ log_installed "Hyprland Lua config"
+
+ if grep -q "quickshell.*Brain_Shell" "$HOME/.config/hypr/hyprland.lua"; then
+ log_installed "Brain Shell exec-once in hyprland.lua"
+ else
+ log_optional "Brain Shell exec-once in hyprland.lua (optional)"
+ fi
+else
+ log_optional "Hyprland Lua config (optional)"
+fi
+
+if [[ -d "$HOME/.local/src/Brain_Shell" ]]; then
+ log_installed "Brain Shell repository"
+else
+ log_missing "Brain Shell repository"
+fi
+
+if [[ -d "$HOME/.config/Brain_Shell" ]]; then
+ log_installed "Brain Shell config directory"
+else
+ log_missing "Brain Shell config directory"
+fi
+
+echo ""
+echo "# BACKUPS"
+BACKUP_COUNT=$(ls -d $HOME/.config.backup-* 2>/dev/null | wc -l)
+
+if [[ $BACKUP_COUNT -gt 0 ]]; then
+ log_info "Found $BACKUP_COUNT config backup(s)"
+ ls -d $HOME/.config.backup-* 2>/dev/null | while read backup; do
+ echo -e " ${BLUE}→${NC} ${backup##*/}"
+ done
+else
+ log_optional "No config backups found"
+fi
+
+echo ""
+echo "# SUMMARY"
+TOTAL=$((INSTALLED + MISSING + OPTIONAL_MISSING))
+
+echo -e "${GREEN}✓ Installed: $INSTALLED${NC}"
+echo -e "${RED}✗ Missing: $MISSING${NC}"
+echo -e "${YELLOW}○ Optional: $OPTIONAL_MISSING${NC}"
+echo ""
+
+if [[ $MISSING -eq 0 ]]; then
+ echo -e "${GREEN}All required dependencies are installed!${NC}"
+ exit 0
+else
+ echo -e "${YELLOW}Some required dependencies are missing.${NC}"
+ echo ""
+ echo "To fix:"
+ echo " • Arch: Re-run install-arch.sh or install missing packages with pacman/yay"
+ echo " • NixOS: Re-run install-nix.sh or add packages to your flake.nix"
+ echo ""
+ exit 1
+fi
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..2d59697
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,61 @@
+{
+ "nodes": {
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1780749050,
+ "narHash": "sha256-3av0pIjlOWQ6rDbNOmpUSvbNnJkGORQKKjb4LtCZsIY=",
+ "owner": "nixos",
+ "repo": "nixpkgs",
+ "rev": "a799d3e3886da994fa307f817a6bc705ae538eeb",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nixos",
+ "repo": "nixpkgs",
+ "ref": "nixos-unstable",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-utils": "flake-utils",
+ "nixpkgs": "nixpkgs"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..931054c
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,195 @@
+{
+ description = "Brain Shell — Modular Quickshell/QML desktop shell for Hyprland";
+
+ inputs = {
+ nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
+
+ flake-utils = {
+ url = "github:numtide/flake-utils";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ };
+
+ outputs =
+ {
+ self,
+ nixpkgs,
+ flake-utils,
+ }:
+ flake-utils.lib.eachDefaultSystem (
+ system:
+ let
+ pkgs = nixpkgs.legacyPackages.${system};
+
+ # ── Runtime dependencies (required at launch) ──────────────────────
+ runtimeDeps = with pkgs; [
+ # Core shell runtime
+ quickshell
+ hyprland
+ qt6.full
+ qt6ct
+
+ # Audio
+ pipewire
+ pipewire-pulse
+ wireplumber
+ playerctl
+ mpv-mpris
+ mpd-mpris
+
+ # Network / Bluetooth
+ networkmanager
+ bluez
+ bluez-utils
+
+ # Display / input utilities
+ brightnessctl
+ wl-clipboard
+ slurp
+ xdg-user-dirs
+ xdg-desktop-portal-hyprland
+
+ # System info
+ upower
+ libnotify
+ polkit
+ lm_sensors
+ rfkill
+
+ # Media & visualiser
+ cava
+ python3
+
+ # Screen recording
+ wf-recorder
+
+ # Wallpaper & theming
+ imagemagick
+ awww
+ matugen
+
+ # Clipboard integration
+ wtype
+ cliphist
+
+ # Power & hardware management
+ envycontrol
+ auto-cpufreq
+
+ # Hyprland ecosystem
+ hyprsunset
+ hyprlock
+ hypridle
+ ];
+
+ # ── Development extras (not needed at runtime) ─────────────────────
+ devDeps = with pkgs; [
+ git
+ bash
+ shellcheck
+ python3Packages.python-lsp-server
+ ];
+
+ # ── Fonts ──────────────────────────────────────────────────────────
+ fonts = with pkgs; [
+ (nerdfonts.override { fonts = [ "JetBrainsMono" ]; })
+ ];
+
+ # ── The Brain Shell package ────────────────────────────────────────
+ brain-shell = pkgs.stdenv.mkDerivation {
+ pname = "brain-shell";
+ version = "0.1.0";
+
+ src = ./.;
+
+ nativeBuildInputs = [ pkgs.makeWrapper ];
+ buildInputs = runtimeDeps ++ fonts;
+
+ installPhase = ''
+ runHook preInstall
+
+ mkdir -p $out/share/brain-shell
+ cp -r . $out/share/brain-shell/
+
+ mkdir -p $out/bin
+ makeWrapper ${pkgs.quickshell}/bin/quickshell $out/bin/brain-shell \
+ --add-flags "-c $out/share/brain-shell" \
+ --set QT_QPA_PLATFORMTHEME qt6ct \
+ --prefix PATH : ${pkgs.lib.makeBinPath runtimeDeps}
+
+ runHook postInstall
+ '';
+
+ meta = with pkgs.lib; {
+ description = "A modular Quickshell/QML desktop shell for Hyprland";
+ homepage = "https://github.com/Brainitech/Brain_Shell";
+ license = licenses.mit;
+ platforms = platforms.linux;
+ mainProgram = "brain-shell";
+ };
+ };
+
+ in
+ {
+ # ── Packages ───────────────────────────────────────────────────────
+ packages = {
+ default = brain-shell;
+ brain-shell = brain-shell;
+ };
+
+ # ── Dev shell (nix develop) ────────────────────────────────────────
+ devShells.default = pkgs.mkShell {
+ name = "brain-shell-dev";
+
+ buildInputs = runtimeDeps ++ devDeps ++ fonts;
+
+ shellHook = ''
+ export QT_QPA_PLATFORMTHEME=qt6ct
+ export BRAIN_SHELL_ROOT="$(pwd)"
+
+ echo ""
+ echo " Brain Shell dev environment"
+ echo " Run: quickshell -c \$BRAIN_SHELL_ROOT"
+ echo " Lint: shellcheck install.sh dots-extra/install-arch.sh"
+ echo ""
+ '';
+ };
+
+ # ── NixOS module ───────────────────────────────────────────────────
+ nixosModules.default =
+ {
+ config,
+ lib,
+ pkgs,
+ ...
+ }:
+ let
+ cfg = config.programs.brain-shell;
+ in
+ {
+ options.programs.brain-shell = {
+ enable = lib.mkEnableOption "Brain Shell desktop shell";
+
+ autostart = lib.mkOption {
+ type = lib.types.bool;
+ default = true;
+ description = "Add brain-shell to Hyprland exec-once.";
+ };
+ };
+
+ config = lib.mkIf cfg.enable {
+ environment.systemPackages = [ brain-shell ];
+
+ # Hyprland exec-once can't be configured reliably via NixOS
+ # modules due to how Home Manager handles it. Users should
+ # add the exec-once entries manually or use the install.sh.
+ };
+ };
+
+ # ── Checks (run by `nix flake check`) ─────────────────────────────
+ checks = {
+ build = brain-shell;
+ };
+ }
+ );
+}
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..2db96ad
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,390 @@
+#!/bin/bash
+# ─────────────────────────────────────────────────────────────────────────────
+# Brain Shell — Main Installer
+# github.com/Brainitech/Brain_Shell v0.1.0
+# ─────────────────────────────────────────────────────────────────────────────
+
+set -eo pipefail
+
+# ── Colors ────────────────────────────────────────────────────────────────────
+RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
+BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'
+DIM='\033[2m'; NC='\033[0m'
+
+# ── Logging ───────────────────────────────────────────────────────────────────
+log_info() { echo -e " ${BLUE}·${NC} $1"; }
+log_ok() { echo -e " ${GREEN}✓${NC} $1"; }
+log_warn() { echo -e " ${YELLOW}⚠${NC} $1"; }
+log_error() { echo -e " ${RED}✗${NC} $1" >&2; }
+die() { echo ""; log_error "$1"; exit 1; }
+
+TOTAL_STEPS=7
+step() {
+ echo ""
+ echo -e "${BOLD}${CYAN} [$1/$TOTAL_STEPS] $2${NC}"
+ echo -e " ${DIM}$(printf '%.0s─' {1..50})${NC}"
+}
+
+# ── Trap ──────────────────────────────────────────────────────────────────────
+trap 'echo ""; log_error "Installation aborted unexpectedly (line $LINENO)."; exit 1' ERR
+
+# ── Banner ────────────────────────────────────────────────────────────────────
+clear
+echo -e "${BOLD}"
+echo " ███████████ ███████████ █████████ █████ ██████ █████ █████████ █████ █████ ██████████ █████ █████ "
+echo "▒▒███▒▒▒▒▒███▒▒███▒▒▒▒▒███ ███▒▒▒▒▒███ ▒▒███ ▒▒██████ ▒▒███ ███▒▒▒▒▒███▒▒███ ▒▒███ ▒▒███▒▒▒▒▒█▒▒███ ▒▒███ "
+echo " ▒███ ▒███ ▒███ ▒███ ▒███ ▒███ ▒███ ▒███▒███ ▒███ ▒███ ▒▒▒ ▒███ ▒███ ▒███ █ ▒ ▒███ ▒███ "
+echo " ▒██████████ ▒██████████ ▒███████████ ▒███ ▒███▒▒███▒███ ▒▒█████████ ▒███████████ ▒██████ ▒███ ▒███ "
+echo " ▒███▒▒▒▒▒███ ▒███▒▒▒▒▒███ ▒███▒▒▒▒▒███ ▒███ ▒███ ▒▒██████ ▒▒▒▒▒▒▒▒███ ▒███▒▒▒▒▒███ ▒███▒▒█ ▒███ ▒███ "
+echo " ▒███ ▒███ ▒███ ▒███ ▒███ ▒███ ▒███ ▒███ ▒▒█████ ███ ▒███ ▒███ ▒███ ▒███ ▒ █ ▒███ █ ▒███ █"
+echo " ███████████ █████ █████ █████ █████ █████ █████ ▒▒█████ ▒▒█████████ █████ █████ ██████████ ███████████ ███████████"
+echo -e "${NC}"
+echo -e " ${DIM}v0.1.0 · github.com/Brainitech/Brain_Shell${NC}"
+echo ""
+
+# ══════════════════════════════════════════════════════════════════════════════
+# STEP 1 — Pre-Flight Checks
+# ══════════════════════════════════════════════════════════════════════════════
+step 1 "Pre-Flight Checks"
+
+# OS
+[[ "$OSTYPE" =~ ^linux ]] || die "This installer only supports Linux."
+log_ok "Linux confirmed"
+
+# No root
+[[ "$EUID" -eq 0 ]] && die "Do not run as root. Use sudo where needed."
+log_ok "Running as user (not root)"
+
+# ── Distro Detection ─────────────────────────────────────────────────────────
+DISTRO_TYPE=""
+if [[ -f /etc/os-release ]]; then
+ source /etc/os-release
+ case "${ID:-}" in
+ arch|manjaro|garuda|cachyos|endeavouros)
+ log_ok "Distro: ${ID} (Arch-based)"
+ DISTRO_TYPE="arch"
+ ;;
+ fedora)
+ log_ok "Distro: ${ID} (Fedora)"
+ DISTRO_TYPE="fedora"
+ ;;
+ debian|ubuntu|pop)
+ log_ok "Distro: ${ID} (Debian-based)"
+ DISTRO_TYPE="debian"
+ ;;
+ nixos)
+ log_ok "Distro: NixOS"
+ DISTRO_TYPE="nix"
+ ;;
+ *)
+ die "Unsupported distro: ${ID:-unknown}. Supported: Arch-based, Fedora, Debian-based, NixOS."
+ ;;
+ esac
+else
+ die "Cannot detect distro — /etc/os-release not found."
+fi
+
+# ── Essential Commands ───────────────────────────────────────────────────────
+has_cmd() { command -v "$1" >/dev/null 2>&1; }
+
+for cmd in git curl bash; do
+ has_cmd "$cmd" || die "Missing essential command: $cmd"
+done
+log_ok "Essential commands available (git, curl, bash)"
+
+# Hyprland session (warn only, don't abort)
+if [[ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
+ log_warn "Not running inside a Hyprland session."
+ log_info "Changes will apply after you restart Hyprland."
+else
+ log_ok "Hyprland session active"
+fi
+
+# Hyprland config
+HYPR_DIR="$HOME/.config/hypr"
+HYPRLAND_CONF=""
+CONFIG_TYPE=""
+
+# Hyprland loads .lua first when both exist; mirror that priority here so
+# the installer always targets the file Hyprland is actually reading.
+if [[ -f "$HYPR_DIR/hyprland.lua" ]]; then
+ HYPRLAND_CONF="$HYPR_DIR/hyprland.lua"
+ CONFIG_TYPE="lua"
+ if [[ -f "$HYPR_DIR/hyprland.conf" ]]; then
+ log_ok "Hyprland config: hyprland.lua ${DIM}(hyprland.conf also present but ignored by Hyprland)${NC}"
+ else
+ log_ok "Hyprland config: hyprland.lua"
+ fi
+elif [[ -f "$HYPR_DIR/hyprland.conf" ]]; then
+ HYPRLAND_CONF="$HYPR_DIR/hyprland.conf"
+ CONFIG_TYPE="conf"
+ log_ok "Hyprland config: hyprland.conf"
+ log_warn "hyprland.conf support is deprecated as of 0.55 and will be removed in a future release."
+ log_info "Consider migrating to hyprland.lua — see https://wiki.hypr.land/Configuring/Start/"
+else
+ die "No Hyprland config found in $HYPR_DIR. Set up Hyprland first."
+fi
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# STEP 2 — Backup
+# ══════════════════════════════════════════════════════════════════════════════
+step 2 "Backup"
+
+BACKUP_TS=$(date +%Y%m%d_%H%M%S)
+BACKUP_DIR="$HOME/.config.backup-${BACKUP_TS}-Brain_Shell"
+mkdir -p "$BACKUP_DIR"
+
+if [[ -d "$HYPR_DIR" ]]; then
+ cp -r "$HYPR_DIR" "$BACKUP_DIR/"
+ log_ok "Backed up: ~/.config/hypr → $BACKUP_DIR"
+else
+ log_warn "~/.config/hypr not found — nothing to back up."
+fi
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# STEP 3 — Repository
+# ══════════════════════════════════════════════════════════════════════════════
+step 3 "Repository"
+
+REPO_PARENT="$HOME/.local/src"
+REPO_DIR="$REPO_PARENT/Brain_Shell"
+mkdir -p "$REPO_PARENT"
+
+if [[ -d "$REPO_DIR/.git" ]]; then
+ log_info "Existing clone found — updating..."
+ git -C "$REPO_DIR" fetch origin main 2>/dev/null || true
+ git -C "$REPO_DIR" checkout main 2>/dev/null || true
+
+ # Compare hashes before pulling
+ REMOTE_HASH=$(git -C "$REPO_DIR" rev-parse origin/main 2>/dev/null || echo "")
+ LOCAL_HASH=$(git -C "$REPO_DIR" rev-parse HEAD 2>/dev/null || echo "")
+
+ if [[ -n "$REMOTE_HASH" && "$REMOTE_HASH" == "$LOCAL_HASH" ]]; then
+ log_ok "Repository already up to date ($(echo "$LOCAL_HASH" | cut -c1-8))"
+ else
+ git -C "$REPO_DIR" pull origin main 2>/dev/null || true
+ log_ok "Repository updated: $REPO_DIR"
+ fi
+else
+ log_info "Cloning from GitHub..."
+ git clone -b main https://github.com/Brainitech/Brain_Shell.git "$REPO_DIR"
+ log_ok "Repository cloned: $REPO_DIR"
+fi
+
+# Ensure CLI is executable
+chmod +x "$REPO_DIR/cli.sh"
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# STEP 4 — Distro-Specific Install
+# ══════════════════════════════════════════════════════════════════════════════
+step 4 "Distro-Specific Installation"
+echo ""
+
+# Check if distro-specific installer exists
+DISTRO_INSTALLER="$REPO_DIR/dots-extra/install-${DISTRO_TYPE}.sh"
+if [[ -f "$DISTRO_INSTALLER" ]]; then
+ chmod +x "$DISTRO_INSTALLER"
+ bash "$DISTRO_INSTALLER" "$HYPRLAND_CONF" "$BACKUP_DIR" "$CONFIG_TYPE"
+else
+ log_warn "Distro installer not found: $DISTRO_INSTALLER"
+ log_info "Skipping dependency installation."
+ log_info "Make sure you have the required packages installed."
+fi
+
+# ── Quickshell verification ──────────────────────────────────────────────────
+if ! has_cmd qs && ! has_cmd quickshell; then
+ log_warn "quickshell not found in PATH after distro install."
+ log_info "Attempting to build from source..."
+
+ QUICKSHELL_REPO="https://git.outfoxxed.me/outfoxxed/quickshell"
+ BUILD_DIR=$(mktemp -d)
+
+ git clone --recursive "$QUICKSHELL_REPO" "$BUILD_DIR" 2>/dev/null || {
+ log_warn "Failed to clone Quickshell repo. Manual install required."
+ rm -rf "$BUILD_DIR"
+ }
+
+ if [[ -d "$BUILD_DIR" ]]; then
+ (
+ cd "$BUILD_DIR"
+ cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$HOME/.local" 2>/dev/null || {
+ log_warn "cmake failed — installing build tools..."
+ case "$DISTRO_TYPE" in
+ arch) sudo pacman -S --needed --noconfirm cmake ninja gcc 2>/dev/null || true ;;
+ fedora) sudo dnf install -y cmake ninja-build gcc-c++ 2>/dev/null || true ;;
+ debian) sudo apt-get install -y cmake ninja-build g++ 2>/dev/null || true ;;
+ esac
+ cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$HOME/.local" 2>/dev/null || {
+ log_warn "cmake still failed. Skipping Quickshell build."
+ exit 1
+ }
+ }
+ cmake --build build 2>/dev/null || { log_warn "Build failed."; exit 1; }
+ cmake --install build 2>/dev/null || { log_warn "Install failed."; exit 1; }
+ ) && {
+ log_ok "Quickshell built and installed to ~/.local/bin/"
+ export PATH="$HOME/.local/bin:$PATH"
+ } || {
+ log_warn "Quickshell build failed. Please install manually."
+ }
+ rm -rf "$BUILD_DIR"
+ fi
+else
+ log_ok "quickshell is available"
+fi
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# STEP 5 — Global Command
+# ══════════════════════════════════════════════════════════════════════════════
+step 5 "Global Command"
+
+BIN_DIR="/usr/local/bin"
+BIN_NAME="brain-shell"
+BIN_PATH="$BIN_DIR/$BIN_NAME"
+REPO_DIR="$HOME/.local/src/Brain_Shell"
+
+# ── Install wrapper (not a symlink — wrapper sets up PATH + QML_IMPORT_PATH first) ──
+if [[ -f "$BIN_PATH" ]]; then
+ # Check if it's our wrapper or an old symlink
+ if [[ -L "$BIN_PATH" ]]; then
+ log_info "Replacing old symlink with wrapper..."
+ sudo rm -f "$BIN_PATH"
+ elif grep -q "Brain_Shell.*wrapper" "$BIN_PATH" 2>/dev/null; then
+ log_ok "Wrapper already installed: $BIN_PATH"
+ else
+ log_warn "$BIN_PATH exists but is not our wrapper — backing up."
+ sudo mv "$BIN_PATH" "${BIN_PATH}.bak-${BACKUP_TS}"
+ fi
+fi
+
+if [[ ! -f "$BIN_PATH" ]] || ! grep -q "Brain_Shell.*wrapper" "$BIN_PATH" 2>/dev/null; then
+ sudo mkdir -p "$BIN_DIR"
+ # Generate wrapper that sets up environment before calling cli.sh
+ sudo tee "$BIN_PATH" > /dev/null << WRAPPEREOF
+#!/usr/bin/env bash
+# Brain_Shell — global launcher wrapper
+INSTALL_DIR="\${BRAIN_SHELL_DIR:-\$HOME/.local/src/Brain_Shell}"
+case ":\$PATH:" in
+ *:"\$HOME/.local/bin":*) ;;
+ *) export PATH="\$HOME/.local/bin:\$PATH" ;;
+esac
+case ":\$QML2_IMPORT_PATH:" in
+ *:"\$HOME/.local/lib/qml":*) ;;
+ *) export QML2_IMPORT_PATH="\$HOME/.local/lib/qml:\$QML2_IMPORT_PATH" ;;
+esac
+export QML_IMPORT_PATH="\$QML2_IMPORT_PATH"
+exec "\${INSTALL_DIR}/cli.sh" "\$@"
+WRAPPEREOF
+ sudo chmod +x "$BIN_PATH"
+ log_ok "Global command created: ${BIN_NAME}"
+fi
+
+# Verify
+if command -v "$BIN_NAME" &>/dev/null; then
+ log_ok "'${BIN_NAME}' is now available system-wide"
+else
+ log_info "Restart your terminal or run: export PATH=\"$BIN_DIR:\$PATH\""
+fi
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# STEP 6 — Fonts & Assets
+# ══════════════════════════════════════════════════════════════════════════════
+step 6 "Fonts & Assets"
+
+# Install JetBrains Mono if missing (Brain Shell primary font)
+if ! fc-list 2>/dev/null | grep -qi "JetBrainsMono"; then
+ log_info "JetBrains Mono not detected — installing..."
+ if ! has_cmd unzip; then
+ log_warn "unzip not found — installing..."
+ case "$DISTRO_TYPE" in
+ arch) sudo pacman -S --needed --noconfirm unzip 2>/dev/null || true ;;
+ fedora) sudo dnf install -y unzip 2>/dev/null || true ;;
+ debian) sudo apt-get install -y unzip 2>/dev/null || true ;;
+ esac
+ fi
+ FONT_DIR="$HOME/.local/share/fonts/jetbrains-mono"
+ mkdir -p "$FONT_DIR"
+ TEMP_DIR=$(mktemp -d)
+ curl -sL "https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip" -o "$TEMP_DIR/jb.zip" 2>/dev/null || {
+ log_warn "Could not download JetBrains Mono. Install manually if text looks off."
+ rm -rf "$TEMP_DIR"
+ }
+ if [[ -f "$TEMP_DIR/jb.zip" ]]; then
+ unzip -q "$TEMP_DIR/jb.zip" -d "$TEMP_DIR"
+ find "$TEMP_DIR" -name "*.ttf" -exec cp {} "$FONT_DIR/" \;
+ rm -rf "$TEMP_DIR"
+ fc-cache -f "$FONT_DIR" 2>/dev/null || true
+ log_ok "JetBrains Mono installed"
+ fi
+else
+ log_ok "JetBrains Mono already available"
+fi
+
+# Ensure wallpapers directory exists
+mkdir -p "$HOME/Pictures/Wallpapers"
+if [[ -d "$REPO_DIR/src/assets/wallpapers" ]]; then
+ cp -n -r "$REPO_DIR/src/assets/wallpapers"/* "$HOME/Pictures/Wallpapers/" 2>/dev/null || true
+ log_ok "Default wallpapers copied to ~/Pictures/Wallpapers/"
+fi
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# STEP 7 — Hyprland Integration
+# ══════════════════════════════════════════════════════════════════════════════
+step 7 "Hyprland Integration"
+
+log_info "Configuring Hyprland autostart..."
+"$REPO_DIR/cli.sh" install hyprland "--${CONFIG_TYPE}" 2>/dev/null && {
+ log_ok "Brain_Shell added to Hyprland autostart (${CONFIG_TYPE})"
+} || {
+ log_warn "Could not auto-configure Hyprland."
+ log_info "Run manually: brain-shell install hyprland"
+}
+
+# ── Post-install verification ────────────────────────────────────────────────
+echo ""
+echo -e " ${DIM}$(printf '%.0s─' {1..50})${NC}"
+log_info "Post-install verification:"
+
+# Check essential binaries
+MISSING=()
+for bin in qs quickshell hyprctl matugen; do
+ has_cmd "$bin" || MISSING+=("$bin")
+done
+
+if [[ ${#MISSING[@]} -eq 0 ]]; then
+ log_ok "All essential binaries available"
+else
+ log_warn "Missing binaries: ${MISSING[*]}"
+ log_info "Some features may not work until these are installed."
+fi
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# DONE
+# ══════════════════════════════════════════════════════════════════════════════
+echo ""
+log_ok "Brain Shell is installed."
+echo ""
+echo -e " ${BOLD}Quick start:${NC}"
+log_info "brain-shell ${DIM}Launch the shell${NC}"
+log_info "brain-shell help ${DIM}Show all commands${NC}"
+log_info "brain-shell run launcher ${DIM}Open app launcher${NC}"
+echo ""
+echo -e " ${BOLD}Restart Hyprland to activate the bar:${NC}"
+log_info "Log out and log back in ${DIM}(recommended)${NC}"
+log_info "hyprctl dispatch exit"
+log_info "Ctrl+Alt+Q ${DIM}(if configured)${NC}"
+echo ""
+echo -e " ${BOLD}Paths:${NC}"
+log_info "Config: ~/.config/Brain_Shell"
+log_info "Source: $REPO_DIR"
+log_info "Data: ~/.local/share/Brain_Shell"
+echo ""
+
+exit 0
diff --git a/shell.qml b/shell.qml
index 4e3f20e..1202835 100644
--- a/shell.qml
+++ b/shell.qml
@@ -1,44 +1,82 @@
-// shell.qml - FINAL WORKING VERSION
+//@ pragma UseQApplication
+//@ pragma NativeTextRendering
+//@ pragma DropExpensiveFonts
+
import Quickshell
import QtQuick
import "./src/windows"
-import "./src/components"
-import "./src/theme/"
+import "./src/popups"
+import "./src/"
ShellRoot {
+ // ── Force-instantiate lazy singletons that need startup behavior ──────
+ property var _keybinds: KeybindService
+ property var _updater: UpdateService
+ property var _ipc: IpcManager
+ property var _hyprSync: HyprlandSyncService
+
+ // ── Deferred non-critical service init (Ambxst pattern) ──────────────
+ // Critical services init on next tick; heavy services deferred 2s
+ QtObject {
+ id: serviceInit
+ Component.onCompleted: {
+ Qt.callLater(() => {
+ // Critical — needed immediately for IPC & keybind interception
+ let _ = IpcManager
+ _ = HyprlandSyncService
+ })
+ }
+ }
+
+ Timer {
+ interval: 2000
+ running: true
+ onTriggered: {
+ // Non-critical — can wait until shell is fully painted
+ let _ = UpdateService
+ _ = ShellConfigService
+ }
+ }
+
+ // ── Per-screen shell layout ──────────────────────────────────────────
Variants {
model: Quickshell.screens
-
+
delegate: Component {
Scope {
required property var modelData
-
- // Store screen name for easy access
- property string screenName: modelData.name
-
- // ===========================================
- // WINDOWS
- // ===========================================
-
- TopBar {
- screen: modelData
- }
-
- Border {
- screen: modelData
- edge: "left"
- }
- Border {
- screen: modelData
- edge: "right"
- }
+ // ── Windows ──────────────────────────────────────
+ // Wallpaper sits at WlrLayer.Background — behind everything
+ Wallpaper { id: wallpaper; screen: modelData }
+
+ TopBar { id: topBar; screen: modelData }
+
+ Border { id: leftBorder; screen: modelData; edge: "left" }
+ Border { id: rightBorder; screen: modelData; edge: "right" }
+ Border { id: bottomBorder; screen: modelData; edge: "bottom" }
+
+ // Rounded screen corners — masks sharp monitor edges
+ ScreenCorners { screen: modelData }
+
+ // ── Overlays ─────────────────────────────────────
+ // Dismisses all popups on click-outside or Escape
+ PopupDismiss { screen: modelData }
+
+ // GPU mode change confirmation modal
+ ConfirmDialog { screen: modelData }
+
+ // Shell update notification
+ UpdatePopup { screen: modelData }
- Border {
- screen: modelData
- edge: "bottom"
+ // ── All popups ───────────────────────────────────
+ // Add new popups in src/popups/PopupLayer.qml only
+ PopupLayer {
+ topBar: topBar
+ leftBorder: leftBorder
+ rightBorder: rightBorder
+ bottomBorder: bottomBorder
}
-
}
}
}
diff --git a/src/assets/wallpapers/brain-shell-default-0.png b/src/assets/wallpapers/brain-shell-default-0.png
new file mode 100644
index 0000000..282999d
Binary files /dev/null and b/src/assets/wallpapers/brain-shell-default-0.png differ
diff --git a/src/assets/wallpapers/brain-shell-default-1.png b/src/assets/wallpapers/brain-shell-default-1.png
new file mode 100644
index 0000000..f0ee77e
Binary files /dev/null and b/src/assets/wallpapers/brain-shell-default-1.png differ
diff --git a/src/assets/wallpapers/brain-shell-default-2.jpg b/src/assets/wallpapers/brain-shell-default-2.jpg
new file mode 100644
index 0000000..b32575d
Binary files /dev/null and b/src/assets/wallpapers/brain-shell-default-2.jpg differ
diff --git a/src/assets/wallpapers/brain-shell-default-3.jpg b/src/assets/wallpapers/brain-shell-default-3.jpg
new file mode 100644
index 0000000..3dfad18
Binary files /dev/null and b/src/assets/wallpapers/brain-shell-default-3.jpg differ
diff --git a/src/assets/wallpapers/brain-shell-default-4.jpg b/src/assets/wallpapers/brain-shell-default-4.jpg
new file mode 100644
index 0000000..111d4a7
Binary files /dev/null and b/src/assets/wallpapers/brain-shell-default-4.jpg differ
diff --git a/src/assets/wallpapers/brain-shell-default-5.jpg b/src/assets/wallpapers/brain-shell-default-5.jpg
new file mode 100644
index 0000000..e4fd0f1
Binary files /dev/null and b/src/assets/wallpapers/brain-shell-default-5.jpg differ
diff --git a/src/components/AnimatedBehavior.qml b/src/components/AnimatedBehavior.qml
new file mode 100644
index 0000000..5e374f2
--- /dev/null
+++ b/src/components/AnimatedBehavior.qml
@@ -0,0 +1,124 @@
+import QtQuick
+import Quickshell
+import "../theme"
+
+/*!
+ AnimatedBehavior v2 — zero-allocation NumberAnimation wrapper.
+ ─────────────────────────────────────────────────────────────
+
+ Properties read pre-computed Anim objects directly (no function calls).
+ New: "variant" supports spring presets + organic settle.
+
+ Usage:
+ Behavior on x { AnimatedBehavior { type: "standard"; size: "normal" } }
+ Behavior on scale { AnimatedBehavior { variant: "springBouncy" } }
+ Behavior on opacity { AnimatedBehavior { variant: "fadeIn" } }
+*/
+NumberAnimation {
+ id: root
+
+ property string type: "standard"
+ property string size: "normal"
+ property string variant: "" // Overrides type+size when set (e.g. "springBouncy", "microGrow")
+
+ // ── Duration — reads pre-computed Anim property directly ──────────────
+ duration: {
+ if (!Anim.enabled) return 0
+ // Variant presets take priority (single lookup)
+ if (variant !== "") {
+ switch (variant) {
+ // Spring presets
+ case "springSnappy": return Anim.springSnappyDur
+ case "springBouncy": return Anim.springBouncyDur
+ case "springGentle": return Anim.springGentleDur
+ case "springWobbly": return Anim.springBouncyDur
+ case "springSettle": return Anim.settleDur
+ // Micro-interaction
+ case "microGrow": return Anim.microHover
+ case "microShrink": return Anim.microHover
+ case "microPulse": return Anim.standardMicro
+ case "ripple": return Anim.standardMicro
+ // Popup presets
+ case "popupOpen": return Anim.emphasizedNormal
+ case "popupClose": return Anim.standardNormal
+ // Fade presets
+ case "fadeIn": return Math.round(Anim.standardNormal * 0.55)
+ case "fadeOut": return Math.round(Anim.standardNormal * 0.2)
+ // Hover
+ case "hoverOn": return Anim.microHover
+ case "hoverOff": return Anim.microHover
+ default: return Anim.standardNormal
+ }
+ }
+ // Standard type+size
+ switch (type + "-" + size) {
+ case "standard-micro": return Anim.standardMicro
+ case "standard-small": return Anim.standardSmall
+ case "standard-normal": return Anim.standardNormal
+ case "standard-large": return Anim.standardLarge
+ case "standard-extraLarge": return Anim.standardXL
+ case "emphasized-small": return Anim.emphasizedSmall
+ case "emphasized-normal": return Anim.emphasizedNormal
+ case "emphasized-large": return Anim.emphasizedLarge
+ case "spatial-fast": return Anim.spatialFast
+ case "spatial-default": return Anim.spatialDefault
+ case "spatial-slow": return Anim.spatialSlow
+ case "spring-snappy": return Anim.springSnappyDur
+ case "spring-normal": return Anim.springNormalDur
+ case "spring-bouncy": return Anim.springBouncyDur
+ case "spring-gentle": return Anim.springGentleDur
+ case "settle": return Anim.settleDur
+ default: return Anim.standardNormal
+ }
+ }
+
+ // ── Easing — single QtObject reference read, zero allocation ───────────
+ readonly property QtObject _ease: {
+ // Variant presets first
+ if (variant !== "") {
+ switch (variant) {
+ case "springSnappy": return Anim._springSnappy
+ case "springBouncy": return Anim._springBouncy
+ case "springGentle": return Anim._springGentle
+ case "springWobbly": return Anim._springWobbly
+ case "springSettle": return Anim._springSettle
+ case "microGrow": return Anim.microGrow
+ case "microShrink": return Anim.microShrink
+ case "microPulse": return Anim.microPulse
+ case "ripple": return Anim.microPulse
+ case "popupOpen": return Anim.emphasized
+ case "popupClose": return Anim.emphasizedExit
+ case "fadeIn": return Anim.outSine
+ case "fadeOut": return Anim.accelerate
+ case "hoverOn": return Anim.microGrow
+ case "hoverOff": return Anim.outQuart
+ default: return Anim.standard
+ }
+ }
+ // Type-based lookup
+ switch (type) {
+ case "standard": return Anim.standard
+ case "outQuart": return Anim.outQuart
+ case "outCubic": return Anim.outCubic
+ case "outQuint": return Anim.outQuint
+ case "outSine": return Anim.outSine
+ case "outBack": return Anim.outBack
+ case "outElastic": return Anim.outElastic
+ case "outBounce": return Anim.outBounce
+ case "inOutBack": return Anim.inOutBack
+ case "inOutCubic": return Anim.inOutCubic
+ case "inOutQuint": return Anim.inOutQuint
+ case "emphasized": return Anim.emphasized
+ case "emphasizedExit": return Anim.emphasizedExit
+ case "decelerate": return Anim.decelerate
+ case "accelerate": return Anim.accelerate
+ case "collapse": return Anim.collapse
+ case "spatial": return Anim.spatial
+ case "linear": return Anim.linear
+ default: return Anim.standard
+ }
+ }
+
+ easing.type: _ease.type
+ easing.bezierCurve: _ease.bezierCurve
+}
diff --git a/src/components/DiskBar.qml b/src/components/DiskBar.qml
new file mode 100644
index 0000000..0619b7b
--- /dev/null
+++ b/src/components/DiskBar.qml
@@ -0,0 +1,89 @@
+import QtQuick
+import "../"
+
+Item {
+ id: root
+
+ property string source: ""
+ property string mount: ""
+ property int usedPct: 0
+ property string usedStr: "—"
+ property string totalStr: "—"
+
+ implicitWidth: 200
+ implicitHeight: 40
+
+ readonly property color barColor: {
+ if (usedPct >= 90) return "#f38ba8"
+ if (usedPct >= 75) return "#f5c47a"
+ return Theme.active
+ }
+
+ // Mount label — left, fixed width
+ Text {
+ id: mountLabel
+ anchors.left: parent.left
+ anchors.verticalCenter: barTrack.verticalCenter
+ text: root.mount
+ font.pixelSize: 10
+ color: Qt.rgba(1, 1, 1, 0.5)
+ width: 32
+ elide: Text.ElideRight
+ }
+
+ // Bar track + fill
+ Item {
+ id: barTrack
+ anchors.left: mountLabel.right
+ anchors.right: pctLabel.left
+ anchors.top: parent.top
+ anchors.topMargin: 12
+ anchors.leftMargin: 6
+ anchors.rightMargin: 6
+ height: 6
+
+ Rectangle {
+ anchors.fill: parent
+ radius: height / 2
+ color: Qt.rgba(1, 1, 1, 0.07)
+ border.color: Qt.rgba(1, 1, 1, 0.06)
+ border.width: 1
+ }
+
+ Rectangle {
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: parent.width * Math.max(0, Math.min(1, root.usedPct / 100))
+ radius: height / 2
+ color: root.barColor
+
+ Behavior on width { NumberAnimation { duration: 400; easing.type: Easing.OutCubic } }
+ Behavior on color { ColorAnimation { duration: 300 } }
+ }
+ }
+
+ // Percentage — right of bar
+ Text {
+ id: pctLabel
+ anchors.right: parent.right
+ anchors.verticalCenter: barTrack.verticalCenter
+ text: root.usedPct + "%"
+ font.pixelSize: 10
+ font.weight: Font.Medium
+ color: root.barColor
+ width: 28
+ horizontalAlignment: Text.AlignRight
+ Behavior on color { ColorAnimation { duration: 300 } }
+ }
+
+ // Size info — below the bar, aligned with bar
+ Text {
+ anchors.horizontalCenter: barTrack.horizontalCenter
+ anchors.top: barTrack.bottom
+ anchors.topMargin: 4
+ text: root.usedStr + " / " + root.totalStr + " · " + root.source
+ font.pixelSize: 9
+ color: Qt.rgba(1, 1, 1, 0.45)
+ }
+}
diff --git a/src/components/IconBtn.qml b/src/components/IconBtn.qml
index 1589c0b..232280f 100644
--- a/src/components/IconBtn.qml
+++ b/src/components/IconBtn.qml
@@ -1,36 +1,39 @@
import QtQuick
-import "../theme/" // For Theme
+import "../"
+import "../theme"
Rectangle {
id: root
width: 24
height: 24
radius: 4
-
- // 1. Correct: referencing the ID 'hover' directly works here
+
color: hover.hovered ? Theme.active : "transparent"
-
- property string text: ""
+
+ property string text: ""
property color textColor: Theme.text
signal clicked()
+ // ── Micro-interactions: organic hover + press feedback ──────────────
+ scale: press.pressed ? 0.92 : (hover.hovered ? 1.08 : 1.0)
+ Behavior on scale { NumberAnimation { duration: Anim.microHover; easing: Anim.outBack } }
+ Behavior on color { ColorAnimation { duration: Anim.microHover } }
+
Text {
anchors.centerIn: parent
text: root.text
-
- // 2. FIX: Changed 'root.hoverHandler.hovered' to 'hover.hovered'
color: hover.hovered ? Theme.background : root.textColor
-
font.pixelSize: 14
+ scale: press.pressed ? 0.95 : 1.0
+ Behavior on scale { NumberAnimation { duration: Anim.microPress; easing: Anim.outBack } }
+ Behavior on color { ColorAnimation { duration: Anim.microHover } }
}
- HoverHandler {
- id: hover
- cursorShape: Qt.PointingHandCursor
- }
-
+ HoverHandler { id: hover; cursorShape: Qt.PointingHandCursor }
+
MouseArea {
+ id: press
anchors.fill: parent
onClicked: root.clicked()
}
-}
\ No newline at end of file
+}
diff --git a/src/components/PopupPage.qml b/src/components/PopupPage.qml
new file mode 100644
index 0000000..af939a3
--- /dev/null
+++ b/src/components/PopupPage.qml
@@ -0,0 +1,63 @@
+import QtQuick
+import QtQuick.Controls
+import "../"
+
+// Reusable scrollable page container for popup content.
+// Clips content, shows a faint scrollbar when needed.
+// Consistent vertical padding relative to popup height.
+//
+// Usage:
+// PopupPage {
+// anchors.fill: parent
+// // children go here — laid out top-to-bottom, scrollable if overflow
+// }
+
+Item {
+ id: root
+
+ // All children go into the scroll content
+ default property alias content: contentCol.data
+
+ // Padding applied inside the scroll area
+ property int padH: 6 // horizontal
+ property int padV: 8 // vertical
+
+ clip: true
+
+ Flickable {
+ id: flick
+ anchors.fill: parent
+ contentWidth: width
+ contentHeight: contentCol.implicitHeight + root.padV * 2
+ boundsBehavior: Flickable.StopAtBounds
+ clip: true
+
+ // Scroll with mouse wheel
+ ScrollBar.vertical: ScrollBar {
+ policy: contentCol.implicitHeight + root.padV * 2 > flick.height
+ ? ScrollBar.AlwaysOn
+ : ScrollBar.AlwaysOff
+ contentItem: Rectangle {
+ implicitWidth: 3
+ implicitHeight: 40
+ radius: 1.5
+ color: Qt.rgba(1, 1, 1, 0.25)
+ }
+ background: Item {}
+ }
+
+ Column {
+ id: contentCol
+ anchors {
+ top: parent.top
+ topMargin: root.padV
+ left: parent.left
+ leftMargin: root.padH
+ // Reserve space for scrollbar when visible
+ right: parent.right
+ rightMargin: root.padH + 6
+ }
+ spacing: 8
+ }
+ }
+}
diff --git a/src/components/PopupSlide.qml b/src/components/PopupSlide.qml
new file mode 100644
index 0000000..c1b2c13
--- /dev/null
+++ b/src/components/PopupSlide.qml
@@ -0,0 +1,142 @@
+import QtQuick
+import "../"
+import "../theme"
+
+// Slide-in/out animation container for all popups.
+//
+// Universal behavior lives here — animation, hover-to-open,
+// self-hover tracking, and close delay. Each popup just wires
+// its own Popups.* bool into `open` and optionally into
+// `triggerHovered`, then listens for `closeRequested`.
+//
+// Usage (click-only popup — e.g. ArchMenu):
+// PopupSlide {
+// id: slide
+// edge: "left"
+// open: Popups.archMenuOpen
+// // content
+// }
+//
+// Usage (hover popup — e.g. AudioPopup):
+// PopupSlide {
+// id: slide
+// edge: "right"
+// open: Popups.audioOpen
+// hoverEnabled: true
+// triggerHovered: Popups.audioTriggerHovered
+// onCloseRequested: Popups.audioOpen = false
+// // content
+// }
+//
+// Always bind PopupWindow.visible to slide.windowVisible.
+
+Item {
+ id: root
+
+ // ── Required ──────────────────────────────────────────────────────────────
+ property string edge: "left" // "left" | "right" | "top" | "bottom"
+ property bool open: false // the Popups.*Open bool for this popup
+
+ // ── Hover-to-open (optional) ──────────────────────────────────────────────
+ property bool hoverEnabled: false
+ property bool triggerHovered: false // bind to Popups.*TriggerHovered
+
+ // ── Universal timing — sourced from Popups singleton ──────────────────────
+ property int slideDuration: Popups.slideDuration
+ property int closeDelay: Popups.hoverCloseDelay
+ // Smooth pop-in/pop-out — no bounce (OutBack was too sharp)
+ property QtObject openEasing: Anim.outCubic
+ property QtObject closeEasing: Anim.outQuart
+
+ // ── Output ────────────────────────────────────────────────────────────────
+ // Bind PopupWindow.visible to this
+ property bool windowVisible: false
+
+ // Emitted after closeDelay when hover leaves — popup sets its Popups.* bool
+ signal closeRequested()
+
+ // ── Internal ──────────────────────────────────────────────────────────────
+ property bool _selfHovered: false
+
+ // The popup should be visually open when:
+ // • its Popups bool is true, OR
+ // • hover is enabled and either the trigger or the popup itself is hovered
+ readonly property bool _effectiveOpen:
+ open || (hoverEnabled && (triggerHovered || _selfHovered))
+
+ default property alias content: inner.data
+
+ clip: true
+
+ // ── State machine ─────────────────────────────────────────────────────────
+ on_EffectiveOpenChanged: {
+ if (_effectiveOpen) {
+ hoverCloseTimer.stop()
+ windowVisible = true
+ } else {
+ if (hoverEnabled) {
+ // Delay so the user can move from trigger to popup without flicker
+ hoverCloseTimer.restart()
+ } else {
+ slideCloseTimer.restart()
+ }
+ }
+ }
+
+ // Wait for slide-out to finish before hiding window (click-close path)
+ Timer {
+ id: slideCloseTimer
+ interval: root.slideDuration + 20
+ onTriggered: root.windowVisible = false
+ }
+
+ // Hover leave — wait closeDelay then emit closeRequested
+ Timer {
+ id: hoverCloseTimer
+ interval: root.closeDelay
+ onTriggered: {
+ // Double-check hover is still gone before requesting close
+ if (!root.triggerHovered && !root._selfHovered) {
+ root.windowVisible = false
+ root.closeRequested()
+ }
+ }
+ }
+
+ // ── Sliding item ──────────────────────────────────────────────────────────
+ Item {
+ id: inner
+ width: parent.width
+ height: parent.height
+
+ x: root._effectiveOpen ? 0 : (root.edge === "left" ? -width :
+ root.edge === "right" ? width : 0)
+
+ y: root._effectiveOpen ? 0 : (root.edge === "top" ? -height :
+ root.edge === "bottom" ? height : 0)
+
+ Behavior on x { NumberAnimation { duration: root.slideDuration; easing: root._effectiveOpen ? root.openEasing : root.closeEasing } }
+ Behavior on y { NumberAnimation { duration: root.slideDuration; easing: root._effectiveOpen ? root.openEasing : root.closeEasing } }
+
+ // Subtle scale during slide — smooth, no overshoot
+ scale: root._effectiveOpen ? 1 : 0.95
+ Behavior on scale {
+ NumberAnimation {
+ duration: root.slideDuration
+ easing.type: root._effectiveOpen ? Easing.OutCubic : Easing.InQuad
+ }
+ }
+ opacity: root._effectiveOpen ? 1 : 0.6
+ Behavior on opacity {
+ NumberAnimation {
+ duration: root.slideDuration
+ easing.type: Easing.OutQuart
+ }
+ }
+
+ // Self-hover tracking — automatically available to all popups
+ HoverHandler {
+ onHoveredChanged: root._selfHovered = hovered
+ }
+ }
+}
diff --git a/src/components/ProfileButton.qml b/src/components/ProfileButton.qml
new file mode 100644
index 0000000..082d205
--- /dev/null
+++ b/src/components/ProfileButton.qml
@@ -0,0 +1,66 @@
+import QtQuick
+import "../"
+
+Item {
+ id: root
+
+ property string label: ""
+ property string icon: ""
+ property bool active: false
+ // `enabled` is inherited from Item — no redeclaration needed
+
+ signal clicked()
+
+ implicitWidth: row.implicitWidth + 24
+ implicitHeight: 28
+
+ opacity: root.enabled ? 1 : 0.35
+ Behavior on opacity { NumberAnimation { duration: 120 } }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: height / 2
+
+ color: root.active
+ ? Theme.active
+ : (hov.hovered && root.enabled ? Qt.rgba(1,1,1,0.08) : "transparent")
+ border.color: root.active
+ ? Theme.active
+ : Qt.rgba(1, 1, 1, 0.18)
+ border.width: 1
+
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Behavior on border.color { ColorAnimation { duration: 120 } }
+ }
+
+ Row {
+ id: row
+ anchors.centerIn: parent
+ spacing: 5
+
+ Text {
+ visible: root.icon !== ""
+ text: root.icon
+ font.pixelSize: 12
+ color: root.active ? Theme.background : Qt.rgba(1, 1, 1, 0.7)
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 120 } }
+ }
+
+ Text {
+ text: root.label
+ font.pixelSize: 11
+ font.weight: root.active ? Font.Medium : Font.Normal
+ color: root.active ? Theme.background : Qt.rgba(1, 1, 1, 0.7)
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 120 } }
+ }
+ }
+
+ HoverHandler { id: hov; cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor }
+ MouseArea {
+ anchors.fill: parent
+ enabled: root.enabled
+ onClicked: root.clicked()
+ }
+}
diff --git a/src/components/ShellPopupBase.qml b/src/components/ShellPopupBase.qml
new file mode 100644
index 0000000..8037169
--- /dev/null
+++ b/src/components/ShellPopupBase.qml
@@ -0,0 +1,91 @@
+import QtQuick
+import "../theme"
+
+/*!
+ ShellPopupBase — reusable Ambxst-style popup animation base.
+
+ Philosophy (from Ambxst):
+ - Scale 0.9→1.0 + opacity 0→1 on open (OutCubic, feels fluid)
+ - Scale 1.0→0.92 + opacity 1→0 on close (faster — OutQuad)
+ - transformOrigin anchored to logical edge (Top/Bottom/Left/Right)
+ - Single animDuration from Theme, with Behavior.enabled guard
+ - scale+opacity > plain opacity — looks more physical
+
+ Usage:
+ ShellPopupBase {
+ transformEdge: "top" // where the popup "grows from"
+ isOpen: someState
+ // content children go here
+ Rectangle { anchors.fill: parent; color: "red" }
+ }
+*/
+Item {
+ id: root
+
+ // ── Public API ────────────────────────────────────────────────────────────
+ property bool isOpen: false
+ property string transformEdge: "top" // top | bottom | left | right | center
+ property bool disableAutoHide: false // set true when parent manages visibility
+
+ property alias popupOpacity: root._popupOpacity
+ property alias popupScale: root._popupScale
+
+ // ── Animations — smooth, no bounce (OutBack was too sharp/bouncy) ────────
+ // Open: smooth deceleration. Close: faster, no overshoot.
+ property real _popupOpacity: isOpen ? 1 : 0
+ property real _popupScale: isOpen ? 1 : 0.92
+
+ readonly property int _dur: Theme.animDuration
+
+ transformOrigin: {
+ switch (transformEdge) {
+ case "top": return Item.Top
+ case "bottom": return Item.Bottom
+ case "left": return Item.Left
+ case "right": return Item.Right
+ default: return Item.Center
+ }
+ }
+
+ opacity: _popupOpacity
+ scale: _popupScale
+ visible: opacity > 0
+
+ Behavior on _popupOpacity {
+ enabled: _dur > 0
+ NumberAnimation {
+ duration: root.isOpen ? _dur : Math.round(_dur * 0.5)
+ easing.type: root.isOpen ? Easing.OutCubic : Easing.InQuad
+ }
+ }
+
+ Behavior on _popupScale {
+ enabled: _dur > 0
+ NumberAnimation {
+ duration: root.isOpen ? Math.round(_dur * 1.1) : Math.round(_dur * 0.4)
+ easing.type: root.isOpen ? Easing.OutCubic : Easing.InQuad
+ }
+ }
+
+ Timer {
+ id: _hideTimer
+ interval: Math.round(_dur * 0.5) + 20
+ onTriggered: root.visible = false
+ }
+
+ onIsOpenChanged: {
+ if (isOpen) {
+ _hideTimer.stop()
+ root.visible = true
+ // Force reset then animate in (Ambxst Qt.callLater pattern)
+ Qt.callLater(function() {
+ root._popupOpacity = 1
+ root._popupScale = 1
+ })
+ } else {
+ root._popupOpacity = 0
+ root._popupScale = 0.88
+ if (!disableAutoHide) _hideTimer.restart()
+ }
+ }
+}
diff --git a/src/components/Speedometer.qml b/src/components/Speedometer.qml
new file mode 100644
index 0000000..51e0a90
--- /dev/null
+++ b/src/components/Speedometer.qml
@@ -0,0 +1,140 @@
+import QtQuick
+import "../"
+
+// Arc gauge component.
+// Name label above the arc, large percent text in the center,
+// detail string (e.g. "11.2 / 16 GB") below the center text.
+// When active is false, arc is greyed and "Off" overlays center.
+//
+// property real size: 1.0 — scale factor. 1.0 = full (120×140). 0.7 = mini.
+
+Item {
+ id: root
+
+ property string label: ""
+ property real percent: 0.0 // 0.0 – 100.0
+ property string centerText: "0%"
+ property string bottomText: ""
+ property bool active: true
+ property color accentColor: Theme.active
+ property real size: 1.0 // scale factor
+
+ implicitWidth: Math.round(120 * size)
+ implicitHeight: Math.round(140 * size)
+
+ // ── Name label ────────────────────────────────────────────────────────────
+ Text {
+ id: nameLabel
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.top
+ text: root.label
+ font.pixelSize: Math.max(7, Math.round(11 * root.size))
+ font.weight: Font.Medium
+ color: root.active ? Qt.rgba(1,1,1,0.55) : Qt.rgba(1,1,1,0.2)
+ Behavior on color { ColorAnimation { duration: 200 } }
+ }
+
+ // ── Arc canvas ────────────────────────────────────────────────────────────
+ Canvas {
+ id: arc
+ anchors {
+ top: nameLabel.bottom
+ topMargin: Math.round(6 * root.size)
+ horizontalCenter: parent.horizontalCenter
+ }
+ width: parent.width
+ height: parent.width
+
+ renderStrategy: Canvas.Cooperative
+ renderTarget: Canvas.Image
+
+ readonly property real cx: width / 2
+ readonly property real cy: height / 2
+ readonly property real radius: width / 2 - Math.round(10 * root.size)
+ readonly property real thickness: Math.max(3, Math.round(8 * root.size))
+
+ readonly property real startAngle: 150 * Math.PI / 180
+ readonly property real sweepAngle: 245 * Math.PI / 180
+
+ onPaint: {
+ var ctx = getContext("2d")
+ ctx.clearRect(0, 0, width, height)
+
+ var sa = arc.startAngle
+ var sw = arc.sweepAngle
+
+ // Track
+ ctx.beginPath()
+ ctx.arc(arc.cx, arc.cy, arc.radius, sa, sa + sw, false)
+ ctx.strokeStyle = Qt.rgba(1, 1, 1, 0.08)
+ ctx.lineWidth = arc.thickness
+ ctx.lineCap = "round"
+ ctx.stroke()
+
+ // Fill
+ var fillPct = root.active ? Math.max(0, Math.min(1, root.percent / 100)) : 0
+ if (fillPct > 0) {
+ ctx.beginPath()
+ ctx.arc(arc.cx, arc.cy, arc.radius, sa, sa + sw * fillPct, false)
+ ctx.strokeStyle = root.active
+ ? Qt.rgba(root.accentColor.r, root.accentColor.g, root.accentColor.b, 1)
+ : Qt.rgba(1, 1, 1, 0.15)
+ ctx.lineWidth = arc.thickness
+ ctx.lineCap = "round"
+ ctx.stroke()
+ }
+ }
+
+ Connections {
+ target: root
+ function onPercentChanged() { arc.requestPaint() }
+ function onActiveChanged() { arc.requestPaint() }
+ function onAccentColorChanged() { arc.requestPaint() }
+ function onSizeChanged() { arc.requestPaint() }
+ }
+ }
+
+ // ── Center text ───────────────────────────────────────────────────────────
+ Item {
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: arc.top
+ width: arc.width
+ height: arc.height
+
+ Column {
+ anchors.centerIn: parent
+ anchors.verticalCenterOffset: Math.round(6 * root.size)
+ spacing: Math.round(2 * root.size)
+ opacity: root.active ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 200 } }
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: root.centerText
+ font.pixelSize: Math.max(10, Math.round(18 * root.size))
+ font.weight: Font.Bold
+ color: Theme.text
+ }
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: root.bottomText
+ font.pixelSize: Math.max(6, Math.round(9 * root.size))
+ color: Qt.rgba(1, 1, 1, 0.4)
+ visible: root.bottomText !== ""
+ }
+ }
+
+ // Deactivated overlay
+ Text {
+ anchors.centerIn: parent
+ anchors.verticalCenterOffset: Math.round(6 * root.size)
+ text: "Off"
+ font.pixelSize: Math.max(8, Math.round(13 * root.size))
+ font.weight: Font.Medium
+ color: Qt.rgba(1, 1, 1, 0.25)
+ opacity: root.active ? 0 : 1
+ Behavior on opacity { NumberAnimation { duration: 200 } }
+ }
+ }
+}
diff --git a/src/components/StatCard.qml b/src/components/StatCard.qml
new file mode 100644
index 0000000..3aee99d
--- /dev/null
+++ b/src/components/StatCard.qml
@@ -0,0 +1,34 @@
+import QtQuick
+import "../"
+
+// Reusable card background. Wrap any stats panel in this for
+// a consistent surface across the stats tab and future dashboard panels.
+//
+// Usage:
+// StatCard {
+// width: ...; height: ...
+// SomeContent { anchors.fill: parent }
+// }
+
+Item {
+ id: root
+
+ default property alias content: inner.data
+ property int padding: 12
+
+ Rectangle {
+ anchors.fill: parent
+ radius: Theme.cornerRadius
+ color: Qt.rgba(1, 1, 1, 0.04)
+ border.color: Qt.rgba(1, 1, 1, 0.07)
+ border.width: 1
+ }
+
+ Item {
+ id: inner
+ anchors {
+ fill: parent
+ margins: root.padding
+ }
+ }
+}
diff --git a/src/components/StatRow.qml b/src/components/StatRow.qml
new file mode 100644
index 0000000..b73b2f5
--- /dev/null
+++ b/src/components/StatRow.qml
@@ -0,0 +1,32 @@
+import QtQuick
+import "../"
+
+// A single horizontal label / value pair.
+// Label is dimmed, value is bright by default.
+// valueColor can be overridden for highlights (e.g. up/down arrows).
+
+Item {
+ id: root
+
+ property string label: ""
+ property string value: ""
+ property color valueColor: Theme.text
+
+ implicitHeight: 20
+
+ Text {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.label
+ font.pixelSize: 11
+ color: Qt.rgba(1, 1, 1, 0.4)
+ }
+
+ Text {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.value
+ font.pixelSize: 11
+ color: root.valueColor
+ }
+}
diff --git a/src/components/StateLayer.qml b/src/components/StateLayer.qml
new file mode 100644
index 0000000..739d78a
--- /dev/null
+++ b/src/components/StateLayer.qml
@@ -0,0 +1,53 @@
+import QtQuick
+import "../theme"
+
+/*!
+ StateLayer — Material 3 interaction overlay.
+
+ Provides hover, press, and focus visual feedback as an overlay
+ on top of a parent item. Use inside a MouseArea.
+
+ Usage:
+ MouseArea {
+ id: ma
+ anchors.fill: parent
+ hoverEnabled: true
+ StateLayer {
+ anchors.fill: parent
+ hovered: ma.containsMouse
+ pressed: ma.pressed
+ }
+ }
+*/
+Rectangle {
+ id: root
+
+ property bool hovered: false
+ property bool pressed: false
+ property bool focused: false
+
+ // Opacity levels (M3 spec)
+ property real hoverOpacity: 0.08
+ property real pressOpacity: 0.12
+ property real focusOpacity: 0.12
+
+ color: Theme.text
+ opacity: 0
+ radius: parent ? parent.radius : 0
+
+ states: [
+ State { name: "pressed"; when: root.pressed
+ PropertyChanges { target: root; opacity: root.pressOpacity } },
+ State { name: "hovered"; when: root.hovered && !root.pressed
+ PropertyChanges { target: root; opacity: root.hoverOpacity } },
+ State { name: "focused"; when: root.focused && !root.hovered && !root.pressed
+ PropertyChanges { target: root; opacity: root.focusOpacity } }
+ ]
+
+ transitions: [
+ Transition {
+ from: ""; to: "*"
+ NumberAnimation { property: "opacity"; duration: Anim.standardSmall; easing: Anim.standard }
+ }
+ ]
+}
diff --git a/src/components/StyledRect.qml b/src/components/StyledRect.qml
new file mode 100644
index 0000000..41d9fed
--- /dev/null
+++ b/src/components/StyledRect.qml
@@ -0,0 +1,66 @@
+import QtQuick
+import Quickshell.Widgets
+import "../theme"
+
+/*!
+ StyledRect — themed container with Material 3-inspired variants.
+
+ Replaces the repeated Rectangle pattern across Brain_Shell:
+ Rectangle { radius: 9; color: Qt.rgba(1,1,1,0.06); border... }
+
+ Usage:
+ StyledRect {
+ variant: "popup"
+ anchors.fill: parent
+ // children go here
+ }
+
+ Variants: "transparent", "bg", "popup", "focus", "primary", "secondary",
+ "error", "surface", "surfaceVariant", "bar"
+*/
+ClippingRectangle {
+ id: root
+
+ required property string variant
+
+ property bool enableBorder: true
+ property real backgroundOpacity: -1 // -1 = use variant default
+
+ antialiasing: true
+ clip: true
+
+ readonly property var _config: {
+ switch (variant) {
+ case "transparent": return { color: "transparent", border: "transparent", opacity: 0 };
+ case "bg": return { color: Theme.background, border: Theme.border, opacity: 1.0 };
+ case "popup": return { color: Qt.rgba(Theme.background.r, Theme.background.g, Theme.background.b, 0.98), border: Qt.rgba(1,1,1,0.12), opacity: 1.0 };
+ case "focus": return { color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.14), border: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.35), opacity: 1.0 };
+ case "primary": return { color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18), border: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.30), opacity: 1.0 };
+ case "secondary": return { color: Qt.rgba(Theme.subtext.r, Theme.subtext.g, Theme.subtext.b, 0.15), border: Qt.rgba(Theme.subtext.r, Theme.subtext.g, Theme.subtext.b, 0.25), opacity: 1.0 };
+ case "error": return { color: Qt.rgba(0.957, 0.263, 0.416, 0.15), border: Qt.rgba(0.957, 0.263, 0.416, 0.35), opacity: 1.0 };
+ case "surface": return { color: Qt.rgba(1,1,1,0.06), border: Qt.rgba(1,1,1,0.10), opacity: 1.0 };
+ case "surfaceVariant": return { color: Qt.rgba(1,1,1,0.04), border: Qt.rgba(1,1,1,0.08), opacity: 1.0 };
+ case "bar": return { color: Theme.background, border: "transparent", opacity: 1.0 };
+ default: return { color: Theme.background, border: Qt.rgba(1,1,1,0.10), opacity: 1.0 };
+ }
+ }
+
+ color: _config.color
+ radius: Theme.cornerRadius
+ border.color: enableBorder ? _config.border : "transparent"
+ border.width: enableBorder ? 1 : 0
+ opacity: backgroundOpacity >= 0 ? backgroundOpacity : _config.opacity
+
+ Behavior on color {
+ enabled: Anim.animationsEnabled
+ ColorAnimation { duration: Anim.standardSmall }
+ }
+ Behavior on border.color {
+ enabled: Anim.animationsEnabled
+ ColorAnimation { duration: Anim.standardSmall }
+ }
+ Behavior on opacity {
+ enabled: Anim.animationsEnabled
+ NumberAnimation { duration: Anim.standardSmall; easing: Anim.standard }
+ }
+}
diff --git a/src/components/Surface.qml b/src/components/Surface.qml
new file mode 100644
index 0000000..b6b1e76
--- /dev/null
+++ b/src/components/Surface.qml
@@ -0,0 +1,43 @@
+import QtQuick
+import "../theme"
+
+/*!
+ Surface — Material 3 elevated surface wrapper.
+
+ Wraps content with a StyledRect at a given elevation level.
+ Elevation maps to background opacity + subtle shadow.
+
+ Usage:
+ Surface {
+ elevation: 1
+ width: 200; height: 100
+ Text { anchors.centerIn: parent; text: "Hello" }
+ }
+*/
+Item {
+ id: root
+
+ // Elevation 0-4 per M3 spec
+ property int elevation: 0
+ property string variant: "surface"
+ property bool enableBorder: true
+
+ readonly property real _elevationOpacity: {
+ switch (elevation) {
+ case 0: return 0.0;
+ case 1: return 0.05;
+ case 2: return 0.08;
+ case 3: return 0.11;
+ case 4: return 0.14;
+ default: return 0.0;
+ }
+ }
+
+ StyledRect {
+ id: bg
+ anchors.fill: parent
+ variant: root.variant
+ enableBorder: root.enableBorder
+ backgroundOpacity: root._elevationOpacity > 0 ? root._elevationOpacity : -1
+ }
+}
diff --git a/src/components/TabSwitcher.qml b/src/components/TabSwitcher.qml
new file mode 100644
index 0000000..697dbb2
--- /dev/null
+++ b/src/components/TabSwitcher.qml
@@ -0,0 +1,236 @@
+import QtQuick
+import "../"
+import "../theme"
+
+// Unified tab switcher - horizontal or vertical.
+//
+// orientation: "horizontal" (default) - Row, fills parent width, tabs spaced equally
+// "vertical" - Column, fills parent height, tabs spaced equally
+//
+// Horizontal: icon + label pill, bottom divider. Used by Dashboard.
+// Vertical: icon-only solid pill. Used by ArchMenu.
+//
+// Model: [{ key: string, icon: string, label?: string }]
+// label is optional - only rendered in horizontal orientation.
+//
+// Sizing contract:
+// Horizontal - parent MUST set width. implicitHeight is 40.
+// Vertical - parent MUST set height. implicitWidth is 40.
+
+Item {
+ id: root
+
+ property var model: []
+ property string currentPage: ""
+ property string orientation: "horizontal" // "horizontal" | "vertical"
+
+ signal pageChanged(string key)
+
+ // ── Default page & reset ──────────────────────────────────────────────────
+ // defaultPage auto-resolves to the first model entry.
+ // Call reset() from the popup's close handler to restore it off-screen.
+ property string defaultPage: model.length > 0 ? model[0].key : ""
+
+ function reset() {
+ pageChanged(defaultPage)
+ }
+
+ implicitWidth: orientation === "vertical" ? 40 : 0
+ implicitHeight: orientation === "horizontal" ? 40 : 0
+
+ // ── Scroll cooldown ───────────────────────────────────────────────────────
+ property bool scrollBusy: false
+
+ Timer {
+ id: scrollCooldown
+ interval: 300
+ repeat: false
+ onTriggered: root.scrollBusy = false
+ }
+
+ WheelHandler {
+ acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
+ onWheel: function(event) {
+ if (root.scrollBusy) return
+ root.scrollBusy = true
+ scrollCooldown.restart()
+ var keys = root.model.map(function(m) { return m.key })
+ var idx = keys.indexOf(root.currentPage)
+ if (event.angleDelta.y < 0)
+ idx = (idx + 1) % keys.length
+ else
+ idx = (idx - 1 + keys.length) % keys.length
+ root.pageChanged(keys[idx])
+ }
+ }
+
+ // ── HORIZONTAL layout - Row ───────────────────────────────────────────────
+ Row {
+ id: hRow
+ anchors.fill: parent
+ visible: root.orientation === "horizontal"
+
+ Repeater {
+ model: root.orientation === "horizontal" ? root.model : []
+
+ delegate: Item {
+ id: hTab
+ readonly property bool isActive: root.currentPage === modelData.key
+
+ width: hRow.width / root.model.length
+ height: hRow.height
+
+ // Pill background
+ Rectangle {
+ id: hBg
+ anchors.centerIn: parent
+ width: hIcon.implicitWidth + hLabel.implicitWidth + 24
+ height: parent.height - 8
+ radius: height / 2
+
+ color: hTab.isActive
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18)
+ : (hHov.hovered ? Qt.rgba(1, 1, 1, 0.07) : "transparent")
+
+ scale: hHov.hovered ? 1.04 : 1.0
+ Behavior on color { ColorAnimation { duration: Anim.standardSmall } }
+ Behavior on scale { NumberAnimation { duration: Anim.microHover; easing: Anim.outBack } }
+ }
+
+ // Icon + label
+ Row {
+ anchors.centerIn: parent
+ spacing: 6
+
+ Text {
+ id: hIcon
+ text: modelData.icon
+ font.pixelSize: 14
+ anchors.verticalCenter: parent.verticalCenter
+ color: hTab.isActive
+ ? Theme.active
+ : (hHov.hovered ? Qt.rgba(1, 1, 1, 0.75) : Qt.rgba(1, 1, 1, 0.4))
+ Behavior on color { ColorAnimation { duration: Anim.standardSmall } }
+ }
+
+ Text {
+ id: hLabel
+ visible: modelData.label !== undefined
+ text: modelData.label ?? ""
+ font.pixelSize: 12
+ font.weight: hTab.isActive ? Font.Medium : Font.Normal
+ anchors.verticalCenter: parent.verticalCenter
+ color: hTab.isActive
+ ? Theme.active
+ : (hHov.hovered ? Qt.rgba(1, 1, 1, 0.75) : Qt.rgba(1, 1, 1, 0.4))
+ Behavior on color { ColorAnimation { duration: Anim.standardSmall } }
+ }
+ }
+
+ HoverHandler { id: hHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: root.pageChanged(modelData.key)
+ }
+ }
+ }
+ }
+
+ // Bottom divider - horizontal only
+ Rectangle {
+ visible: root.orientation === "horizontal"
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 1
+ color: Qt.rgba(1, 1, 1, 0.07)
+ }
+
+ // ── VERTICAL layout - Column ──────────────────────────────────────────────
+ Column {
+ id: vCol
+ anchors.centerIn: parent
+ visible: root.orientation === "vertical"
+ width: root.width
+
+ readonly property int tabH: 60
+ spacing: root.model.length > 1
+ ? (root.height - root.model.length * tabH) / (root.model.length - 1)
+ : 0
+
+ readonly property bool hasLabels:
+ root.model.length > 0 &&
+ root.model[0].label !== undefined &&
+ root.model[0].label !== ""
+
+ Repeater {
+ model: root.orientation === "vertical" ? root.model : []
+
+ delegate: Rectangle {
+ id: vTab
+ readonly property bool isActive: root.currentPage === modelData.key
+
+ width: vCol.width
+ height: vCol.tabH
+ radius: Theme.cornerRadius * 2
+
+ color: vTab.isActive
+ ? Theme.active
+ : (vHov.hovered ? Qt.rgba(1, 1, 1, 0.08) : "transparent")
+
+ scale: vHov.hovered ? 1.03 : 1.0
+ Behavior on color { ColorAnimation { duration: Anim.standardSmall } }
+ Behavior on scale { NumberAnimation { duration: Anim.microHover; easing: Anim.outBack } }
+
+ // Icon-only (no label)
+ Text {
+ visible: !vCol.hasLabels
+ anchors.centerIn: parent
+ text: modelData.icon
+ font.pixelSize: 16
+ color: vTab.isActive ? Theme.background : Theme.text
+ Behavior on color { ColorAnimation { duration: Anim.standardSmall } }
+ }
+
+ // Icon + label row
+ Row {
+ visible: vCol.hasLabels
+ anchors {
+ left: parent.left
+ leftMargin: 16
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 12
+
+ Text {
+ text: modelData.icon
+ font.pixelSize: 15
+ anchors.verticalCenter: parent.verticalCenter
+ color: vTab.isActive
+ ? Theme.background
+ : (vHov.hovered ? Qt.rgba(1, 1, 1, 0.80) : Qt.rgba(1, 1, 1, 0.42))
+ Behavior on color { ColorAnimation { duration: Anim.standardSmall } }
+ }
+
+ Text {
+ text: modelData.label ?? ""
+ font.pixelSize: 12
+ font.weight: vTab.isActive ? Font.Medium : Font.Normal
+ anchors.verticalCenter: parent.verticalCenter
+ color: vTab.isActive
+ ? Theme.background
+ : (vHov.hovered ? Qt.rgba(1, 1, 1, 0.80) : Qt.rgba(1, 1, 1, 0.42))
+ Behavior on color { ColorAnimation { duration: Anim.standardSmall } }
+ }
+ }
+
+ HoverHandler { id: vHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: root.pageChanged(modelData.key)
+ }
+ }
+ }
+ }
+ }
+
diff --git a/src/components/TimeInput.qml b/src/components/TimeInput.qml
new file mode 100644
index 0000000..5b192d5
--- /dev/null
+++ b/src/components/TimeInput.qml
@@ -0,0 +1,150 @@
+import QtQuick
+
+// TimeInput — reusable HH:MM input
+// Props : hours (int, readonly), minutes (int, readonly), minuteStep (int, default 1)
+// Call : initialize(h, m) to push values from outside
+
+Item {
+ id: root
+
+ readonly property int hours: hVal
+ readonly property int minutes: mVal
+
+ property int minuteStep: 1
+ property int hVal: 0
+ property int mVal: 0
+
+ function initialize(h, m) { hVal = h; mVal = m }
+
+ function zp(n) { var s = "" + n; return s.length < 2 ? "0" + s : s }
+ function incH() { hVal = hVal >= 23 ? 0 : hVal + 1 }
+ function decH() { hVal = hVal <= 0 ? 23 : hVal - 1 }
+ function incM() { var n = mVal + minuteStep; mVal = n > 59 ? 0 : n }
+ function decM() { var n = mVal - minuteStep; mVal = n < 0 ? Math.floor(59 / minuteStep) * minuteStep : n }
+
+ implicitWidth: _row.implicitWidth
+ implicitHeight: _row.implicitHeight
+
+ Row {
+ id: _row
+ spacing: 8
+
+ // ══ HOURS ═════════════════════════════════════════════════════════════
+ Column {
+ spacing: 2
+ width: 36
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "HH"; font.pixelSize: 9; font.weight: Font.Medium
+ font.family: "JetBrains Mono"
+ color: Qt.rgba(1,1,1,0.3)
+ }
+
+ Rectangle {
+ width: parent.width; height: 22; radius: 6
+ color: hUpH.hovered ? Qt.rgba(1,1,1,0.08) : Qt.rgba(1,1,1,0.04)
+ border.color: Qt.rgba(1,1,1,0.08); border.width: 1
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "▲"; font.pixelSize: 9; color: Qt.rgba(1,1,1,0.4) }
+ HoverHandler { id: hUpH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root.incH() }
+ }
+
+ Item {
+ width: parent.width; height: 30
+
+ Text {
+ anchors.centerIn: parent
+ text: root.zp(root.hVal)
+ font.pixelSize: 20; font.weight: Font.Bold
+ font.family: "JetBrains Mono"
+ color: Qt.rgba(235/255, 240/255, 255/255, 0.9)
+ }
+
+ WheelHandler {
+ acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
+ onWheel: function(ev) {
+ ev.accepted = true
+ if (ev.angleDelta.y > 0) root.incH()
+ else root.decH()
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width; height: 22; radius: 6
+ color: hDnH.hovered ? Qt.rgba(1,1,1,0.08) : Qt.rgba(1,1,1,0.04)
+ border.color: Qt.rgba(1,1,1,0.08); border.width: 1
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "▼"; font.pixelSize: 9; color: Qt.rgba(1,1,1,0.4) }
+ HoverHandler { id: hDnH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root.decH() }
+ }
+ }
+
+ // Colon
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.verticalCenterOffset: 8
+ text: ":"
+ font.pixelSize: 22; font.weight: Font.Bold
+ font.family: "JetBrains Mono"
+ color: Qt.rgba(1,1,1,0.3)
+ }
+
+ // ══ MINUTES ═══════════════════════════════════════════════════════════
+ Column {
+ spacing: 2
+ width: 36
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "MM"; font.pixelSize: 9; font.weight: Font.Medium
+ font.family: "JetBrains Mono"
+ color: Qt.rgba(1,1,1,0.3)
+ }
+
+ Rectangle {
+ width: parent.width; height: 22; radius: 6
+ color: mUpH.hovered ? Qt.rgba(1,1,1,0.08) : Qt.rgba(1,1,1,0.04)
+ border.color: Qt.rgba(1,1,1,0.08); border.width: 1
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "▲"; font.pixelSize: 9; color: Qt.rgba(1,1,1,0.4) }
+ HoverHandler { id: mUpH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root.incM() }
+ }
+
+ Item {
+ width: parent.width; height: 30
+
+ Text {
+ anchors.centerIn: parent
+ text: root.zp(root.mVal)
+ font.pixelSize: 20; font.weight: Font.Bold
+ font.family: "JetBrains Mono"
+ color: Qt.rgba(235/255, 240/255, 255/255, 0.9)
+ }
+
+ WheelHandler {
+ acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
+ onWheel: function(ev) {
+ ev.accepted = true
+ if (ev.angleDelta.y > 0) root.incM()
+ else root.decM()
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width; height: 22; radius: 6
+ color: mDnH.hovered ? Qt.rgba(1,1,1,0.08) : Qt.rgba(1,1,1,0.04)
+ border.color: Qt.rgba(1,1,1,0.08); border.width: 1
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "▼"; font.pixelSize: 9; color: Qt.rgba(1,1,1,0.4) }
+ HoverHandler { id: mDnH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root.decM() }
+ }
+ }
+ }
+}
diff --git a/src/components/qmldir b/src/components/qmldir
new file mode 100644
index 0000000..729f037
--- /dev/null
+++ b/src/components/qmldir
@@ -0,0 +1,15 @@
+StyledRect StyledRect.qml
+Surface Surface.qml
+StateLayer StateLayer.qml
+AnimatedBehavior AnimatedBehavior.qml
+ShellPopupBase ShellPopupBase.qml
+IconBtn IconBtn.qml
+TabSwitcher TabSwitcher.qml
+PopupPage PopupPage.qml
+PopupSlide PopupSlide.qml
+StatCard StatCard.qml
+StatRow StatRow.qml
+DiskBar DiskBar.qml
+Speedometer Speedometer.qml
+TimeInput TimeInput.qml
+ProfileButton ProfileButton.qml
diff --git a/src/config/brain-shell-colors.json.example b/src/config/brain-shell-colors.json.example
new file mode 100644
index 0000000..f035d0c
--- /dev/null
+++ b/src/config/brain-shell-colors.json.example
@@ -0,0 +1,8 @@
+{
+ "background": "{{ colors.surface.default.hex }}",
+ "active": "{{ colors.primary.default.hex }}",
+ "text": "{{ colors.on_surface.default.hex }}",
+ "subtext": "{{ colors.on_surface_variant.default.hex }}",
+ "border": "{{ colors.outline_variant.default.hex }}",
+ "iconFont": "{{ colors.primary_container.default.hex }}"
+}
diff --git a/src/config/colors.conf.template b/src/config/colors.conf.template
new file mode 100644
index 0000000..20dff5e
--- /dev/null
+++ b/src/config/colors.conf.template
@@ -0,0 +1,13 @@
+$wallpaper = {{image}}
+$background = {{colors.background.default.hex_stripped}}
+$foreground = {{colors.on_background.default.hex_stripped}}
+
+$primary = {{colors.primary.default.hex_stripped}}
+$secondary = {{colors.secondary.default.hex_stripped}}
+$tertiary = {{colors.tertiary.default.hex_stripped}}
+$surface = {{colors.surface.default.hex_stripped}}
+$surface_bright = {{colors.surface_bright.default.hex_stripped}}
+$outline = {{colors.outline.default.hex_stripped}}
+$error = {{colors.error.default.hex_stripped | set_lightness: -20.0}}
+
+$shadow = {{colors.shadow.default.hex_stripped}}
diff --git a/src/config/hypridle.conf b/src/config/hypridle.conf
new file mode 100644
index 0000000..4ea7a15
--- /dev/null
+++ b/src/config/hypridle.conf
@@ -0,0 +1,27 @@
+general {
+ lock_cmd = pidof hyprlock || hyprlock -c ~/.local/src/Brain_Shell/src/config/hyprlock.conf # avoid starting multiple hyprlock instances.
+ before_sleep_cmd = loginctl lock-session # lock before suspend.
+ #after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "enable" })' # to avoid having to press a key twice to turn on the display.
+}
+
+listener {
+ timeout = 300 # 5min.
+ on-timeout = brightnessctl -s set 0 # set monitor backlight to minimum, avoid 0 on OLED monitor.
+ on-resume = brightnessctl -r # monitor backlight restore.
+}
+
+listener {
+ timeout = 330 # 5.5min
+ on-timeout = loginctl lock-session # lock screen when timeout has passed
+}
+
+listener {
+ timeout = 360 # 6min
+ on-timeout = brightnessctl -s set 0 # screen off when timeout has passed
+ on-resume = brightnessctl -r # screen on when activity is detected after timeout has fired.
+}
+
+listener {
+ timeout = 900 # 15min
+ on-timeout = systemctl suspend # suspend pc
+}
diff --git a/src/config/hyprlock.conf b/src/config/hyprlock.conf
new file mode 100644
index 0000000..4978758
--- /dev/null
+++ b/src/config/hyprlock.conf
@@ -0,0 +1,151 @@
+# src/config/hyprlock.conf
+
+$font = JetBrainsMono Nerd Font
+$font_bold = JetBrainsMono Nerd Font ExtraBold
+
+# Source the dynamically generated Matugen colors
+source = ~/.local/src/Brain_Shell/src/config/colors.conf
+
+# BACKGROUND
+background {
+ monitor =
+ path = ~/.curr_wall_static.jpg
+ blur_passes = 2
+ contrast = 0.8916
+ brightness = 0.8172
+ vibrancy = 0.1696
+ vibrancy_darkness = 0.0
+}
+
+# GENERAL
+general {
+}
+
+# Day
+label {
+ monitor =
+ text = cmd[update:1000] echo -e "$(date +"%A")"
+ color = rgb($primary)
+ font_size = 90
+ font_family = $font_bold
+ position = 0, 350
+ halign = center
+ valign = center
+}
+
+# Date-Month
+label {
+ monitor =
+ text = cmd[update:1000] echo -e "$(date +"%d %B")"
+ color = rgb($primary)
+ font_size = 40
+ font_family = $font_bold
+ position = 0, 250
+ halign = center
+ valign = center
+}
+
+# Time
+label {
+ monitor =
+ text = cmd[update:1000] echo "$(date +"- %I:%M -") "
+ color = rgb($primary)
+ font_size = 20
+ font_family = $font_bold
+ position = 0, 190
+ halign = center
+ valign = center
+}
+
+# USER-BOX
+shape {
+ monitor =
+ size = 300, 60
+ color = rgb($surface)
+ rounding = -1
+ border_size = 0
+ border_color = rgb(255, 255, 255, 0)
+ rotate = 0
+ xray = false
+
+ position = 0, -130
+ halign = center
+ valign = center
+}
+
+# USER
+label {
+ monitor =
+ text = $USER
+ color = rgb($surface_bright)
+ font_size = 18
+ font_family = $font_bold
+ position = 0, -130
+ halign = center
+ valign = center
+}
+
+# INPUT FIELD
+input-field {
+ monitor =
+ size = 300, 60
+ outline_thickness = 2
+ dots_size = 0.2
+ dots_spacing = 0.2
+ dots_center = true
+
+ # Fully integrated Matugen variables
+ outer_color = rgb($outline)
+ inner_color = rgb($surface_bright)
+ font_color = rgb($primary)
+
+ fade_on_empty = false
+ font_family = $font_bold
+ placeholder_text = 🔒 Enter Pass
+ hide_input = false
+ position = 0, -210
+ halign = center
+ valign = center
+}
+
+# Suspend
+label {
+ monitor =
+ text =
+ color = rgb($secondary)
+ font_size = 45
+ font_family = $font
+ onclick = systemctl suspend
+ # Anchored to center, shifted left
+ position = -200, 100
+ halign = center
+ valign = bottom
+}
+
+# Reboot
+label {
+ monitor =
+ text =
+ color = rgb($secondary)
+ font_size = 45
+ font_family = $font
+ onclick = reboot now
+ # Anchored perfectly in the center
+ position = 0, 100
+ halign = center
+ valign = bottom
+}
+
+# Power off
+label {
+ monitor =
+ text =
+ color = rgb($secondary)
+ font_size = 45
+ font_family = $font
+ onclick = shutdown now
+ # Anchored to center, shifted right
+ position = 200, 100
+ halign = center
+ valign = bottom
+}
diff --git a/src/config/matugen.toml b/src/config/matugen.toml
new file mode 100644
index 0000000..6d7ebf2
--- /dev/null
+++ b/src/config/matugen.toml
@@ -0,0 +1,10 @@
+[config]
+# Mandatory config section for matugen
+
+[templates.brain_shell]
+input_path = './brain-shell-colors.json.example'
+output_path = '~/.cache/brain-shell/colors.json'
+
+[templates.hyprland_colors]
+input_path = "colors.conf.template"
+output_path = "colors.conf"
\ No newline at end of file
diff --git a/src/config/shaders/Chroma.glsl b/src/config/shaders/Chroma.glsl
new file mode 100644
index 0000000..93bf523
--- /dev/null
+++ b/src/config/shaders/Chroma.glsl
@@ -0,0 +1,19 @@
+#version 300 es
+precision highp float;
+
+in vec2 v_texcoord;
+uniform sampler2D tex;
+out vec4 fragColor;
+
+#define STRENGTH 0.003 // How far the red and blue channels split
+
+void main() {
+ vec2 offset = vec2(STRENGTH, 0.0);
+
+ // Shift red right, keep green center, shift blue left
+ float r = texture(tex, v_texcoord + offset).r;
+ float g = texture(tex, v_texcoord).g;
+ float b = texture(tex, v_texcoord - offset).b;
+
+ fragColor = vec4(r, g, b, 1.0);
+}
diff --git a/src/config/shaders/Grayscale.glsl b/src/config/shaders/Grayscale.glsl
new file mode 100644
index 0000000..435592c
--- /dev/null
+++ b/src/config/shaders/Grayscale.glsl
@@ -0,0 +1,13 @@
+#version 300 es
+precision highp float;
+
+in vec2 v_texcoord;
+uniform sampler2D tex;
+out vec4 fragColor;
+
+void main() {
+ vec4 pixColor = texture(tex, v_texcoord);
+ // Standard luminance weights
+ float gray = dot(pixColor.rgb, vec3(0.2126, 0.7152, 0.0722));
+ fragColor = vec4(vec3(gray), pixColor.a);
+}
diff --git a/src/config/shaders/HDR.glsl b/src/config/shaders/HDR.glsl
new file mode 100644
index 0000000..7a45e2d
--- /dev/null
+++ b/src/config/shaders/HDR.glsl
@@ -0,0 +1,20 @@
+#version 300 es
+precision highp float;
+
+in vec2 v_texcoord;
+uniform sampler2D tex;
+out vec4 fragColor;
+
+void main() {
+ vec4 color = texture(tex, v_texcoord);
+
+ // Milder contrast curve
+ vec3 sCurve = color.rgb * color.rgb * (3.0 - 2.0 * color.rgb);
+ color.rgb = mix(color.rgb, sCurve, 0.4);
+
+ // Gentle 10% saturation boost
+ float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
+ color.rgb = mix(vec3(luma), color.rgb, 1.10);
+
+ fragColor = vec4(clamp(color.rgb, 0.0, 1.0), color.a);
+}
diff --git a/src/config/shaders/HighContrast.glsl b/src/config/shaders/HighContrast.glsl
new file mode 100644
index 0000000..b9a2a6f
--- /dev/null
+++ b/src/config/shaders/HighContrast.glsl
@@ -0,0 +1,15 @@
+#version 300 es
+precision highp float;
+
+in vec2 v_texcoord;
+uniform sampler2D tex;
+out vec4 fragColor;
+
+const float CONTRAST = 1.35; // Adjust this higher or lower to taste
+
+void main() {
+ vec4 c = texture(tex, v_texcoord);
+ // Simple math to push darks darker and lights lighter
+ vec3 color = (c.rgb - vec3(0.5)) * CONTRAST + vec3(0.5);
+ fragColor = vec4(clamp(color, 0.0, 1.0), c.a);
+}
diff --git a/src/config/shaders/Sepia.glsl b/src/config/shaders/Sepia.glsl
new file mode 100644
index 0000000..014092f
--- /dev/null
+++ b/src/config/shaders/Sepia.glsl
@@ -0,0 +1,17 @@
+#version 300 es
+precision highp float;
+
+in vec2 v_texcoord;
+uniform sampler2D tex;
+out vec4 fragColor;
+
+void main() {
+ vec4 pixColor = texture(tex, v_texcoord);
+
+ // Classic sepia matrix
+ float r = dot(pixColor.rgb, vec3(0.393, 0.769, 0.189));
+ float g = dot(pixColor.rgb, vec3(0.349, 0.686, 0.168));
+ float b = dot(pixColor.rgb, vec3(0.272, 0.534, 0.131));
+
+ fragColor = vec4(r, g, b, pixColor.a);
+}
diff --git a/src/config/shaders/invert-colors.glsl b/src/config/shaders/invert-colors.glsl
new file mode 100644
index 0000000..c852d17
--- /dev/null
+++ b/src/config/shaders/invert-colors.glsl
@@ -0,0 +1,11 @@
+#version 300 es
+precision highp float;
+
+in vec2 v_texcoord;
+uniform sampler2D tex;
+out vec4 fragColor;
+
+void main() {
+ vec4 pixColor = texture(tex, v_texcoord);
+ fragColor = vec4(1.0 - pixColor.rgb, pixColor.a);
+}
diff --git a/src/modules/Center/CenterContent.qml b/src/modules/Center/CenterContent.qml
index 72f47d9..74b79fd 100644
--- a/src/modules/Center/CenterContent.qml
+++ b/src/modules/Center/CenterContent.qml
@@ -1,55 +1,870 @@
import QtQuick
+import QtQuick.Effects
import Quickshell.Hyprland
import Quickshell.Services.Mpris
-import "../../theme/"
+import Quickshell.Io
+import "../../"
+import "../../services/home/."
+
+// CenterContent — scrollable dynamic island carousel.
+//
+// Active item order:
+// "title" — always present (default)
+// "music" — MPRIS player present
+// "timer" — ClockState.timerRunning
+// "stopwatch" — ClockState.swRunning
+//
+// CenterNotchMonitor (internal QtObject) watches ClockState and
+// handles urgent transitions:
+// • timer <= 30s remaining → force-scroll to timer, text blinks red
+// • stopwatch active → appears in carousel, scrolls if on title
+//
+// Cava bars: single Rectangle per bar, anchors.centerIn — grows
+// symmetrically. No center rounding artefact. 5px wide.
Item {
- width: 150 // Max width for the center notch content
- height: 30
-
- // The "Carousel" or List
- ListView {
- id: statusList
- anchors.fill: parent
- orientation: ListView.Vertical
- spacing: 15
- clip: true // Cut off text that slides out
-
- // This makes it snap to items when you scroll
- snapMode: ListView.SnapOneItem
-
- model: ObjectModel {
- Text {
- width: 150; height: 30
- verticalAlignment: Text.AlignVCenter
- horizontalAlignment: Text.AlignHCenter
- text: "Work In Progress"
- color: "#ffffff"
- }
- // Item 1: Active Window
- Text {
- width: 150; height: 30
- verticalAlignment: Text.AlignVCenter
- horizontalAlignment: Text.AlignHCenter
- text: Hyprland.activeToplevel ? Hyprland.activeToplevel.title : "Desktop"
- color: Theme.text
- elide: Text.ElideRight
- }
-
- // Item 3: Hostname (Static for now)
- Text {
- width: 150; height: 30
- verticalAlignment: Text.AlignVCenter
- horizontalAlignment: Text.AlignHCenter
- text: "ArchLinux"
- color: "#FFFFFF"
- }
- }
- }
-
- // "Open menu will be control panel"
- MouseArea {
- anchors.fill: parent
- onClicked: console.log("Open Control Panel")
- }
-}
\ No newline at end of file
+ id: root
+
+ width: Theme.cNotchMinWidth
+ height: 30
+
+ // ── Required notch width for the current carousel item ────────────────────
+ // TopBar.cWidth reads this so the notch always matches what is visible,
+ // even if the user scrolls away from record_active while recording.
+ readonly property int fw: Theme.notchRadius
+ readonly property int requiredWidth: Theme.cNotchMinWidth
+ // ── MPRIS ─────────────────────────────────────────────────────────────────
+ readonly property var player: Mpris.players.values.length > 0
+ ? Mpris.players.values[0] : null
+ readonly property bool isPlaying: player?.playbackState === MprisPlaybackState.Playing
+ ?? false
+ readonly property string artUrl: player?.trackArtUrl ?? ""
+
+ property string activeTitle: "Desktop"
+
+ // ── App name helper ───────────────────────────────────────────────────────
+ // 2. Process to fetch the initialTitle
+ property var _titleProc: Process {
+ command: ["hyprctl", "activewindow", "-j"]
+ running: false
+
+ onRunningChanged: {
+ if (running) {
+ }
+ }
+
+ stdout: StdioCollector {
+ id: titleOut
+ }
+
+ onExited: function(exitCode, exitStatus) {
+
+ var out = titleOut.text.trim()
+ // Check for empty, Invalid, or empty JSON object
+ if (exitCode !== 0 || out === "" || out === "Invalid" || out === "{}") {
+ root.activeTitle = "Desktop"
+ return
+ }
+
+ try {
+ // Parse the JSON natively in Quickshell
+ var data = JSON.parse(out)
+ var title = data.initialTitle || ""
+
+ if (title !== "") {
+ // Capitalize the first letter (e.g., "kitty" -> "Kitty")
+ var finalTitle = title.charAt(0).toUpperCase() + title.slice(1)
+ root.activeTitle = finalTitle
+ } else {
+ root.activeTitle = "Desktop"
+ }
+ } catch(e) {
+ root.activeTitle = "Desktop"
+ }
+ }
+ }
+
+ Connections{
+ target: Hyprland
+ // 3. Your Raw Event Monitor
+ function onRawEvent(event) {
+ // 3. Trigger title fetch on any window/workspace focus change
+ var titleTriggers = ["workspace", "activewindow", "activespecial", "destroyworkspace", "closewindow", "changefloatingmode"]
+
+ if (titleTriggers.includes(event.name)) {
+ _titleProc.running = false
+ _titleProc.running = true
+ }
+ }
+ }
+
+ // ── Dynamic item list ─────────────────────────────────────────────────────
+ property var _items: ["title"]
+ property int _carouselIndex: 0
+ readonly property real _itemStride: 45 // 30px height + 15px spacing
+
+ function _rebuildItems(autoScrollType) {
+ var currentType = (_items.length > _carouselIndex)
+ ? _items[_carouselIndex] : "title"
+
+ var list = ["title"]
+ if (root.player !== null) list.push("music")
+ if (ClockState.timerStarted) list.push("timer")
+ if (ClockState.swStarted) list.push("stopwatch")
+ if (ShellState.screenRecord && !ScreenRecService.recording) list.push("record_setup")
+ if (ScreenRecService.recording) list.push("record_active")
+
+ root._items = list
+
+ var idx = list.indexOf(currentType)
+ if (idx < 0) idx = 0
+
+ if (autoScrollType) {
+ var nIdx = list.indexOf(autoScrollType)
+ if (nIdx >= 0) {
+ // Screen rec always takes priority — scroll regardless of where we are.
+ // Other items only auto-scroll when coming from "title".
+ var isScreenRec = (autoScrollType === "record_setup" ||
+ autoScrollType === "record_active")
+ if (isScreenRec || currentType === "title")
+ idx = nIdx
+ }
+ }
+
+ root._carouselIndex = idx
+ statusList.contentY = idx * root._itemStride
+ }
+
+ // Force-scroll to a specific type regardless of where the user is
+ function _forceScrollTo(type) {
+ var idx = root._items.indexOf(type)
+ if (idx < 0) return
+ root._carouselIndex = idx
+ statusList.contentY = idx * root._itemStride
+ }
+
+ onPlayerChanged: _rebuildItems(player !== null ? "music" : null)
+
+ // ── State monitor — timer urgency + carousel transitions ─────────────────
+ readonly property bool timerUrgent:
+ ClockState.timerRunning && ClockState.timerLeft <= 30 && ClockState.timerLeft > 0
+
+ Connections {
+ target: ClockState
+
+ function onTimerRunningChanged() {
+ root._rebuildItems(ClockState.timerRunning ? "timer" : null)
+ }
+
+ function onSwStartedChanged() {
+ root._rebuildItems(ClockState.swStarted ? "stopwatch" : null)
+ root._forceScrollTo("stopwatch")
+ }
+
+ function onTimerLeftChanged() {
+ if (ClockState.timerRunning && ClockState.timerLeft === 30 || ClockState.timerRunning && ClockState.timerLeft === 10)
+ root._forceScrollTo("timer")
+ }
+
+ function onTimerStartedChanged() {
+ root._rebuildItems(ClockState.timerStarted ? "timer" : null)
+ root._forceScrollTo("timer")
+ }
+ }
+
+ Connections {
+ target: ShellState
+ function onScreenRecordChanged() {
+ if (ShellState.screenRecord && !ScreenRecService.recording)
+ root._rebuildItems("record_setup")
+ else if (!ShellState.screenRecord)
+ root._rebuildItems(null)
+ }
+ }
+
+ Connections {
+ target: ScreenRecService
+ function onRecordingChanged() {
+ if (ScreenRecService.recording)
+ root._rebuildItems("record_active")
+ else
+ root._rebuildItems(null)
+ }
+ }
+
+ // ── Scroll debounce ───────────────────────────────────────────────────────
+ property bool _scrollBusy: false
+ Timer {
+ id: scrollCooldown
+ interval: 250
+ onTriggered: root._scrollBusy = false
+ }
+
+ // ── Cava — shared via CavaService singleton ─────────────────────────────
+ readonly property int _cavaBars: CavaService.barCount
+ readonly property var _bars: CavaService.bars
+
+ // ── Carousel ──────────────────────────────────────────────────────────────
+ Item {
+ anchors.fill: parent
+
+ opacity: Popups.dashboardOpen ? 0 : 1
+ visible: opacity > 0
+ Behavior on opacity { NumberAnimation { duration: 150 } }
+
+ WheelHandler {
+ acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
+ onWheel: function(event) {
+ // Block scroll only during setup (not during active recording)
+ if (ShellState.screenRecord && !ScreenRecService.recording) return
+ if (root._scrollBusy) return
+ root._scrollBusy = true
+ scrollCooldown.restart()
+
+ var maxIdx = root._items.length - 1
+ if (event.angleDelta.y < 0)
+ root._carouselIndex = Math.min(maxIdx, root._carouselIndex + 1)
+ else
+ root._carouselIndex = Math.max(0, root._carouselIndex - 1)
+
+ statusList.contentY = root._carouselIndex * root._itemStride
+ }
+ }
+
+ ListView {
+ id: statusList
+ anchors.fill: parent
+ orientation: ListView.Vertical
+ spacing: 15
+ clip: true
+ snapMode: ListView.SnapOneItem
+ interactive: false
+
+ Behavior on contentY {
+ NumberAnimation { duration: 300; easing.type: Easing.OutCubic }
+ }
+
+ model: root._items
+
+ delegate: Item {
+ required property string modelData
+ required property int index
+
+ width: Theme.cNotchMinWidth
+ height: 30
+
+ // ── Title ──────────────────────────────────────────────────────
+ Text {
+ anchors.fill: parent
+ visible: modelData === "title"
+ text: root.activeTitle
+ color: Theme.text
+ font.pixelSize: 13
+ verticalAlignment: Text.AlignVCenter
+ horizontalAlignment: Text.AlignHCenter
+ // leftPadding: 8u rightPadding: 8
+ elide: Text.ElideRight
+ }
+
+ // ── Music ──────────────────────────────────────────────────────
+ Item {
+ anchors.fill: parent
+ anchors.leftMargin: root.fw/2
+ anchors.rightMargin: root.fw/2
+ visible: modelData === "music"
+
+ readonly property int artSize: 20
+ readonly property int artPad: 7
+
+ Item {
+ x: parent.artPad
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.artSize
+ height: parent.artSize
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.38)
+ border.width: 1
+ visible: root.artUrl === ""
+ Text {
+ anchors.centerIn: parent
+ text: "♪"
+ font.pixelSize: 9
+ color: Theme.active
+ }
+ }
+
+ Rectangle {
+ id: artMask
+ anchors.fill: parent
+ radius: width / 2
+ visible: false
+ layer.enabled: true
+ }
+
+ Image {
+ anchors.fill: parent
+ source: root.artUrl
+ fillMode: Image.PreserveAspectCrop
+ smooth: true
+ cache: true
+ visible: root.artUrl !== ""
+ sourceSize.width: 128
+ sourceSize.height: 128
+ asynchronous: true
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ maskEnabled: true
+ maskSource: artMask
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1.0
+ }
+ }
+ }
+
+ Item {
+ id: barsArea
+ anchors {
+ left: parent.left
+ leftMargin: parent.artPad + parent.artSize + 5
+ right: parent.right
+ rightMargin: 5
+ top: parent.top
+ bottom: parent.bottom
+ }
+
+ readonly property real _barW: 5
+ readonly property real _barSpacing: Math.max(
+ 1,
+ (width - _barW * root._cavaBars) / Math.max(1, root._cavaBars - 1))
+ readonly property real _maxBarH: height / 2
+
+ Row {
+ anchors.fill: parent
+ spacing: barsArea._barSpacing
+
+ Repeater {
+ model: root._bars
+ delegate: Item {
+ required property int modelData
+ width: barsArea._barW
+ height: barsArea.height
+ readonly property real _amp: modelData / 100.0
+ Rectangle {
+ anchors.centerIn: parent
+ width: barsArea._barW
+ height: Math.max(2, _amp * barsArea._maxBarH * 2)
+ radius: width / 2
+ color: Qt.rgba(
+ Theme.active.r, Theme.active.g, Theme.active.b,
+ 0.28 + _amp * 0.72)
+ Behavior on height {
+ NumberAnimation { duration: 50; easing.type: Easing.OutCubic }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // ── Timer ──────────────────────────────────────────────────────
+ Item {
+ anchors.fill: parent
+ visible: modelData === "timer"
+
+ // Icon — left edge of notch
+ Text {
+ anchors {
+ left: parent.left
+ leftMargin: root.fw
+ verticalCenter: parent.verticalCenter
+ }
+ text: ""
+ font.pixelSize: 16
+ color: root.timerUrgent ? "#ff5555" : Theme.active
+ Behavior on color { ColorAnimation { duration: 200 } }
+ }
+
+ // Time display — centered in remaining space
+ Text {
+ id: timerText
+ anchors {
+ left: parent.left
+ leftMargin: 8
+ right: parent.right
+ rightMargin: 8
+ verticalCenter: parent.verticalCenter
+ }
+ text: ClockState.timerDisplay
+ font.pixelSize: 15
+ font.weight: Font.Bold
+ font.family: "JetBrains Mono"
+ horizontalAlignment: Text.AlignHCenter
+ color: root.timerUrgent ? "#ff5555" : Theme.text
+ Behavior on color { ColorAnimation { duration: 200 } }
+
+ // Blink when urgent — opacity pulses 1 → 0.25 → 1
+ SequentialAnimation on opacity {
+ id: timerBlink
+ running: root.timerUrgent
+ loops: Animation.Infinite
+ NumberAnimation { to: 0.25; duration: 500; easing.type: Easing.InOutSine }
+ NumberAnimation { to: 1.0; duration: 500; easing.type: Easing.InOutSine }
+ }
+
+ // Snap back to full opacity when blink stops
+ Connections {
+ target: timerBlink
+ function onRunningChanged() {
+ if (!timerBlink.running) timerText.opacity = 1.0
+ }
+ }
+ }
+ // Icon — right edge of notch
+ Row{
+ anchors {
+ right: parent.right
+ rightMargin: root.fw
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: root.fw
+
+ Text {
+ anchors {
+ verticalCenter: parent.verticalCenter
+ }
+ text: ClockState.timerRunning ? "" : ""
+ font.pixelSize: 16
+ color: _timerPauseHov.hovered ? Theme.active : Theme.text
+ HoverHandler { id: _timerPauseHov; }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: ClockState.timerRunning = !ClockState.timerRunning
+ }
+ }
+ Text {
+ anchors {
+ verticalCenter: parent.verticalCenter
+ }
+ text: ""
+ font.pixelSize: 16
+ color: _timerResetHov.hovered ? Theme.active : Theme.text
+ HoverHandler { id: _timerResetHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ ClockState.requestTimerReset()
+ }
+ }
+ }
+ }
+ }
+ // ── Stopwatch ──────────────────────────────────────────────────
+ Item {
+ anchors.fill: parent
+ visible: modelData === "stopwatch"
+
+ // Icon — left edge of notch
+ Text {
+ anchors {
+ left: parent.left
+ leftMargin: root.fw
+ verticalCenter: parent.verticalCenter
+ }
+ text: ""
+ font.pixelSize: 16
+ color: Theme.active
+ }
+
+ // Running time — centered in remaining space
+ Text {
+ anchors {
+ left: parent.left
+ leftMargin: 8
+ right: parent.right
+ rightMargin: 8
+ verticalCenter: parent.verticalCenter
+ }
+ text: ClockState.swDisplay
+ font.pixelSize: 15
+ font.weight: Font.Bold
+ font.family: "JetBrains Mono"
+ horizontalAlignment: Text.AlignHCenter
+ color: Theme.text
+ }
+ // Icon — right edge of notch
+ Row{
+ anchors {
+ right: parent.right
+ rightMargin: root.fw
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: root.fw
+
+ Text {
+ anchors {
+ verticalCenter: parent.verticalCenter
+ }
+ text: ClockState.swRunning ? "" : ""
+ font.pixelSize: 16
+ color: _pauseHov.hovered ? Theme.active : Theme.text
+ HoverHandler { id: _pauseHov; }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ ClockState.swRunning = !ClockState.swRunning
+ }
+ }
+ }
+ Text {
+ anchors {
+ verticalCenter: parent.verticalCenter
+ }
+ text: ""
+ font.pixelSize: 16
+ color: _notchResetHov.hovered ? Theme.active : Theme.text
+
+ HoverHandler { id: _notchResetHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ ClockState.requestStopwatchReset()
+ }
+ }
+ }
+ }
+ }
+
+ // ── Record setup — strip buttons + Record button ───────────────
+ Item {
+ anchors{
+ fill: parent
+ leftMargin: root.fw/2
+ rightMargin: root.fw/2
+ }
+
+ visible: modelData === "record_setup"
+
+ Row {
+ anchors { fill: parent; leftMargin: 8; rightMargin: 8 }
+ spacing: 6
+
+ // ── Capture strip button ───────────────────────────────
+ Item {
+ anchors.verticalCenter: parent.verticalCenter
+ width: csRow.implicitWidth + 14
+ height: 22
+
+ Rectangle {
+ anchors.fill: parent
+ radius: height / 2
+ color: ScreenRecService.openStrip === "capture"
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.15)
+ : csH.hovered ? Qt.rgba(1,1,1,0.08) : Qt.rgba(1,1,1,0.04)
+ border.color: ScreenRecService.openStrip === "capture"
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.3)
+ : Qt.rgba(1,1,1,0.1)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Behavior on border.color { ColorAnimation { duration: 100 } }
+ }
+ Row {
+ id: csRow
+ anchors.centerIn: parent
+ spacing: 5
+ Text {
+ text: ScreenRecService.captureIcon
+ font.pixelSize: 13
+ color: ScreenRecService.openStrip === "capture"
+ ? Theme.active : Qt.rgba(1,1,1,0.7)
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ Text {
+ text: ScreenRecService.captureLabel
+ font.pixelSize: 11
+ color: ScreenRecService.openStrip === "capture"
+ ? Theme.active : Qt.rgba(1,1,1,0.7)
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ Text {
+ text: "▾"; font.pixelSize: 8
+ color: Qt.rgba(1,1,1,0.35)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ HoverHandler {
+ id: csH
+ onHoveredChanged: {
+ if (hovered) {
+ var pos = parent.mapToItem(null, 0, 0)
+ ScreenRecService.popupTargetX = pos.x
+ ScreenRecService.popupTargetWidth = parent.width
+
+ ScreenRecService.openStrip = "capture"
+ ScreenRecService.keepStripOpen()
+ } else {
+ ScreenRecService.scheduleStripClose()
+ }
+ }
+ }
+ }
+
+ // ── Audio strip button ─────────────────────────────────
+ Item {
+ anchors.verticalCenter: parent.verticalCenter
+ width: asRow.implicitWidth + 14
+ height: 22
+
+ Rectangle {
+ anchors.fill: parent
+ radius: height / 2
+ color: ScreenRecService.openStrip === "audio"
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.15)
+ : asH.hovered ? Qt.rgba(1,1,1,0.08) : Qt.rgba(1,1,1,0.04)
+ border.color: ScreenRecService.openStrip === "audio"
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.3)
+ : Qt.rgba(1,1,1,0.1)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Behavior on border.color { ColorAnimation { duration: 100 } }
+ }
+ Row {
+ id: asRow
+ anchors.centerIn: parent
+ spacing: 5
+ Text {
+ text: "🎙"; font.pixelSize: 12
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Text {
+ text: ScreenRecService.audioLabel
+ font.pixelSize: 11
+ color: ScreenRecService.openStrip === "audio"
+ ? Theme.active : Qt.rgba(1,1,1,0.7)
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ Text {
+ text: "▾"; font.pixelSize: 8
+ color: Qt.rgba(1,1,1,0.35)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ HoverHandler {
+ id: asH
+ onHoveredChanged: {
+ if (hovered) {
+ var pos = parent.mapToItem(null, 0, 0)
+ ScreenRecService.popupTargetX = pos.x
+ ScreenRecService.popupTargetWidth = parent.width
+
+ ScreenRecService.openStrip = "audio"
+ ScreenRecService.keepStripOpen()
+ } else {
+ ScreenRecService.scheduleStripClose()
+ }
+ }
+ }
+ }
+
+ // Flexible spacer
+ Item {
+ anchors.verticalCenter: parent.verticalCenter
+ height: 1
+ width: parent.width
+ - csRow.implicitWidth - 14
+ - asRow.implicitWidth - 14
+ - recBtnLabel.implicitWidth - 24
+ - parent.spacing * 3
+ }
+
+ // ── Record button ──────────────────────────────────────
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: recBtnLabel.implicitWidth + 24
+ height: 22
+ radius: height / 2
+ color: recBtnH.hovered
+ ? Qt.rgba(0.9, 0.2, 0.2, 0.85)
+ : Qt.rgba(0.8, 0.1, 0.1, 0.7)
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Row {
+ anchors.centerIn: parent
+ spacing: 5
+ Rectangle {
+ width: 7; height: 7; radius: 4
+ color: "#ffffff"
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Text {
+ id: recBtnLabel
+ text: "Record"
+ font.pixelSize: 11; font.weight: Font.Medium
+ color: "#ffffff"
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ HoverHandler { id: recBtnH}
+ MouseArea { anchors.fill: parent;cursorShape: Qt.PointingHandCursor; onClicked: ScreenRecService.startRecording() }
+ }
+ }
+ }
+
+ // ── Record active — ● (Left) | Timer + Cava (Center) | Trash + Stop (Right) ──
+ Item {
+ anchors{
+ fill: parent
+ leftMargin: root.fw/2
+ rightMargin: root.fw/2
+ }
+ visible: modelData === "record_active"
+
+ // Left: dot + timer, anchored left
+ Row {
+ anchors {
+ left: parent.left
+ leftMargin: 10
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 7
+
+ // Pulsing red dot
+ Rectangle {
+ width: 8; height: 8; radius: 4
+ color: "#ff4444"
+ anchors.verticalCenter: parent.verticalCenter
+ SequentialAnimation on opacity {
+ running: ScreenRecService.recording
+ loops: Animation.Infinite
+ NumberAnimation { to: 0.25; duration: 600; easing.type: Easing.InOutSine }
+ NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutSine }
+ }
+ }
+
+ // Elapsed time
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: ScreenRecService.elapsedDisplay
+ font.pixelSize: 13; font.weight: Font.Bold
+ font.family: "JetBrains Mono"
+ color: Theme.text
+ }
+ }
+
+ // Center: cava
+ Item {
+ id: recCava
+ anchors.centerIn: parent
+ width: 44
+ height: 20
+
+ readonly property real _bw: 4
+ readonly property real _sp: Math.max(1, (width - _bw * 12) / 5)
+ readonly property real _maxH: height / 2
+
+ Row {
+ anchors.fill: parent
+ spacing: recCava._sp
+
+ Repeater {
+ model: ScreenRecService.audioBars
+ delegate: Item {
+ required property int modelData
+ width: recCava._bw
+ height: recCava.height
+ readonly property real _amp: modelData / 100.0
+ Rectangle {
+ anchors.centerIn: parent
+ width: recCava._bw
+ height: Math.max(2, _amp * recCava._maxH * 2)
+ radius: width / 2
+ color: ScreenRecService.audioMic || ScreenRecService.audioSystem
+ ? Qt.rgba(0.95, 0.3, 0.3, 0.30 + _amp * 0.70)
+ : Qt.rgba(1, 1, 1, 0.10)
+ Behavior on height {
+ NumberAnimation { duration: 50; easing.type: Easing.OutCubic }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Right: trash + stop, anchored right
+ Row {
+ anchors {
+ right: parent.right
+ rightMargin: 10
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: root.fw/2
+
+ // Discard button
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 22; height: 22; radius: 5
+ color: recDiscardH.hovered
+ ? Qt.rgba(1, 1, 1, 0.12)
+ : Qt.rgba(1, 1, 1, 0.05)
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text {
+ anchors.centerIn: parent
+ text: ""
+ font.pixelSize: 11
+ color: recDiscardH.hovered
+ ? Qt.rgba(1, 0.4, 0.4, 1.0)
+ : Qt.rgba(1, 1, 1, 0.4)
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ HoverHandler { id: recDiscardH }
+ MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: ScreenRecService.discardRecording() }
+ }
+
+ // Stop button
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 22; height: 22; radius: 5
+ color: recStopH.hovered
+ ? Qt.rgba(0.9, 0.2, 0.2, 0.55)
+ : Qt.rgba(0.8, 0.1, 0.1, 0.32)
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text {
+ anchors.centerIn: parent
+ text: "⏹"
+ font.pixelSize: 10
+ color: "#ff9999"
+ }
+ HoverHandler { id: recStopH }
+ MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: ScreenRecService.stopRecording() }
+ }
+ }
+ }
+
+ } // delegate
+ }
+ }
+
+ // ── Click to toggle dashboard ─────────────────────────────────────────────
+ // TapHandler has lower implicit grab priority than child MouseAreas.
+ // Clicks on Stop / Discard buttons are handled by their own MouseAreas
+ // first and never reach here. Tapping empty notch space opens dashboard.
+ TapHandler {
+ onTapped: {
+ // Do nothing during screen rec setup — ESC / cancel button handles it
+ if (ShellState.screenRecord && !ScreenRecService.recording) return
+ var next = !Popups.dashboardOpen
+ Popups.closeAll()
+ Popups.dashboardOpen = next
+ }
+ }
+ }
diff --git a/src/modules/Center/DashStats.qml b/src/modules/Center/DashStats.qml
new file mode 100644
index 0000000..b0ab96a
--- /dev/null
+++ b/src/modules/Center/DashStats.qml
@@ -0,0 +1,163 @@
+import QtQuick
+import "../../"
+import "../../components"
+import "../../services/"
+
+Item {
+ id: root
+
+ CpuService { id: cpu; active: root.visible }
+ MemService { id: mem; active: root.visible }
+ NetService { id: net; active: root.visible }
+ ThermalService { id: thermal; active: root.visible }
+ FanControl { id: fan }
+ DiskService { id: disk; active: root.visible }
+ EnvyControlService { id: envy }
+ CpuFreqService { id: cpuFreq }
+ GpuService {
+ id: gpu
+ active: root.visible
+ envyMode: envy.currentMode
+ }
+
+ Column {
+ anchors {
+ fill: parent
+ bottomMargin: 8
+ topMargin: 8
+ }
+ spacing: 8
+
+ // Speedometers
+ Row {
+ id: speedoRow
+ width: parent.width
+ anchors.topMargin: 4
+ height: 160
+ spacing: 8
+
+ StatCard {
+ width: (parent.width - parent.spacing * 3) / 4
+ height: parent.height
+ Speedometer {
+ anchors.centerIn: parent
+ label: "CPU"
+ percent: cpu.usagePercent
+ centerText: cpu.usagePercent + "%"
+ bottomText: cpuFreq.curFreqStr
+ active: true
+ accentColor: Theme.active
+ }
+ }
+
+ StatCard {
+ width: (parent.width - parent.spacing * 3) / 4
+ height: parent.height
+ Speedometer {
+ anchors.centerIn: parent
+ label: "RAM"
+ percent: mem.usagePercent
+ centerText: mem.usagePercent + "%"
+ bottomText: mem.usedStr + " / " + mem.totalStr
+ active: true
+ accentColor: "#cba6f7"
+ }
+ }
+
+ StatCard {
+ width: (parent.width - parent.spacing * 3) / 4
+ height: parent.height
+ Speedometer {
+ anchors.centerIn: parent
+ label: "iGPU"
+ percent: gpu.igpu.freqPercent
+ centerText: gpu.igpu.freqPercent + "%"
+ bottomText: gpu.igpu.curMhz
+ active: true
+ accentColor: "#89dceb"
+ }
+ }
+
+ StatCard {
+ width: (parent.width - parent.spacing * 3) / 4
+ height: parent.height
+ Speedometer {
+ anchors.centerIn: parent
+ label: "dGPU"
+ percent: gpu.dgpu.active ? gpu.dgpu.usagePercent : 0
+ centerText: gpu.dgpu.active ? (gpu.dgpu.usagePercent + "%") : "0%"
+ bottomText: gpu.dgpu.active ? (gpu.dgpu.usedVram + " / " + gpu.dgpu.totalVram) : ""
+ active: gpu.dgpu.active
+ accentColor: "#a6e3a1"
+ }
+ }
+ }
+
+ Row{
+ width: parent.width
+ height: 100
+ spacing: 8
+ // Thermal strip
+ StatCard {
+ width: (parent.width-parent.spacing)/2
+ height: parent.height
+ padding: 6
+
+ TempPanel {
+ anchors.fill: parent
+ service: thermal
+ dgpuActive: gpu.dgpu.active
+ }
+ }
+
+ // Fan control strip
+ StatCard {
+ width: (parent.width-parent.spacing)/2
+ height: parent.height
+ padding: 6
+
+ FanPanel {
+ anchors.fill: parent
+ service: fan
+ }
+ }
+ }
+ // Net | Disk | Power
+ Row {
+ width: parent.width
+ height: parent.height - speedoRow.height - 100 - parent.spacing
+ spacing: 8
+
+ // Network — narrow, only 3 rows
+ StatCard {
+ width: Math.round(parent.width * 0.20)
+ height: parent.height
+ NetStatsPanel {
+ anchors.fill: parent
+ service: net
+ }
+ }
+
+ // Disks — moderate, horizontal bars stack vertically
+ StatCard {
+ width: Math.round(parent.width * 0.35)
+ height: parent.height
+ DiskPanel {
+ anchors.fill: parent
+ service: disk
+ }
+ }
+
+ // Power — widest, two button rows need space
+ StatCard {
+ width: parent.width - Math.round(parent.width * 0.20) - Math.round(parent.width * 0.35) - parent.spacing * 2
+ height: parent.height
+ PowerPanel {
+ anchors.fill: parent
+ cpuFreqService: cpuFreq
+ envyService: envy
+ }
+ }
+ }
+ }
+}
diff --git a/src/modules/Center/DiskPanel.qml b/src/modules/Center/DiskPanel.qml
new file mode 100644
index 0000000..561bd9f
--- /dev/null
+++ b/src/modules/Center/DiskPanel.qml
@@ -0,0 +1,121 @@
+import QtQuick
+import QtQuick.Controls.Basic
+import "../../"
+import "../../components"
+
+Item {
+ id: root
+
+ required property var service
+
+ readonly property bool _scrollable: flickable.contentHeight > flickable.height
+
+ // ── Header ──────────────────────────────────────────────────────────────
+ Item {
+ id: headerRow
+ anchors {
+ top: parent.top
+ left: parent.left
+ right: parent.right
+ }
+ implicitHeight: headerLabel.implicitHeight
+ height: implicitHeight
+
+ Text {
+ id: headerLabel
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "Disks"
+ font.pixelSize: 11
+ font.weight: Font.Medium
+ color: Qt.rgba(1, 1, 1, 0.4)
+ }
+
+ Text {
+ visible: root.service.disks.length > 0
+ anchors {
+ right: parent.right
+ rightMargin: 8
+ verticalCenter: parent.verticalCenter
+ }
+ text: root.service.disks.length
+ font.pixelSize: 9
+ font.weight: Font.Medium
+ color: Qt.rgba(1, 1, 1, 0.25)
+ }
+ }
+
+ // ── Standalone scrollbar — lives outside the Flickable ──────────────────
+ ScrollBar {
+ id: vScroll
+ visible: root._scrollable
+ orientation: Qt.Vertical
+ anchors {
+ top: flickable.top
+ bottom: flickable.bottom
+ right: parent.right
+ rightMargin: 3
+ }
+ // Manually bind to Flickable
+ size: flickable.visibleArea.heightRatio
+ position: flickable.visibleArea.yPosition
+ onPositionChanged: if (active) flickable.contentY = position * flickable.contentHeight
+
+ contentItem: Rectangle {
+ implicitWidth: 2
+ implicitHeight: 20
+ radius: width / 2
+ color: Qt.rgba(1, 1, 1, 0.5)
+ opacity: vScroll.active ? 1.0 : 0.0
+ Behavior on opacity {
+ NumberAnimation { duration: 400; easing.type: Easing.InOutQuad }
+ }
+ }
+
+ background: Rectangle {
+ implicitWidth: 2
+ radius: width / 2
+ color: Qt.rgba(1, 1, 1, 0.08)
+ }
+ }
+
+ // ── Flickable — stops before the scrollbar lane ──────────────────────────
+ Flickable {
+ id: flickable
+ anchors {
+ top: headerRow.bottom
+ topMargin: 6
+ left: parent.left
+ right: parent.right
+ rightMargin: root._scrollable ? 12 : 8
+ bottom: parent.bottom
+ bottomMargin: 4
+ leftMargin: 8
+ }
+ clip: true
+ contentHeight: diskColumn.implicitHeight
+ contentWidth: width
+ boundsBehavior: Flickable.StopAtBounds
+
+ flickDeceleration: 2500
+ maximumFlickVelocity: 1200
+
+ Column {
+ id: diskColumn
+ width: flickable.width
+ spacing: 10
+
+ Repeater {
+ model: root.service.disks
+
+ delegate: DiskBar {
+ width: parent.width
+ source: modelData.source
+ mount: modelData.mount
+ usedPct: modelData.usedPct
+ usedStr: modelData.usedStr
+ totalStr: modelData.totalStr
+ }
+ }
+ }
+ }
+}
diff --git a/src/modules/Center/FanPanel.qml b/src/modules/Center/FanPanel.qml
new file mode 100644
index 0000000..dc52183
--- /dev/null
+++ b/src/modules/Center/FanPanel.qml
@@ -0,0 +1,46 @@
+import QtQuick
+import "../../"
+import "../../components"
+
+Item {
+ id: root
+
+ required property var service
+
+ Column {
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 10
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "Fan Control"
+ font.pixelSize: 14
+ color: Qt.rgba(1, 1, 1, 0.35)
+ }
+
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: parent.parent.width * 0.1
+
+ ProfileButton {
+ icon: ""
+ label: "Quiet"
+ active: service.mode === "quiet"
+ onClicked: service.setMode("quiet")
+ }
+ ProfileButton {
+ icon: ""
+ label: "Auto"
+ active: service.mode === "auto"
+ onClicked: service.setMode("auto")
+ }
+ ProfileButton {
+ icon: ""
+ label: "Max"
+ active: service.mode === "max"
+ onClicked: service.setMode("max")
+ }
+ }
+ }
+}
diff --git a/src/modules/Center/NetStatsPanel.qml b/src/modules/Center/NetStatsPanel.qml
new file mode 100644
index 0000000..748cea8
--- /dev/null
+++ b/src/modules/Center/NetStatsPanel.qml
@@ -0,0 +1,48 @@
+import QtQuick
+import "../../"
+import "../../components"
+
+Item {
+ id: root
+
+ required property var service
+
+ Column {
+ anchors.centerIn: parent
+ width: parent.width - 16
+ spacing: 10
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "Network"
+ font.pixelSize: 11
+ font.weight: Font.Medium
+ color: Qt.rgba(1, 1, 1, 0.4)
+ }
+
+ Column {
+ width: parent.width
+ spacing: 6
+
+ StatRow {
+ width: parent.width
+ label: "Interface"
+ value: root.service.iface
+ }
+
+ StatRow {
+ width: parent.width
+ label: "↑ Upload"
+ value: root.service.upSpeed
+ valueColor: "#90ef90"
+ }
+
+ StatRow {
+ width: parent.width
+ label: "↓ Download"
+ value: root.service.downSpeed
+ valueColor: "#a6d0f7"
+ }
+ }
+ }
+}
diff --git a/src/modules/Center/PowerPanel.qml b/src/modules/Center/PowerPanel.qml
new file mode 100644
index 0000000..a0a76d7
--- /dev/null
+++ b/src/modules/Center/PowerPanel.qml
@@ -0,0 +1,96 @@
+import QtQuick
+import "../../"
+import "../../components"
+
+Item {
+ id: root
+
+ required property var cpuFreqService
+ required property var envyService
+
+ Column {
+ anchors.centerIn: parent
+ spacing: 16
+
+ Column {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 8
+
+ // Label + lock icon hinting auto-cpufreq manages this
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 5
+
+ Text {
+ text: ""
+ font.pixelSize: 11
+ color: Qt.rgba(1, 1, 1, 0.25)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Text {
+ text: "Power Profile"
+ font.pixelSize: 11
+ font.weight: Font.Medium
+ color: Qt.rgba(1, 1, 1, 0.4)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 6
+
+ ProfileButton {
+ label: root.cpuFreqService.activeProfile === "performance" ? "Performance" : "Power Saver"
+ active: true
+ enabled: true
+ }
+ }
+ }
+
+ Rectangle {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: 200
+ height: 1
+ color: Qt.rgba(1, 1, 1, 0.07)
+ }
+
+ Column {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 8
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "GPU Mode"
+ font.pixelSize: 11
+ font.weight: Font.Medium
+ color: Qt.rgba(1, 1, 1, 0.4)
+ }
+
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 6
+
+ ProfileButton {
+ label: "Integrated"
+ active: root.envyService.currentMode === "integrated"
+ enabled: !root.envyService.busy
+ onClicked: root.envyService.switchMode("integrated")
+ }
+ ProfileButton {
+ label: "Hybrid"
+ active: root.envyService.currentMode === "hybrid"
+ enabled: !root.envyService.busy
+ onClicked: root.envyService.switchMode("hybrid")
+ }
+ }
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "GPU mode switch requires a reboot"
+ font.pixelSize: 10
+ color: Qt.rgba(1, 1, 1, 0.25)
+ }
+ }
+ }
+}
diff --git a/src/modules/Center/TempPanel.qml b/src/modules/Center/TempPanel.qml
new file mode 100644
index 0000000..e6250f9
--- /dev/null
+++ b/src/modules/Center/TempPanel.qml
@@ -0,0 +1,82 @@
+import QtQuick
+import "../../"
+import "../../components"
+
+Item {
+ id: root
+
+ required property var service
+ property bool dgpuActive: false
+ property string fanMode: "quiet"
+ property int maxFanRpm: 5500
+
+ function tempColor(t) {
+ if (t >= 90) return "#f38ba8"
+ if (t >= 75) return "#f5c47a"
+ if (t >= 60) return "#fab387"
+ return Theme.active
+ }
+
+ readonly property real s: 0.60
+
+ // ── Speedometers — anchored left ──────────────────────────────────────────
+ Row {
+ id: speedoRow
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: parent.width * 0.05
+
+ Speedometer {
+ size: root.s
+ label: ""
+ percent: Math.min(100, service.cpuTemp)
+ centerText: service.cpuTempStr
+ bottomText: "CPU"
+ active: true
+ accentColor: root.tempColor(service.cpuTemp)
+ }
+
+ Speedometer {
+ size: root.s
+ label: ""
+ percent: root.dgpuActive ? Math.min(100, service.gpuTemp) : 0
+ centerText: root.dgpuActive ? service.gpuTempStr : "—"
+ bottomText: "GPU"
+ active: root.dgpuActive
+ accentColor: root.tempColor(service.gpuTemp)
+ }
+
+ Rectangle {
+ width: 1
+ height: parent.height * 0.7
+ anchors.verticalCenter: parent.verticalCenter
+ color: Qt.rgba(1, 1, 1, 0.08)
+ }
+
+ Speedometer {
+ visible: service.fanCount >= 1
+ size: root.s
+ label: ""
+ percent: Math.min(100, service.fan1Rpm / root.maxFanRpm * 100)
+ centerText: service.fan1Rpm > 999
+ ? (service.fan1Rpm / 1000).toFixed(1) + "k"
+ : service.fan1Rpm + ""
+ bottomText: "Fan 1"
+ active: service.fan1Rpm > 0
+ accentColor: "#89dceb"
+ }
+
+ Speedometer {
+ visible: service.fanCount >= 2
+ size: root.s
+ label: ""
+ percent: Math.min(100, service.fan2Rpm / root.maxFanRpm * 100)
+ centerText: service.fan2Rpm > 999
+ ? (service.fan2Rpm / 1000).toFixed(1) + "k"
+ : service.fan2Rpm + ""
+ bottomText: "Fan 2"
+ active: service.fan2Rpm > 0
+ accentColor: "#89dceb"
+ }
+ }
+}
diff --git a/src/modules/Left/ControlPanel.qml b/src/modules/Left/ControlPanel.qml
index 3165483..b1c485f 100644
--- a/src/modules/Left/ControlPanel.qml
+++ b/src/modules/Left/ControlPanel.qml
@@ -1,13 +1,13 @@
import QtQuick
-import Quickshell
import "../../components"
-import "../../windows"
-import "../../theme/" // Theme
+import "../../"
IconBtn {
text: ""
textColor: "#1793d1"
onClicked: {
- console.log("Arch icon clicked on screen:", screenName)
- }
- }
\ No newline at end of file
+ var next = !Popups.archMenuOpen
+ Popups.closeAll()
+ Popups.archMenuOpen = next
+ }
+}
diff --git a/src/modules/Left/LayoutDisplayer.qml b/src/modules/Left/LayoutDisplayer.qml
new file mode 100644
index 0000000..2fc1345
--- /dev/null
+++ b/src/modules/Left/LayoutDisplayer.qml
@@ -0,0 +1,180 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Hyprland
+import "../../"
+
+// ─── LayoutDisplayer ────────────────────────────────────────────────────────
+// Small icon button beside the Workspaces module.
+// Shows the active tiling layout for the focused workspace.
+//
+// Layout → symbol map:
+// dwindle → (nf-md-view_quilt)
+// master → (nf-md-view_split_vertical)
+// monocle → (nf-md-fullscreen)
+// scroller → (nf-md-scroll_horizontal)
+//
+// Update triggers (event-driven, no forever-loop):
+// • Component.onCompleted — initial read
+// • focusedWorkspaceChanged — workspace switch
+// • focusedToplevelChanged — window focus change (covers most layout shifts)
+// • 4 s safety timer — catches layout change with no following event
+// ────────────────────────────────────────────────────────────────────────────
+
+Item {
+ id: root
+
+ implicitWidth: 26
+ implicitHeight: 26
+
+ // ── State ────────────────────────────────────────────────────────────────
+
+ property string configProvider: ShellState.configProvider
+ property string currentLayout: ""
+ property string numWindows: ""
+ property var availableLayouts: ["dwindle", "master", "monocle", "scrolling"]
+
+ // ── Symbol map ───────────────────────────────────────────────────────────
+
+ function layoutSymbol(name) {
+ switch (name.toLowerCase()) {
+ case "dwindle": return "><" // nf-md-view_quilt
+ case "master": return "M" // nf-md-view_split_vertical
+ case "monocle": return "|"+root.numWindows+"|" // nf-md-fullscreen
+ case "scrolling": return "<"+root.numWindows+">" // nf-md-scroll_horizontal (hyprscroller)
+ default: return "Unknown" // nf-md-view_dashboard (unknown fallback)
+ }
+ }
+
+ // ── hyprctl query ────────────────────────────────────────────────────────
+ // Runs `hyprctl -j activeworkspace` and parses `lastlayout` from the JSON.
+
+ Process {
+ id: queryProc
+
+ command: ["hyprctl", "-j", "activeworkspace"]
+ running: false
+
+ stdout: StdioCollector {
+ id: collector
+ onStreamFinished: {
+ try {
+ const obj = JSON.parse(collector.text)
+ if (obj && obj.tiledLayout) {
+ root.currentLayout = obj.tiledLayout.toLowerCase()
+ root.numWindows = obj.windows > 0 ? obj.windows.toString() : " "
+ }
+ } catch (e) {
+ // malformed JSON — keep current value
+ }
+ }
+ }
+ }
+
+ function refresh() {
+ if (!queryProc.running) queryProc.running = true
+ }
+
+ // ── Triggers ─────────────────────────────────────────────────────────────
+
+ Component.onCompleted: refresh()
+
+ Connections {
+ target: Hyprland
+
+ // Quickshell emits (name, data) for raw events
+ function onRawEvent(event) {
+ // console.log("RawEvent_name: "+ event.name)
+ // console.log("RawEvent_data: "+ event.data)
+ refresh() // Refresh on every event; the proc will ignore if still running
+ }
+ }
+
+ // Safety net: catches the case where the user changes layout
+ // but no window event follows (e.g. switch layout on an empty workspace).
+ Timer {
+ interval: 4000
+ running: true
+ repeat: true
+ onTriggered: root.refresh()
+ }
+ // ── Layout Changer ───────────────────────────────────────────────────────
+ Process {
+ id: setLayoutProc
+ running: false
+ }
+
+ function cycleLayout(step) {
+ let idx = availableLayouts.indexOf(root.currentLayout)
+ if (idx === -1) idx = 0 // Fallback if unknown
+
+ // Calculate next index (handles negative steps for right-click)
+ idx = (idx + step + availableLayouts.length) % availableLayouts.length
+ let newLayout = availableLayouts[idx]
+
+ // 1. Run the hyprctl command silently based on config style
+ if (root.configProvider === "lua") {
+ setLayoutProc.command = ["hyprctl", "eval", `hl.config({ general = { layout = "${newLayout}" } })`]
+ } else {
+ setLayoutProc.command = ["hyprctl", "keyword", "general:layout", newLayout]
+ }
+
+ setLayoutProc.running = true
+
+ // 2. Optimistically update the UI instantly (no waiting for IPC/Timer)
+ root.currentLayout = newLayout
+ }
+
+ // ── Visual ───────────────────────────────────────────────────────────────
+
+ Rectangle {
+ id: bg
+ anchors.fill: parent
+ radius: 6
+ color: mouseArea.containsMouse ? Qt.rgba(1, 1, 1, 0.08) : "transparent"
+
+ Behavior on color {
+ ColorAnimation { duration: 120 }
+ }
+
+ MouseArea {
+ id: mouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+
+ onClicked: (mouse) => {
+ if (mouse.button === Qt.LeftButton) {
+ cycleLayout(1) // Forward
+ } else if (mouse.button === Qt.RightButton) {
+ cycleLayout(-1) // Backward
+ }
+ }
+ }
+
+ Text {
+ id: icon
+ anchors.centerIn: parent
+ text: root.currentLayout !== "" ? layoutSymbol(root.currentLayout) : "…"
+ font.family: "JetBrainsMono Nerd Font"
+ font.pixelSize: 14
+ color: "#cdd6f4"
+
+ // Brief scale-pop on symbol change
+ Behavior on text {
+ SequentialAnimation {
+ NumberAnimation {
+ target: icon; property: "scale"
+ to: 0.6; duration: 80
+ easing.type: Easing.InQuad
+ }
+ NumberAnimation {
+ target: icon; property: "scale"
+ to: 1.0; duration: 120
+ easing.type: Easing.OutBack
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/modules/Left/LeftContent.qml b/src/modules/Left/LeftContent.qml
index 2ce6d7d..ba45b72 100644
--- a/src/modules/Left/LeftContent.qml
+++ b/src/modules/Left/LeftContent.qml
@@ -2,7 +2,7 @@ import QtQuick
import Quickshell
import "../../components"
import "../../windows"
-import "../../theme/" // Theme
+import "../../"
Row {
spacing: 5
@@ -11,25 +11,10 @@ Row {
// 1. Arch Icon (Power Menu Trigger)
ControlPanel{}
- // 3. Vertical Separator
- Rectangle {
- width: 1;
- height: 16
- color: Theme.border
- anchors.verticalCenter: parent.verticalCenter
- }
-
- // 4. Workspaces
+ // 2. Workspaces
Workspaces {}
+
+ //3. LayoutDisplay
+ LayoutDisplayer {}
- // 5. Vertical Separator
- Rectangle {
- width: 1;
- height: 16
- color: Theme.border
- anchors.verticalCenter: parent.verticalCenter
- }
-
- // 6. Performance Control
- Performance{}
}
diff --git a/src/modules/Left/Performance.qml b/src/modules/Left/Performance.qml
deleted file mode 100644
index b9bbb21..0000000
--- a/src/modules/Left/Performance.qml
+++ /dev/null
@@ -1,10 +0,0 @@
-import Quickshell
-import QtQuick
-import "../../components"
-import "../../windows"
-import "../../theme/"
-
-IconBtn {
- text: "⚡"
- onClicked: console.log("Open Perf Menu")
- }
\ No newline at end of file
diff --git a/src/modules/Left/Workspaces.qml b/src/modules/Left/Workspaces.qml
index ae6ec3d..5c5f1ba 100644
--- a/src/modules/Left/Workspaces.qml
+++ b/src/modules/Left/Workspaces.qml
@@ -1,10 +1,31 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
-import "../../theme/"
+import "../../"
Rectangle {
id: root
+
+ // ── Config Provider ────────────────────────────────────────────────
+ property string configProvider: ShellState.configProvider
+
+ // ── Workspace Dispatch Function ────────────────────────────────────
+ function dispatchWorkspace(wsTarget, isSpecialToggle = false) {
+ if (root.configProvider === "lua") {
+ if (isSpecialToggle) {
+ Hyprland.dispatch(`hl.dsp.workspace.toggle_special("${wsTarget}")`);
+ } else {
+ Hyprland.dispatch(`hl.dsp.focus({ workspace = "${wsTarget}" })`);
+ }
+ } else {
+ // Default to 'conf' (Hyprland IPC style)
+ if (isSpecialToggle) {
+ Hyprland.dispatch(`togglespecialworkspace ${wsTarget}`);
+ } else {
+ Hyprland.dispatch(`workspace ${wsTarget}`);
+ }
+ }
+ }
// --- 1. Capsule Container ---
color: Theme.wsBackground
@@ -16,7 +37,42 @@ Rectangle {
// --- 2. LOGIC: Raw Event Listener ---
property bool isScratchpad: false
+
+ property bool scrollBusy: false
+ Timer {
+ id: scrollCooldown
+ interval: 300 // ms — tune up if still too fast, down if sluggish
+ repeat: false
+ onTriggered: root.scrollBusy = false
+ }
+
+ // ---Wheel: cycle through occupied workspaces ---
+ WheelHandler {
+ acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
+ onWheel: function(event) {
+ if (root.scrollBusy) return; // Ignore if still in cooldown
+ root.scrollBusy = true;
+ scrollCooldown.restart();
+ // Fetch and sort occupied workspace IDs numerically
+ let occupied = Hyprland.workspaces.values.map(w => w.id).sort((a, b) => a - b);
+ if (occupied.length === 0) return; // Safety check
+
+ let currentId = Hyprland.focusedWorkspace?.id || occupied[0];
+ let idx = occupied.indexOf(currentId);
+ if (idx === -1) idx = 0; // Fallback if current isn't in the array
+
+ // Inverted scroll logic: Up (>0) goes to Next, Down (<0) goes to Prev
+ if (event.angleDelta.y < 0) {
+ idx = (idx + 1) % occupied.length;
+ } else {
+ idx = (idx - 1 + occupied.length) % occupied.length;
+ }
+
+ //Hyprland.dispatch(`workspace ${occupied[idx]}`);
+ root.dispatchWorkspace(occupied[idx]);
+ }
+ }
Connections {
target: Hyprland
@@ -25,7 +81,7 @@ Rectangle {
// console.log("RawEvent_name: "+ event.name)
// console.log("RawEvent_data: "+ event.data)
// 1. Handle Scratchpad Toggle
- if (event.name === "activespecial") {
+ if (event.name === "activespecial" || event.name === "activespecialv2") {
// Event data format: "workspaceName,monitorName"
// Example: "special:magic,eDP-1" or ",eDP-1" (closed)
const wsName = event.data.split(',')[0];
@@ -40,7 +96,8 @@ Rectangle {
}
}
}
-
+
+
// --- 3. Workspace Dots ---
Row {
id: workspaceRow
@@ -63,6 +120,8 @@ Rectangle {
property var ws: Hyprland.workspaces.values.find(w => w.id === index + 1)
property bool isActive: Hyprland.focusedWorkspace?.id === (index + 1)
property bool isOccupied: ws !== undefined
+ property bool isUrgent: ws !== undefined && ws.urgent
+
height: Theme.wsDotSize
radius: height / 2
@@ -70,17 +129,45 @@ Rectangle {
color: {
if (isActive) return Theme.wsActive
+ if (isUrgent) return Theme.wsUrgent
if (isOccupied) return Theme.wsOccupied
return Theme.wsEmpty
}
- Behavior on width { NumberAnimation { duration: 200; easing.type: Easing.OutBack } }
+ Behavior on width { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: 200 } }
+ // --- Urgent pulse ---
+ SequentialAnimation {
+ running: dot.isUrgent && !dot.isActive
+ loops: Animation.Infinite
+
+ NumberAnimation {
+ target: dot
+ property: "scale"
+ to: 1.35
+ duration: 400
+ easing.type: Easing.InOutSine
+ }
+ NumberAnimation {
+ target: dot
+ property: "scale"
+ to: 1.0
+ duration: 400
+ easing.type: Easing.InOutSine
+ }
+ }
+
+ // Reset scale when no longer urgent
+ onIsUrgentChanged: {
+ if (!isUrgent) scale = 1.0
+ }
+
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
- onClicked: Hyprland.dispatch(`workspace ${index + 1}`)
+ //onClicked: Hyprland.dispatch(`workspace ${index + 1}`)
+ onClicked: root.dispatchWorkspace(index + 1)
}
}
}
@@ -109,7 +196,8 @@ Rectangle {
MouseArea {
anchors.fill: parent
- onClicked: Hyprland.dispatch("togglespecialworkspace")
+ //onClicked: Hyprland.dispatch("togglespecialworkspace magic")
+ onClicked: root.dispatchWorkspace("magic", true)
}
}
-}
\ No newline at end of file
+}
diff --git a/src/modules/Right/Audio.qml b/src/modules/Right/Audio.qml
index 6af8ed3..6153a04 100644
--- a/src/modules/Right/Audio.qml
+++ b/src/modules/Right/Audio.qml
@@ -1,10 +1,84 @@
import QtQuick
-import Quickshell.Services.SystemTray
+import Quickshell.Services.Pipewire
import "../../components"
-import "../../windows"
-import "../../theme/"
+import "../../"
+import "../../theme"
-IconBtn{
- text: ""
- onClicked: console.log("Sound Popup")
-}
\ No newline at end of file
+Item {
+ id: root
+
+ property bool showPercentage: false
+
+ implicitWidth: row.implicitWidth + 6
+ implicitHeight: row.implicitHeight
+
+ readonly property var sink: Pipewire.defaultAudioSink
+
+ PwObjectTracker {
+ objects: root.sink ? [root.sink] : []
+ }
+
+ readonly property string icon: {
+ if (!sink?.ready) return ""
+ if (sink.audio.muted) return ""
+ if (sink.audio.volume > 0.6) return ""
+ if (sink.audio.volume > 0.2) return ""
+ return ""
+ }
+
+ readonly property int pct: sink?.ready ? Math.round(sink.audio.volume * 100) : 0
+
+ HoverHandler {
+ id: hov
+ }
+
+ Row {
+ id: row
+ anchors.centerIn: parent
+ spacing: 3
+
+ Text {
+ id: iconText
+ text: root.icon
+ color: hov.hovered ? Theme.active : Theme.text
+ font.pixelSize: 18
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 120 } }
+ }
+
+ Item {
+ id: pctWrapper
+ property bool show: root.showPercentage || hov.hovered
+ implicitWidth: show ? pctText.implicitWidth + 2 : 0
+ implicitHeight: pctText.implicitHeight
+ clip: true
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on implicitWidth { NumberAnimation { duration: Anim.standardNormal; easing: Anim.standard } }
+
+ Text {
+ id: pctText
+ text: root.pct + "%"
+ color: hov.hovered ? Theme.active : Theme.text
+ font.pixelSize: 12
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 120 } }
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+
+ onClicked: function(mouse) {
+ if (mouse.button === Qt.RightButton) {
+ if (root.sink?.ready)
+ root.sink.audio.muted = !root.sink.audio.muted
+ } else {
+ var next = !Popups.audioOpen
+ Popups.closeAll()
+ Popups.audioOpen = next
+ }
+ }
+ }
+}
diff --git a/src/modules/Right/Battery.qml b/src/modules/Right/Battery.qml
index 3e4e821..b9ca263 100644
--- a/src/modules/Right/Battery.qml
+++ b/src/modules/Right/Battery.qml
@@ -1,11 +1,29 @@
import QtQuick
-import Quickshell.Services.SystemTray
-import "../../components"
-import "../../windows"
-import "../../theme/"
+import "../../services"
+import "../../"
+Item {
+ // Set to true to always show percentage beside the icon.
+ // When false (default), percentage only shows on hover.
+ property bool showPercentage: false
-IconBtn {
- text: ""
- onClicked: console.log("Bat Popup")
- }
\ No newline at end of file
+ implicitWidth: visible ? status.implicitWidth : 0
+ implicitHeight: visible ? status.implicitHeight : 0
+
+ visible: status.visible
+
+ BatteryStatus {
+ id: status
+ anchors.centerIn: parent
+ showPercentage: parent.showPercentage
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ var next = !Popups.batteryOpen
+ Popups.closeAll()
+ Popups.batteryOpen = next
+ }
+ }
+}
diff --git a/src/modules/Right/Clock.qml b/src/modules/Right/Clock.qml
index e78f263..f18cbe3 100644
--- a/src/modules/Right/Clock.qml
+++ b/src/modules/Right/Clock.qml
@@ -1,17 +1,76 @@
import QtQuick
-import "../../theme/"
+import "../../"
Text {
- text: Qt.formatDateTime(new Date(), "hh:mm")
- color: Theme.text
- font.bold: true
- anchors.verticalCenter: parent.verticalCenter
+ id: clock
+ text: Qt.formatDateTime(new Date(), "hh:mm")
+ color: clockHov.hovered ? Theme.active : Theme.text
+ Behavior on color { ColorAnimation { duration: 120 } }
+ font.bold: true
+ anchors.verticalCenter: parent.verticalCenter
+ font.pixelSize: 16
- Timer {
- interval: 1000
- running: true
- repeat: true
- onTriggered: parent.text =
- Qt.formatDateTime(new Date(), "hh:mm")
+ property int formatMode: 0
+
+ state: "time"
+ states: [
+ State {
+ name: "time"
+ PropertyChanges { target: clock; formatMode: 0 }
+ },
+ State {
+ name: "timeSeconds"
+ PropertyChanges { target: clock; formatMode: 1 }
+ },
+ State {
+ name: "date"
+ PropertyChanges { target: clock; formatMode: 2 }
+ }
+ ]
+
+ HoverHandler { id: clockHov }
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ onClicked: (mouse) => {
+ if (mouse.button === Qt.RightButton) {
+ if (clock.state === "time" || clock.state === "timeSeconds") {
+ clock.state = "date"
+ } else if (clock.state === "date" || clock.state === "timeSeconds") {
+ clock.state = "time"
+ }
+ } else {
+ if (clock.state === "time"|| clock.state === "date") {
+ clock.state = "timeSeconds"
+ } else if (clock.state === "timeSeconds" || clock.state === "date") {
+ clock.state = "time"
+ }
+ }
+ updateText()
}
- }
\ No newline at end of file
+ }
+
+ Timer {
+ interval: 1000
+ running: true
+ repeat: true
+ onTriggered: updateText()
+ }
+
+ function updateText() {
+ let now = new Date()
+ switch(formatMode) {
+ case 0:
+ text = Qt.formatDateTime(now, "hh:mm")
+ break
+ case 1:
+ text = Qt.formatDateTime(now, "hh:mm:ss")
+ break
+ case 2:
+ text = Qt.formatDateTime(now, "dd-MM-yyyy")
+ break
+ }
+ }
+
+ Component.onCompleted: updateText()
+}
diff --git a/src/modules/Right/Network.qml b/src/modules/Right/Network.qml
index 0bc051c..4f087ef 100644
--- a/src/modules/Right/Network.qml
+++ b/src/modules/Right/Network.qml
@@ -1,11 +1,170 @@
import QtQuick
-import Quickshell.Services.SystemTray
+import Quickshell.Io
import "../../components"
-import "../../windows"
-import "../../theme/"
+import "../../"
+Item {
+ id: root
-IconBtn {
- text: ""
- onClicked: console.log("Wifi Popup")
- }
\ No newline at end of file
+ implicitWidth: row.implicitWidth + 6
+ implicitHeight: row.implicitHeight
+
+ property int _signal: 0
+ property bool _ethernet: false
+ property string _connectivity: "unknown"
+
+ readonly property bool _limited: {
+ var c = _connectivity
+ return c === "limited" || c === "portal" || c === "none"
+ }
+
+ readonly property string _netIcon: {
+ if (_ethernet) return _limited ? "" : ""
+ if (_signal <= 0) return ""
+ if (_limited) return ""
+
+ if (_signal > 75) return ""
+ if (_signal > 50) return ""
+ if (_signal > 25) return ""
+ return ""
+ }
+
+ readonly property color _netColor: {
+ if (!_ethernet && _signal <= 0) return Qt.rgba(1,1,1,0.28)
+ if (_connectivity === "none") return "#f87171"
+ if (_limited) return "#f5c47a"
+ return hov.hovered ? Theme.active : Theme.text
+ }
+
+ // VPN blink
+ property real _vpnOpacity: 1.0
+ SequentialAnimation on _vpnOpacity {
+ running: ShellState.vpnConnecting; loops: Animation.Infinite
+ NumberAnimation { to: 0.20; duration: 500; easing.type: Easing.InOutSine }
+ NumberAnimation { to: 1.0; duration: 500; easing.type: Easing.InOutSine }
+ }
+ Connections {
+ target: ShellState
+ function onVpnConnectingChanged() {
+ if (!ShellState.vpnConnecting) root._vpnOpacity = 1.0
+ }
+ }
+
+ // Polling
+ Process {
+ id: wifiPoll
+ command: ["bash", "-c", "nmcli -t -f ACTIVE,SIGNAL dev wifi 2>/dev/null | grep '^yes:' | head -1 | cut -d: -f2"]
+ running: false
+ stdout: SplitParser { onRead: function(l) { var s = parseInt(l.trim()); root._signal = isNaN(s) ? 0 : s } }
+ }
+ Process {
+ id: ethPoll
+ command: ["bash", "-c", "nmcli -t -f TYPE,STATE dev 2>/dev/null | grep -c 'ethernet:connected'"]
+ running: false
+ stdout: SplitParser { onRead: function(l) { root._ethernet = parseInt(l.trim()) > 0 } }
+ }
+ Process {
+ id: connPoll
+ command: ["bash", "-c", "nmcli -t -f CONNECTIVITY general 2>/dev/null | head -1"]
+ running: false
+ stdout: SplitParser {
+ onRead: function(l) { var v = l.trim().toLowerCase(); if (v !== "") root._connectivity = v }
+ }
+ }
+ // Network status — 30s when idle, 5s when popup open (was: always 5s)
+ Timer {
+ interval: Popups.networkOpen ? 5000 : 30000; running: true; repeat: true
+ onTriggered: {
+ wifiPoll.running = false; wifiPoll.running = true
+ ethPoll.running = false; ethPoll.running = true
+ connPoll.running = false; connPoll.running = true
+ }
+ }
+ Component.onCompleted: { wifiPoll.running = true; ethPoll.running = true; connPoll.running = true }
+
+ HoverHandler { id: hov; onHoveredChanged: Popups.networkTriggerHovered = hovered }
+
+ Row {
+ id: row
+ anchors.centerIn: parent
+ spacing: 4
+
+ // WiFi/ethernet icon — opens to wifi tab
+ Text {
+ id: netIcon
+ text: root._netIcon
+ color: root._netColor
+ font.pixelSize: 16
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 200 } }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ Popups.closeAll()
+ Popups.networkPage = "wifi"
+ Popups.networkOpen = true
+ }
+ }
+ }
+
+ // VPN shield — opens to vpn tab
+ Text {
+ visible: ShellState.vpnActive || ShellState.vpnConnecting
+ text: ShellState.vpnConnecting ? "" : ""
+ font.pixelSize: 14
+ anchors.verticalCenter: parent.verticalCenter
+ opacity: root._vpnOpacity
+ color: ShellState.vpnActive ? Theme.active : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.70)
+ Behavior on color { ColorAnimation { duration: 200 } }
+ Behavior on opacity { NumberAnimation { duration: 80 } }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ Popups.closeAll()
+ Popups.networkPage = "vpn"
+ Popups.networkOpen = true
+ }
+ }
+ }
+
+ // Bluetooth — opens to bluetooth tab
+ Text {
+ visible: ShellState.btPowered
+ text: ShellState.btConnected ? "" : ""
+ font.pixelSize: 14
+ anchors.verticalCenter: parent.verticalCenter
+ color: ShellState.btConnected ? (hov.hovered ? Theme.active : Theme.text) : Qt.rgba(1,1,1,0.32)
+ Behavior on color { ColorAnimation { duration: 200 } }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ Popups.closeAll()
+ Popups.networkPage = "bluetooth"
+ Popups.networkOpen = true
+ }
+ }
+ }
+
+ // Hotspot — opens to hotspot tab
+ Text {
+ visible: ShellState.hotspot
+ text: ""
+ font.pixelSize: 14
+ anchors.verticalCenter: parent.verticalCenter
+ color: Theme.active
+ Behavior on color { ColorAnimation { duration: 200 } }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ Popups.closeAll()
+ Popups.networkPage = "hotspot"
+ Popups.networkOpen = true
+ }
+ }
+ }
+ }
+}
diff --git a/src/modules/Right/Notifications.qml b/src/modules/Right/Notifications.qml
index 35308d7..31392b2 100644
--- a/src/modules/Right/Notifications.qml
+++ b/src/modules/Right/Notifications.qml
@@ -2,9 +2,17 @@ import QtQuick
import Quickshell.Services.SystemTray
import "../../components"
import "../../windows"
-import "../../theme/"
+import "../../"
+import "../../services/"
IconBtn {
- text: ""
- onClicked: console.log("Open Notifs")
- }
\ No newline at end of file
+ text: ShellState.dnd
+ ? ""
+ : NotificationService.count > 0 ? "" : ""
+
+ onClicked: {
+ var next = !Popups.notificationsOpen
+ Popups.closeAll()
+ Popups.notificationsOpen = next
+ }
+}
\ No newline at end of file
diff --git a/src/modules/Right/RightContent.qml b/src/modules/Right/RightContent.qml
index 82b0ff7..0fe0998 100644
--- a/src/modules/Right/RightContent.qml
+++ b/src/modules/Right/RightContent.qml
@@ -2,26 +2,45 @@ import QtQuick
import Quickshell
import "../../components"
import "../../windows"
-import "../../theme/"
+import "../../"
+import "../../theme"
-Row{
+Item {
id: root
- spacing: 8
-
- Row{
- spacing: 2
-
+
+ // The TopBar State handles expanding the notch for notifications/network/toasts
+ implicitWidth: contentRow.implicitWidth
+
+ implicitHeight: contentRow.implicitHeight
+
+ // ── Normal content — fades out when any right popup opens ─────────────────
+ Row {
+ id: contentRow
+ //anchors.centerIn: parent
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 6
+
+ opacity: (Popups.notificationsOpen || Popups.networkOpen) ? 0 : 1
+ visible: opacity > 0
+ Behavior on opacity { NumberAnimation { duration: Anim.standardSmall; easing: Anim.standard } }
+
Network{}
-
Audio{}
-
Battery{}
-
Clock{}
-
SysTray{}
-
Notifications{}
-
}
-}
\ No newline at end of file
+
+ // ── Open indicator — fades in when any right popup opens ──────────────────
+ Text {
+ anchors.centerIn: parent
+ text: "▾"
+ color: Theme.active
+ font.pixelSize: 14
+ opacity: (Popups.notificationsOpen || Popups.networkOpen) ? 1 : 0
+ visible: opacity > 0
+ Behavior on opacity { NumberAnimation { duration: Anim.standardSmall; easing: Anim.standard } }
+ }
+}
diff --git a/src/modules/Right/SysTray.qml b/src/modules/Right/SysTray.qml
index c710dcb..11b1e0f 100644
--- a/src/modules/Right/SysTray.qml
+++ b/src/modules/Right/SysTray.qml
@@ -1,27 +1,61 @@
import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls
import Quickshell.Services.SystemTray
import "../../components"
import "../../windows"
-import "../../theme/"
+import "../../"
-Row{
+RowLayout {
id: root
-Row {
+
+ RowLayout {
id: trayRow
- spacing: 5
- visible: false
+ Layout.alignment: Qt.AlignVCenter
+
+ property bool isOpen: false
+
+ // Smooth fade-and-slide instead of abrupt disappearance
+ visible: opacity > 0
+ opacity: isOpen ? 1 : 0
+ Layout.preferredWidth: isOpen ? implicitWidth : 0
+ clip: true
+
+ Behavior on opacity { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
+ Behavior on Layout.preferredWidth { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
Repeater {
model: SystemTray.items
- delegate: Image {
- width: 16
- height: 16
- source: modelData.icon
- anchors.verticalCenter: parent.verticalCenter
+ delegate: Rectangle {
+ width: 26
+ height: 26
+ radius: 6
+ color: trayMouse.containsMouse ? Qt.rgba(1, 1, 1, 0.1) : "transparent"
+
+ Image {
+ width: 16
+ height: 16
+ anchors.centerIn: parent
+ source: modelData.icon
+ smooth: true
+ }
MouseArea {
+ id: trayMouse
anchors.fill: parent
- onClicked: modelData.activate()
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+
+ onClicked: (mouse) => {
+ if (mouse.button === Qt.LeftButton) {
+ modelData.activate()
+ } else if (mouse.button === Qt.RightButton) {
+ if (typeof modelData.contextMenu === "function") {
+ modelData.contextMenu()
+ }
+ }
+ }
}
}
}
@@ -29,7 +63,8 @@ Row {
// Tray Toggle Button
IconBtn {
- text: trayRow.visible ? "" : ""
- onClicked: trayRow.visible = !trayRow.visible
+ Layout.alignment: Qt.AlignVCenter
+ text: trayRow.isOpen ? "" : ""
+ onClicked: trayRow.isOpen = !trayRow.isOpen
}
-}
\ No newline at end of file
+}
diff --git a/src/popups/ArchMenu.qml b/src/popups/ArchMenu.qml
new file mode 100644
index 0000000..7fd2691
--- /dev/null
+++ b/src/popups/ArchMenu.qml
@@ -0,0 +1,117 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import "../shapes"
+import "../services"
+import "../components"
+import "../"
+import "../theme"
+
+PopupWindow {
+ id: root
+
+ required property var anchorWindow
+
+ readonly property int fw: Theme.cornerRadius
+ readonly property int fh: Theme.cornerRadius
+
+ readonly property var pageHeights: ({
+ "power": 270,
+ "performance": 190,
+ "stats": 250
+ })
+ readonly property var pageWidths: ({
+ "power": 220,
+ "performance": 260,
+ "stats": 390
+ })
+
+ readonly property int contentWidth: pageWidths[page] ?? 220
+ readonly property int contentHeight: pageHeights[page] ?? 220
+
+ property string page: "power"
+
+ color: "transparent"
+ visible: slide.windowVisible
+ mask: Region { item: maskProxy }
+
+ implicitWidth: (pageWidths["stats"] ?? 220) + fw
+ implicitHeight: (pageHeights["stats"] ?? 220) + fh * 2
+
+ anchor.window: anchorWindow
+ anchor.gravity: Edges.Right
+ anchor.rect: Qt.rect(
+ 0,
+ anchorWindow.height / 2,
+ anchorWindow.width,
+ implicitHeight
+ )
+
+ Item {
+ id: maskProxy
+ x: 0
+ y: (root.implicitHeight - sizer.height) / 2-root.fh
+ width: sizer.width
+ height: sizer.height
+ }
+
+ PopupSlide {
+ id: slide
+ anchors.fill: parent
+ edge: "left"
+ hoverEnabled: false
+ triggerHovered: Popups.archMenuTriggerHovered
+ open: Popups.archMenuOpen
+ onCloseRequested: Popups.archMenuOpen = false
+
+ Item {
+ id: sizer
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ clip: true
+
+ width: root.contentWidth + root.fw
+ height: root.contentHeight + root.fh * 2
+
+ Behavior on width { NumberAnimation { duration: Anim.standardNormal; easing: Anim.outCubic } }
+ Behavior on height { NumberAnimation { duration: Anim.standardNormal; easing: Anim.outCubic } }
+
+ PopupShape {
+ id: bg
+ anchors.fill: parent
+ attachedEdge: "left"
+ color: Theme.background
+ radius: Theme.cornerRadius
+ flareWidth: root.fw
+ flareHeight: root.fh
+ }
+
+ ShellPopupBase {
+ id: content
+ anchors {
+ fill: parent
+ leftMargin: root.fw - 4
+ rightMargin: 8
+ topMargin: root.fh + 6
+ bottomMargin: root.fh + 6
+ }
+ isOpen: Popups.archMenuOpen
+ transformEdge: "left"
+ disableAutoHide: true
+
+ Item {
+ width: parent.width
+ height: parent.height
+ clip: true
+ PopupPage {
+ anchors.fill: parent
+ visible: root.page === "power"
+ PowerMenu {
+ width: parent.width
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/popups/AudioPopup.qml b/src/popups/AudioPopup.qml
new file mode 100644
index 0000000..dc22a85
--- /dev/null
+++ b/src/popups/AudioPopup.qml
@@ -0,0 +1,120 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import "../shapes"
+import "../components"
+import "../services"
+import "../"
+import "../theme"
+
+PopupWindow {
+ id: root
+
+ required property var anchorWindow
+
+ readonly property int fw: Theme.cornerRadius
+ readonly property int fh: Theme.cornerRadius
+
+ readonly property var pageWidths: ({
+ "output": 200,
+ "input": 200,
+ "mixer": 300
+ })
+
+ readonly property int popupHeight: 340
+
+ readonly property int maxWidth: 300
+
+ color: "transparent"
+ visible: slide.windowVisible
+ mask: Region { item: maskProxy }
+
+ anchor.window: anchorWindow
+ anchor.rect: Qt.rect(
+ Theme.cornerRadius,
+ anchorWindow.height / 2,
+ 0,
+ popupHeight
+ )
+ anchor.gravity: Edges.Left
+
+ Item {
+ id: maskProxy
+ x: root.maxWidth - sizer.width
+ y: ((root.popupHeight - sizer.height) / 2) -root.fh
+ width: sizer.width
+ height: sizer.height
+ }
+
+ implicitWidth: maxWidth
+ implicitHeight: popupHeight
+
+ PopupSlide {
+ id: slide
+ anchors.fill: parent
+ edge: "right"
+ open: Popups.audioOpen
+ hoverEnabled: false
+ triggerHovered: Popups.audioTriggerHovered
+ onCloseRequested: Popups.audioOpen = false
+
+ Connections {
+ target: Popups
+ function onAudioOpenChanged() {
+ if (!Popups.audioOpen) audioResetTimer.restart()
+ else audioControl.page = Popups.audioPage
+ }
+
+ function onAudioPageChanged() {
+ audioControl.page = Popups.audioPage
+ }
+ }
+
+ Timer {
+ id: audioResetTimer
+ interval: Anim.standardNormal + 20
+ onTriggered: audioControl.reset()
+ }
+
+ Item {
+ id: sizer
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ clip: true
+
+ width: (root.pageWidths[audioControl.page] ?? root.maxWidth)
+ height: root.popupHeight
+
+Behavior on width { NumberAnimation { duration: Anim.standardNormal; easing: Anim.outCubic } }
+
+ PopupShape {
+ id: bg
+ anchors.fill: parent
+ attachedEdge: "right"
+ color: Theme.background
+ radius: Theme.cornerRadius
+ flareWidth: root.fw
+ flareHeight: root.fh
+ }
+
+ ShellPopupBase {
+ id: content
+ anchors {
+ fill: parent
+ topMargin: root.fh + 6
+ bottomMargin: root.fh + 6
+ leftMargin: 10
+ rightMargin: root.fw - 4
+ }
+ isOpen: Popups.audioOpen
+ transformEdge: "right"
+ disableAutoHide: true
+
+ AudioControl {
+ id: audioControl
+ anchors.fill: parent
+ }
+ }
+ }
+ }
+}
diff --git a/src/popups/BluetoothTab.qml b/src/popups/BluetoothTab.qml
new file mode 100644
index 0000000..e826a81
--- /dev/null
+++ b/src/popups/BluetoothTab.qml
@@ -0,0 +1,659 @@
+import QtQuick
+import Quickshell.Io
+import "../"
+import "../components"
+
+// BluetoothTab — bluetooth device management.
+// _btPowered tracks adapter state; overlay shows when off.
+// _parseDevices writes ShellState.btPowered + btConnected on every refresh.
+// Scan disabled while adapter is off.
+
+Item {
+ id: root
+
+ property var _allDevices: []
+ property bool _scanning: false
+ property bool _btPowered: true
+ property string _actionMac: ""
+ property string _removeMac: ""
+ property string _removingMac: ""
+ property string _pairingMac: ""
+
+ readonly property var _paired: {
+ var r = []
+ for (var i = 0; i < _allDevices.length; i++)
+ if (_allDevices[i].paired) r.push(_allDevices[i])
+ return r
+ }
+ readonly property var _available: {
+ var r = []
+ for (var i = 0; i < _allDevices.length; i++)
+ if (!_allDevices[i].paired) r.push(_allDevices[i])
+ return r
+ }
+
+ function _iconFromName(name) {
+ var n = name.toLowerCase()
+ if (n.match(/head(phone|set)|earphone|earpad|airpod|buds|wf-|wh-|ep-|tws/)) return "headphone"
+ if (n.match(/speaker|soundbar|boom|jbl|bose|harman|charge|flip|pulse/)) return "speaker"
+ if (n.match(/keyboard|kbd/)) return "keyboard"
+ if (n.match(/mouse|trackpad|trackball|mx master|mx anywhere/)) return "mouse"
+ if (n.match(/phone|iphone|android|galaxy|pixel|oneplus|xperia|redmi/)) return "phone"
+ if (n.match(/macbook|laptop|thinkpad|xps|zenbook|surface/)) return "laptop"
+ if (n.match(/watch|band|garmin|fitbit|amazfit|mi band|polar/)) return "watch"
+ if (n.match(/controller|gamepad|dualshock|dualsense|xbox|joycon|steam/)) return "gamepad"
+ if (n.match(/tv |television|bravia|smart-tv/)) return "tv"
+ return "default"
+ }
+
+ function _glyph(t) {
+ switch(t){
+ case "headphone": return ""
+ case "speaker": return ""
+ case "keyboard": return ""
+ case "mouse": return ""
+ case "phone": return ""
+ case "laptop": return ""
+ case "watch": return ""
+ case "gamepad": return ""
+ case "tv": return ""
+ default: return ""
+ }
+ }
+
+ Connections {
+ target: Popups
+ function onNetworkOpenChanged() {
+ if (Popups.networkOpen && root.visible) {
+ root._pairingMac = ""
+ root._removeMac = ""
+ root._removingMac = ""
+ root._actionMac = ""
+ root._loadDevices()
+ }
+ }
+ }
+
+ // List query — includes POWERED check
+ Process {
+ id: listProc
+ command: [
+ "bash", "-c",
+ "echo 'POWERED:'; " +
+ "bluetoothctl show 2>/dev/null | awk '/Powered:/{print $2}'; " +
+ "echo 'PAIRED:'; " +
+ "bluetoothctl devices Paired 2>/dev/null | awk '{print $2}'; " +
+ "echo 'CONNECTED:'; " +
+ "bluetoothctl devices Connected 2>/dev/null | awk '{print $2}'; " +
+ "echo 'ALL:'; " +
+ "bluetoothctl devices 2>/dev/null"
+ ]
+ running: false
+ stdout: StdioCollector { onStreamFinished: root._parseDevices(text) }
+ }
+
+ // Scan — pipe commands into interactive bluetoothctl
+ Process {
+ id: scanProc
+ command: [
+ "bash", "-c",
+ "trap 'echo scan off | bluetoothctl 2>/dev/null' EXIT; " +
+ "(echo 'power on'; echo 'scan on'; sleep 8) | timeout 9 bluetoothctl 2>/dev/null"
+ ]
+ running: false
+ stdout: SplitParser {
+ onRead: function(line) {
+ var m = line.match(/\[NEW\]\s+Device\s+([0-9A-Fa-f:]{17})\s+(.+)/)
+ if (!m) return
+ var mac = m[1]; var name = m[2].trim()
+ var devs = root._allDevices.slice()
+ for (var i = 0; i < devs.length; i++) if (devs[i].mac === mac) return
+ devs.push({ mac: mac, name: name, paired: false, connected: false, iconType: root._iconFromName(name) })
+ root._allDevices = devs
+ }
+ }
+ onRunningChanged: if (!running) { root._scanning = false; scanPollTimer.stop(); root._loadDevices() }
+ }
+
+ Timer { id: scanPollTimer; interval: 2000; repeat: true; running: false; onTriggered: root._loadDevices() }
+
+ Process {
+ id: actionProc
+ command: []
+ running: false
+ onRunningChanged: if (!running) { root._actionMac = ""; root._loadDevices() }
+ }
+
+ Process {
+ id: removeProc
+ command: []
+ running: false
+ onRunningChanged: if (!running) { root._removingMac = ""; root._loadDevices() }
+ }
+
+ Process {
+ id: powerProc
+ command: []
+ running: false
+ onRunningChanged: if (!running) root._loadDevices()
+ }
+
+ Process { id: bluemanProc; command: ["blueman-manager"]; running: false }
+
+ Timer { id: refreshTimer; interval: 8000; repeat: true; running: Popups.networkOpen; onTriggered: if (!root._scanning) root._loadDevices() }
+
+ function _loadDevices() {
+ if (listProc.running) return
+ listProc.running = false
+ listProc.running = true
+ }
+
+ function _parseDevices(raw) {
+ var lines = raw.split("\n")
+ var mode = ""; var paired = {}; var conn = {}; var known = {}
+
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i].trim()
+ if (line === "POWERED:") { mode = "powered"; continue }
+ if (line === "PAIRED:") { mode = "paired"; continue }
+ if (line === "CONNECTED:") { mode = "connected"; continue }
+ if (line === "ALL:") { mode = "all"; continue }
+ if (line === "") continue
+
+ if (mode === "powered") {
+ var p = line.toLowerCase() === "yes"
+ root._btPowered = p
+ ShellState.btPowered = p
+ continue
+ }
+ if (mode === "paired") { paired[line] = true; continue }
+ if (mode === "connected") { conn[line] = true; continue }
+ if (mode === "all") {
+ var parts = line.split(" ")
+ if (parts.length < 3 || parts[0] !== "Device") continue
+ var mac = parts[1]; var name = parts.slice(2).join(" ")
+ if (mac && name) known[mac] = name
+ }
+ }
+
+ ShellState.btConnected = Object.keys(conn).length > 0
+
+ var seenMac = {}; var devs = []
+ for (var mac in known) {
+ if (seenMac[mac]) continue
+ seenMac[mac] = true
+ devs.push({ mac: mac, name: known[mac], paired: !!paired[mac], connected: !!conn[mac], iconType: root._iconFromName(known[mac]) })
+ }
+ var existing = root._allDevices
+ for (var j = 0; j < existing.length; j++) {
+ var d = existing[j]
+ if (seenMac[d.mac]) continue
+ seenMac[d.mac] = true
+ devs.push({ mac: d.mac, name: d.name, paired: !!paired[d.mac], connected: !!conn[d.mac], iconType: d.iconType })
+ }
+ root._allDevices = devs
+ }
+
+ function _setPower(on) {
+ root._btPowered = on
+ ShellState.btPowered = on
+ if (!on) { ShellState.btConnected = false; root._allDevices = [] }
+ powerProc.command = ["bluetoothctl", "power", on ? "on" : "off"]
+ powerProc.running = false
+ powerProc.running = true
+ }
+
+ function _startScan() {
+ if (!root._btPowered) return
+ if (root._scanning) {
+ root._scanning = false
+ scanProc.running = false
+ scanPollTimer.stop()
+ root._loadDevices()
+ return
+ }
+ root._scanning = true
+ scanProc.running = false
+ scanProc.running = true
+ scanPollTimer.restart()
+ }
+
+ function _connect(mac) {
+ root._actionMac = mac
+ root._pairingMac = ""
+ actionProc.command = ["bluetoothctl", "connect", mac]
+ actionProc.running = false; actionProc.running = true
+ }
+
+ function _disconnect(mac) {
+ root._actionMac = mac
+ actionProc.command = ["bluetoothctl", "disconnect", mac]
+ actionProc.running = false; actionProc.running = true
+ }
+
+ function _pair(mac, pin) {
+ root._actionMac = mac; root._pairingMac = ""
+ actionProc.command = pin !== ""
+ ? ["bash", "-c",
+ "(echo 'default-agent'; echo 'trust " + mac + "'; echo 'pair " + mac + "'; sleep 1; echo '" + pin + "'; sleep 4) | timeout 12 bluetoothctl 2>/dev/null"]
+ : ["bash", "-c",
+ "(echo 'default-agent'; echo 'trust " + mac + "'; echo 'pair " + mac + "'; sleep 1; echo 'yes'; sleep 4) | timeout 12 bluetoothctl 2>/dev/null"]
+ actionProc.running = false; actionProc.running = true
+ }
+
+ function _remove(mac) {
+ root._removeMac = ""; root._removingMac = mac
+ removeProc.command = ["bash", "-c",
+ "bluetoothctl untrust " + mac + " 2>/dev/null; " +
+ "bluetoothctl disconnect " + mac + " 2>/dev/null; " +
+ "bluetoothctl remove " + mac + " 2>/dev/null"]
+ removeProc.running = false; removeProc.running = true
+ }
+
+ Component.onCompleted: _loadDevices()
+
+ // ── Scan rings ────────────────────────────────────────────────────────────
+ component ScanRings: Item {
+ id: ringsRoot
+ property string centerGlyph: ""
+ property int glyphSize: 18
+ Repeater {
+ model: 4
+ delegate: Rectangle {
+ required property int index
+ anchors.centerIn: parent
+ width: ringsRoot.width; height: ringsRoot.width; radius: ringsRoot.width / 2
+ color: "transparent"
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.80)
+ border.width: 1.5; opacity: 0; scale: 0.08
+ SequentialAnimation {
+ running: root._scanning; loops: Animation.Infinite
+ PauseAnimation { duration: index * 650 }
+ ParallelAnimation {
+ NumberAnimation { property: "scale"; from: 0.08; to: 1.0; duration: 2200; easing.type: Easing.OutCubic }
+ NumberAnimation { property: "opacity"; from: 0.80; to: 0.0; duration: 2200; easing.type: Easing.OutQuad }
+ }
+ }
+ }
+ }
+ Text {
+ anchors.centerIn: parent; text: ringsRoot.centerGlyph; font.pixelSize: ringsRoot.glyphSize
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.55)
+ SequentialAnimation on opacity {
+ running: root._scanning; loops: Animation.Infinite
+ NumberAnimation { to: 0.20; duration: 700; easing.type: Easing.InOutSine }
+ NumberAnimation { to: 0.80; duration: 700; easing.type: Easing.InOutSine }
+ }
+ }
+ }
+
+ // ── Device row ────────────────────────────────────────────────────────────
+ component DeviceRow: Item {
+ id: dRow
+ required property var device
+ required property bool isPaired
+
+ readonly property bool isConnected: device.connected
+ readonly property bool inAction: root._actionMac === device.mac
+ readonly property bool inRemove: root._removingMac === device.mac
+ readonly property bool isPairingOpen: root._pairingMac === device.mac
+ readonly property bool isRemovePending: root._removeMac === device.mac
+
+ width: parent?.width ?? 0
+ height: baseRow.height + expandArea.height
+
+ Rectangle {
+ anchors.fill: parent; radius: Theme.cornerRadius
+ color: dRow.isConnected
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.07)
+ : rowHov.hovered && !dRow.isPaired ? Qt.rgba(1,1,1,0.04) : "transparent"
+ border.color: dRow.isConnected
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18)
+ : dRow.isRemovePending ? Qt.rgba(248/255,113/255,113/255,0.22) : Qt.rgba(1,1,1,0.06)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 130 } }
+ Behavior on border.color { ColorAnimation { duration: 130 } }
+ }
+
+ Item {
+ id: baseRow
+ anchors { top: parent.top; left: parent.left; right: parent.right }
+ height: 50
+
+ Text {
+ anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter }
+ text: root._glyph(dRow.device.iconType); font.pixelSize: 18
+ color: dRow.isConnected ? Theme.active
+ : (dRow.inAction || dRow.inRemove) ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.5) : Qt.rgba(1,1,1,0.32)
+ Behavior on color { ColorAnimation { duration: 150 } }
+ }
+
+ Column {
+ anchors { left: parent.left; leftMargin: 44; verticalCenter: parent.verticalCenter }
+ spacing: 3
+ Text {
+ text: dRow.device.name; font.pixelSize: 13
+ font.weight: dRow.isConnected ? Font.Medium : Font.Normal
+ color: dRow.isConnected ? Theme.text : Qt.rgba(1,1,1,0.68)
+ width: 160; elide: Text.ElideRight
+ }
+ Text {
+ visible: dRow.isConnected || dRow.inAction || dRow.inRemove
+ text: dRow.inRemove ? "Removing…" : dRow.inAction ? "Working…" : "Connected"
+ font.pixelSize: 10
+ color: (dRow.inAction || dRow.inRemove) ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.55) : Theme.active
+ }
+ }
+
+ Row {
+ anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter }
+ spacing: 6
+
+ // Spinner
+ Text {
+ visible: dRow.inAction || dRow.inRemove
+ text: "○"; font.pixelSize: 15; color: Theme.active
+ anchors.verticalCenter: parent.verticalCenter
+ SequentialAnimation on opacity {
+ running: dRow.inAction || dRow.inRemove; loops: Animation.Infinite
+ NumberAnimation { to: 0.15; duration: 450 }
+ NumberAnimation { to: 1.0; duration: 450 }
+ }
+ }
+
+ // Paired: connect/disconnect pill
+ Rectangle {
+ visible: dRow.isPaired && !dRow.inAction && !dRow.inRemove
+ anchors.verticalCenter: parent.verticalCenter
+ width: togContent.implicitWidth + 20; height: 28; radius: 14
+ color: dRow.isConnected ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.14) : togH.hovered ? Qt.rgba(1,1,1,0.09) : Qt.rgba(1,1,1,0.04)
+ border.color: dRow.isConnected ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.36) : Qt.rgba(1,1,1,0.11)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Row {
+ id: togContent; anchors.centerIn: parent; spacing: 7
+ Rectangle { width: 7; height: 7; radius: 4; anchors.verticalCenter: parent.verticalCenter; color: dRow.isConnected ? Theme.active : Qt.rgba(1,1,1,0.25); Behavior on color { ColorAnimation { duration: 150 } } }
+ Text { text: dRow.isConnected ? "Connected" : "Connect"; font.pixelSize: 11; font.weight: Font.Medium; anchors.verticalCenter: parent.verticalCenter; color: dRow.isConnected ? Theme.active : Qt.rgba(1,1,1,0.48); Behavior on color { ColorAnimation { duration: 120 } } }
+ }
+ HoverHandler { id: togH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: dRow.isConnected ? root._disconnect(dRow.device.mac) : root._connect(dRow.device.mac) }
+ }
+
+ // Paired: remove button
+ Item {
+ visible: dRow.isPaired && !dRow.inAction && !dRow.inRemove
+ width: 28; height: 28; anchors.verticalCenter: parent.verticalCenter
+ Rectangle { anchors.fill: parent; radius: 7; color: rmH.hovered ? Qt.rgba(248/255,113/255,113/255,0.20) : dRow.isRemovePending ? Qt.rgba(248/255,113/255,113/255,0.12) : "transparent"; Behavior on color { ColorAnimation { duration: 100 } } }
+ Text { anchors.centerIn: parent; text: ""; font.pixelSize: 13; color: (rmH.hovered || dRow.isRemovePending) ? "#f87171" : Qt.rgba(1,1,1,0.25); Behavior on color { ColorAnimation { duration: 100 } } }
+ HoverHandler { id: rmH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: { root._pairingMac = ""; root._removeMac = dRow.isRemovePending ? "" : dRow.device.mac } }
+ }
+
+ // Available: Pair + PIN icon
+ Row {
+ visible: !dRow.isPaired && !dRow.inAction
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 6
+
+ Rectangle {
+ width: pairLbl.implicitWidth + 20; height: 28; radius: 8
+ color: pairH.hovered ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.22) : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.09)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.35); border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text { id: pairLbl; anchors.centerIn: parent; text: "Pair"; font.pixelSize: 11; font.weight: Font.Medium; color: Theme.active }
+ HoverHandler { id: pairH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: { root._removeMac = ""; root._pairingMac = ""; root._pair(dRow.device.mac, "") } }
+ }
+
+ Item {
+ width: 24; height: 28; anchors.verticalCenter: parent?.verticalCenter
+ Rectangle { anchors.fill: parent; radius: 6; color: pinH.hovered ? Qt.rgba(1,1,1,0.10) : dRow.isPairingOpen ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.12) : Qt.rgba(1,1,1,0.04); border.color: dRow.isPairingOpen ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.30) : Qt.rgba(1,1,1,0.09); border.width: 1; Behavior on color { ColorAnimation { duration: 100 } } }
+ Text { anchors.centerIn: parent; text: ""; font.pixelSize: 12; color: dRow.isPairingOpen ? Theme.active : pinH.hovered ? Qt.rgba(1,1,1,0.7) : Qt.rgba(1,1,1,0.28); Behavior on color { ColorAnimation { duration: 100 } } }
+ HoverHandler { id: pinH; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ root._removeMac = ""
+ root._pairingMac = dRow.isPairingOpen ? "" : dRow.device.mac
+ if (!dRow.isPairingOpen) Qt.callLater(function() { pinInput.forceActiveFocus() })
+ else pinInput.text = ""
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Expandable
+ Item {
+ id: expandArea
+ anchors { top: baseRow.bottom; left: parent.left; right: parent.right }
+ clip: true
+ height: dRow.isRemovePending ? removeRow.implicitHeight + 16 : dRow.isPairingOpen ? pinRow.implicitHeight + 16 : 0
+ Behavior on height { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
+
+ // Remove confirmation
+ Item {
+ id: removeRow
+ anchors { left: parent.left; right: parent.right; top: parent.top; topMargin: 8 }
+ implicitHeight: 32
+ opacity: dRow.isRemovePending ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 140 } }
+ Rectangle {
+ anchors { fill: parent; leftMargin: 8; rightMargin: 8 }
+ radius: 8; color: Qt.rgba(248/255,113/255,113/255,0.06); border.color: Qt.rgba(248/255,113/255,113/255,0.22); border.width: 1
+ Row {
+ anchors.centerIn: parent; spacing: 12
+ Text { anchors.verticalCenter: parent.verticalCenter; text: "Remove this device?"; font.pixelSize: 11; color: Qt.rgba(1,1,1,0.5) }
+ Rectangle { width: 58; height: 24; radius: 6; color: cxH.hovered ? Qt.rgba(1,1,1,0.09) : Qt.rgba(1,1,1,0.04); Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "Cancel"; font.pixelSize: 10; color: Qt.rgba(1,1,1,0.42) }
+ HoverHandler { id: cxH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._removeMac = "" }
+ }
+ Rectangle { width: 64; height: 24; radius: 6; color: rxH.hovered ? Qt.rgba(248/255,113/255,113/255,0.40) : Qt.rgba(248/255,113/255,113/255,0.18); Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "Remove"; font.pixelSize: 10; font.weight: Font.Medium; color: "#f87171" }
+ HoverHandler { id: rxH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._remove(dRow.device.mac) }
+ }
+ }
+ }
+ }
+
+ // PIN row
+ Item {
+ id: pinRow
+ anchors { left: parent.left; right: parent.right; top: parent.top; topMargin: 8 }
+ implicitHeight: pinCol.implicitHeight
+ opacity: dRow.isPairingOpen ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 140 } }
+ Column {
+ id: pinCol
+ anchors { left: parent.left; right: parent.right; leftMargin: 8; rightMargin: 8 }
+ spacing: 6
+ Text { width: parent.width; text: "Legacy PIN pairing — enter the PIN shown on your device"; font.pixelSize: 10; color: Qt.rgba(1,1,1,0.30); wrapMode: Text.WordWrap }
+ Row {
+ width: parent.width; spacing: 8
+ Rectangle {
+ width: parent.width - pairConfBtn.width - parent.spacing; height: 32; radius: 8
+ color: Qt.rgba(1,1,1,0.06)
+ border.color: pinInput.activeFocus ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.55) : Qt.rgba(1,1,1,0.12)
+ border.width: 1; Behavior on border.color { ColorAnimation { duration: 120 } }
+ Text { anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: "PIN (optional)…"; font.pixelSize: 12; color: Qt.rgba(1,1,1,0.22); visible: pinInput.text === "" }
+ TextInput {
+ id: pinInput
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter; color: Theme.text
+ font.pixelSize: 12; font.family: "JetBrains Mono"
+ inputMethodHints: Qt.ImhDigitsOnly; maximumLength: 8
+ selectionColor: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.35); clip: true
+ Keys.onReturnPressed: root._pair(dRow.device.mac, text)
+ }
+ }
+ Rectangle {
+ id: pairConfBtn; width: 64; height: 32; radius: 8
+ color: pcH.hovered ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.30) : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.14)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.42); border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text { anchors.centerIn: parent; text: "Pair"; font.pixelSize: 11; font.weight: Font.Medium; color: Theme.active }
+ HoverHandler { id: pcH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._pair(dRow.device.mac, pinInput.text) }
+ }
+ }
+ }
+ }
+ }
+
+ onIsPairingOpenChanged: { if (!isPairingOpen) pinInput.text = "" }
+ HoverHandler { id: rowHov; enabled: !dRow.isPaired }
+ }
+
+ // ── Main layout ───────────────────────────────────────────────────────────
+ Column {
+ anchors.fill: parent; spacing: 0
+
+ // Header
+ Item {
+ width: parent.width; height: 40
+
+ Text { anchors { left: parent.left; leftMargin: 2; verticalCenter: parent.verticalCenter }
+ text: "Bluetooth"; font.pixelSize: 15; font.weight: Font.Bold; color: Theme.text }
+
+ Row {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ spacing: 8
+
+ // Power toggle
+ Rectangle {
+ width: 32; height: 32; radius: 8
+ color: pwrH.hovered ? (root._btPowered ? Qt.rgba(248/255,113/255,113/255,0.18) : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18)) : Qt.rgba(1,1,1,0.04)
+ border.color: root._btPowered ? Qt.rgba(1,1,1,0.10) : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.30); border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Behavior on border.color { ColorAnimation { duration: 120 } }
+ Text { anchors.centerIn: parent; text: "⏻"; font.pixelSize: 14; color: root._btPowered ? (pwrH.hovered ? "#f87171" : Qt.rgba(1,1,1,0.32)) : Theme.active; Behavior on color { ColorAnimation { duration: 120 } } }
+ HoverHandler { id: pwrH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._setPower(!root._btPowered) }
+ }
+
+ // Settings — blueman-manager
+ Rectangle {
+ width: 32; height: 32; radius: 8
+ color: settH.hovered ? Qt.rgba(1,1,1,0.09) : Qt.rgba(1,1,1,0.03)
+ border.color: Qt.rgba(1,1,1,0.10); border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text { anchors.centerIn: parent; text: ""; font.pixelSize: 14; color: settH.hovered ? Qt.rgba(1,1,1,0.75) : Qt.rgba(1,1,1,0.30); Behavior on color { ColorAnimation { duration: 100 } } }
+ HoverHandler { id: settH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: { bluemanProc.running = false; bluemanProc.running = true } }
+ }
+
+ // Scan / Stop pill — disabled when adapter is off
+ Rectangle {
+ width: scanRow.implicitWidth + 20; height: 30; radius: 15
+ opacity: root._btPowered ? 1.0 : 0.35
+ Behavior on opacity { NumberAnimation { duration: 200 } }
+ color: root._scanning ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18) : scanH.hovered && root._btPowered ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.12) : Qt.rgba(1,1,1,0.05)
+ border.color: root._scanning ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.48) : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.28); border.width: 1
+ Behavior on color { ColorAnimation { duration: 130 } }
+ Row {
+ id: scanRow; anchors.centerIn: parent; spacing: 7
+ Rectangle {
+ width: 7; height: 7; radius: 4; anchors.verticalCenter: parent.verticalCenter
+ color: root._scanning ? Theme.active : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.55)
+ Behavior on color { ColorAnimation { duration: 120 } }
+ SequentialAnimation on opacity {
+ running: root._scanning; loops: Animation.Infinite; NumberAnimation { to: 0.15; duration: 450 }
+ NumberAnimation { to: 1.0; duration: 450 }
+ }
+ }
+ Text { anchors.verticalCenter: parent.verticalCenter; text: root._scanning ? "Stop" : "Scan"; font.pixelSize: 12; font.weight: Font.Medium; color: root._scanning ? Theme.active : Qt.rgba(1,1,1,0.6); Behavior on color { ColorAnimation { duration: 130 } } }
+ }
+ HoverHandler { id: scanH; cursorShape: root._btPowered ? Qt.PointingHandCursor : Qt.ArrowCursor }
+ MouseArea { anchors.fill: parent; onClicked: if (root._btPowered) root._startScan() }
+ }
+ }
+ }
+
+ Rectangle { width: parent.width; height: 1; color: Qt.rgba(1,1,1,0.07) }
+ Item { width: parent.width; height: 8 }
+
+ // Scan animation strip
+ Item {
+ width: parent.width; height: root._scanning ? 90 : 0; clip: true
+ Behavior on height { NumberAnimation { duration: 250; easing.type: Easing.OutCubic } }
+ ScanRings { anchors.centerIn: parent; width: 52; height: 52; centerGlyph: ""; glyphSize: 14 }
+ Text { anchors { horizontalCenter: parent.horizontalCenter; bottom: parent.bottom; bottomMargin: 6 }
+ text: "Scanning for devices…"; font.pixelSize: 10; color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.50) }
+ }
+
+ Flickable {
+ width: parent.width
+ height: parent.height - 49 - (root._scanning ? 90 : 0)
+ contentWidth: width; contentHeight: devCol.height
+ clip: true; boundsBehavior: Flickable.StopAtBounds
+ Behavior on height { NumberAnimation { duration: 250; easing.type: Easing.OutCubic } }
+
+ Column {
+ id: devCol; width: parent.width; height: implicitHeight; spacing: 4
+
+ Item { width: parent.width; height: visible ? pLbl.implicitHeight + 4 : 0; visible: root._paired.length > 0
+ Text { id: pLbl; text: "PAIRED"; font.pixelSize: 9; font.weight: Font.Bold; font.letterSpacing: 1.2; color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.5) } }
+
+ Repeater {
+ model: root._paired
+ delegate: DeviceRow { required property var modelData; width: devCol.width - 2; x: 1; device: modelData; isPaired: true }
+ }
+
+ Item { width: parent.width; height: 10; visible: root._paired.length > 0 && root._available.length > 0 }
+
+ Item { width: parent.width; height: visible ? aLbl.implicitHeight + 4 : 0; visible: root._available.length > 0
+ Text { id: aLbl; text: root._scanning ? "DISCOVERED" : "AVAILABLE"; font.pixelSize: 9; font.weight: Font.Bold; font.letterSpacing: 1.2; color: Qt.rgba(1,1,1,0.25) } }
+
+ Repeater {
+ model: root._available
+ delegate: DeviceRow { required property var modelData; width: devCol.width - 2; x: 1; device: modelData; isPaired: false }
+ }
+
+ // Empty state
+ Item {
+ width: parent.width; height: 120
+ visible: !root._scanning && root._allDevices.length === 0 && root._btPowered
+ Column { anchors.centerIn: parent; spacing: 10
+ Text { anchors.horizontalCenter: parent.horizontalCenter; text: ""; font.pixelSize: 32; color: Qt.rgba(1,1,1,0.08) }
+ Text { anchors.horizontalCenter: parent.horizontalCenter; text: "No devices found"; font.pixelSize: 12; color: Qt.rgba(1,1,1,0.2) }
+ Text { anchors.horizontalCenter: parent.horizontalCenter; text: "Tap Scan to discover nearby devices"; font.pixelSize: 10; color: Qt.rgba(1,1,1,0.14) }
+ }
+ }
+
+ Item { width: parent.width; height: 8 }
+ }
+ }
+ }
+
+ // ── Bluetooth off overlay — anchors.fill + topMargin, no overflow ─────────
+ Item {
+ anchors { fill: parent; topMargin: 49 }
+ visible: !root._btPowered
+ z: 2
+
+ Rectangle { anchors.fill: parent; color: Qt.rgba(Theme.background.r, Theme.background.g, Theme.background.b, 0.95) }
+
+ Column {
+ anchors.centerIn: parent; spacing: 16
+ Text { anchors.horizontalCenter: parent.horizontalCenter; text: ""; font.pixelSize: 42; color: Qt.rgba(1,1,1,0.12) }
+ Text { anchors.horizontalCenter: parent.horizontalCenter; text: "Bluetooth is off"; font.pixelSize: 14; font.weight: Font.Medium; color: Qt.rgba(1,1,1,0.30) }
+ Rectangle {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: enableRow.implicitWidth + 24; height: 34; radius: 17
+ color: enableH.hovered ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.22) : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.12)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.40); border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Row { id: enableRow; anchors.centerIn: parent; spacing: 8
+ Text { anchors.verticalCenter: parent.verticalCenter; text: ""; font.pixelSize: 14; color: Theme.active }
+ Text { anchors.verticalCenter: parent.verticalCenter; text: "Turn On"; font.pixelSize: 12; font.weight: Font.Medium; color: Theme.active }
+ }
+ HoverHandler { id: enableH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._setPower(true) }
+ }
+ }
+ }
+}
diff --git a/src/popups/ClipboardPopup.qml b/src/popups/ClipboardPopup.qml
new file mode 100644
index 0000000..1fa589d
--- /dev/null
+++ b/src/popups/ClipboardPopup.qml
@@ -0,0 +1,102 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import "../shapes"
+import "../components"
+import "../"
+import "../theme"
+
+PanelWindow {
+ id: root
+
+ readonly property int popupWidth: 420
+ readonly property int popupHeight: 560
+ readonly property int fw: Theme.cornerRadius
+ readonly property int fh: Theme.cornerRadius
+
+ anchors.right: true
+ anchors.bottom: true
+
+ implicitWidth: popupWidth + fw
+ implicitHeight: popupHeight + fh
+
+ exclusionMode: ExclusionMode.Ignore
+ color: "transparent"
+
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
+
+ mask: Region { item: maskProxy }
+ Item {
+ id: maskProxy
+ x: root.implicitWidth - sizer.width
+ y: root.implicitHeight - sizer.height
+ width: sizer.width
+ height: sizer.height
+ }
+
+ property bool windowVisible: false
+ visible: windowVisible
+
+ Connections {
+ target: Popups
+ function onClipboardOpenChanged() {
+ if (Popups.clipboardOpen) {
+ closeTimer.stop()
+ root.windowVisible = true
+ } else {
+ closeTimer.restart()
+ }
+ }
+ }
+
+ Timer {
+ id: closeTimer
+ interval: Anim.standardNormal + 20
+ onTriggered: {
+ if (!Popups.clipboardOpen)
+ root.windowVisible = false
+ }
+ }
+
+ Item {
+ id: sizer
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.rightMargin: Theme.borderWidth
+ anchors.bottomMargin: Theme.borderWidth
+ clip: true
+
+ width: Popups.clipboardOpen ? root.popupWidth + root.fw : 1
+ height: Popups.clipboardOpen ? root.popupHeight + root.fh : 1
+
+ Behavior on width { NumberAnimation { duration: Anim.standardNormal; easing: Anim.outCubic } }
+ Behavior on height { NumberAnimation { duration: Anim.standardNormal; easing: Anim.outCubic } }
+
+ PopupShape {
+ anchors.fill: parent
+ attachedEdge: "bottom-right"
+ color: Theme.background
+ radius: Theme.cornerRadius
+ flareWidth: root.fw
+ flareHeight: root.fh
+ }
+
+ ShellPopupBase {
+ id: content
+ anchors {
+ fill: parent
+ topMargin: root.fh + 8
+ leftMargin: root.fw + 10
+ bottomMargin: 8
+ }
+ isOpen: Popups.clipboardOpen
+ transformEdge: "bottom"
+ disableAutoHide: true
+ clip: true
+
+ HistoryTab { anchors.fill: parent }
+ }
+ }
+}
diff --git a/src/popups/Dashboard.qml b/src/popups/Dashboard.qml
new file mode 100644
index 0000000..b734107
--- /dev/null
+++ b/src/popups/Dashboard.qml
@@ -0,0 +1,212 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import "../shapes"
+import "../components"
+import "../modules/Center/"
+import '../services/'
+import "../"
+import "../theme"
+
+// Dashboard — PanelWindow required for TextInput keyboard focus on Wayland.
+// Uses WlrKeyboardFocus.Exclusive so TextInputs inside pages receive key events.
+//
+// Positioning mirrors the original PopupWindow behaviour: the sizer's top sits
+// exactly at the notch-bar bottom (topMargin: Theme.notchHeight), so there is
+// no vertical offset compared to the PopupWindow version.
+
+PanelWindow {
+ id: root
+
+ // Kept so existing instantiation sites that pass anchorWindow: … still compile.
+ required property var anchorWindow
+
+ readonly property int fw: Theme.notchRadius
+ readonly property int fh: Theme.notchRadius
+ readonly property int animDuration: Anim.standardNormal
+
+ property string page: Popups.dashboardPage
+
+ // ── Per-page content widths ───────────────────────────────────────────────
+ readonly property var _pageWidths: ({
+ "home": 900,
+ "stats": 900,
+ "kanban": 900,
+ "launcher": 560,
+ "config": 900
+ })
+
+ function _applyPageWidth(p) {
+ var w = _pageWidths[p]
+ Popups.dashboardPageWidth = (w !== undefined) ? w : 900
+ }
+
+ onPageChanged: _applyPageWidth(page)
+
+ color: "transparent"
+ visible: windowVisible
+
+ anchors.top: true
+ anchors.left: true
+ anchors.right: true
+ anchors.bottom: true
+
+ exclusionMode: ExclusionMode.Ignore
+
+ WlrLayershell.layer: WlrLayer.Overlay
+ // Exclusive focus only when fully open and visible — no timer delay.
+ // (A delayed grab creates a 15ms hole where first clicks are lost.)
+ WlrLayershell.keyboardFocus: (windowVisible && Popups.dashboardOpen) ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
+
+ property bool windowVisible: false
+
+ Connections {
+ target: Popups
+ function onDashboardOpenChanged() {
+ if (Popups.dashboardOpen) {
+ closeTimer.stop()
+ root.windowVisible = true
+ root._applyPageWidth(root.page)
+ } else {
+ closeTimer.restart()
+ }
+ }
+
+ function onDashboardPageChanged() {
+ root.page = Popups.dashboardPage
+ }
+ }
+
+ Timer {
+ id: closeTimer
+ interval: root.animDuration + 20
+ onTriggered: {
+ root.windowVisible = false
+ tabBar.reset()
+ }
+ }
+
+ // ── Backdrop — closes popup when clicking outside the sizer ──────────────
+ MouseArea {
+ anchors.fill: parent
+ onClicked: Popups.dashboardOpen = false
+ }
+
+ // ── Sizer — anchored at y=0, expands downward FROM inside the notch.
+ // PopupShape flare melts into the notch. clip: false lets the flare
+ // extend above y=0 into the notch area (was clipped → black gap).
+ Item {
+ id: sizer
+ anchors.top: parent.top
+ anchors.horizontalCenter: parent.horizontalCenter
+ clip: false
+
+ width: Popups.dashboardOpen ? Popups.dashboardPageWidth + 2 * root.fw : Theme.cNotchMinWidth + 2 * root.fw
+ height: Popups.dashboardOpen ? Theme.dashboardHeight : Theme.notchHeight / 2
+
+ Behavior on width { NumberAnimation { duration: root.animDuration; easing: Anim.outCubic } }
+ Behavior on height { NumberAnimation { duration: root.animDuration; easing: Anim.outCubic } }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {}
+ }
+
+ // ── Background — flare extends into notch area (above sizer bounds) ──
+ PopupShape {
+ anchors.fill: parent
+ attachedEdge: "top"
+ color: Theme.background
+ radius: Theme.cornerRadius
+ flareWidth: root.fw
+ flareHeight: Theme.notchHeight // must cover notch (40px), not just corner
+ }
+
+ // ── Content (Ambxst scale+opacity combo) ──────────────────────────
+ ShellPopupBase {
+ id: content
+ anchors {
+ fill: parent
+ topMargin: root.fh + 8
+ leftMargin: root.fw + 8
+ rightMargin: root.fw + 8
+ bottomMargin: 8
+ }
+ isOpen: Popups.dashboardOpen
+ transformEdge: "top"
+ disableAutoHide: true
+
+ Column {
+ anchors.fill: parent
+ spacing: 0
+
+ // ── Tab bar ───────────────────────────────────────────────────
+ TabSwitcher {
+ id: tabBar
+ orientation: "horizontal"
+ width: parent.width
+ currentPage: root.page
+ model: [
+ { key: "home", icon: "", label: "Home" },
+ { key: "stats", icon: "", label: "System" },
+ { key: "kanban", icon: "", label: "Tasks" },
+ { key: "launcher", icon: "", label: "Apps" },
+ { key: "config", icon: "", label: "Config" },
+ ]
+ onPageChanged: function(key) { Popups.dashboardPage = key }
+ }
+
+ // ── Page area — simple visibility toggle (original pattern) ─────
+ // Pages are created once and toggled via visible binding.
+ // This is lighter than Loader/StackView and avoids re-creation lag.
+ Item {
+ id: pageArea
+ focus: true
+
+ width: parent.width
+ height: parent.height - tabBar.height
+
+ // ── Home ────────────────────────────────────────────────
+ Item {
+ anchors.fill: parent
+ visible: root.page === "home"
+ DashHome { anchors.fill: parent }
+ }
+
+ // ── System Stats ─────────────────────────────────────────
+ Item {
+ anchors.fill: parent
+ visible: root.page === "stats"
+ DashStats { anchors.fill: parent }
+ }
+
+ // ── Kanban Tasks ─────────────────────────────────────────
+ Item {
+ anchors.fill: parent
+ visible: root.page === "kanban"
+ KanbanBoard { anchors.fill: parent }
+ }
+
+ // ── App Launcher ─────────────────────────────────────────
+ Item {
+ anchors.fill: parent
+ visible: root.page === "launcher"
+ AppLauncher { anchors.fill: parent }
+ }
+
+ // ── Config ───────────────────────────────────────────────
+ Item {
+ anchors.fill: parent
+ visible: root.page === "config"
+ ShellConfig { anchors.fill: parent }
+ }
+
+ Keys.onEscapePressed: Popups.dashboardOpen = false
+ }
+ }
+ }
+ }
+
+ // ── No page components needed — pages are declared inline above ──────────
+}
diff --git a/src/popups/HistoryTab.qml b/src/popups/HistoryTab.qml
new file mode 100644
index 0000000..08622bf
--- /dev/null
+++ b/src/popups/HistoryTab.qml
@@ -0,0 +1,494 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell.Io
+import "../"
+import "../theme"
+
+Item {
+ id: root
+
+ readonly property var pinned: ClipboardService.pinned ?? []
+ readonly property var history: ClipboardService.entries ?? []
+
+ // ── Flat unified model ─────────────────────────────────────────────────────
+ readonly property var flatModel: {
+ var result = []
+ var pins = root.pinned
+ var hist = root.history
+
+ var lookup = {}
+ for (var k = 0; k < pins.length; k++) {
+ if (pins[k].preview) lookup[pins[k].preview] = true
+ if (pins[k].text) lookup[pins[k].text] = true
+ }
+
+ for (var pi = 0; pi < pins.length; pi++) {
+ result.push({
+ kind: "pinned",
+ text: pins[pi].text ?? "",
+ preview: pins[pi].preview ?? pins[pi].text ?? "",
+ storedId: pins[pi].id ?? "",
+ pinIndex: pi,
+ isImage: false
+ })
+ }
+
+ for (var hi = 0; hi < hist.length; hi++) {
+ var e = hist[hi]
+ if (!e.isImage && (lookup[e.preview] || lookup[(e.preview ?? "").trim()])) continue
+ result.push({
+ kind: "entry",
+ id: e.id,
+ preview: e.preview,
+ isImage: e.isImage ?? false
+ })
+ }
+
+ return result
+ }
+
+ Column {
+ anchors.fill: parent
+ spacing: 0
+
+ // ── Header ─────────────────────────────────────────────────────────────
+ Item {
+ width: parent.width
+ height: 44
+
+ Text {
+ text: "Clipboard"
+ font.pixelSize: 14
+ font.weight: Font.DemiBold
+ color: Theme.text
+ }
+
+ // Clear unpinned history button
+ Rectangle {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter; rightMargin: 4 }
+ width: clearRow.implicitWidth + 14
+ height: 26; radius: 8
+ color: clearH.hovered
+ ? Qt.rgba(248/255, 113/255, 113/255, 0.18)
+ : Qt.rgba(1, 1, 1, 0.04)
+ border.color: Qt.rgba(248/255, 113/255, 113/255, clearH.hovered ? 0.38 : 0.12)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 150 } }
+ Behavior on border.color { ColorAnimation { duration: 150 } }
+
+ Row {
+ id: clearRow
+ anchors.centerIn: parent
+ spacing: 5
+ Text {
+ text: ""; font.pixelSize: 12
+ color: Qt.rgba(248/255, 113/255, 113/255, 0.80)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Text {
+ text: "Clear"; font.pixelSize: 10
+ color: Qt.rgba(248/255, 113/255, 113/255, 0.80)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ HoverHandler { id: clearH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: ClipboardService.wipeHistory() }
+ }
+ }
+
+ // ── Content ────────────────────────────────────────────────────────────
+ Item {
+ width: parent.width
+ height: parent.height - 45
+
+ // Loading state
+ Column {
+ anchors.centerIn: parent; spacing: 10
+ visible: (ClipboardService.loading ?? false)
+ && root.history.length === 0
+ && root.pinned.length === 0
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "○"; font.pixelSize: 22; color: Theme.active
+ SequentialAnimation on opacity {
+ running: parent.visible; loops: Animation.Infinite
+ NumberAnimation { to: 0.15; duration: 500 }
+ NumberAnimation { to: 1.0; duration: 500 }
+ }
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "Loading…"; font.pixelSize: 12; color: Qt.rgba(1,1,1,0.25)
+ }
+ }
+
+ // Empty state
+ Column {
+ anchors.centerIn: parent; spacing: 10
+ visible: !(ClipboardService.loading ?? false)
+ && root.history.length === 0
+ && root.pinned.length === 0
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: ""; font.pixelSize: 32; color: Qt.rgba(1,1,1,0.08)
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "Clipboard is empty"; font.pixelSize: 12; color: Qt.rgba(1,1,1,0.20)
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "Copy something to get started"; font.pixelSize: 10; color: Qt.rgba(1,1,1,0.13)
+ }
+ }
+
+ // ── List ───────────────────────────────────────────────────────────
+ ListView {
+ id: mainList
+ anchors {
+ fill: parent
+ topMargin: 6
+ leftMargin: 2
+ rightMargin: 12
+ bottomMargin: 4
+ }
+ clip: true
+ spacing: 4
+ boundsBehavior: Flickable.StopAtBounds
+ visible: root.flatModel.length > 0
+ model: root.flatModel
+ cacheBuffer: 2000
+
+ // Point to the detached scrollbar below
+ ScrollBar.vertical: vbar
+
+ // Smooth repositioning when items are removed
+ displaced: Transition {
+ NumberAnimation { property: "y"; duration: 220; easing.type: Easing.OutCubic }
+ }
+
+ delegate: ClipRow {
+ required property var modelData
+ required property int index
+ width: mainList.width - 4
+
+ isPinned: modelData.kind === "pinned"
+ entryId: modelData.kind === "pinned" ? (modelData.storedId ?? "") : (modelData.id ?? "")
+ previewText: modelData.preview ?? ""
+ fullText: modelData.kind === "pinned" ? (modelData.text ?? "") : ""
+ isImage: modelData.isImage ?? false
+ pinnedIndex: modelData.kind === "pinned" ? modelData.pinIndex : -1
+ }
+ }
+
+ // ── External Scrollbar placed at the absolute edge ────────────────
+ ScrollBar {
+ id: vbar
+ anchors {
+ top: parent.top
+ bottom: parent.bottom
+ right: parent.right
+ topMargin: 6
+ bottomMargin: 4
+ }
+ policy: ScrollBar.AsNeeded
+ contentItem: Rectangle {
+ implicitWidth: 4
+ implicitHeight: 10
+ radius: 2
+ color: Qt.rgba(1, 1, 1, 0.22)
+ }
+ }
+ }
+ }
+
+
+// ── ClipRow ────────────────────────────────────────────────────────────────────
+component ClipRow: Item {
+ id: row
+
+ property bool isPinned: false
+ property string entryId: "" // cliphist row id (for history) or storedId (for pinned)
+ property string previewText: "" // cliphist list preview string
+ property string fullText: "" // full decoded text (pinned items only)
+ property bool isImage: false
+ property int pinnedIndex: -1
+
+ // Image preview: decoded to a temp file per entry id
+ property string _imgPath: ""
+
+ property var _imgDecodeProc: Process {
+ command: []
+ running: false
+ onRunningChanged: {
+ if (!running) row._imgPath = "/tmp/clip_prev_" + row.entryId
+ }
+ }
+
+ Component.onCompleted: {
+ if (row.isImage && row.entryId !== "") {
+ _imgDecodeProc.command = ["bash", "-c",
+ "cliphist decode '" + row.entryId + "' > '/tmp/clip_prev_" + row.entryId + "' 2>/dev/null"]
+ _imgDecodeProc.running = true
+ }
+ }
+
+ // Collapse height to zero for animated removal
+ property bool _removing: false
+
+ height: _removing ? 0 : (card.height + 2)
+ opacity: _removing ? 0 : 1
+ clip: true
+
+ Behavior on height { NumberAnimation { duration: 210; easing.type: Easing.InCubic } }
+ Behavior on opacity { NumberAnimation { duration: 160 } }
+
+ // ── Card ──────────────────────────────────────────────────────────────────
+ Rectangle {
+ id: card
+ anchors { left: parent.left; right: parent.right; top: parent.top; topMargin: 1 }
+
+ height: Math.max(row.isImage ? 66 : 42, innerRow.height + 18)
+ radius: 9
+
+ color: row.isPinned
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, rHov.hovered ? 0.10 : 0.055)
+ : rHov.hovered ? Qt.rgba(1, 1, 1, 0.065) : Qt.rgba(1, 1, 1, 0.024)
+
+ border.color: row.isPinned
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, rHov.hovered ? 0.30 : 0.18)
+ : rHov.hovered ? Qt.rgba(1, 1, 1, 0.13) : Qt.rgba(1, 1, 1, 0.065)
+ border.width: 1
+
+ Behavior on color { ColorAnimation { duration: 140 } }
+ Behavior on border.color { ColorAnimation { duration: 140 } }
+
+ // ── Inner layout ──────────────────────────────────────────────────────
+ Row {
+ id: innerRow
+ anchors {
+ left: parent.left; leftMargin: 10
+ right: parent.right; rightMargin: 8
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 8
+ height: Math.max(row.isImage ? 56 : 22, previewLabel.implicitHeight)
+
+ // Left: image thumbnail -OR- text glyph
+ Item {
+ id: leftSlot
+ width: row.isImage ? 74 : 18
+ height: innerRow.height
+ anchors.verticalCenter: parent.verticalCenter
+
+ // ── Image thumbnail ───────────────────────────────────────────
+ Rectangle {
+ visible: row.isImage
+ anchors {
+ left: parent.left; top: parent.top
+ bottom: parent.bottom; right: parent.right
+ rightMargin: 6
+ }
+ radius: 6
+ color: Qt.rgba(1, 1, 1, 0.05)
+ clip: true
+
+ Image {
+ id: thumbImg
+ anchors.fill: parent
+ source: row._imgPath !== "" ? ("file://" + row._imgPath) : ""
+ fillMode: Image.PreserveAspectCrop
+ smooth: true
+ asynchronous: true
+ sourceSize.width: 128
+ sourceSize.height: 128
+ opacity: thumbImg.status === Image.Ready ? 1.0 : 0.0
+ Behavior on opacity { NumberAnimation { duration: 280; easing.type: Easing.OutCubic } }
+ }
+
+ // Placeholder while loading / no path yet
+ Item {
+ anchors.fill: parent
+ visible: thumbImg.status !== Image.Ready
+
+ Rectangle {
+ anchors.fill: parent
+ color: Qt.rgba(1,1,1,0.03)
+ }
+ Text {
+ anchors.centerIn: parent
+ text: "🖼"; font.pixelSize: 18; opacity: 0.22
+ }
+ }
+ }
+
+ // ── Text icon glyph ───────────────────────────────────────────
+ Text {
+ visible: !row.isImage
+ anchors.verticalCenter: parent.verticalCenter
+ text: ""
+ font.pixelSize: 12
+ color: Qt.rgba(1, 1, 1, 0.22)
+ }
+ }
+
+ // ── Preview text / image label ─────────────────────────────────────
+ Text {
+ id: previewLabel
+ width: innerRow.width
+ - leftSlot.width
+ - actionsRow.implicitWidth
+ - innerRow.spacing * 2
+ - 2
+ anchors.verticalCenter: parent.verticalCenter
+
+ text: row.isImage ? "Image" : row.previewText
+ font.pixelSize: 12
+ color: row.isImage
+ ? Qt.rgba(1, 1, 1, 0.28)
+ : Qt.rgba(1, 1, 1, 0.78)
+ font.italic: row.isImage
+ elide: Text.ElideRight
+ maximumLineCount: 2
+ wrapMode: Text.WordWrap
+ }
+
+ // ── Action buttons (appear on hover) ──────────────────────────────
+ Row {
+ id: actionsRow
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+ opacity: rHov.hovered ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 160 } }
+
+ // Copy
+ ActionBtn {
+ icon: ""
+ onClicked: {
+ if (row.isPinned) ClipboardService.copyText(row.fullText || row.previewText)
+ else ClipboardService.copyEntry(row.entryId)
+ Popups.clipboardOpen = false
+ }
+ }
+
+ // Pin / Unpin (hidden for image entries — images can't be pinned)
+ ActionBtn {
+ icon: row.isPinned ? "" : ""
+ active: row.isPinned
+ visible: !row.isImage
+ onClicked: {
+ if (row.isPinned)
+ ClipboardService.unpinAt(row.pinnedIndex)
+ else
+ ClipboardService.pinEntry(row.entryId, row.previewText)
+ }
+ }
+
+ // Delete / fully remove
+ ActionBtn {
+ icon: ""
+ danger: true
+ onClicked: {
+ row._removing = true
+ delayedAction.isPinned = row.isPinned
+ delayedAction.pinnedIndex = row.pinnedIndex
+ delayedAction.entryId = row.entryId
+ delayedAction.restart()
+ }
+ }
+ }
+ }
+
+ // ── Pin badge: small circle in the top-right corner ───────────────────
+ Rectangle {
+ visible: row.isPinned
+ anchors { top: parent.top; right: parent.right; topMargin: 5; rightMargin: 4 }
+ width: 18; height: 18; radius: 8
+ color: Theme.active
+
+ Text {
+ anchors.centerIn: parent
+ text: " "; font.pixelSize: 8; font.weight: Font.Bold
+ color: Qt.rgba(0, 0, 0, 0.65)
+ }
+
+ scale: row.isPinned ? 1.0 : 0.0
+ Behavior on scale { NumberAnimation { duration: 220; easing.type: Easing.OutBack } }
+ }
+ }
+
+ HoverHandler { id: rHov }
+
+ // ── Double-tap: copy + close popup + paste into active field ───────────────
+ TapHandler {
+ onDoubleTapped: {
+ if (row.isPinned) ClipboardService.copyText(row.fullText || row.previewText)
+ else ClipboardService.copyEntry(row.entryId)
+ Popups.clipboardOpen = false
+ ClipboardService.typeFromClipboard()
+ }
+ }
+
+ // Fires after the collapse animation completes so the list reflows smoothly
+ Timer {
+ id: delayedAction
+ property bool isPinned: false
+ property int pinnedIndex: -1
+ property string entryId: ""
+ interval: 220
+ onTriggered: {
+ if (isPinned) {
+ // Remove from pin list
+ if (pinnedIndex >= 0) ClipboardService.unpinAt(pinnedIndex)
+ if (entryId !== "") ClipboardService.deleteEntry(entryId)
+ } else {
+ if (entryId !== "") ClipboardService.deleteEntry(entryId)
+ }
+ }
+ }
+}
+
+
+// ── ActionBtn ──────────────────────────────────────────────────────────────────
+component ActionBtn: Rectangle {
+ id: ab
+ property string icon: ""
+ property bool active: false
+ property bool danger: false
+ signal clicked()
+
+ width: 26; height: 26; radius: 7
+
+ color: ab.danger
+ ? (aH.hovered ? Qt.rgba(248/255, 113/255, 113/255, 0.20) : "transparent")
+ : ab.active
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.22)
+ : (aH.hovered ? Qt.rgba(1, 1, 1, 0.11) : "transparent")
+
+ Behavior on color { ColorAnimation { duration: 110 } }
+
+ // Subtle scale-up on hover
+ transform: Scale {
+ origin.x: 13; origin.y: 13
+ xScale: aH.hovered ? 1.10 : 1.0
+ yScale: aH.hovered ? 1.10 : 1.0
+ Behavior on xScale { NumberAnimation { duration: 130; easing.type: Easing.OutCubic } }
+ Behavior on yScale { NumberAnimation { duration: 130; easing.type: Easing.OutCubic } }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ text: ab.icon
+ font.pixelSize: 13
+ color: ab.danger
+ ? (aH.hovered ? "#f87171" : Qt.rgba(248/255, 113/255, 113/255, 0.50))
+ : ab.active
+ ? Theme.active
+ : (aH.hovered ? Qt.rgba(1, 1, 1, 0.88) : Qt.rgba(1, 1, 1, 0.38))
+ Behavior on color { ColorAnimation { duration: 110 } }
+ }
+
+ HoverHandler { id: aH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: ab.clicked() }
+}
+}
diff --git a/src/popups/HotspotTab.qml b/src/popups/HotspotTab.qml
new file mode 100644
index 0000000..a8d5720
--- /dev/null
+++ b/src/popups/HotspotTab.qml
@@ -0,0 +1,207 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import "../"
+import "../components"
+
+// HotspotTab — config editor for hotspot SSID/password.
+// The actual start/stop toggle lives in QuickSettings tile.
+// Config stored in src/user_data/hotspot.json.
+
+Item {
+ id: root
+
+ property string _ssid: "BrainShell"
+ property string _password: "changeme1"
+ property bool _showPass: false
+ property bool _dirty: false // unsaved changes
+
+ readonly property string _cfgPath:
+ Quickshell.env("HOME") + "/.config/Brain_Shell/src/user_data/hotspot.json"
+
+ // ── Load ──────────────────────────────────────────────────────────────────
+ Process {
+ id: loadProc
+ command: ["bash", "-c",
+ "[ -f '" + root._cfgPath + "' ] || " +
+ "(mkdir -p \"$(dirname '" + root._cfgPath + "')\" && " +
+ "printf '%s' '{\"ssid\":\"BrainShell\",\"password\":\"changeme1\"}' " +
+ "> '" + root._cfgPath + "'); " +
+ "cat '" + root._cfgPath + "'"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (text.trim() === "") return
+ try {
+ var o = JSON.parse(text)
+ if (o.ssid) root._ssid = o.ssid
+ if (o.password) root._password = o.password
+ } catch(e) {}
+ }
+ }
+ }
+
+ // ── Save ──────────────────────────────────────────────────────────────────
+ Process {
+ id: saveProc; command: []; running: false
+ onRunningChanged: if (!running) root._dirty = false
+ }
+
+ function _save() {
+ var j = JSON.stringify({ ssid: root._ssid, password: root._password })
+ saveProc.command = ["bash", "-c",
+ "printf '%s' '" + j.replace(/'/g, "'\\''") + "' > '" + root._cfgPath + "'"]
+ saveProc.running = false; saveProc.running = true
+ }
+
+ // Also update QuickSettings in-memory values so the tile uses new creds immediately
+ function _applyToQuickSettings() {
+ // Walk to the parent DashHome → QuickSettings sibling is not accessible,
+ // so we just save to disk; QS reads from disk on next hotspot start.
+ }
+
+ Connections {
+ target: Popups
+ function onNetworkOpenChanged() {
+ if (Popups.networkOpen && root.visible)
+ loadProc.running = true
+ }
+ }
+
+ Component.onCompleted: loadProc.running = true
+
+ // ── Layout ────────────────────────────────────────────────────────────────
+ Column {
+ anchors.fill: parent; spacing: 0
+
+ // Header
+ Item {
+ width: parent.width; height: 40
+ Text { anchors { left: parent.left; leftMargin: 2
+ verticalCenter: parent.verticalCenter }
+ text: "Hotspot"
+ font.pixelSize: 15; font.weight: Font.Bold; color: Theme.text }
+
+ // Active indicator
+ Row {
+ anchors { left: parent.left; leftMargin: 76;
+ verticalCenter: parent.verticalCenter }
+ spacing: 6
+ Rectangle { width: 7; height: 7; radius: 4; anchors.verticalCenter: parent.verticalCenter; color: ShellState.hotspot ? Theme.active : Qt.rgba(1,1,1,0.22); Behavior on color { ColorAnimation { duration: 200 } } }
+ Text { anchors.verticalCenter: parent.verticalCenter; text: ShellState.hotspot ? "Active" : "Inactive"; font.pixelSize: 11; color: ShellState.hotspot ? Theme.active : Qt.rgba(1,1,1,0.32) }
+ }
+ }
+
+ Rectangle { width: parent.width; height: 1; color: Qt.rgba(1,1,1,0.07) }
+ Item { width: parent.width; height: 8 }
+
+ Flickable {
+ width: parent.width; height: parent.height - 49
+ contentWidth: width; contentHeight: mainCol.implicitHeight + 8
+ clip: true; boundsBehavior: Flickable.StopAtBounds
+
+ Column {
+ id: mainCol; width: parent.width; spacing: 14
+
+ // Info banner
+ Rectangle {
+ width: parent.width; height: infoCol.implicitHeight + 16; radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.06)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18); border.width: 1
+
+ Column {
+ id: infoCol;
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 12 }
+ spacing: 4
+ Text { width: parent.width; text: " Toggle hotspot from the Quick Settings panel."; font.pixelSize: 11; color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.7); wrapMode: Text.WordWrap }
+ Text { width: parent.width; text: "Requires an ethernet connection. Shares the same WiFi channel as your current connection."; font.pixelSize: 10; color: Qt.rgba(1,1,1,0.30); wrapMode: Text.WordWrap; lineHeight: 1.4 }
+ }
+ }
+
+ // Config card
+ Rectangle {
+ width: parent.width; height: cfgCol.implicitHeight + 20; radius: Theme.cornerRadius
+ color: Qt.rgba(1,1,1,0.04); border.color: Qt.rgba(1,1,1,0.07); border.width: 1
+
+ Column {
+ id: cfgCol; anchors { left: parent.left; right: parent.right; top: parent.top; margins: 12 }
+ spacing: 12
+
+ Text { text: "CREDENTIALS"; font.pixelSize: 9; font.weight: Font.Bold; font.letterSpacing: 1.2; color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.5) }
+
+ // SSID
+ Item {
+ width: parent.width; height: 32
+ Text { anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ text: "SSID"; font.pixelSize: 11; font.weight: Font.Medium; color: Qt.rgba(1,1,1,0.45); width: 72 }
+ Rectangle {
+ anchors { left: parent.left; leftMargin: 76; right: parent.right; verticalCenter: parent.verticalCenter }
+ height: 28; radius: 7
+ color: ssidInput.activeFocus ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.08) : Qt.rgba(1,1,1,0.05)
+ border.color: ssidInput.activeFocus ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.45) : Qt.rgba(1,1,1,0.11); border.width: 1
+ Behavior on border.color { ColorAnimation { duration: 120 } }
+ TextInput {
+ id: ssidInput; anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter; color: Theme.text; font.pixelSize: 12
+ selectionColor: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.35)
+ clip: true; maximumLength: 32
+ text: root._ssid
+ onTextChanged: { root._ssid = text; root._dirty = true }
+ }
+ }
+ }
+
+ // Password
+ Item {
+ width: parent.width; height: 32
+ Text { anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ text: "Password"; font.pixelSize: 11; font.weight: Font.Medium; color: Qt.rgba(1,1,1,0.45); width: 72 }
+ Rectangle {
+ anchors { left: parent.left; leftMargin: 76; right: eyeBtn.left; rightMargin: 6; verticalCenter: parent.verticalCenter }
+ height: 28; radius: 7
+ color: passInput.activeFocus ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.08) : Qt.rgba(1,1,1,0.05)
+ border.color: passInput.activeFocus ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.45) : Qt.rgba(1,1,1,0.11); border.width: 1
+ Behavior on border.color { ColorAnimation { duration: 120 } }
+ TextInput {
+ id: passInput; anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter; color: Theme.text; font.pixelSize: 12
+ selectionColor: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.35)
+ echoMode: root._showPass ? TextInput.Normal : TextInput.Password
+ clip: true; maximumLength: 63
+ text: root._password
+ onTextChanged: { root._password = text; root._dirty = true }
+ }
+ }
+ Item {
+ id: eyeBtn; anchors { right: parent.right;
+ verticalCenter: parent.verticalCenter }
+ width: 28; height: 28
+ Rectangle { anchors.fill: parent; radius: 6; color: eyeH.hovered ? Qt.rgba(1,1,1,0.08) : "transparent" }
+ Text { anchors.centerIn: parent; text: root._showPass ? "" : ""; font.pixelSize: 13; color: root._showPass ? Theme.active : Qt.rgba(1,1,1,0.28) }
+ HoverHandler { id: eyeH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._showPass = !root._showPass }
+ }
+ }
+
+ // Save button — only visible when dirty
+ Rectangle {
+ visible: root._dirty
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: 90; height: 28; radius: 8
+ color: saveH.hovered
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.28)
+ : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.14)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.40); border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text { anchors.centerIn: parent; text: "Save"; font.pixelSize: 12; font.weight: Font.Medium; color: Theme.active }
+ HoverHandler { id: saveH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._save() }
+ }
+ }
+ }
+
+ Item { width: parent.width; height: 4 }
+ }
+ }
+ }
+}
diff --git a/src/popups/NetworkPopup.qml b/src/popups/NetworkPopup.qml
new file mode 100644
index 0000000..a7f4fcb
--- /dev/null
+++ b/src/popups/NetworkPopup.qml
@@ -0,0 +1,173 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import "../shapes"
+import "../components"
+import "../theme"
+import "../"
+
+PanelWindow {
+ id: root
+
+ readonly property int popupWidth: Theme.networkPopupWidth // 480
+ readonly property int popupHeight: 648
+ readonly property int fw: Theme.notchRadius
+ readonly property int fh: Theme.notchRadius
+
+ property string page: Popups.networkPage
+
+ anchors.right: true
+ anchors.top: true
+
+ // Window height = popup content only — sizer starts at y:0
+ implicitWidth: popupWidth + fw
+ implicitHeight: popupHeight
+
+ exclusionMode: ExclusionMode.Ignore
+ color: "transparent"
+
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
+
+ // Mask tracks sizer — limits input region to visible content only
+ mask: Region { item: maskProxy }
+
+ Item {
+ id: maskProxy
+ x: root.implicitWidth - sizer.width
+ y: 0
+ width: sizer.width
+ height: sizer.height
+ }
+
+ // ── Visibility gate ───────────────────────────────────────────────────────
+ property bool windowVisible: false
+ visible: windowVisible
+
+ Connections {
+ target: Popups
+ function onNetworkOpenChanged() {
+ if (Popups.networkOpen) {
+ closeTimer.stop()
+ root.windowVisible = true
+ // Use requested page if set, otherwise default to wifi
+ root.page = (Popups.networkPage && Popups.networkPage !== "")
+ ? Popups.networkPage : "wifi"
+ } else {
+ closeTimer.restart()
+ }
+ }
+
+ function onNetworkPageChanged() {
+ root.page = Popups.networkPage
+ }
+ }
+
+ Timer {
+ id: closeTimer
+ interval: Anim.standardNormal + 20
+ onTriggered: { if (!Popups.networkOpen) root.windowVisible = false }
+ }
+
+ // ── Sizer — clip container, grows downward from y:0 ──────────────────────
+ Item {
+ id: sizer
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.borderWidth
+ y: 0
+ clip: true
+
+ width: Popups.networkOpen
+ ? root.popupWidth + 9
+ : Theme.rNotchMinWidth + root.fw
+
+ height: Popups.networkOpen ? root.popupHeight : 1
+
+ Behavior on width { NumberAnimation { duration: Anim.standardNormal; easing: Anim.outCubic } }
+ Behavior on height { NumberAnimation { duration: Anim.standardNormal; easing: Anim.outCubic } }
+
+ PopupShape {
+ anchors.fill: parent
+ attachedEdge: "right"
+ color: Theme.background
+ radius: Theme.cornerRadius
+ flareWidth: root.fw
+ flareHeight: root.fh
+ }
+
+ Keys.onEscapePressed: Popups.networkOpen = false
+
+ ShellPopupBase {
+ id: contentArea
+ anchors {
+ fill: parent
+ topMargin: Theme.notchHeight
+ leftMargin: root.fw
+ rightMargin: root.fw/2
+ bottomMargin: root.fh + Theme.cornerRadius
+ }
+ isOpen: Popups.networkOpen
+ transformEdge: "right"
+ disableAutoHide: true
+ clip: true
+ // ── Tab page area ─────────────────────────────────────────────────
+ Item {
+ id: tabContent
+ anchors {
+ top: parent.top
+ left: parent.left
+ right: parent.right
+ bottom: tabBar.top
+ }
+
+ Loader {
+ anchors.fill: parent
+ active: root.page === "wifi"
+ source: "WifiTab.qml"
+ }
+
+ Loader {
+ anchors.fill: parent
+ active: root.page === "bluetooth"
+ source: "BluetoothTab.qml"
+ }
+
+ // VPN — WireGuard connections
+ Loader {
+ anchors.fill: parent
+ active: root.page === "vpn"
+ source: "VPNTab.qml"
+ }
+
+ // Hotspot — virtual AP interface
+ Loader {
+ anchors.fill: parent
+ active: root.page === "hotspot"
+ source: "HotspotTab.qml"
+ }
+ }
+
+ // ── Tab bar — lifted by cornerRadius from the popup bottom ────────
+ TabSwitcher {
+ id: tabBar
+ anchors {
+ left: parent.left
+ right: parent.right
+ bottom: parent.bottom
+ bottomMargin: -16
+ }
+ orientation: "horizontal"
+ width: parent.width
+ currentPage: root.page
+ model: [
+ { key: "wifi", icon: "", label: "Wi-Fi" },
+ { key: "bluetooth", icon: "", label: "Bluetooth" },
+ { key: "vpn", icon: "", label: "VPN" },
+ { key: "hotspot", icon: "", label: "Hotspot" },
+ ]
+ onPageChanged: function(key) { Popups.networkPage = key }
+ }
+ }
+ }
+}
diff --git a/src/popups/NotificationToast.qml b/src/popups/NotificationToast.qml
new file mode 100644
index 0000000..c08905a
--- /dev/null
+++ b/src/popups/NotificationToast.qml
@@ -0,0 +1,340 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Services.Notifications
+import "../shapes/"
+import "../services/"
+import "../theme"
+import "../"
+
+PopupWindow {
+ id: root
+
+ required property var anchorWindow
+
+ readonly property int toastWidth: Theme.notificationToastWidth+(fw/2)
+ readonly property int fw: Theme.notchRadius
+ readonly property int fh: Theme.notchRadius
+
+ implicitWidth: toastWidth + fw
+ implicitHeight: 180
+
+ anchor.window: root.anchorWindow
+ anchor.rect: Qt.rect(
+ root.anchorWindow.width - toastWidth/2-fw+1,
+ -Theme.notchHeight-20,
+ toastWidth,
+ Theme.notchHeight
+ )
+ anchor.gravity: Edges.Bottom
+ anchor.adjustment: PopupAdjustment.None
+
+ color: "transparent"
+ visible: windowVisible
+
+ property bool windowVisible: false
+ property bool showing: false
+ property var current: null
+ property var queue: []
+
+ Connections {
+ target: NotificationService
+ function onNotificationAdded(n) {
+ if (!n || !n.tracked) return
+ if (root.current === null) {
+ root.startShow(n)
+ } else {
+ root.queue = [...root.queue, n]
+ }
+ }
+ }
+
+ function startShow(n) {
+ root.current = n
+ root.showing = false
+ root.windowVisible = true
+ slideInTimer.restart()
+ Popups.notificationToastOpen = false
+ }
+
+ function startDismiss() {
+ autoTimer.stop()
+ root.showing = false
+ Popups.notificationToastOpen = false
+ slideOutTimer.restart()
+ }
+
+ Connections {
+ target: root.current
+ ignoreUnknownSignals: true
+ function onClosed() { root.startDismiss() }
+ }
+
+ Timer {
+ id: slideInTimer
+ interval: 30
+ onTriggered: { root.showing = true; Popups.notificationToastOpen = true; autoTimer.restart() }
+ }
+
+ Timer {
+ id: autoTimer
+ interval: 5000
+ onTriggered: root.startDismiss()
+ }
+
+ Timer {
+ id: slideOutTimer
+ interval: Anim.standardNormal + 20
+ onTriggered: {
+ if (root.queue.length > 0) {
+ const next = root.queue[0]
+ root.queue = root.queue.slice(1)
+ root.startShow(next)
+ } else {
+ root.current = null
+ root.windowVisible = false
+ }
+ }
+ }
+
+ // ── Card ───────────────────────────────────────────────────
+ Item {
+ id: card
+ anchors.right: parent.right
+ anchors.top: parent.top
+ clip: true
+
+
+ width: root.showing
+ ? root.toastWidth + root.fw
+ : root.fw
+
+ height: root.showing
+ ? (cardCol.y + cardCol.implicitHeight + 24 + root.fh)
+ : root.fh
+
+Behavior on width { NumberAnimation { duration: Anim.standardNormal; easing: Anim.outCubic } }
+ Behavior on height { NumberAnimation { duration: Anim.standardNormal; easing: Anim.outCubic } }
+
+ PopupShape {
+ anchors.fill: parent
+ attachedEdge: "right"
+ color: Theme.background
+ radius: Theme.cornerRadius
+ flareWidth: root.fw
+ flareHeight: root.fh
+ }
+
+ Rectangle {
+ anchors {
+ right: parent.right
+ top: parent.top
+ bottom: parent.bottom
+ topMargin: fh*1.2
+ bottomMargin: fh*1.2
+ rightMargin: root.fw
+ }
+ width: 3
+ radius: 2
+ color: {
+ if (!root.current) return "#ABB2BF"
+ switch (root.current.urgency) {
+ case NotificationUrgency.Critical: return "#e06c75"
+ case NotificationUrgency.Low: return Qt.rgba(1,1,1,0.25)
+ default: return "#ABB2BF"
+ }
+ }
+ }
+
+ Item {
+ anchors.fill: parent
+ opacity: root.showing ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 150 } }
+ Rectangle {
+ id: progressBar
+ anchors {
+ right: parent.right
+ rightMargin: root.fw
+ bottom: cardCol.bottom
+ bottomMargin: -10
+ }
+ height: 2
+ radius: 1
+ color: Theme.active
+ opacity: 0.5
+
+ property bool running: false
+
+ // Use toastWidth so the bar stays within the visible body, not the flare
+ width: running ? 0 : root.toastWidth - 10
+ Behavior on width {
+ enabled: progressBar.running
+ NumberAnimation { duration: 5000; easing.type: Easing.Linear }
+ }
+
+ Connections {
+ target: root
+ function onShowingChanged() {
+ if (root.showing) {
+ progressBar.running = false
+ progressTick.restart()
+ } else {
+ progressBar.running = false
+ }
+ }
+ }
+
+ Timer {
+ id: progressTick
+ interval: 16
+ onTriggered: progressBar.running = true
+ }
+ }
+
+ Column {
+ id: cardCol
+ anchors {
+ left: parent.left; leftMargin: 14
+ right: parent.right; rightMargin: root.fw + 6
+
+ }
+ spacing: 2
+ bottomPadding: 10
+ y: root.fh + 6
+ // No fixed height — sizes to content
+
+ Row {
+ id: headerRow
+ width: parent.width
+ height: 40
+ spacing: 8
+
+ Item {
+ width: 16
+ height: 16
+ anchors.verticalCenter: parent.verticalCenter
+
+ Image {
+ id: toastIcon
+ anchors.fill: parent
+ source: {
+ var ic = root.current?.appIcon ?? ""
+ if (ic === "") return ""
+ if (ic.startsWith("/")) return "file://" + ic
+ return "image://icon/" + ic
+ }
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ visible: status === Image.Ready
+ sourceSize.width: 16
+ sourceSize.height: 16
+ }
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: Qt.rgba(1,1,1,0.1)
+ visible: toastIcon.status !== Image.Ready
+ Text {
+ anchors.centerIn: parent
+ text: (root.current?.appName ?? "?").charAt(0).toUpperCase()
+ color: Theme.text
+ font.pixelSize: 9
+ font.bold: true
+ }
+ }
+ }
+
+ Text {
+ width: parent.width - 16 - 24 - parent.spacing * 2
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.current?.appName ?? ""
+ color: Theme.subtext
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ }
+
+ Item {
+ width: 20
+ height: 20
+ anchors.verticalCenter: parent.verticalCenter
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: xHover.containsMouse ? Qt.rgba(1,1,1,0.12) : "transparent"
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ Text {
+ anchors.centerIn: parent
+ text: "✕"
+ color: Theme.subtext
+ font.pixelSize: 9
+ }
+ HoverHandler { id: xHover }
+ TapHandler { onTapped: root.startDismiss() }
+ }
+ }
+
+ Text {
+ width: parent.width
+ text: root.current?.summary ?? ""
+ color: Theme.text
+ font.pixelSize: 13
+ font.bold: true
+ wrapMode: Text.WordWrap
+ maximumLineCount: 2
+ elide: Text.ElideRight
+ visible: text !== ""
+ }
+
+ Text {
+ width: parent.width
+ text: root.current?.body ?? ""
+ color: Theme.subtext
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ maximumLineCount: 2
+ elide: Text.ElideRight
+ textFormat: Text.StyledText
+ visible: text !== ""
+ }
+
+ Row {
+ spacing: 6
+ topPadding: 2
+ visible: (root.current?.actions?.length ?? 0) > 0
+
+ Repeater {
+ model: root.current?.actions ?? []
+ delegate: Item {
+ required property var modelData
+ width: actionLbl.width + 20
+ height: 24
+ Rectangle {
+ anchors.fill: parent
+ radius: 4
+ color: actHover.containsMouse
+ ? Qt.rgba(1,1,1,0.18)
+ : Qt.rgba(1,1,1,0.08)
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ Text {
+ id: actionLbl
+ anchors.centerIn: parent
+ text: modelData?.text ?? ""
+ color: Theme.text
+ font.pixelSize: 11
+ }
+ HoverHandler { id: actHover }
+ TapHandler {
+ onTapped: {
+ modelData?.invoke()
+ root.startDismiss()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/popups/NotificationsPopup.qml b/src/popups/NotificationsPopup.qml
new file mode 100644
index 0000000..4f4fed2
--- /dev/null
+++ b/src/popups/NotificationsPopup.qml
@@ -0,0 +1,122 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import "../components"
+import "../shapes/"
+import "../services/"
+import "../"
+import "../theme"
+
+PopupWindow {
+ id: root
+
+ required property var anchorWindow
+
+ readonly property int popupWidth: Theme.notificationsWidth
+ readonly property int maxHeight: 700
+ readonly property int fw: Theme.notchRadius
+ readonly property int fh: Theme.notchRadius
+ readonly property int animDuration: Anim.standardNormal
+
+ // Fixed — never zero, never dynamic
+ implicitWidth: popupWidth +fw
+ implicitHeight: maxHeight
+
+ anchor.window: root.anchorWindow
+ anchor.rect: Qt.rect(
+ (anchorWindow.width - Theme.notificationsWidth / 2)-(fw/2),
+ 0,
+ Theme.notificationsWidth,
+ Theme.notchHeight
+ )
+ anchor.gravity: Edges.Bottom
+ anchor.adjustment: PopupAdjustment.None
+
+ Item {
+ id: maskProxy
+ x: root.implicitWidth - sizer.width-root.fw
+ y: -root.fh
+ width: sizer.width
+ height: sizer.height
+ }
+
+ color: "transparent"
+ visible: windowVisible
+ mask: Region { item: maskProxy }
+
+ // ── Visibility gate ───────────────────────────────────────
+ // Window stays alive until the close animation finishes.
+ property bool windowVisible: false
+
+ Connections {
+ target: Popups
+ function onNotificationsOpenChanged() {
+ if (Popups.notificationsOpen) {
+ root.windowVisible = true
+ } else {
+ closeTimer.restart()
+ }
+ }
+ }
+
+ Timer {
+ id: closeTimer
+ interval: root.animDuration + 20
+ onTriggered: root.windowVisible = false
+ }
+
+ // ── Sizer ─────────────────────────────────────────────────
+ // Anchored top-right so it grows leftward + downward from
+ // the right notch — mirroring how Dashboard grows from center.
+ Item {
+ id: sizer
+ anchors.top: parent.top
+ anchors.right: parent.right
+ clip: true
+
+ // Width: rNotchMinWidth → notificationsWidth (+ fw for flare region)
+ width: Popups.notificationsOpen
+ ? Theme.notificationsWidth + root.fw
+ : Theme.rNotchMinWidth + root.fw
+
+ // Height: fh (invisible sliver) → full content height
+ height: Popups.notificationsOpen
+ ? notifList.height + Theme.popupPadding * 2 + root.fh
+ : root.fh
+
+ Behavior on width { NumberAnimation { duration: root.animDuration; easing: Anim.outCubic } }
+ Behavior on height { NumberAnimation { duration: root.animDuration; easing: Anim.outCubic } }
+
+ // ── Background ─────────────────────────────────────────
+ PopupShape {
+ anchors.fill: parent
+ attachedEdge: "right"
+ color: Theme.background
+ radius: Theme.cornerRadius
+ flareWidth: root.fw
+ flareHeight: root.fh
+ }
+
+ // ── Content ────────────────────────────────────────────
+ // Inset clear of the flare region.
+ // Fades in slowly after expansion, fades out fast on close.
+ ShellPopupBase {
+ id: content
+ anchors {
+ fill: parent
+ topMargin: root.fh + 4
+ leftMargin: root.fw + 4
+ rightMargin: 4
+ bottomMargin: 4
+ }
+ isOpen: Popups.notificationsOpen
+ transformEdge: "right"
+ disableAutoHide: true
+ NotificationList {
+ id: notifList
+ width: parent.width
+ }
+ }
+ }
+}
diff --git a/src/popups/PopupLayer.qml b/src/popups/PopupLayer.qml
new file mode 100644
index 0000000..1e81c80
--- /dev/null
+++ b/src/popups/PopupLayer.qml
@@ -0,0 +1,68 @@
+import QtQuick
+import Quickshell
+import "../"
+
+// ============================================================
+// PopupLayer — popup window instantiation.
+//
+// Popups are created as direct children so they can react to
+// Popups.*Open signals via Connections on startup.
+// Each popup's PanelWindow manages its own visibility/lifetime
+// internally (WlrLayer.Overlay + visible: windowVisible).
+//
+// To add a new popup:
+// 1. Create the .qml file in src/popups/
+// 2. Add it below
+// ============================================================
+
+Item {
+ id: popupLayer
+
+ required property var topBar
+ required property var leftBorder
+ required property var rightBorder
+ required property var bottomBorder
+
+ // ── Left border → center ──────────────────────────────────
+ ArchMenu {
+ anchorWindow: popupLayer.leftBorder
+ }
+
+ // ── Bottom border → slides up ────────────────────────────
+ WallpaperPopup {}
+
+ // ── Bottom-right corner → clipboard ──────────────────────
+ ClipboardPopup {}
+
+ // ── Right notch → audio ──────────────────────────────────
+ AudioPopup {
+ anchorWindow: popupLayer.rightBorder
+ }
+
+ // ── Quick control ─────────────────────────────────────────
+ QuickControl {
+ anchorWindow: popupLayer.topBar
+ }
+
+ // ── Center notch → dashboard ─────────────────────────────
+ Dashboard {
+ anchorWindow: popupLayer.topBar
+ }
+
+ // ── Right notch → notifications ───────────────────────────
+ NotificationsPopup {
+ anchorWindow: popupLayer.topBar
+ }
+
+ NotificationToast {
+ anchorWindow: popupLayer.rightBorder
+ }
+
+ // ── Screen record options ─────────────────────────────────
+ ScreenRecOptionsPopup {
+ anchorWindow: popupLayer.topBar
+ }
+
+ // ── Network popup ─────────────────────────────────────────
+ NetworkPopup {}
+}
diff --git a/src/popups/QuickControl.qml b/src/popups/QuickControl.qml
new file mode 100644
index 0000000..9ce2b76
--- /dev/null
+++ b/src/popups/QuickControl.qml
@@ -0,0 +1,319 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Services.Pipewire
+import "../shapes"
+import "../components"
+import "../services"
+import "../"
+import "../theme"
+
+PopupWindow {
+ id: root
+
+ required property var anchorWindow
+
+ // ── Config ────────────────────────────────────────────────────────────────
+ readonly property int fw: Theme.cornerRadius
+ readonly property int fh: Theme.cornerRadius
+ readonly property int popupHeight: 340
+ readonly property int popupWidth: 180 // Thinner than the 300px AudioPopup
+
+ color: "transparent"
+ visible: slide.windowVisible
+ mask: Region { item: maskProxy }
+
+ // ── Position: Right Center ────────────────────────────────────────────────
+ anchor.window: anchorWindow
+ anchor.rect: Qt.rect(
+ anchorWindow.width - root.fw,
+ (root.screen.height + root.fh + 5)/2,
+ 0,
+ 0
+ )
+ anchor.gravity: Edges.Right
+
+ Item {
+ id: maskProxy
+ x: root.popupWidth - sizer.width
+ y: ((root.popupHeight - sizer.height) / 2) - root.fh
+ width: sizer.width
+ height: sizer.height
+ }
+
+ implicitWidth: popupWidth
+ implicitHeight: popupHeight
+
+ // ── Audio State ───────────────────────────────────────────────────────────
+ readonly property var sink: Pipewire.defaultAudioSink
+ PwObjectTracker {
+ objects: root.sink ? [root.sink] : []
+ }
+
+ // ── Brightness State ──────────────────────────────────────────────────────
+ property real _bVal: 0.72
+ property int _bMax: 100
+ property bool _bBusy: false
+
+ Process {
+ id: brightRead
+ command: ["bash", "-c", "brightnessctl -m"]
+ running: false
+ stdout: SplitParser {
+ onRead: function(line) {
+ var parts = line.split(",")
+ if (parts.length >= 5) {
+ var cur = parseInt(parts[2])
+ var max = parseInt(parts[4])
+ if (max > 0) {
+ root._bMax = max
+ root._bVal = cur / max
+ }
+ }
+ }
+ }
+ }
+
+ Process {
+ id: brightWrite
+ command: ["bash", "-c", "brightnessctl set " + (Math.round(root._bVal * root._bMax) <= 0 ? 2 : Math.round(root._bVal * root._bMax))]
+ running: false
+ onRunningChanged: if (!running) root._bBusy = false
+ }
+
+ Timer {
+ id: bDebounce
+ interval: 50; repeat: false
+ onTriggered: { root._bBusy = true; brightWrite.running = true }
+ }
+
+ // Brightness poll — 5s when control is open (was: 1s always = 60/min)
+ Timer {
+ interval: Popups.quickOpen ? 2000 : 30000; running: true; repeat: true
+ onTriggered: if (!root._bBusy) brightRead.running = true
+ }
+
+ Component.onCompleted: brightRead.running = true
+
+ function setBrightness(v) {
+ root._bVal = Math.max(0.0, Math.min(1.0, v))
+ bDebounce.restart()
+ }
+
+ // ── Layout ────────────────────────────────────────────────────────────────
+ PopupSlide {
+ id: slide
+ anchors.fill: parent
+ edge: "right"
+ open: Popups.quickOpen
+ hoverEnabled: true
+ triggerHovered: Popups.quickTriggerHovered
+ onCloseRequested: Popups.quickOpen = false
+
+ Item {
+ id: sizer
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ clip: true
+
+ width: root.popupWidth
+ height: root.popupHeight
+
+ Behavior on width { NumberAnimation { duration: Anim.standardNormal; easing: Anim.outCubic } }
+
+ PopupShape {
+ id: bg
+ anchors.fill: parent
+ attachedEdge: "right"
+ color: Theme.background
+ radius: Theme.cornerRadius
+ flareWidth: root.fw
+ flareHeight: root.fh
+ }
+
+ // ── Sliders Layout ────────────────────────────────────────────────
+ Row {
+ anchors {
+ fill: parent
+ topMargin: root.fh + 30
+ leftMargin: 8
+ rightMargin: 8
+ }
+ spacing: 8
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ // Audio Slider
+ ChannelColumn {
+ icon: {
+ if (!root.sink?.ready) return ""
+ if (root.sink.audio.muted) return ""
+ if (root.sink.audio.volume > 0.6) return ""
+ if (root.sink.audio.volume > 0.2) return ""
+ return ""
+ }
+ value: root.sink?.ready ? root.sink.audio.volume : 0
+ muted: root.sink?.audio.muted ?? false
+ active: root.sink?.ready ?? false
+
+ onVolumeChanged: function(v) {
+ if (root.sink?.ready) root.sink.audio.volume = v
+ }
+ onMuteToggled: {
+ if (root.sink?.ready) root.sink.audio.muted = !root.sink.audio.muted
+ }
+ }
+
+ // Brightness Slider
+ ChannelColumn {
+ icon: ""
+ value: root._bVal
+ muted: false
+ active: true
+
+ onVolumeChanged: function(v) {
+ root.setBrightness(v)
+ }
+ }
+ }
+ }
+ }
+
+ // ── Reusable ChannelColumn Component ──────────────────────────────────────
+ component ChannelColumn: Item {
+ id: col
+
+ property string label: ""
+ property string icon: ""
+ property real value: 0.0
+ property bool muted: false
+ property bool active: false
+
+ readonly property int trackHeight: 180
+ readonly property int barW: 22
+ readonly property int thumbD: barW - 6
+
+ signal volumeChanged(real value)
+ signal muteToggled()
+
+ implicitWidth: inner.implicitWidth
+ implicitHeight: inner.implicitHeight
+
+ readonly property string pctText: active ? Math.round(value * 100) + "%" : "--%"
+
+ Column {
+ id: inner
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 12
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: col.pctText
+ color: col.muted ? Qt.rgba(1,1,1,0.25) : Theme.text
+ font.pixelSize: 13
+ font.bold: true
+ Behavior on color { ColorAnimation { duration: 150 } }
+ }
+
+ Item {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: col.barW
+ height: col.trackHeight
+
+ Rectangle {
+ id: track
+ anchors.fill: parent
+ radius: width / 2
+ color: Qt.rgba(1,1,1,0.08)
+
+ // Fill bar
+ Rectangle {
+ anchors { bottom: parent.bottom; left: parent.left; right: parent.right }
+ height: Math.max(radius * 2, parent.height * col.value)
+ radius: parent.radius
+ color: col.muted ? Qt.rgba(1,1,1,0.15) : Theme.active
+ Behavior on color { ColorAnimation { duration: 150 } }
+ Behavior on height { NumberAnimation { duration: 80; easing.type: Easing.OutCubic } }
+ }
+
+ // Thumb
+ Rectangle {
+ id: thumb
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: col.thumbD
+ height: width
+ radius: width / 2
+ color: col.muted ? Qt.rgba(1,1,1,0.3) : "#ffffff"
+ y: {
+ var travel = track.height - height
+ return Math.max(0, Math.min(travel, (1.0 - col.value) * travel))
+ }
+ Behavior on color { ColorAnimation { duration: 150 } }
+ }
+
+ // Drag to change value
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.SizeVerCursor
+ function calc(my) {
+ var travel = track.height - thumb.height
+ return Math.max(0.0, Math.min(1.0, 1.0 - (my - thumb.height / 2) / travel))
+ }
+ onPressed: col.volumeChanged(calc(mouseY))
+ onPositionChanged: if (pressed) col.volumeChanged(calc(mouseY))
+ }
+
+ // Scroll wheel to change value
+ WheelHandler {
+ acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
+ onWheel: function(event) {
+ var step = 0.05
+ var delta = event.angleDelta.y > 0 ? step : -step
+ col.volumeChanged(Math.max(0.0, Math.min(1.0, col.value + delta)))
+ }
+ }
+ }
+ }
+
+ // Icon & Mute Toggle
+ Rectangle {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: col.barW + 16
+ height: 28
+ radius: Theme.cornerRadius
+ color: col.muted
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.2)
+ : Qt.rgba(1,1,1,0.06)
+ Behavior on color { ColorAnimation { duration: 150 } }
+
+ Text {
+ anchors.centerIn: parent
+ text: col.icon
+ font.pixelSize: 14
+ color: col.muted ? Theme.active : Qt.rgba(1,1,1,0.55)
+ Behavior on color { ColorAnimation { duration: 150 } }
+ }
+
+ Rectangle {
+ anchors.fill: parent; radius: parent.radius
+ color: muteHov.hovered ? Qt.rgba(1,1,1,0.05) : "transparent"
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ HoverHandler { id: muteHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: col.muteToggled()}
+ }
+
+ // Label
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: col.label
+ color: Qt.rgba(1,1,1,0.3)
+ font.pixelSize: 10
+ font.capitalization: Font.AllUppercase
+ font.letterSpacing: 1
+ elide: Text.ElideRight
+ width: col.barW + 50
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/popups/ScreenRecOptionsPopup.qml b/src/popups/ScreenRecOptionsPopup.qml
new file mode 100644
index 0000000..4c9ba81
--- /dev/null
+++ b/src/popups/ScreenRecOptionsPopup.qml
@@ -0,0 +1,150 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import "../services"
+import "../"
+
+// ScreenRecOptionsPopup — minimal dropdown under the center notch.
+//
+// No checkboxes, no radio dots. Selection shown by background highlight only.
+// Window sized exactly to content — no mask, no dead space.
+
+PopupWindow {
+ id: root
+
+ required property var anchorWindow
+
+ readonly property int _padH: 5
+ readonly property int _padV: 5
+
+ implicitWidth: optCol.implicitWidth + _padH * 2
+ implicitHeight: optCol.implicitHeight + _padV * 2
+
+ anchor.window: root.anchorWindow
+ anchor.gravity: Edges.Bottom
+ anchor.adjustment: PopupAdjustment.None
+ anchor.rect: Qt.rect(
+ ScreenRecService.popupTargetX + (ScreenRecService.popupTargetWidth / 2),
+ 25,
+ root.implicitWidth,
+ Theme.notchHeight
+ )
+
+ color: "transparent"
+ visible: ScreenRecService.openStrip !== ""
+
+ HoverHandler {
+ onHoveredChanged: {
+ if (hovered) ScreenRecService.keepStripOpen()
+ else ScreenRecService.scheduleStripClose()
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: Theme.cornerRadius - 6
+ color: Theme.background
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.15)
+ border.width: 1
+ }
+
+ Column {
+ id: optCol
+ x: _padH
+ y: _padV
+ spacing: 2
+
+ // Capture — radio (one active at a time)
+ Repeater {
+ model: ScreenRecService.openStrip === "capture"
+ ? ["screen", "window", "region"] : []
+ delegate: OptionRow {
+ required property string modelData
+ required property int index
+ _icon: ScreenRecService._captureIcons[modelData] ?? ""
+ _label: ScreenRecService._captureLabels[modelData] ?? ""
+ _selected: ScreenRecService.captureTarget === modelData
+ onClicked: ScreenRecService.captureTarget = modelData
+ }
+ }
+
+ // Audio — checkboxes (independent)
+ Repeater {
+ model: ScreenRecService.openStrip === "audio"
+ ? ["mic", "system", "none"] : []
+ delegate: OptionRow {
+ required property string modelData
+ required property int index
+
+ readonly property var _icons: ({ mic: "", system: "", none: "" })
+ readonly property var _labels: ({ mic: "Mic", system: "System", none: "No Audio" })
+
+ _icon: _icons[modelData] ?? ""
+ _label: _labels[modelData] ?? ""
+ _selected: {
+ if (modelData === "none") return !ScreenRecService.audioMic && !ScreenRecService.audioSystem
+ if (modelData === "mic") return ScreenRecService.audioMic
+ return ScreenRecService.audioSystem
+ }
+
+ onClicked: {
+ if (modelData === "none") {
+ ScreenRecService.audioMic = false
+ ScreenRecService.audioSystem = false
+ } else if (modelData === "mic") {
+ ScreenRecService.audioMic = !ScreenRecService.audioMic
+ } else {
+ ScreenRecService.audioSystem = !ScreenRecService.audioSystem
+ }
+ }
+ }
+ }
+ }
+
+ // ── Option row — highlight only, no dots or checkboxes ────────────────────
+ component OptionRow: Item {
+ id: row
+
+ property string _icon: ""
+ property string _label: ""
+ property bool _selected: false
+
+ signal clicked()
+
+ implicitWidth: 8 + 14 + 6 + _lbl.implicitWidth + 12
+ implicitHeight: 26
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 5
+ color: row._selected
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18)
+ : rH.hovered ? Qt.rgba(1, 1, 1, 0.07) : "transparent"
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+
+ Row {
+ anchors { left: parent.left; leftMargin: 8; verticalCenter: parent.verticalCenter }
+ spacing: 6
+
+ Text {
+ text: row._icon
+ font.pixelSize: 13
+ color: row._selected ? Theme.active : Qt.rgba(1, 1, 1, 0.45)
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ Text {
+ id: _lbl
+ text: row._label
+ font.pixelSize: 12
+ color: row._selected ? Theme.active : Qt.rgba(1, 1, 1, 0.70)
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ }
+
+ HoverHandler { id: rH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: row.clicked() }
+ }
+}
diff --git a/src/popups/VPNTab.qml b/src/popups/VPNTab.qml
new file mode 100644
index 0000000..364d560
--- /dev/null
+++ b/src/popups/VPNTab.qml
@@ -0,0 +1,634 @@
+import QtQuick
+import Quickshell.Io
+import "../"
+import "../components"
+
+// VPNTab — WireGuard connections via nmcli.
+//
+// Rules:
+// • Only one connection active at a time. Requesting a new one disconnects
+// the current first (in the same bash command so there is no gap).
+// • No autoconnect: all WireGuard profiles have connection.autoconnect disabled
+// on first load to prevent boot reconnect.
+// • Kill switch: adds an nftables rule that drops all non-WireGuard traffic
+// while a VPN is active. Removed cleanly on disconnect.
+// • Notifications: notify-send on connect, disconnect, and failure.
+// • ShellState.vpnActive / vpnConnecting / vpnName reflect current status
+// so the bar icon can react.
+
+Item {
+ id: root
+
+ // ── State ─────────────────────────────────────────────────────────────────
+ property var _connections: [] // [{name, active, busy}]
+ property var _buf: []
+ property bool _loading: false
+ property bool _killSwitch: false // persisted in-memory; toggle in UI
+ property string _pendingName: "" // name being connected (for notif)
+ property string _activeName: "" // currently active connection name
+
+ // ── Disable autoconnect for all WireGuard profiles on first load ──────────
+ // Runs once at startup. Safe to re-run (idempotent nmcli modify).
+ Process {
+ id: disableAutoconnectProc
+ command: ["bash", "-c",
+ "nmcli -t -f NAME,TYPE con show" +
+ " | awk -F: '$2==\"wireguard\"{print $1}'" +
+ " | while IFS= read -r name; do" +
+ " nmcli con modify \"$name\" connection.autoconnect no 2>/dev/null;" +
+ " done"]
+ running: false
+ }
+
+ // ── List WireGuard connections ─────────────────────────────────────────────
+ Process {
+ id: wgProc
+ running: false
+ command: ["sh", "-c",
+ "nmcli -t -f NAME,TYPE,ACTIVE con show" +
+ " | awk -F: '$2==\"wireguard\"{print $1 \"|\" ($3==\"yes\" ? \"active\" : \"inactive\")}'"]
+ stdout: SplitParser {
+ onRead: function(data) {
+ var line = data.trim()
+ if (!line) return
+ var sep = line.lastIndexOf("|")
+ if (sep < 0) return
+ root._buf = root._buf.concat([{
+ name: line.substring(0, sep),
+ active: line.substring(sep + 1) === "active",
+ busy: false
+ }])
+ }
+ }
+ onExited: function(code, status) {
+ var buf = root._buf.slice()
+
+ // Only mark busy for connections whose proc is STILL running.
+ // Previously we carried busy from _connections which caused the row
+ // to stay "Connecting…" forever after the proc already finished.
+ var connectingName = connectProc.running ? connectProc._name : ""
+ var disconnectingName = disconnectProc.running ? disconnectProc._name : ""
+
+ buf.sort(function(a, b) { return a.name.localeCompare(b.name) })
+ for (var j = 0; j < buf.length; j++) {
+ if (buf[j].name === connectingName || buf[j].name === disconnectingName)
+ buf[j].busy = true
+ }
+
+ root._buf = []
+ root._connections = buf
+ root._loading = false
+
+ // Sync ShellState
+ var active = buf.filter(function(c) { return c.active })
+ if (active.length > 0) {
+ root._activeName = active[0].name
+ ShellState.vpnActive = true
+ ShellState.vpnConnecting = connectProc.running
+ ShellState.vpnName = active[0].name
+ } else {
+ root._activeName = ""
+ ShellState.vpnActive = false
+ ShellState.vpnConnecting = connectProc.running
+ ShellState.vpnName = connectProc.running ? connectProc._name : ""
+ }
+ }
+ }
+
+ // ── Connect process ────────────────────────────────────────────────────────
+ // Disconnects any active WireGuard first, then brings up the requested one.
+ // Kill switch is applied/removed as part of the same flow.
+ Process {
+ id: connectProc
+ running: false
+ command: []
+ property string _name: ""
+
+ stderr: StdioCollector { id: connectStderr }
+
+ onExited: function(code, status) {
+ if (code === 0) {
+ // Immediately reflect connected state without waiting for wgProc
+ var cname = connectProc._name
+ var cons = root._connections.slice()
+ for (var i = 0; i < cons.length; i++) {
+ var isTarget = cons[i].name === cname
+ cons[i] = {
+ name: cons[i].name,
+ active: isTarget,
+ busy: false
+ }
+ }
+ root._connections = cons
+ ShellState.vpnActive = true
+ ShellState.vpnConnecting = false
+ ShellState.vpnName = cname
+
+ root._notify(
+ "VPN Connected",
+ " " + cname + " is now active.\nYour traffic is encrypted.",
+ "normal"
+ )
+ } else {
+ // Failure — clear busy on all, reset ShellState
+ var cons2 = root._connections.slice()
+ for (var j = 0; j < cons2.length; j++)
+ cons2[j] = { name: cons2[j].name, active: cons2[j].active, busy: false }
+ root._connections = cons2
+
+ ShellState.vpnConnecting = false
+ ShellState.vpnActive = false
+ ShellState.vpnName = ""
+
+ root._notify(
+ "VPN Failed",
+ "Could not connect to " + connectProc._name + ".\n" + connectStderr.text.trim(),
+ "critical"
+ )
+ }
+ root._refresh()
+ }
+ }
+
+ // ── Disconnect process ─────────────────────────────────────────────────────
+ Process {
+ id: disconnectProc
+ running: false
+ command: []
+ property string _name: ""
+
+ onExited: function(code, status) {
+ // Immediately reflect disconnected state
+ var dname = disconnectProc._name
+ var cons = root._connections.slice()
+ for (var i = 0; i < cons.length; i++)
+ cons[i] = { name: cons[i].name, active: false, busy: false }
+ root._connections = cons
+
+ ShellState.vpnActive = false
+ ShellState.vpnConnecting = false
+ ShellState.vpnName = ""
+
+ root._notify(
+ "VPN Disconnected",
+ " " + dname + " has been disconnected.",
+ "low"
+ )
+ root._refresh()
+ }
+ }
+
+ // ── Kill switch — nmcli only, no pkexec/nftables ──────────────────────────
+ // When enabled: downs all active WireGuard connections via nmcli.
+ // No root required. Toggle reflects immediately in UI.
+ Process {
+ id: killSwitchProc
+ running: false
+ command: []
+ onExited: function(code, status) {
+ // After kill switch fires, refresh to reflect new state
+ root._refresh()
+ }
+ }
+
+ // ── notify-send process ────────────────────────────────────────────────────
+ Process {
+ id: notifyProc
+ running: false
+ command: []
+ }
+
+ // ── nmcli monitor — debounced refresh ─────────────────────────────────────
+ Process {
+ id: monitorProc
+ running: Popups.networkOpen
+ command: ["nmcli", "monitor"]
+ stdout: SplitParser {
+ onRead: function(data) { monitorDebounce.restart() }
+ }
+ }
+
+ Timer {
+ id: monitorDebounce
+ interval: 600; repeat: false
+ onTriggered: root._refresh()
+ }
+
+ // Also poll every 8s while popup is open to catch external changes
+ Timer {
+ interval: 8000; repeat: true; running: Popups.networkOpen
+ onTriggered: root._refresh()
+ }
+
+ // ── Logic ─────────────────────────────────────────────────────────────────
+
+ function _refresh() {
+ if (wgProc.running) return
+ root._loading = true
+ root._buf = []
+ wgProc.running = false
+ wgProc.running = true
+ }
+
+ function _notify(title, body, urgency) {
+ // urgency: "low" | "normal" | "critical"
+ notifyProc.command = [
+ "notify-send",
+ "--app-name=Brain Shell",
+ "--urgency=" + urgency,
+ "--icon=network-vpn",
+ title,
+ body
+ ]
+ notifyProc.running = false
+ notifyProc.running = true
+ }
+
+ function _applyKillSwitch() {
+ // Down all active WireGuard connections via nmcli — no root needed
+ killSwitchProc.command = ["bash", "-c",
+ "nmcli -g NAME,TYPE connection show --active" +
+ " | awk -F: '$2==\"wireguard\" {print $1}'" +
+ " | xargs -r -I {} nmcli connection down \"{}\""]
+ killSwitchProc.running = false
+ killSwitchProc.running = true
+
+ // Update ShellState immediately
+ ShellState.vpnActive = false
+ ShellState.vpnConnecting = false
+ ShellState.vpnName = ""
+ }
+
+ function _removeKillSwitch() {
+ // Nothing to undo — nmcli disconnect is the action itself.
+ // Just update state; user reconnects manually if desired.
+ root._killSwitch = false
+ }
+
+ function _connect(name) {
+ if (connectProc.running || disconnectProc.running) return
+
+ // Capture currently active names BEFORE marking anything busy
+ var activeNames = root._connections
+ .filter(function(c) { return c.active })
+ .map(function(c) { return c.name })
+
+ // Mark ONLY the selected connection and the currently active one as busy.
+ // All other connections remain untouched.
+ var cons = root._connections.slice()
+ for (var i = 0; i < cons.length; i++) {
+ var isBusy = cons[i].name === name
+ || activeNames.indexOf(cons[i].name) >= 0
+ if (isBusy)
+ cons[i] = { name: cons[i].name, active: cons[i].active, busy: true }
+ }
+ root._connections = cons
+
+ ShellState.vpnConnecting = true
+ ShellState.vpnActive = false
+ ShellState.vpnName = name
+
+ connectProc._name = name
+
+ // Down any active WireGuard first (using the reliable nmcli pattern),
+ // then bring up the requested connection
+ var downCmd = "nmcli -g NAME,TYPE connection show --active" +
+ " | awk -F: '$2==\"wireguard\" {print $1}'" +
+ " | xargs -r -I {} nmcli connection down \"{}\""
+
+ connectProc.command = ["bash", "-c",
+ downCmd + "; nmcli con up \"" + name + "\" 2>&1"]
+ connectProc.running = false
+ connectProc.running = true
+ }
+
+ function _disconnect(name) {
+ if (connectProc.running || disconnectProc.running) return
+
+ var cons = root._connections.slice()
+ for (var i = 0; i < cons.length; i++)
+ if (cons[i].name === name)
+ cons[i] = { name: cons[i].name, active: cons[i].active, busy: true }
+ root._connections = cons
+
+ disconnectProc._name = name
+ disconnectProc.command = ["bash", "-c",
+ "nmcli con down \"" + name + "\" 2>/dev/null"]
+ disconnectProc.running = false
+ disconnectProc.running = true
+ }
+
+ function _toggleKillSwitch() {
+ if (root._killSwitch) {
+ // Turning off — just clear the flag, no action needed
+ root._killSwitch = false
+ } else {
+ // Turning on — immediately down all active WireGuard connections
+ root._killSwitch = true
+ if (ShellState.vpnActive || ShellState.vpnConnecting)
+ root._applyKillSwitch()
+ }
+ }
+
+ // Reset on popup open
+ Connections {
+ target: Popups
+ function onNetworkOpenChanged() {
+ if (Popups.networkOpen && root.visible)
+ root._refresh()
+ }
+ }
+
+ Component.onCompleted: {
+ // Disable autoconnect for all WireGuard profiles silently
+ disableAutoconnectProc.running = true
+ root._refresh()
+ }
+
+ // ── Layout ────────────────────────────────────────────────────────────────
+ Column {
+ anchors.fill: parent; spacing: 0
+
+ // Header
+ Item {
+ width: parent.width; height: 40
+
+ Text {
+ anchors { left: parent.left; leftMargin: 2; verticalCenter: parent.verticalCenter }
+ text: "VPN"; font.pixelSize: 15; font.weight: Font.Bold; color: Theme.text
+ }
+
+ Row {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ spacing: 8
+
+ // Kill switch toggle
+ Rectangle {
+ height: 28; radius: 14
+ width: ksRow.implicitWidth + 18
+ color: root._killSwitch
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18)
+ : ksH.hovered ? Qt.rgba(1,1,1,0.08) : Qt.rgba(1,1,1,0.04)
+ border.color: root._killSwitch
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.40)
+ : Qt.rgba(1,1,1,0.10)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 130 } }
+ Behavior on border.color { ColorAnimation { duration: 130 } }
+
+ Row {
+ id: ksRow; anchors.centerIn: parent; spacing: 6
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: ""; font.pixelSize: 13
+ color: root._killSwitch ? Theme.active : Qt.rgba(1,1,1,0.40)
+ Behavior on color { ColorAnimation { duration: 130 } }
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Kill Switch"; font.pixelSize: 11; font.weight: Font.Medium
+ color: root._killSwitch ? Theme.active : Qt.rgba(1,1,1,0.45)
+ Behavior on color { ColorAnimation { duration: 130 } }
+ }
+ }
+
+ HoverHandler { id: ksH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._toggleKillSwitch() }
+ }
+
+ // Refresh
+ Rectangle {
+ width: 32; height: 32; radius: 8
+ color: rfH.hovered ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.15) : Qt.rgba(1,1,1,0.05)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.28)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
+
+ Text {
+ id: rfIcon; anchors.centerIn: parent; text: ""; font.pixelSize: 15
+ color: root._loading
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.4)
+ : Theme.active
+ Behavior on color { ColorAnimation { duration: 150 } }
+ RotationAnimator {
+ target: rfIcon; from: 0; to: 360; duration: 900
+ loops: Animation.Infinite; running: root._loading
+ easing.type: Easing.Linear
+ }
+ }
+ HoverHandler { id: rfH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: if (!root._loading) root._refresh() }
+ }
+ }
+ }
+
+ Rectangle { width: parent.width; height: 1; color: Qt.rgba(1,1,1,0.07) }
+ Item { width: parent.width; height: 8 }
+
+ // Connection list
+ Flickable {
+ width: parent.width; height: parent.height - 49
+ contentWidth: width; contentHeight: conCol.height
+ clip: true; boundsBehavior: Flickable.StopAtBounds
+
+ Column {
+ id: conCol; width: parent.width; height: implicitHeight; spacing: 6
+
+ // Active section
+ Item {
+ width: parent.width; height: visible ? aLbl.implicitHeight + 4 : 0
+ visible: root._connections.some(function(c) { return c.active })
+ Text {
+ id: aLbl; text: "ACTIVE"
+ font.pixelSize: 9; font.weight: Font.Bold; font.letterSpacing: 1.2
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.5)
+ }
+ }
+
+ Repeater {
+ model: root._connections.filter(function(c) { return c.active })
+ delegate: VPNRow {
+ required property var modelData
+ width: conCol.width - 2; x: 1; con: modelData
+ }
+ }
+
+ Item {
+ width: parent.width; height: 6
+ visible: root._connections.some(function(c) { return c.active })
+ && root._connections.some(function(c) { return !c.active })
+ }
+
+ // Available section
+ Item {
+ width: parent.width; height: visible ? iLbl.implicitHeight + 4 : 0
+ visible: root._connections.some(function(c) { return !c.active })
+ Text {
+ id: iLbl; text: "AVAILABLE"
+ font.pixelSize: 9; font.weight: Font.Bold; font.letterSpacing: 1.2
+ color: Qt.rgba(1,1,1,0.25)
+ }
+ }
+
+ Repeater {
+ model: root._connections.filter(function(c) { return !c.active })
+ delegate: VPNRow {
+ required property var modelData
+ width: conCol.width - 2; x: 1; con: modelData
+ }
+ }
+
+ // Empty state
+ Item {
+ width: parent.width; height: 180
+ visible: !root._loading && root._connections.length === 0
+ Column {
+ anchors.centerIn: parent; spacing: 12
+ Text { anchors.horizontalCenter: parent.horizontalCenter; text: ""; font.pixelSize: 36; color: Qt.rgba(1,1,1,0.08) }
+ Text { anchors.horizontalCenter: parent.horizontalCenter; text: "No WireGuard connections"; font.pixelSize: 13; color: Qt.rgba(1,1,1,0.2) }
+ Text { anchors.horizontalCenter: parent.horizontalCenter; text: "Import a config to get started:"; font.pixelSize: 10; color: Qt.rgba(1,1,1,0.14); horizontalAlignment: Text.AlignHCenter }
+ Rectangle {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: codeText.implicitWidth + 24; height: 26; radius: 6
+ color: Qt.rgba(1,1,1,0.05); border.color: Qt.rgba(1,1,1,0.10); border.width: 1
+ Text {
+ id: codeText; anchors.centerIn: parent
+ text: "nmcli con import type wireguard file "
+ font.pixelSize: 9; font.family: "JetBrains Mono"
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.5)
+ }
+ }
+ }
+ }
+
+ // Loading state
+ Item {
+ width: parent.width; height: 80
+ visible: root._loading && root._connections.length === 0
+ Column {
+ anchors.centerIn: parent; spacing: 8
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "○"; font.pixelSize: 20; color: Theme.active
+ SequentialAnimation on opacity {
+ running: root._loading && root._connections.length === 0
+ loops: Animation.Infinite
+ NumberAnimation { to: 0.15; duration: 550 }
+ NumberAnimation { to: 1.0; duration: 550 }
+ }
+ }
+ Text { anchors.horizontalCenter: parent.horizontalCenter; text: "Loading…"; font.pixelSize: 11; color: Qt.rgba(1,1,1,0.25) }
+ }
+ }
+
+ Item { width: parent.width; height: 8 }
+ }
+ }
+ }
+
+ // ── VPN connection row ────────────────────────────────────────────────────
+ component VPNRow: Item {
+ id: vRow
+ required property var con // { name, active, busy }
+ height: 54
+
+ property bool _wasActive: false
+ onConChanged: {
+ if (con.active && !_wasActive) pulseAnim.restart()
+ _wasActive = con.active
+ }
+
+ // Card background
+ Rectangle {
+ id: card; anchors.fill: parent; radius: Theme.cornerRadius
+ color: vRow.con.active
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.08)
+ : vHov.hovered ? Qt.rgba(1,1,1,0.04) : "transparent"
+ border.color: vRow.con.active
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.22)
+ : Qt.rgba(1,1,1,0.07)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 200 } }
+ Behavior on border.color { ColorAnimation { duration: 200 } }
+
+ SequentialAnimation {
+ id: pulseAnim; running: false
+ ColorAnimation { target: card; to: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.30); duration: 160 }
+ ColorAnimation { target: card; to: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.08); duration: 500; easing.type: Easing.OutCubic }
+ }
+ }
+
+ Row {
+ anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter }
+ spacing: 12
+
+ // Shield glyph
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: ""; font.pixelSize: 20
+ color: vRow.con.active
+ ? Theme.active
+ : vRow.con.busy
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.5)
+ : Qt.rgba(1,1,1,0.28)
+ Behavior on color { ColorAnimation { duration: 200 } }
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter; spacing: 4
+
+ Text {
+ text: vRow.con.name; font.pixelSize: 13
+ font.weight: vRow.con.active ? Font.Medium : Font.Normal
+ color: vRow.con.active ? Theme.text : Qt.rgba(1,1,1,0.65)
+ width: 160; elide: Text.ElideRight
+ }
+ Text {
+ font.pixelSize: 10
+ text: vRow.con.busy
+ ? (vRow.con.active ? "Disconnecting…" : "Connecting…")
+ : vRow.con.active ? "Connected" : "Disconnected"
+ color: vRow.con.busy
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.60)
+ : vRow.con.active ? Theme.active : Qt.rgba(1,1,1,0.32)
+ Behavior on color { ColorAnimation { duration: 200 } }
+ }
+ }
+ }
+
+ // Right: spinner or status dot
+ Item {
+ anchors { right: parent.right; rightMargin: 12; verticalCenter: parent.verticalCenter }
+ width: 28; height: 28
+
+ Text {
+ anchors.centerIn: parent; visible: vRow.con.busy
+ text: "○"; font.pixelSize: 16; color: Theme.active
+ SequentialAnimation on opacity {
+ running: vRow.con.busy; loops: Animation.Infinite
+ NumberAnimation { to: 0.15; duration: 450 }
+ NumberAnimation { to: 1.0; duration: 450 }
+ }
+ }
+
+ Rectangle {
+ anchors.centerIn: parent; visible: !vRow.con.busy
+ width: 10; height: 10; radius: 5
+ color: vRow.con.active
+ ? Theme.active
+ : vHov.hovered ? Qt.rgba(1,1,1,0.35) : Qt.rgba(1,1,1,0.18)
+ Behavior on color { ColorAnimation { duration: 200 } }
+ }
+ }
+
+ HoverHandler { id: vHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ enabled: !vRow.con.busy
+ onClicked: vRow.con.active
+ ? root._disconnect(vRow.con.name)
+ : root._connect(vRow.con.name)
+ }
+ }
+}
diff --git a/src/popups/WallpaperPopup.qml b/src/popups/WallpaperPopup.qml
new file mode 100644
index 0000000..4ba1c1b
--- /dev/null
+++ b/src/popups/WallpaperPopup.qml
@@ -0,0 +1,759 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import QtQuick.Layouts
+import "../shapes"
+import "../components"
+import "../services"
+import "../theme"
+import "../"
+
+PanelWindow {
+ id: root
+
+ anchors.top: true
+ anchors.left: true
+ anchors.right: true
+ anchors.bottom: true
+
+ exclusionMode: ExclusionMode.Ignore
+ color: "transparent"
+
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.keyboardFocus: (windowVisible && Popups.wallpaperOpen) ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
+
+ readonly property int panelWidth: 980
+ readonly property int panelHeight: 420
+ readonly property int fw: Theme.notchRadius
+ readonly property int fh: Theme.notchRadius
+
+ property bool windowVisible: false
+ visible: windowVisible
+
+ // ── Self-hover tracking ───────────────────────────────────────────────────
+ property bool selfHovered: true
+
+ property bool allowHover: false
+
+ // ── Hover close timer ─────────────────────────────────────────────────────
+ // Fires when both the trigger region and the popup itself are no longer hovered.
+ Timer {
+ id: hoverCloseTimer
+ interval: Popups.hoverCloseDelay
+ onTriggered: {
+ if (root.allowHover && !Popups.wallpaperTriggerHovered && !root.selfHovered)
+ Popups.wallpaperOpen = false
+ }
+ }
+
+ onSelfHoveredChanged: {
+ if (root.allowHover) {
+ if (!selfHovered && !Popups.wallpaperTriggerHovered) hoverCloseTimer.restart()
+ else hoverCloseTimer.stop()
+ }
+ }
+
+ Timer {
+ id: focusTimer
+ interval: 150
+ onTriggered: searchInput.forceActiveFocus()
+ }
+
+ Connections {
+ target: Popups
+ function onWallpaperTriggerHoveredChanged() {
+ if (Popups.wallpaperTriggerHovered) {
+ if (root.allowHover) {
+ hoverCloseTimer.stop()
+ if (!Popups.wallpaperOpen) {
+ closeTimer.stop()
+ root.windowVisible = true
+ Popups.wallpaperOpen = true
+ WallpaperService.refresh()
+ WallpaperService.previewWall = ""
+ content.schemePopupOpen = false
+ content.folderMode = false
+ content.appliedScheme = WallpaperService.scheme
+ searchInput.text = ""
+ searchInput.forceActiveFocus()
+ focusTimer.restart()
+ }
+ }
+ } else {
+ if (root.allowHover && !root.selfHovered) hoverCloseTimer.restart()
+ }
+ }
+
+ function onWallpaperOpenChanged() {
+ if (Popups.wallpaperOpen) {
+ closeTimer.stop()
+ hoverCloseTimer.stop()
+ root.windowVisible = true
+ WallpaperService.refresh()
+ WallpaperService.previewWall = ""
+ content.schemePopupOpen = false
+ content.folderMode = false
+ content.appliedScheme = WallpaperService.scheme
+ searchInput.text = ""
+ searchInput.forceActiveFocus()
+ focusTimer.restart()
+ } else {
+ closeTimer.restart()
+ }
+ }
+ }
+
+ Timer {
+ id: closeTimer
+ interval: Anim.standardNormal + 20
+ onTriggered: { if (!Popups.wallpaperOpen) root.windowVisible = false }
+ }
+
+ Connections {
+ target: WallpaperService
+ function onWallpapersChanged() {
+ if (!Popups.wallpaperOpen) return
+ var walls = WallpaperService.wallpapers
+ if (!walls || walls.length === 0) return
+ var target = WallpaperService.currentWall
+ for (var i = 0; i < walls.length; i++) {
+ if (walls[i] === target) {
+ WallpaperService.previewWall = target
+ wallGrid.targetCenterIndex = i
+ centerLockTimer.restart()
+ wallGrid.forceLayout()
+ wallGrid.positionViewAtIndex(i, ListView.Center)
+ return
+ }
+ }
+ }
+ // Force grid refresh when thumbnails finish generating
+ function onThumbnailsReady() {
+ if (!Popups.wallpaperOpen) return
+ wallGrid.forceLayout()
+ }
+ }
+
+ Timer {
+ id: centerLockTimer
+ interval: Anim.standardNormal
+ onTriggered: wallGrid.targetCenterIndex = -1
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: Popups.wallpaperOpen = false
+ }
+
+ Item {
+ id: sizer
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: Theme.borderWidth
+ clip: true
+
+ width: Popups.wallpaperOpen ? root.panelWidth + 2 * root.fw : Theme.cNotchMinWidth + 2 * root.fw
+ height: Popups.wallpaperOpen ? root.panelHeight : 1
+
+ Behavior on width { NumberAnimation { duration: Anim.standardNormal; easing: Anim.outCubic } }
+ Behavior on height { NumberAnimation { duration: Anim.standardNormal; easing: Anim.outCubic } }
+
+ HoverHandler {
+ onHoveredChanged: root.selfHovered = hovered
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {}
+ }
+
+ PopupShape {
+ anchors.fill: parent
+ attachedEdge: "bottom"
+ color: Theme.background
+ radius: Theme.cornerRadius
+ flareWidth: root.fw
+ flareHeight: root.fh
+ }
+
+ ShellPopupBase {
+ id: content
+ anchors {
+ fill: parent
+ topMargin: 16
+ bottomMargin: root.fh + 8
+ leftMargin: root.fw + 16
+ rightMargin: root.fw + 16
+ }
+ isOpen: Popups.wallpaperOpen
+ transformEdge: "top"
+ disableAutoHide: true
+
+ property string searchQuery: ""
+ property bool schemePopupOpen: false
+ property bool folderMode: false
+ property string appliedScheme: WallpaperService.scheme
+
+ readonly property var filteredWallpapers: {
+ var q = searchQuery.toLowerCase()
+ if (q === "") return WallpaperService.wallpapers
+ return WallpaperService.wallpapers.filter(function(p) {
+ return p.split("/").pop().toLowerCase().indexOf(q) !== -1
+ })
+ }
+
+ readonly property bool applyActive:
+ WallpaperService.previewWall !== "" ||
+ (WallpaperService.currentWall !== "" &&
+ WallpaperService.scheme !== content.appliedScheme)
+
+ ListView {
+ id: wallGrid
+ property int targetCenterIndex: -1
+ onWidthChanged: {
+ if (Popups.wallpaperOpen && targetCenterIndex !== -1 && count > targetCenterIndex)
+ positionViewAtIndex(targetCenterIndex, ListView.Center)
+ }
+
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: divider.top
+ anchors.bottomMargin: 8
+
+ // Prevent QFont::setPointSize(0) when sizer animates from 0
+ visible: parent.height > 10
+
+ orientation: ListView.Horizontal
+ spacing: 14
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+ interactive: false
+ ScrollBar.horizontal: ScrollBar {
+ policy: ScrollBar.AsNeeded
+ height: 6
+ }
+ model: content.filteredWallpapers
+
+ Text {
+ anchors.centerIn: parent
+ visible: wallGrid.count === 0
+ text: "No wallpapers found in " + WallpaperService.wallpaperDir
+ color: Qt.rgba(1,1,1,0.25)
+ font.pixelSize: 13
+ }
+
+ delegate: Item {
+ id: cardDelegate
+ required property string modelData
+ required property int index
+ property bool isPreview: WallpaperService.previewWall === modelData
+ property bool isCurrent: WallpaperService.currentWall === modelData
+ readonly property int labelH: 30
+
+ width: isPreview ? (130 * 1.2) : 130
+ height: isPreview ? wallGrid.height : wallGrid.height - 14
+ Behavior on width { NumberAnimation { duration: 120; easing.type: Easing.InOutCubic } }
+
+ Item {
+ id: cardContent
+ anchors.fill: parent
+ visible: false
+
+ Image {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ height: parent.height - cardDelegate.labelH
+ source: {
+ var thumb = WallpaperService.thumbnailFor(modelData)
+ if (thumb && thumb !== "") return "file://" + thumb
+ return modelData.indexOf("://") !== -1 ? modelData : "file://" + modelData
+ }
+ fillMode: Image.PreserveAspectCrop
+ asynchronous: true
+ cache: false
+ }
+
+ Rectangle {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ height: cardDelegate.labelH
+ color: isPreview
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.22)
+ : Qt.rgba(1,1,1,0.09)
+ Text {
+ anchors.centerIn: parent
+ width: parent.width - 10
+ text: modelData.split("/").pop().replace(/\.[^/.]+$/, "")
+ color: isPreview ? Theme.active : Qt.rgba(1,1,1,0.65)
+ font.pixelSize: 10
+ font.weight: isPreview ? Font.Medium : Font.Normal
+ elide: Text.ElideRight
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+ }
+
+ // ── Rounded-corner mask source ─────────────────────────
+ Rectangle {
+ id: cardMask
+ anchors.fill: parent
+ radius: 10
+ visible: false
+ layer.enabled: true
+ layer.smooth: true
+ }
+
+ // ── Masked card output (the only visible rendering) ────
+ MultiEffect {
+ id: cardEffect
+ source: cardContent
+ anchors.fill: parent
+ maskEnabled: true
+ maskSource: cardMask
+ maskThresholdMin: 0.4
+ maskSpreadAtMin: 0.0
+ z: 1
+ }
+
+ // ── Selection / current border ─────────────────────────
+ Rectangle {
+ anchors.fill: parent
+ radius: 10
+ color: "transparent"
+ border.width: isPreview ? 2 : 1
+ border.color: isPreview ? Theme.active
+ : isCurrent ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.45)
+ : Qt.rgba(1,1,1,0.15)
+ z: 2
+ Behavior on border.color { ColorAnimation { duration: 120 } }
+ Behavior on border.width { NumberAnimation { duration: 120 } }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ content.schemePopupOpen = false
+ // Two-click behavior: first click selects, second click applies
+ if (WallpaperService.previewWall === cardDelegate.modelData) {
+ // Second click → apply and close
+ content.appliedScheme = WallpaperService.scheme
+ WallpaperService.apply(cardDelegate.modelData)
+ Popups.wallpaperOpen = false
+ } else {
+ // First click → select / preview only
+ WallpaperService.previewWall = cardDelegate.modelData
+ }
+ }
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: divider.top
+ anchors.bottomMargin: 8
+ z: wallGrid.z + 1
+ acceptedButtons: Qt.NoButton
+ onWheel: function(wheel) {
+ wallGrid.contentX = Math.max(0,
+ Math.min(wallGrid.contentWidth - wallGrid.width,
+ wallGrid.contentX - wheel.angleDelta.y))
+ }
+ }
+
+ Rectangle {
+ id: divider
+ anchors.bottom: utilBar.top
+ anchors.bottomMargin: 8
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 1
+ color: Qt.rgba(1,1,1,0.07)
+ }
+
+ Item {
+ id: utilBar
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: -20
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 32
+
+ Row {
+ anchors.centerIn: parent
+ spacing: 8
+
+ Rectangle {
+ id: folderBtn
+ width: 32
+ height: 32
+ radius: 8
+ color: folderBtnMA.containsMouse
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.14)
+ : (content.folderMode ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18) : Qt.rgba(1,1,1,0.04))
+ border.color: (content.folderMode || folderBtnMA.containsMouse)
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.4)
+ : Qt.rgba(1,1,1,0.09)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Behavior on border.color { ColorAnimation { duration: 100 } }
+ Text {
+ anchors.centerIn: parent; text: ""; font.pixelSize: 15
+ color: (content.folderMode || folderBtnMA.containsMouse) ? Theme.active : Qt.rgba(1,1,1,0.5)
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ MouseArea {
+ id: folderBtnMA
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ content.schemePopupOpen = false
+ content.folderMode = !content.folderMode
+ if (content.folderMode) {
+ dirInput.text = WallpaperService.wallpaperDir
+ dirInput.forceActiveFocus()
+ dirInput.selectAll()
+ } else {
+ searchInput.forceActiveFocus()
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ id: filterBox
+ width: 300
+ height: 32
+ radius: 8
+ color: filterBoxMA.containsMouse ? Qt.rgba(1,1,1,0.08) : Qt.rgba(1,1,1,0.06)
+ border.color: (searchInput.activeFocus || dirInput.activeFocus)
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.5)
+ : (filterBoxMA.containsMouse ? Qt.rgba(1,1,1,0.15) : Qt.rgba(1,1,1,0.1))
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Behavior on border.color { ColorAnimation { duration: 100 } }
+
+ MouseArea {
+ id: filterBoxMA
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.NoButton
+ }
+
+ Item {
+ anchors.fill: parent
+ anchors.leftMargin: 10
+ anchors.rightMargin: 10
+ visible: !content.folderMode
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Search wallpapers…"
+ color: (searchInput.activeFocus || filterBoxMA.containsMouse) ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.7) : Qt.rgba(1,1,1,0.28)
+ font.pixelSize: 12; visible: searchInput.text === ""
+ }
+
+ TextInput {
+ id: searchInput
+ anchors.fill: parent
+ verticalAlignment: TextInput.AlignVCenter
+ color: Theme.text
+ font.pixelSize: 12
+ selectionColor: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.35)
+ clip: true
+ onTextChanged: content.searchQuery = text
+
+ Keys.onReturnPressed: {
+ var walls = content.filteredWallpapers
+ if (!walls || walls.length === 0) return
+ var previewInSearch = false
+ for (var i = 0; i < walls.length; i++) {
+ if (walls[i] === WallpaperService.previewWall) { previewInSearch = true; break }
+ }
+ var target = previewInSearch ? WallpaperService.previewWall : walls[0]
+ content.appliedScheme = WallpaperService.scheme
+ WallpaperService.apply(target)
+ Popups.wallpaperOpen = false
+ }
+ Keys.onLeftPressed: {
+ var walls = content.filteredWallpapers
+ if (!walls || walls.length === 0) return
+ var cur = WallpaperService.previewWall; var idx = -1
+ for (var i = 0; i < walls.length; i++) { if (walls[i] === cur) { idx = i; break } }
+ idx = (idx <= 0) ? walls.length - 1 : idx - 1
+ WallpaperService.previewWall = walls[idx]
+ wallGrid.positionViewAtIndex(idx, ListView.Center)
+ }
+ Keys.onRightPressed: {
+ var walls = content.filteredWallpapers
+ if (!walls || walls.length === 0) return
+ var cur = WallpaperService.previewWall; var idx = -1
+ for (var i = 0; i < walls.length; i++) { if (walls[i] === cur) { idx = i; break } }
+ idx = (idx < 0 || idx >= walls.length - 1) ? 0 : idx + 1
+ WallpaperService.previewWall = walls[idx]
+ wallGrid.positionViewAtIndex(idx, ListView.Center)
+ }
+ Keys.onEscapePressed: {
+ if (searchInput.text !== "") {
+ var target = WallpaperService.previewWall !== ""
+ ? WallpaperService.previewWall : WallpaperService.currentWall
+ var walls = WallpaperService.wallpapers; var idx = 0
+ for (var i = 0; i < walls.length; i++) { if (walls[i] === target) { idx = i; break } }
+ searchInput.text = ""
+ wallGrid.forceLayout()
+ wallGrid.positionViewAtIndex(idx, ListView.Center)
+ } else { Popups.closeAll() }
+ }
+ }
+ }
+
+ Item {
+ anchors.fill: parent
+ anchors.leftMargin: 10
+ anchors.rightMargin: 10
+ visible: content.folderMode
+
+ Text {
+ id: pathLbl
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Path: "
+ color: (dirInput.activeFocus || filterBoxMA.containsMouse) ? Theme.active : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.7)
+ font.pixelSize: 11
+ }
+
+ TextInput {
+ id: dirInput
+ anchors.left: pathLbl.right
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ verticalAlignment: TextInput.AlignVCenter
+ color: Theme.text
+ font.pixelSize: 12
+ font.family: "JetBrains Mono"
+ selectionColor: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.35)
+ clip: true
+ Keys.onReturnPressed: {
+ WallpaperService.wallpaperDir = dirInput.text
+ WallpaperService.refresh()
+ content.folderMode = false
+ searchInput.forceActiveFocus()
+ }
+ Keys.onEscapePressed: {
+ content.folderMode = false
+ searchInput.forceActiveFocus()
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ id: schemeBtn
+ width: schemeBtnRow.implicitWidth + 20
+ height: 32
+ radius: 8
+ color: schemeBtnMA.containsMouse
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.14)
+ : (content.schemePopupOpen ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18) : Qt.rgba(1,1,1,0.04))
+ border.color: (content.schemePopupOpen || schemeBtnMA.containsMouse)
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.4)
+ : Qt.rgba(1,1,1,0.09)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Behavior on border.color { ColorAnimation { duration: 100 } }
+
+ Row {
+ id: schemeBtnRow; anchors.centerIn: parent; spacing: 7
+ Text {
+ text: ""
+ font.pixelSize: 14
+ color: (content.schemePopupOpen || schemeBtnMA.containsMouse) ? Theme.active : Qt.rgba(1,1,1,0.55)
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ Text {
+ text: WallpaperService.scheme
+ font.pixelSize: 12
+ color: (content.schemePopupOpen || schemeBtnMA.containsMouse) ? Theme.active : Qt.rgba(1,1,1,0.7)
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ Text {
+ text: content.schemePopupOpen ? "▴" : "▾"
+ font.pixelSize: 8
+ color: (content.schemePopupOpen || schemeBtnMA.containsMouse) ? Theme.active : Qt.rgba(1,1,1,0.35)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: schemeBtnMA
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: content.schemePopupOpen = !content.schemePopupOpen
+ }
+ }
+ }
+
+ Rectangle {
+ id: applyBtn
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ property bool active: content.applyActive
+ width: active ? 90 : 0
+ height: 32
+ radius: 8
+ opacity: active ? 1 : 0
+ clip: true
+ color: applyBtnMA.containsMouse
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.28)
+ : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18)
+ border.color: applyBtnMA.containsMouse
+ ? Theme.active
+ : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.4)
+ border.width: 1
+ Behavior on width { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
+ Behavior on opacity { NumberAnimation { duration: 160 } }
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Behavior on border.color { ColorAnimation { duration: 100 } }
+ Text {
+ anchors.centerIn: parent
+ text: WallpaperService.applying ? "…" : "Apply"
+ font.pixelSize: 12
+ font.weight: Font.Medium
+ color: Theme.active
+ opacity: applyBtn.active ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 100 } }
+ }
+ MouseArea {
+ id: applyBtnMA
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ enabled: applyBtn.active && !WallpaperService.applying
+ onClicked: {
+ var target = WallpaperService.previewWall !== ""
+ ? WallpaperService.previewWall : WallpaperService.currentWall
+ content.appliedScheme = WallpaperService.scheme
+ WallpaperService.apply(target)
+ Popups.wallpaperOpen = false
+ }
+ }
+ }
+ }
+
+ TapHandler {
+ enabled: content.schemePopupOpen
+ onTapped: content.schemePopupOpen = false
+ }
+ }
+
+ Rectangle {
+ id: schemeDropdown
+ z: 100
+ visible: content.schemePopupOpen
+ clip: false
+
+ width: schemeDropdownCol.implicitWidth + 32
+ height: schemeDropdownCol.implicitHeight + 16
+ radius: Theme.cornerRadius
+
+ color: Theme.background
+ border.color: Theme.active
+ border.width: 1
+
+ opacity: content.schemePopupOpen ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 140 } }
+
+ onVisibleChanged: {
+ if (visible) {
+ var pos = schemeBtn.mapToItem(sizer, 0, 0)
+ x = Math.min(pos.x, sizer.width - width - 4)
+ y = pos.y - height - 6
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {}
+ }
+
+ ColumnLayout {
+ id: schemeDropdownCol
+ anchors.centerIn: parent
+ spacing: 4
+
+ Repeater {
+ model: WallpaperService.schemes
+ delegate: Rectangle {
+ id: schemeItem
+ required property string modelData
+ property bool sel: WallpaperService.scheme === modelData
+
+ Layout.fillWidth: true
+ // Pad minimumWidth to ensure space between text and rectangle edges
+ Layout.minimumWidth: schemeItemText.implicitWidth + 40
+ Layout.preferredHeight: 32
+
+ radius: 8
+ color: schemeItemMA.containsMouse
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.14)
+ : (sel ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.22) : "transparent")
+
+ border.color: (sel || schemeItemMA.containsMouse)
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.28)
+ : "transparent"
+ border.width: 1
+
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Behavior on border.color { ColorAnimation { duration: 100 } }
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: 12
+ spacing: 10
+
+ Text {
+ text: sel ? "●" : "○"
+ font.pixelSize: 10
+ color: (sel || schemeItemMA.containsMouse) ? Theme.active : Qt.rgba(1,1,1,0.3)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Text {
+ id: schemeItemText
+ text: modelData
+ font.pixelSize: 13
+ color: (sel || schemeItemMA.containsMouse) ? Theme.text : Qt.rgba(1,1,1,0.65)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: schemeItemMA
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ WallpaperService.scheme = modelData
+ content.schemePopupOpen = false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/popups/WifiTab.qml b/src/popups/WifiTab.qml
new file mode 100644
index 0000000..bde08cd
--- /dev/null
+++ b/src/popups/WifiTab.qml
@@ -0,0 +1,622 @@
+import QtQuick
+import Quickshell.Io
+import "../"
+import "../components"
+
+// WifiTab
+// Connect → attempt without password → if "secret" in stderr → expand field inline.
+// Off overlay is a direct child of root Item (z:2), not inside Column — no overflow.
+
+Item {
+ id: root
+
+ property var _networks: []
+ property var _needsPassword: ({})
+ property bool _scanning: false
+ property bool _wifiEnabled: true
+ property string _connectingTo: ""
+ property string _forgetSsid: ""
+ property string _expandSsid: ""
+
+ readonly property var _current: {
+ for (var i = 0; i < _networks.length; i++)
+ if (_networks[i].inUse) return _networks[i]
+ return null
+ }
+ readonly property var _available: {
+ var r = []
+ for (var i = 0; i < _networks.length; i++)
+ if (!_networks[i].inUse) r.push(_networks[i])
+ return r
+ }
+
+ Connections {
+ target: Popups
+ function onNetworkOpenChanged() {
+ if (Popups.networkOpen) {
+ root._forgetSsid = ""
+ root._expandSsid = ""
+ root._connectingTo = ""
+ root._needsPassword = ({})
+ root._checkRadio()
+ root._scan(false)
+ }
+ }
+ }
+
+ // ── Processes ─────────────────────────────────────────────────────────────
+
+ Process {
+ id: scanProc
+ command: []
+ running: false
+ stdout: SplitParser {
+ onRead: function(line) {
+ var t = line.trim()
+ if (t === "") return
+ var lastC = t.lastIndexOf(":")
+ if (lastC < 0) return
+ var security = t.substring(lastC + 1)
+ var t2 = t.substring(0, lastC)
+ var secC = t2.lastIndexOf(":")
+ if (secC < 0) return
+ var signalStr = t2.substring(secC + 1)
+ var t3 = t2.substring(0, secC)
+ var firstC = t3.indexOf(":")
+ if (firstC < 0) return
+ var inUseStr = t3.substring(0, firstC)
+ var ssid = t3.substring(firstC + 1).replace(/\\:/g, ":")
+ if (ssid === "" || ssid === "--") return
+ var inUse = inUseStr.trim() === "*"
+ var signal = parseInt(signalStr.trim()) || 0
+ var secured = security.trim() !== "" && security.trim() !== "--"
+ var nets = root._networks.slice()
+ var found = false
+ for (var i = 0; i < nets.length; i++) {
+ if (nets[i].ssid === ssid) {
+ if (inUse || signal > nets[i].signal)
+ nets[i] = { ssid: ssid, signal: signal, secured: secured, inUse: inUse }
+ found = true; break
+ }
+ }
+ if (!found) nets.push({ ssid: ssid, signal: signal, secured: secured, inUse: inUse })
+ root._networks = nets
+ }
+ }
+ onRunningChanged: if (!running) root._scanning = false
+ }
+
+ // First attempt — captures stderr to detect secret requirement
+ Process {
+ id: connectProc
+ command: []
+ running: false
+ property string _ssid: ""
+ stderr: StdioCollector { id: connectStderr }
+ onExited: function(code, status) {
+ if (code === 0) {
+ // Success — clear password state and close the field
+ var np = Object.assign({}, root._needsPassword)
+ delete np[connectProc._ssid]
+ root._needsPassword = np
+ root._expandSsid = ""
+ } else {
+ var err = connectStderr.text.toLowerCase()
+ if (err.indexOf("secret") >= 0 || err.indexOf("password") >= 0
+ || err.indexOf("no network") < 0) {
+ var np2 = Object.assign({}, root._needsPassword)
+ np2[connectProc._ssid] = true
+ root._needsPassword = np2
+ root._expandSsid = connectProc._ssid
+ }
+ }
+ root._connectingTo = ""
+ root._scan(false)
+ }
+ }
+
+ Process {
+ id: passProc
+ command: []
+ running: false
+ onRunningChanged: if (!running) {
+ root._connectingTo = ""
+ root._expandSsid = ""
+ root._scan(false)
+ }
+ }
+
+ Process {
+ id: actionProc
+ command: []
+ running: false
+ onRunningChanged: if (!running) {
+ root._connectingTo = ""
+ root._forgetSsid = ""
+ root._expandSsid = ""
+ root._needsPassword = ({})
+ root._scan(false)
+ }
+ }
+
+ Process { id: nmtuiProc; command: ["kitty", "--title", "nmtui", "nmtui"]; running: false }
+
+ Process {
+ id: radioProc; command: []; running: false
+ onRunningChanged: if (!running) root._checkRadio()
+ }
+
+ Process {
+ id: radioCheckProc
+ command: ["bash", "-c", "nmcli radio wifi"]
+ running: false
+ stdout: SplitParser {
+ onRead: function(line) { root._wifiEnabled = line.trim() === "enabled" }
+ }
+ }
+
+ function _checkRadio() { radioCheckProc.running = false; radioCheckProc.running = true }
+
+ function _setWifiEnabled(on) {
+ root._wifiEnabled = on
+ radioProc.command = ["bash", "-c", "nmcli radio wifi " + (on ? "on" : "off")]
+ radioProc.running = false; radioProc.running = true
+ }
+
+ function _scan(rescan) {
+ if (_scanning || !root._wifiEnabled) return
+ _scanning = true; _networks = []
+ scanProc.command = ["bash", "-c",
+ "nmcli -t -f IN-USE,SSID,SIGNAL,SECURITY dev wifi list " +
+ (rescan ? "--rescan yes" : "--rescan no") + " 2>/dev/null"]
+ scanProc.running = false; scanProc.running = true
+ }
+
+ function _disconnect() {
+ actionProc.command = ["bash", "-c",
+ "nmcli con down \"$(nmcli -t -f NAME,TYPE con show --active" +
+ " | grep ':802-11-wireless' | head -1 | cut -d: -f1)\" 2>/dev/null"]
+ actionProc.running = false; actionProc.running = true
+ }
+
+ function _forget(ssid) {
+ actionProc.running = false;
+ _forgetSsid = "";
+
+ actionProc.command = [
+ "bash", "-c",
+ "for uuid in $(nmcli -g UUID,TYPE connection show | awk -F: '$2==\"802-11-wireless\"{print $1}'); do " +
+ "if [ \"$(nmcli -g 802-11-wireless.ssid connection show \"$uuid\" 2>/dev/null)\" = \"$1\" ]; then " +
+ "nmcli connection delete \"$uuid\"; " +
+ "fi; done",
+ "--", ssid
+ ];
+
+ actionProc.running = true;
+ }
+
+ function _connectFirst(ssid) {
+ _connectingTo = ssid; _expandSsid = ""
+ connectProc._ssid = ssid
+ connectProc.command = ["bash", "-c",
+ "nmcli con up id \"" + ssid + "\" 2>&1 ||" +
+ " nmcli dev wifi connect \"" + ssid + "\" 2>&1"]
+ connectProc.running = false; connectProc.running = true
+ }
+
+ function _connectWithPassword(ssid, password) {
+ _connectingTo = ssid; _expandSsid = ""
+ var np = Object.assign({}, root._needsPassword)
+ delete np[ssid]
+ root._needsPassword = np
+ passProc.command = ["bash", "-c",
+ "nmcli dev wifi connect \"" + ssid + "\" password \"" + password + "\" 2>/dev/null"]
+ passProc.running = false; passProc.running = true
+ }
+
+ Component.onCompleted: { _checkRadio(); _scan(false) }
+
+ // ── Components ────────────────────────────────────────────────────────────
+
+ component ScanRings: Item {
+ id: ringsRoot
+ property string centerGlyph: ""
+ property int glyphSize: 18
+ Repeater {
+ model: 4
+ delegate: Rectangle {
+ required property int index
+ anchors.centerIn: parent
+ width: ringsRoot.width; height: ringsRoot.width; radius: ringsRoot.width / 2
+ color: "transparent"
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.80)
+ border.width: 1.5; opacity: 0; scale: 0.08
+ SequentialAnimation {
+ running: root._scanning; loops: Animation.Infinite
+ PauseAnimation { duration: index * 650 }
+ ParallelAnimation {
+ NumberAnimation { property: "scale"; from: 0.08; to: 1.0; duration: 2200; easing.type: Easing.OutCubic }
+ NumberAnimation { property: "opacity"; from: 0.80; to: 0.0; duration: 2200; easing.type: Easing.OutQuad }
+ }
+ }
+ }
+ }
+ Text {
+ anchors.centerIn: parent; text: ringsRoot.centerGlyph; font.pixelSize: ringsRoot.glyphSize
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.55)
+ SequentialAnimation on opacity {
+ running: root._scanning; loops: Animation.Infinite
+ NumberAnimation { to: 0.20; duration: 700; easing.type: Easing.InOutSine }
+ NumberAnimation { to: 0.80; duration: 700; easing.type: Easing.InOutSine }
+ }
+ }
+ }
+
+ component SignalBars: Item {
+ id: barsRoot
+ required property int signal
+ width: 18; height: 14
+ Row {
+ anchors.bottom: parent.bottom; anchors.horizontalCenter: parent.horizontalCenter; spacing: 2
+ Repeater {
+ model: 4
+ delegate: Rectangle {
+ required property int index
+ width: 3; height: 4 + index * 3; radius: 1; anchors.bottom: parent?.bottom
+ readonly property bool lit: {
+ switch (index) {
+ case 0: return barsRoot.signal > 0
+ case 1: return barsRoot.signal > 25
+ case 2: return barsRoot.signal > 50
+ case 3: return barsRoot.signal > 75
+ }; return false
+ }
+ color: lit ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.85) : Qt.rgba(1,1,1,0.15)
+ Behavior on color { ColorAnimation { duration: 200 } }
+ }
+ }
+ }
+ }
+
+ component NetworkRow: Item {
+ id: netRow
+ required property var net
+ required property bool isCurrent
+ readonly property bool isForgetPending: root._forgetSsid === net.ssid
+ readonly property bool isExpanded: root._expandSsid === net.ssid
+ readonly property bool isConnecting: root._connectingTo === net.ssid
+ readonly property bool needsPassword: !!root._needsPassword[net.ssid]
+ property bool _showPass: false
+ width: parent?.width ?? 0
+ height: baseRow.height + expandArea.height
+
+ Rectangle {
+ anchors.fill: parent; radius: Theme.cornerRadius
+ color: netRow.isCurrent
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.07)
+ : rHov.hovered ? Qt.rgba(1,1,1,0.04) : "transparent"
+ border.color: netRow.isCurrent
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18)
+ : netRow.needsPassword
+ ? Qt.rgba(245/255,196/255,122/255,0.30)
+ : Qt.rgba(1,1,1,0.06)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 130 } }
+ Behavior on border.color { ColorAnimation { duration: 130 } }
+ }
+
+ Item {
+ id: baseRow
+ anchors { top: parent.top; left: parent.left; right: parent.right }
+ height: 48
+
+ Column {
+ anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter }
+ spacing: 3
+ Text {
+ text: netRow.net.ssid; font.pixelSize: 13
+ font.weight: netRow.isCurrent ? Font.Medium : Font.Normal
+ color: netRow.isCurrent ? Theme.text : Qt.rgba(1,1,1,0.7)
+ width: 170; elide: Text.ElideRight
+ }
+ Text {
+ visible: netRow.needsPassword && !netRow.isCurrent
+ text: "Password required"; font.pixelSize: 10
+ color: Qt.rgba(245/255,196/255,122/255,0.80)
+ }
+ Text { visible: netRow.isCurrent; text: "Connected"; font.pixelSize: 10; color: Theme.active }
+ }
+
+ Row {
+ anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter }
+ spacing: 6
+
+ Text {
+ visible: netRow.net.secured && !netRow.isCurrent
+ text: ""; font.pixelSize: 11; color: Qt.rgba(1,1,1,0.28)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Item {
+ width: 22; height: 16; anchors.verticalCenter: parent.verticalCenter
+ SignalBars { anchors.centerIn: parent; signal: netRow.net.signal }
+ }
+ Item {
+ visible: netRow.isConnecting; width: 20; height: 20; anchors.verticalCenter: parent.verticalCenter
+ Text {
+ anchors.centerIn: parent; text: "○"; font.pixelSize: 14; color: Theme.active
+ SequentialAnimation on opacity {
+ running: netRow.isConnecting; loops: Animation.Infinite
+ NumberAnimation { to: 0.2; duration: 500 }
+ NumberAnimation { to: 1.0; duration: 500 }
+ }
+ }
+ }
+ // Disconnect
+ Item {
+ visible: netRow.isCurrent; width: 28; height: 28; anchors.verticalCenter: parent.verticalCenter
+ Rectangle { anchors.fill: parent; radius: 6; color: dH.hovered ? Qt.rgba(1,1,1,0.10) : "transparent"; Behavior on color { ColorAnimation { duration: 100 } } }
+ Text { anchors.centerIn: parent; text: ""; font.pixelSize: 14; color: dH.hovered ? Qt.rgba(1,1,1,0.65) : Qt.rgba(1,1,1,0.35); Behavior on color { ColorAnimation { duration: 100 } } }
+ HoverHandler { id: dH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._disconnect() }
+ }
+ // Forget
+ Item {
+ visible: netRow.isCurrent; width: 28; height: 28; anchors.verticalCenter: parent.verticalCenter
+ Rectangle {
+ anchors.fill: parent; radius: 6
+ color: fH.hovered ? Qt.rgba(248/255,113/255,113/255,0.15) : netRow.isForgetPending ? Qt.rgba(248/255,113/255,113/255,0.10) : "transparent"
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ Text { anchors.centerIn: parent; text: ""; font.pixelSize: 13; color: (fH.hovered || netRow.isForgetPending) ? "#f87171" : Qt.rgba(1,1,1,0.3); Behavior on color { ColorAnimation { duration: 100 } } }
+ HoverHandler { id: fH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._forgetSsid = netRow.isForgetPending ? "" : netRow.net.ssid }
+ }
+ // Connect
+ Rectangle {
+ visible: !netRow.isCurrent && !netRow.isConnecting
+ anchors.verticalCenter: parent.verticalCenter
+ width: connectLbl.implicitWidth + 20; height: 28; radius: 8
+ color: conH.hovered ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.22) : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.09)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.35); border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text { id: connectLbl; anchors.centerIn: parent; text: netRow.isExpanded ? "Retry" : "Connect"; font.pixelSize: 11; font.weight: Font.Medium; color: Theme.active }
+ HoverHandler { id: conH; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ root._forgetSsid = ""
+ if (netRow.isExpanded && passInput.text !== "")
+ root._connectWithPassword(netRow.net.ssid, passInput.text)
+ else
+ root._connectFirst(netRow.net.ssid)
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ id: expandArea
+ anchors { top: baseRow.bottom; left: parent.left; right: parent.right }
+ clip: true
+ height: netRow.isForgetPending ? forgetRow.implicitHeight + 16 : netRow.isExpanded ? passRow.implicitHeight + 16 : 0
+ Behavior on height { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
+
+ Item {
+ id: forgetRow
+ anchors { left: parent.left; right: parent.right; top: parent.top; topMargin: 8 }
+ implicitHeight: 32
+ opacity: netRow.isForgetPending ? 1 : 0
+ visible: opacity > 0
+ Behavior on opacity { NumberAnimation { duration: 150 } }
+ Rectangle {
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ radius: 8; color: Qt.rgba(248/255,113/255,113/255,0.07)
+ border.color: Qt.rgba(248/255,113/255,113/255,0.22); border.width: 1
+ Row {
+ anchors.centerIn: parent; spacing: 12
+ Text { anchors.verticalCenter: parent.verticalCenter; text: "Forget this network?"; font.pixelSize: 11; color: Qt.rgba(1,1,1,0.55) }
+ Rectangle {
+ width: 54; height: 24; radius: 6; color: cfH.hovered ? Qt.rgba(1,1,1,0.09) : Qt.rgba(1,1,1,0.04)
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "Cancel"; font.pixelSize: 10; color: Qt.rgba(1,1,1,0.45) }
+ HoverHandler { id: cfH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._forgetSsid = "" }
+ }
+ Rectangle {
+ width: 54; height: 24; radius: 6
+ color: ffH.hovered ? Qt.rgba(248/255,113/255,113/255,0.35) : Qt.rgba(248/255,113/255,113/255,0.18)
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "Forget"; font.pixelSize: 10; font.weight: Font.Medium; color: "#f87171" }
+ HoverHandler { id: ffH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._forget(netRow.net.ssid) }
+ }
+ }
+ }
+ }
+
+ Item {
+ id: passRow
+ anchors { left: parent.left; right: parent.right; top: parent.top; topMargin: 8 }
+ implicitHeight: 40
+ opacity: netRow.isExpanded ? 1 : 0
+ visible: opacity > 0
+ Behavior on opacity { NumberAnimation { duration: 150 } }
+ Row {
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ spacing: 8
+ Rectangle {
+ width: parent.width - parent.spacing; height: 32; radius: 8
+ color: Qt.rgba(1,1,1,0.06)
+ border.color: passInput.activeFocus ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.55) : Qt.rgba(1,1,1,0.12)
+ border.width: 1; Behavior on border.color { ColorAnimation { duration: 120 } }
+ Text { anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: "Password…"; font.pixelSize: 12; color: Qt.rgba(1,1,1,0.22); visible: passInput.text === "" }
+ TextInput {
+ id: passInput
+ // Updated anchors to make room for the eye button
+ anchors { left: parent.left; leftMargin: 10; right: eyeBtn.left; rightMargin: 6; top: parent.top; bottom: parent.bottom }
+ verticalAlignment: TextInput.AlignVCenter; color: Theme.text; font.pixelSize: 12
+ // Toggle echoMode based on state
+ echoMode: netRow._showPass ? TextInput.Normal : TextInput.Password
+ selectionColor: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.35); clip: true
+ Keys.onReturnPressed: { if (text.length > 0) root._connectWithPassword(netRow.net.ssid, text) }
+ }
+
+ // Added Show Password Button
+ Item {
+ id: eyeBtn
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ width: 28; height: 28
+ Rectangle { anchors.fill: parent; radius: 6; color: eyeH.hovered ? Qt.rgba(1,1,1,0.08) : "transparent" }
+ Text {
+ anchors.centerIn: parent
+ text: netRow._showPass ? "" : ""
+ font.pixelSize: 13
+ color: netRow._showPass ? Theme.active : Qt.rgba(1,1,1,0.28)
+ }
+ HoverHandler { id: eyeH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: netRow._showPass = !netRow._showPass }
+ }
+ }
+ }
+ }
+
+ onVisibleChanged: { if (visible && netRow.isExpanded) Qt.callLater(function() { passInput.forceActiveFocus() }) }
+ }
+
+ onIsExpandedChanged: {
+ if (isExpanded) Qt.callLater(function() { passInput.forceActiveFocus() })
+ else passInput.text = ""
+ }
+
+ HoverHandler { id: rHov; enabled: !netRow.isCurrent }
+ }
+
+ // ── Layout — Column fills root, overlay is z:2 sibling ───────────────────
+ Column {
+ anchors.fill: parent; spacing: 0
+
+ Item {
+ width: parent.width; height: 40
+ Text { anchors { left: parent.left; leftMargin: 2; verticalCenter: parent.verticalCenter }
+ text: "Wi-Fi"; font.pixelSize: 15; font.weight: Font.Bold; color: Theme.text }
+ Row {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ spacing: 8
+
+ Rectangle {
+ width: 32; height: 32; radius: 8
+ color: wfPwrH.hovered ? (root._wifiEnabled ? Qt.rgba(248/255,113/255,113/255,0.18) : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18)) : Qt.rgba(1,1,1,0.04)
+ border.color: root._wifiEnabled ? Qt.rgba(1,1,1,0.10) : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.30)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Behavior on border.color { ColorAnimation { duration: 120 } }
+ Text { anchors.centerIn: parent; text: "⏻"; font.pixelSize: 14; color: root._wifiEnabled ? (wfPwrH.hovered ? "#f87171" : Qt.rgba(1,1,1,0.32)) : Theme.active; Behavior on color { ColorAnimation { duration: 120 } } }
+ HoverHandler { id: wfPwrH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._setWifiEnabled(!root._wifiEnabled) }
+ }
+
+ Rectangle {
+ width: 32; height: 32; radius: 8
+ color: settH.hovered ? Qt.rgba(1,1,1,0.09) : Qt.rgba(1,1,1,0.03)
+ border.color: Qt.rgba(1,1,1,0.10); border.width: 1; Behavior on color { ColorAnimation { duration: 100 } }
+ Text { anchors.centerIn: parent; text: ""; font.pixelSize: 14; color: settH.hovered ? Qt.rgba(1,1,1,0.75) : Qt.rgba(1,1,1,0.30); Behavior on color { ColorAnimation { duration: 100 } } }
+ HoverHandler { id: settH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: { nmtuiProc.running = false; nmtuiProc.running = true } }
+ }
+
+ Rectangle {
+ width: 32; height: 32; radius: 8
+ color: rfH.hovered ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.15) : Qt.rgba(1,1,1,0.05)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.28); border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Text {
+ id: rfIcon; anchors.centerIn: parent; text: ""; font.pixelSize: 15
+ color: root._scanning ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.4) : (root._wifiEnabled ? Theme.active : Qt.rgba(1,1,1,0.18))
+ Behavior on color { ColorAnimation { duration: 150 } }
+ RotationAnimator { target: rfIcon; from: 0; to: 360; duration: 900; loops: Animation.Infinite; running: root._scanning; easing.type: Easing.Linear }
+ }
+ HoverHandler { id: rfH; cursorShape: root._wifiEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor }
+ MouseArea { anchors.fill: parent; onClicked: if (!root._scanning && root._wifiEnabled) root._scan(true) }
+ }
+ }
+ }
+
+ Rectangle { width: parent.width; height: 1; color: Qt.rgba(1,1,1,0.07) }
+ Item { width: parent.width; height: 8 }
+
+ Flickable {
+ id: flick; width: parent.width; height: parent.height - 49
+ contentWidth: width; contentHeight: contentCol.height; clip: true; boundsBehavior: Flickable.StopAtBounds
+ Column {
+ id: contentCol; width: flick.width; height: implicitHeight; spacing: 4
+
+ Item { width: parent.width; height: visible ? sLbl1.implicitHeight + 4 : 0; visible: root._current !== null
+ Text { id: sLbl1; text: "CONNECTED"; font.pixelSize: 9; font.weight: Font.Bold; font.letterSpacing: 1.2; color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.5) } }
+
+ NetworkRow { visible: root._current !== null; width: parent.width - 2; x: 1; net: root._current ?? { ssid: "", signal: 0, secured: false, inUse: true }; isCurrent: true }
+
+ Item { width: parent.width; height: 10; visible: root._current !== null && root._available.length > 0 }
+
+ Item { width: parent.width; height: visible ? sLbl2.implicitHeight + 4 : 0; visible: root._available.length > 0
+ Text { id: sLbl2; text: "AVAILABLE"; font.pixelSize: 9; font.weight: Font.Bold; font.letterSpacing: 1.2; color: Qt.rgba(1,1,1,0.25) } }
+
+ Repeater {
+ model: root._available
+ delegate: NetworkRow { required property var modelData; width: contentCol.width - 2; x: 1; net: modelData; isCurrent: false }
+ }
+
+ Item {
+ width: parent.width; height: 160
+ visible: !root._scanning && root._networks.length === 0 && root._wifiEnabled
+ Column { anchors.centerIn: parent; spacing: 10
+ Text { anchors.horizontalCenter: parent.horizontalCenter; text: ""; font.pixelSize: 34; color: Qt.rgba(1,1,1,0.08) }
+ Text { anchors.horizontalCenter: parent.horizontalCenter; text: "No networks found"; font.pixelSize: 12; color: Qt.rgba(1,1,1,0.2) } }
+ }
+
+ Item {
+ width: parent.width; height: 160
+ visible: root._scanning && root._networks.length === 0
+ ScanRings { anchors { horizontalCenter: parent.horizontalCenter; top: parent.top; topMargin: 12 }
+ width: 96; height: 96; centerGlyph: ""; glyphSize: 18 }
+ Text { anchors { horizontalCenter: parent.horizontalCenter; bottom: parent.bottom; bottomMargin: 8 }
+ text: "Scanning…"; font.pixelSize: 11; color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.5) }
+ }
+
+ Item { width: parent.width; height: 8 }
+ }
+ }
+ }
+
+ // ── WiFi off overlay — covers list area only, stops at parent bounds ─────
+ Item {
+ anchors {
+ fill: parent
+ topMargin: 49 // below header 40 + divider 1 + gap 8
+ }
+ visible: !root._wifiEnabled
+ z: 2
+
+ Rectangle { anchors.fill: parent; color: Qt.rgba(Theme.background.r, Theme.background.g, Theme.background.b, 0.95) }
+
+ Column {
+ anchors.centerIn: parent; spacing: 16
+ Text { anchors.horizontalCenter: parent.horizontalCenter; text: ""; font.pixelSize: 42; color: Qt.rgba(1,1,1,0.12) }
+ Text { anchors.horizontalCenter: parent.horizontalCenter; text: "Wi-Fi is off"; font.pixelSize: 14; font.weight: Font.Medium; color: Qt.rgba(1,1,1,0.30) }
+ Rectangle {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: wfEnRow.implicitWidth + 24; height: 34; radius: 17
+ color: wfEnH.hovered ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.22) : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.12)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.40); border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Row { id: wfEnRow; anchors.centerIn: parent; spacing: 8
+ Text { anchors.verticalCenter: parent.verticalCenter; text: ""; font.pixelSize: 14; color: Theme.active }
+ Text { anchors.verticalCenter: parent.verticalCenter; text: "Turn On"; font.pixelSize: 12; font.weight: Font.Medium; color: Theme.active }
+ }
+ HoverHandler { id: wfEnH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._setWifiEnabled(true) }
+ }
+ }
+ }
+}
diff --git a/src/qmldir b/src/qmldir
index 96aef52..4bfea76 100644
--- a/src/qmldir
+++ b/src/qmldir
@@ -1 +1,15 @@
-singleton Theme Theme/Theme.qml
+singleton Theme theme/Theme.qml
+singleton Popups state/Popups.qml
+singleton IpcManager state/IpcManager.qml
+singleton ShellState state/ShellState.qml
+singleton ClockState state/ClockState.qml
+singleton ScreenRecService 1.0 services/ScreenRecService.qml
+singleton CavaService 1.0 services/CavaService.qml
+singleton ClipboardService 1.0 services/ClipboardService.qml
+singleton WallpaperService 1.0 services/WallpaperService.qml
+singleton VideoWallpaperService 1.0 services/VideoWallpaperService.qml
+singleton ShellConfigService 1.0 services/ShellConfigService.qml
+singleton KeybindService 1.0 services/config_tab/KeybindService.qml
+singleton HyprlandSyncService 1.0 services/HyprlandSyncService.qml
+singleton UpdateService 1.0 services/UpdateService.qml
+singleton FocusGrabManager 1.0 services/FocusGrabManager.qml
\ No newline at end of file
diff --git a/src/scripts/GfxSwitch.sh b/src/scripts/GfxSwitch.sh
new file mode 100755
index 0000000..875bc42
--- /dev/null
+++ b/src/scripts/GfxSwitch.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+echo "authenticated"
+envycontrol --switch "$1"
\ No newline at end of file
diff --git a/src/scripts/PowerControl.sh b/src/scripts/PowerControl.sh
new file mode 100755
index 0000000..c0cd0bd
--- /dev/null
+++ b/src/scripts/PowerControl.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+case "$1" in
+ shutdown)
+ (setsid bash -c 'hyprshutdown --post-cmd "systemctl poweroff"' &>/dev/null &);;
+ reboot)
+ (setsid bash -c 'hyprshutdown --post-cmd "systemctl reboot"' &>/dev/null &);;
+ logout)
+ (setsid bash -c 'hyprshutdown --post-cmd "loginctl terminate-user $USER"' &>/dev/null &);;
+ *)
+ exit 1
+ ;;
+esac
\ No newline at end of file
diff --git a/src/scripts/colorpicker.py b/src/scripts/colorpicker.py
new file mode 100644
index 0000000..d7ee9f2
--- /dev/null
+++ b/src/scripts/colorpicker.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+import colorsys
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+
+def cmd(*args, input=None):
+ return subprocess.check_output(args, input=input)
+
+
+for dep in ("grim", "slurp", "magick", "wl-copy", "notify-send"):
+ if subprocess.call(["which", dep], stdout=subprocess.DEVNULL) != 0:
+ subprocess.call(
+ [
+ "notify-send",
+ "Color Picker",
+ f"Missing dependency: {dep}",
+ "-u",
+ "critical",
+ ]
+ )
+ sys.exit(1)
+
+coords = cmd("slurp", "-p").decode().strip()
+if not coords:
+ sys.exit(0)
+
+raw = subprocess.Popen(["grim", "-g", coords, "-t", "ppm", "-"], stdout=subprocess.PIPE)
+
+rgb_str = cmd(
+ "magick",
+ "-",
+ "-format",
+ "%[fx:int(255*r)] %[fx:int(255*g)] %[fx:int(255*b)]",
+ "info:-",
+ input=raw.stdout.read(),
+).decode()
+
+r, g, b = map(int, rgb_str.split())
+
+hex_color = f"#{r:02X}{g:02X}{b:02X}"
+rgb_color = f"rgb({r}, {g}, {b})"
+
+rn, gn, bn = r / 255, g / 255, b / 255
+h, s, v = colorsys.rgb_to_hsv(rn, gn, bn)
+hsv_color = f"hsv({round(h*360)}, {round(s*100)}%, {round(v*100)}%)"
+
+icon_fd, icon_path = tempfile.mkstemp(suffix=".png")
+os.close(icon_fd)
+icon = Path(icon_path)
+cmd("magick", "-size", "64x64", f"xc:{hex_color}", str(icon))
+
+subprocess.run(["wl-copy"], input=hex_color.encode())
+
+proc = subprocess.Popen(
+ [
+ "notify-send",
+ "Color Picked",
+ f"{hex_color} copied to clipboard",
+ "-i",
+ str(icon),
+ "-a",
+ "ColorPicker",
+ "-u",
+ "normal",
+ "--action=hex=Copy HEX",
+ "--action=rgb=Copy RGB",
+ "--action=hsv=Copy HSV",
+ ],
+ stdout=subprocess.PIPE,
+)
+
+action = proc.communicate()[0].decode().strip()
+
+if action == "rgb":
+ subprocess.run(["wl-copy"], input=rgb_color.encode())
+ subprocess.call(
+ [
+ "notify-send",
+ "Color Picker",
+ f"RGB copied: {rgb_color}",
+ "-i",
+ str(icon),
+ "-u",
+ "low",
+ ]
+ )
+elif action == "hsv":
+ subprocess.run(["wl-copy"], input=hsv_color.encode())
+ subprocess.call(
+ [
+ "notify-send",
+ "Color Picker",
+ f"HSV copied: {hsv_color}",
+ "-i",
+ str(icon),
+ "-u",
+ "low",
+ ]
+ )
+else:
+ subprocess.run(["wl-copy"], input=hex_color.encode())
+ subprocess.call(
+ [
+ "notify-send",
+ "Color Picker",
+ f"HEX copied: {hex_color}",
+ "-i",
+ str(icon),
+ "-u",
+ "low",
+ ]
+ )
diff --git a/src/scripts/lockwall.py b/src/scripts/lockwall.py
new file mode 100755
index 0000000..c82c851
--- /dev/null
+++ b/src/scripts/lockwall.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""
+Lockscreen Wallpaper Frame Extractor for NothingLess
+Extracts first frame from video/GIF wallpapers for lockscreen background.
+Only processes video and GIF files - skips regular images.
+"""
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+from typing import Optional, Tuple
+
+# Supported extensions for processing
+VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".avi", ".mkv"}
+GIF_EXTENSIONS = {".gif"}
+
+
+class LockscreenWallpaperGenerator:
+ def __init__(self, wallpaper_path: str, data_path: str):
+ self.current_wallpaper = Path(wallpaper_path).expanduser()
+ self.data_path = Path(data_path)
+ self.lockscreen_dir: Optional[Path] = None
+
+ def validate_wallpaper(self) -> bool:
+ """Validate wallpaper exists."""
+ if not self.current_wallpaper.exists():
+ print(f"ERROR: Wallpaper not found: {self.current_wallpaper}")
+ return False
+
+ # Setup lockscreen directory in QuickShell data path
+ self.lockscreen_dir = self.data_path / "lockscreen"
+ self.lockscreen_dir.mkdir(parents=True, exist_ok=True)
+
+ print(f"✓ Current wallpaper: {self.current_wallpaper.name}")
+ print(f"✓ Lockscreen cache: {self.lockscreen_dir}")
+ return True
+
+ def is_video_or_gif(self) -> bool:
+ """Check if current wallpaper is a video or GIF."""
+ ext = self.current_wallpaper.suffix.lower()
+ return ext in VIDEO_EXTENSIONS or ext in GIF_EXTENSIONS
+
+ def get_output_path(self) -> Path:
+ """Get output path for lockscreen wallpaper."""
+ if self.lockscreen_dir is None:
+ raise RuntimeError("Lockscreen directory not initialized")
+
+ # Create output filename: original_name.extension.jpg
+ output_name = self.current_wallpaper.name + ".jpg"
+ return self.lockscreen_dir / output_name
+
+ def clean_lockscreen_dir(self) -> None:
+ """Remove all existing files in lockscreen directory."""
+ if self.lockscreen_dir is None:
+ return
+
+ try:
+ for file in self.lockscreen_dir.glob("*"):
+ if file.is_file():
+ file.unlink()
+ print(f"✓ Removed old file: {file.name}")
+ except Exception as e:
+ print(f"WARNING: Failed to clean directory: {e}")
+
+ def extract_first_frame(self) -> Tuple[bool, str]:
+ """Extract first frame from video/GIF using FFmpeg."""
+ output_path = self.get_output_path()
+
+ try:
+ # FFmpeg command to extract first frame
+ cmd = [
+ "ffmpeg",
+ "-y",
+ "-i",
+ str(self.current_wallpaper),
+ "-vframes",
+ "1", # Extract only first frame
+ "-q:v",
+ "2", # High quality
+ "-f",
+ "image2", # Force image format
+ str(output_path),
+ ]
+
+ print(f"⚡ Extracting first frame...")
+
+ # Run FFmpeg
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
+
+ if result.returncode == 0 and output_path.exists():
+ print(f"✅ Frame saved: {output_path.name}")
+ return True, "Success"
+ else:
+ error_msg = result.stderr.strip() if result.stderr else "Unknown error"
+ return False, error_msg
+
+ except subprocess.TimeoutExpired:
+ return False, "Timeout"
+ except Exception as e:
+ return False, str(e)
+
+ def run(self) -> int:
+ """Main execution function."""
+ print("🔒 NothingLess Lockscreen Wallpaper Generator")
+ print("=" * 40)
+
+ # Validate wallpaper
+ if not self.validate_wallpaper():
+ return 1
+
+ # Check if wallpaper is video or GIF
+ if not self.is_video_or_gif():
+ ext = self.current_wallpaper.suffix.lower()
+ print(f"ℹ️ Wallpaper is a regular image ({ext})")
+ print("ℹ️ No processing needed - use wallpaper directly")
+ return 0
+
+ # Clean existing files
+ self.clean_lockscreen_dir()
+
+ # Extract first frame
+ success, message = self.extract_first_frame()
+
+ if success:
+ print("🎉 Lockscreen wallpaper ready!")
+ return 0
+ else:
+ print(f"❌ Failed to extract frame: {message}")
+ return 1
+
+
+def main():
+ """Entry point."""
+ if len(sys.argv) != 3:
+ print("Usage: python3 lockscreen_wallpaper.py ")
+ print(
+ "Example: python3 lockscreen_wallpaper.py /path/to/video.mp4 ~/.local/share/quickshell"
+ )
+ return 1
+
+ wallpaper_path = sys.argv[1]
+ data_path = sys.argv[2]
+ generator = LockscreenWallpaperGenerator(wallpaper_path, data_path)
+ return generator.run()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/scripts/thumbgen.py b/src/scripts/thumbgen.py
new file mode 100644
index 0000000..fbf8091
--- /dev/null
+++ b/src/scripts/thumbgen.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+"""thumbgen.py — thumbnail generator for Brain_Shell wallpapers.
+Generates thumbnails for images, videos, and GIFs using FFmpeg.
+Usage: python3 thumbgen.py [output_path] [--size 256]
+"""
+import os, sys, subprocess
+
+def generate_thumbnail(input_path, output_path=None, size=256):
+ if not os.path.exists(input_path):
+ print(f"ERROR: {input_path} not found", file=sys.stderr)
+ return None
+
+ ext = os.path.splitext(input_path)[1].lower()
+ is_video = ext in ('.mp4', '.webm', '.mkv', '.mov', '.avi', '.gif')
+
+ if output_path is None:
+ base = os.path.splitext(os.path.basename(input_path))[0]
+ output_path = f"/tmp/brain_thumb_{base}.jpg"
+
+ if is_video:
+ # Extract frame at 1 second or first frame for GIFs
+ time_flag = "00:00:01" if ext != '.gif' else "00:00:00"
+ cmd = [
+ "ffmpeg", "-y",
+ "-ss", time_flag,
+ "-i", input_path,
+ "-vframes", "1",
+ "-vf", f"scale={size}:{size}:force_original_aspect_ratio=decrease,pad={size}:{size}:(ow-iw)/2:(oh-ih)/2",
+ "-q:v", "2",
+ output_path
+ ]
+ else:
+ # Static image — resize with ImageMagick or ffmpeg
+ if shutil_which("magick"):
+ cmd = ["magick", input_path, "-resize", f"{size}x{size}^", "-gravity", "center", "-extent", f"{size}x{size}", "-quality", "80", "-strip", output_path]
+ else:
+ cmd = [
+ "ffmpeg", "-y",
+ "-i", input_path,
+ "-vf", f"scale={size}:{size}:force_original_aspect_ratio=decrease,pad={size}:{size}:(ow-iw)/2:(oh-ih)/2",
+ "-q:v", "2",
+ output_path
+ ]
+
+ try:
+ subprocess.run(cmd, capture_output=True, timeout=30)
+ if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
+ print(output_path)
+ return output_path
+ except Exception as e:
+ print(f"ERROR: {e}", file=sys.stderr)
+
+ return None
+
+def shutil_which(cmd):
+ import shutil
+ return shutil.which(cmd)
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("Usage: python3 thumbgen.py [output] [--size 256]")
+ sys.exit(1)
+
+ inp = sys.argv[1]
+ out = sys.argv[2] if len(sys.argv) > 2 and not sys.argv[2].startswith("--") else None
+ size = 256
+ for i, arg in enumerate(sys.argv):
+ if arg == "--size" and i + 1 < len(sys.argv):
+ size = int(sys.argv[i + 1])
+
+ result = generate_thumbnail(inp, out, size)
+ if result:
+ print(result)
+ else:
+ sys.exit(1)
diff --git a/src/scripts/thumbgen_batch.py b/src/scripts/thumbgen_batch.py
new file mode 100755
index 0000000..2c5f30b
--- /dev/null
+++ b/src/scripts/thumbgen_batch.py
@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+"""
+Thumbnail Generator for Brain_Shell — mirrors NothingLess' approach.
+
+Key design:
+- Proxy directory structure: thumbnails mirror the wallpaper dir layout
+- Deterministic paths: no hashes, no JSON maps — path is predictable
+- Stale detection: only regenerates when source is newer than thumbnail
+- Multithreaded: configurable workers, default 4
+
+Output: writes thumbnails to cache dir. WallpaperService computes paths directly.
+"""
+import os
+import sys
+import subprocess
+import shutil
+from pathlib import Path
+from concurrent.futures import ThreadPoolExecutor, as_completed
+import threading
+
+VIDEO_EXTS = {'.mp4', '.webm', '.mkv', '.mov', '.avi'}
+IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.tif', '.tiff', '.bmp'}
+GIF_EXTS = {'.gif'}
+ALL_EXTS = VIDEO_EXTS | IMAGE_EXTS | GIF_EXTS
+THUMB_SIZE = 256
+MAX_WORKERS = 4
+
+_im_v7: bool | None = None
+
+def im_v7() -> bool:
+ global _im_v7
+ if _im_v7 is not None:
+ return _im_v7
+ try:
+ r = subprocess.run(["magick", "--version"], capture_output=True, timeout=5)
+ _im_v7 = r.returncode == 0
+ except Exception:
+ _im_v7 = False
+ return _im_v7
+
+def scan_dir(root: Path) -> list[Path]:
+ """Recursively find media files, excluding hidden dirs."""
+ files = []
+ for ext in ALL_EXTS:
+ for f in root.rglob(f"*{ext}"):
+ rel = f.relative_to(root)
+ if any(p.startswith(".") for p in rel.parts[:-1]):
+ continue
+ files.append(f)
+ return sorted(set(files))
+
+def thumb_path(file_path: Path, wall_dir: Path, cache_dir: Path) -> Path:
+ """Deterministic thumbnail path that mirrors the wallpaper directory."""
+ rel = file_path.relative_to(wall_dir)
+ return cache_dir / rel.parent / (file_path.name + ".jpg")
+
+def needs_update(src: Path, thumb: Path) -> bool:
+ if not thumb.exists():
+ return True
+ try:
+ return src.stat().st_mtime > thumb.stat().st_mtime
+ except OSError:
+ return True
+
+def generate_one(inp: Path, out: Path) -> bool:
+ ext = inp.suffix.lower()
+ out.parent.mkdir(parents=True, exist_ok=True)
+
+ try:
+ if ext in VIDEO_EXTS:
+ subprocess.run([
+ "ffmpeg", "-y", "-ss", "00:00:00.100", "-i", str(inp),
+ "-vframes", "1",
+ "-vf", f"scale={THUMB_SIZE}:{THUMB_SIZE}:force_original_aspect_ratio=increase,crop={THUMB_SIZE}:{THUMB_SIZE}",
+ "-q:v", "2", "-f", "image2", str(out)
+ ], capture_output=True, timeout=30)
+ elif ext in GIF_EXTS:
+ subprocess.run([
+ "ffmpeg", "-y", "-i", str(inp),
+ "-vframes", "1",
+ "-vf", f"scale={THUMB_SIZE}:{THUMB_SIZE}:force_original_aspect_ratio=increase,crop={THUMB_SIZE}:{THUMB_SIZE}",
+ "-q:v", "2", "-f", "image2", str(out)
+ ], capture_output=True, timeout=15)
+ else:
+ if im_v7():
+ subprocess.run([
+ "magick", str(inp), "-resize", f"{THUMB_SIZE}x{THUMB_SIZE}^",
+ "-gravity", "center", "-extent", f"{THUMB_SIZE}x{THUMB_SIZE}",
+ "-quality", "85", "-strip", str(out)
+ ], capture_output=True, timeout=15)
+ elif shutil.which("convert"):
+ subprocess.run([
+ "convert", str(inp), "-resize", f"{THUMB_SIZE}x{THUMB_SIZE}^",
+ "-gravity", "center", "-extent", f"{THUMB_SIZE}x{THUMB_SIZE}",
+ "-quality", "85", "-strip", str(out)
+ ], capture_output=True, timeout=15)
+ else:
+ subprocess.run([
+ "ffmpeg", "-y", "-i", str(inp),
+ "-vf", f"scale={THUMB_SIZE}:{THUMB_SIZE}:force_original_aspect_ratio=increase,crop={THUMB_SIZE}:{THUMB_SIZE}",
+ "-q:v", "2", "-f", "image2", str(out)
+ ], capture_output=True, timeout=15)
+
+ return out.exists() and out.stat().st_size > 0
+ except Exception:
+ return False
+
+def main():
+ import argparse
+ ap = argparse.ArgumentParser()
+ ap.add_argument("wallpaper_dir")
+ ap.add_argument("--cache", default=os.path.expanduser("~/.cache/Brain_Shell/thumbnails"))
+ ap.add_argument("--size", type=int, default=THUMB_SIZE)
+ ap.add_argument("--workers", type=int, default=MAX_WORKERS)
+ ap.add_argument("--quiet", action="store_true")
+ args = ap.parse_args()
+
+ wall_dir = Path(args.wallpaper_dir).expanduser().resolve()
+ cache_dir = Path(args.cache)
+
+ if not wall_dir.exists():
+ if not args.quiet:
+ print(f"[thumbgen] directory not found: {wall_dir}", file=sys.stderr)
+ return
+
+ cache_dir.mkdir(parents=True, exist_ok=True)
+
+ files = scan_dir(wall_dir)
+ if not files:
+ return
+
+ work = []
+ for f in files:
+ tp = thumb_path(f, wall_dir, cache_dir)
+ if needs_update(f, tp):
+ work.append((f, tp))
+
+ if not work:
+ if not args.quiet:
+ print(f"[thumbgen] all {len(files)} thumbnails up to date", file=sys.stderr)
+ return
+
+ if not args.quiet:
+ print(f"[thumbgen] {len(work)}/{len(files)} need generation ({args.workers} workers)", file=sys.stderr)
+
+ lock = threading.Lock()
+ done = [0]
+ failed = [0]
+
+ def process(item):
+ inp, out = item
+ ok = generate_one(inp, out)
+ with lock:
+ done[0] += 1
+ if not ok:
+ failed[0] += 1
+
+ with ThreadPoolExecutor(max_workers=args.workers) as ex:
+ futures = [ex.submit(process, w) for w in work]
+ for _ in as_completed(futures):
+ pass
+
+ if not args.quiet:
+ ok = done[0] - failed[0]
+ print(f"[thumbgen] done: {ok} ok, {failed[0]} failed", file=sys.stderr)
+
+if __name__ == "__main__":
+ main()
diff --git a/src/services/AppLauncher.qml b/src/services/AppLauncher.qml
new file mode 100644
index 0000000..1dddc9f
--- /dev/null
+++ b/src/services/AppLauncher.qml
@@ -0,0 +1,337 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Io
+import "../"
+import "../theme"
+import "../components"
+import "."
+
+// AppLauncher — scrollable app list + bottom search bar.
+// Lives inside Dashboard.qml on the "launcher" page.
+// Uses native Quickshell DesktopEntries via AppSearch (no Python process).
+
+Item {
+ id: root
+
+ // ── State ─────────────────────────────────────────────────────────────────
+ property var apps: []
+ property bool loading: true
+ property int selIndex: -1
+ property string query: ""
+
+ readonly property var filtered: {
+ var q = query.toLowerCase().trim()
+ if (q === "") return apps
+ return AppSearch.fuzzyQuery(q)
+ }
+
+ // ── Preload apps at startup (not just when launcher becomes visible) ─────
+ Component.onCompleted: _loadApps()
+
+ // Track when DesktopEntries discovers new apps — refresh the list
+ Connections {
+ target: AppSearch
+ function onListChanged() {
+ if (root.visible) _loadApps()
+ }
+ }
+
+ // ── Reload when becoming visible (catches any missed updates) ────────────
+ onVisibleChanged: {
+ if (!visible) return
+ root.query = ""
+ root.selIndex = -1
+ searchInput.text = ""
+ _loadApps()
+ focusTimer.restart()
+ }
+
+ function _loadApps() {
+ root.loading = (root.apps.length === 0)
+ var list = []
+ try {
+ list = AppSearch.getAllApps()
+ console.log("[AppLauncher] loaded", list.length, "apps")
+ } catch(e) {
+ console.warn("[AppLauncher] AppSearch failed:", e)
+ }
+ root.apps = list || []
+ root.loading = false
+ root.selIndex = root.apps.length > 0 ? 0 : -1
+ }
+
+ Timer {
+ id: focusTimer
+ interval: 150
+ onTriggered: {
+ searchInput.forceActiveFocus()
+ FocusGrabManager.requestGrab(searchInput)
+ }
+ }
+
+ // ── Launch ────────────────────────────────────────────────────────────────
+ Process {
+ id: _fallbackLaunchProc
+ command: []
+ running: false
+ }
+
+ function launch(app) {
+ if (app && app.execute) {
+ app.execute()
+ UsageTracker.recordLaunch(app.id)
+ } else if (app && app.execString) {
+ // Fallback for compatibility — use declarative Process
+ _fallbackLaunchProc.command = ["bash", "-c", "setsid " + app.execString + " &>/dev/null &"]
+ _fallbackLaunchProc.running = false
+ _fallbackLaunchProc.running = true
+ UsageTracker.recordLaunch(app.id || app.name)
+ }
+ Popups.dashboardOpen = false
+ }
+
+ // ── Layout ────────────────────────────────────────────────────────────────
+ Column {
+ anchors.fill: parent
+ spacing: 8
+
+ // App list
+ Item {
+ width: parent.width
+ height: parent.height - searchBar.height - parent.spacing
+
+ // Loading state
+ Column {
+ anchors.centerIn: parent
+ spacing: 12
+ visible: root.loading
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: ""; font.pixelSize: 32
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.3)
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "Loading apps…"
+ color: Qt.rgba(1,1,1,0.25)
+ font.pixelSize: 13
+ }
+ }
+
+ // Empty / no results state
+ Column {
+ anchors.centerIn: parent
+ spacing: 10
+ visible: !root.loading && root.filtered.length === 0
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: root.query !== "" ? "" : ""
+ font.pixelSize: 28
+ color: Qt.rgba(1,1,1,0.18)
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: root.query !== "" ? "No results" : "No apps found"
+ color: Qt.rgba(1,1,1,0.25)
+ font.pixelSize: 13
+ }
+ }
+
+ // App list
+ ListView {
+ id: appList
+ anchors.fill: parent
+ visible: !root.loading && root.filtered.length > 0
+ model: root.filtered
+ clip: true
+ spacing: 3
+ boundsBehavior: Flickable.StopAtBounds
+ cacheBuffer: 2000
+
+ ScrollBar.vertical: ScrollBar {
+ policy: ScrollBar.AsNeeded
+ contentItem: Rectangle {
+ implicitWidth: 3
+ implicitHeight: 40
+ radius: 1.5
+ color: Qt.rgba(1, 1, 1, 0.22)
+ }
+ background: Item {}
+ }
+
+ delegate: Rectangle {
+ required property var modelData
+ required property int index
+
+ width: appList.width - 8
+ height: 46
+ radius: 9
+
+ readonly property bool isSel: root.selIndex === index
+
+ color: isSel
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.14)
+ : rowH.hovered ? Qt.rgba(1,1,1,0.06) : "transparent"
+ border.color: isSel
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.28)
+ : rowH.hovered ? Qt.rgba(1,1,1,0.08) : "transparent"
+ border.width: 1
+
+ Behavior on color { ColorAnimation { duration: Anim.standardSmall } }
+ Behavior on border.color { ColorAnimation { duration: Anim.standardSmall } }
+
+ Row {
+ anchors {
+ left: parent.left; leftMargin: 12
+ right: parent.right; rightMargin: 12
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 12
+
+ // App icon
+ Item {
+ width: 28; height: 28
+ anchors.verticalCenter: parent.verticalCenter
+
+ Image {
+ id: ico
+ anchors.fill: parent
+ source: {
+ var s = modelData.icon
+ if (!s || s === "") return ""
+ if (s.startsWith("/")) return "file://" + s
+ return "image://icon/" + s
+ }
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ sourceSize.width: 28
+ sourceSize.height: 28
+ }
+
+ // Letter fallback
+ Rectangle {
+ anchors.fill: parent
+ radius: 7
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18)
+ visible: ico.status !== Image.Ready || modelData.icon === ""
+ Text {
+ anchors.centerIn: parent
+ text: modelData.name.charAt(0).toUpperCase()
+ font.pixelSize: 13; font.bold: true
+ color: Theme.active
+ }
+ }
+ }
+
+ // App name
+ Text {
+ width: parent.width - 28 - parent.spacing
+ anchors.verticalCenter: parent.verticalCenter
+ text: modelData.name
+ font.pixelSize: 13
+ color: isSel ? Theme.active : Theme.text
+ elide: Text.ElideRight
+ Behavior on color { ColorAnimation { duration: Anim.standardSmall } }
+ }
+ }
+
+ HoverHandler { id: rowH; cursorShape: Qt.PointingHandCursor }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onEntered: root.selIndex = index
+ onClicked: root.launch(modelData)
+ }
+ }
+ }
+ }
+
+ // Search bar
+ StyledRect {
+ id: searchBar
+ width: parent.width; height: 44
+ variant: "surface"
+ border.color: searchInput.activeFocus
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.50)
+ : Qt.rgba(1,1,1,0.12)
+ border.width: 1
+ Behavior on border.color { ColorAnimation { duration: Anim.standardSmall } }
+
+ Row {
+ anchors { fill: parent; leftMargin: 14; rightMargin: 14 }
+ spacing: 10
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: ""; font.pixelSize: 16
+ color: searchInput.activeFocus
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.7)
+ : Qt.rgba(1,1,1,0.35)
+ Behavior on color { ColorAnimation { duration: Anim.standardSmall } }
+ }
+
+ Item {
+ width: parent.width - 26 - parent.spacing
+ height: parent.height
+ anchors.verticalCenter: parent.verticalCenter
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Search apps…"
+ color: Qt.rgba(1,1,1,0.22)
+ font.pixelSize: 13
+ visible: searchInput.text === ""
+ }
+
+ TextInput {
+ id: searchInput
+ anchors { fill: parent; topMargin: 2; bottomMargin: 2 }
+ verticalAlignment: TextInput.AlignVCenter
+ color: Theme.text
+ font.pixelSize: 13
+ selectionColor: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.35)
+ clip: true
+
+ onTextChanged: {
+ root.query = text
+ root.selIndex = root.filtered.length > 0 ? 0 : -1
+ if (root.filtered.length > 0)
+ appList.positionViewAtIndex(0, ListView.Beginning)
+ }
+
+ Keys.onUpPressed: {
+ if (root.selIndex > 0) {
+ root.selIndex--
+ appList.positionViewAtIndex(root.selIndex, ListView.Contain)
+ }
+ }
+
+ Keys.onDownPressed: {
+ if (root.selIndex < root.filtered.length - 1) {
+ root.selIndex++
+ appList.positionViewAtIndex(root.selIndex, ListView.Contain)
+ }
+ }
+
+ Keys.onReturnPressed: {
+ if (root.selIndex >= 0 && root.selIndex < root.filtered.length)
+ root.launch(root.filtered[root.selIndex])
+ }
+
+ Keys.onEscapePressed: {
+ if (text !== "") {
+ text = ""
+ } else {
+ Popups.dashboardOpen = false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/services/AppSearch.qml b/src/services/AppSearch.qml
new file mode 100644
index 0000000..5b6e728
--- /dev/null
+++ b/src/services/AppSearch.qml
@@ -0,0 +1,228 @@
+pragma Singleton
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import "."
+
+/*!
+ AppSearch — native DesktopEntries app provider + fuzzy search.
+
+ Replaces the Python list_apps.py script with Quickshell's built-in
+ DesktopEntries singleton, providing instant app loading and fuzzy search.
+
+ Usage:
+ property var apps: AppSearch.getAllApps()
+ property var results: AppSearch.fuzzyQuery("fire")
+*/
+QtObject {
+ id: root
+
+ // ── Icon cache ────────────────────────────────────────────────────────────
+ property var iconCache: ({})
+
+ function getCachedIcon(str) {
+ if (!str) return "application-x-executable";
+ if (iconCache[str]) return iconCache[str];
+ const result = guessIcon(str);
+ iconCache[str] = result;
+ return result;
+ }
+
+ function guessIcon(str) {
+ if (!str || str.length === 0) return "application-x-executable";
+ // Try direct icon name
+ if (Quickshell.iconPath(str, true).length > 0) return str;
+ // Try with common suffixes
+ const dashed = str.toLowerCase().replace(/\s+/g, "-");
+ if (Quickshell.iconPath(dashed, true).length > 0) return dashed;
+ // Fallback: use a generic executable icon instead of a missing one
+ return "application-x-executable";
+ }
+
+ // ── DesktopEntries integration ────────────────────────────────────────────
+ readonly property list list: Array.from(DesktopEntries.applications.values)
+ .sort((a, b) => a.name.localeCompare(b.name))
+
+ property var allAppsCache: null
+ property var searchIndex: []
+
+ // Debounce index building — DesktopEntries populates incrementally,
+ // so onListChanged fires once per app. A 200ms debounce batches them.
+ property var _indexTimer: Timer {
+ interval: 200
+ repeat: false
+ onTriggered: {
+ console.log("[AppSearch] building index, count:", list.length)
+ buildIndex()
+ }
+ }
+
+ onListChanged: {
+ allAppsCache = null;
+ _indexTimer.restart();
+ }
+
+ Component.onCompleted: {
+ console.log("[AppSearch] completed, DesktopEntries apps:", list.length)
+ _indexTimer.restart()
+ }
+
+ function buildIndex() {
+ const newIndex = [];
+ for (let i = 0; i < list.length; i++) {
+ const app = list[i];
+ newIndex.push({
+ name: app.name.toLowerCase(),
+ command: (app.command && app.command.length > 0) ? app.command.join(' ').toLowerCase() : "",
+ executable: (app.command && app.command.length > 0) ? app.command[0].toLowerCase() : "",
+ comment: (app.comment || "").toLowerCase(),
+ genericName: (app.genericName || "").toLowerCase(),
+ keywords: (app.keywords || []).map(k => k.toLowerCase()),
+ original: app
+ });
+ }
+ searchIndex = newIndex;
+ }
+
+ function invalidateCache() {
+ allAppsCache = null;
+ }
+
+ // ── Get all apps (sorted by usage) ────────────────────────────────────────
+ function getAllApps() {
+ if (allAppsCache) return allAppsCache;
+
+ const results = [];
+ for (let i = 0; i < list.length; i++) {
+ const app = list[i];
+ const usageScore = UsageTracker ? UsageTracker.getUsageScore(app.id) : 0;
+ let iconToUse = app.icon || "application-x-executable";
+ iconToUse = getCachedIcon(iconToUse);
+
+ results.push({
+ name: app.name,
+ icon: iconToUse,
+ id: app.id,
+ execString: app.execString,
+ comment: app.comment || "",
+ categories: app.categories || [],
+ runInTerminal: app.runInTerminal || false,
+ usageScore: usageScore,
+ execute: () => { launchApp(app); }
+ });
+ }
+ results.sort((a, b) => {
+ if (a.usageScore !== b.usageScore) return b.usageScore - a.usageScore;
+ return a.name.localeCompare(b.name);
+ });
+ allAppsCache = results;
+ return results;
+ }
+
+ // ── Fuzzy search ──────────────────────────────────────────────────────────
+ function fuzzyQuery(search) {
+ if (!search || search.length === 0) return [];
+ const searchLower = search.toLowerCase();
+ const results = [];
+
+ if (searchIndex.length === 0 && list.length > 0) buildIndex();
+
+ for (let i = 0; i < searchIndex.length; i++) {
+ const entry = searchIndex[i];
+ let score = 0;
+ let matchFound = false;
+
+ if (entry.name === searchLower) {
+ score += 100; matchFound = true;
+ } else if (entry.name.startsWith(searchLower)) {
+ score += 80; matchFound = true;
+ } else if (entry.name.includes(searchLower)) {
+ score += 60; matchFound = true;
+ }
+
+ if (entry.command && entry.command.includes(searchLower)) {
+ score += 40; matchFound = true;
+ }
+ if (entry.executable.includes(searchLower)) {
+ score += 50; matchFound = true;
+ }
+ if (entry.comment && entry.comment.includes(searchLower)) {
+ score += 30; matchFound = true;
+ }
+ if (entry.genericName && entry.genericName.includes(searchLower)) {
+ score += 25; matchFound = true;
+ }
+ if (entry.keywords.length > 0) {
+ for (let j = 0; j < entry.keywords.length; j++) {
+ if (entry.keywords[j].includes(searchLower)) {
+ score += 20; matchFound = true; break;
+ }
+ }
+ }
+
+ if (matchFound) {
+ const app = entry.original;
+ const usageScore = UsageTracker ? UsageTracker.getUsageScore(app.id) : 0;
+ let iconToUse = app.icon || "application-x-executable";
+ iconToUse = getCachedIcon(iconToUse);
+ results.push({
+ name: app.name,
+ icon: iconToUse,
+ score: score,
+ id: app.id,
+ execString: app.execString,
+ comment: app.comment || "",
+ categories: app.categories || [],
+ runInTerminal: app.runInTerminal || false,
+ usageScore: usageScore,
+ execute: () => { launchApp(app); }
+ });
+ }
+ }
+
+ results.sort((a, b) => {
+ const totalA = a.score + a.usageScore;
+ const totalB = b.score + b.usageScore;
+ if (totalA !== totalB) return totalB - totalA;
+ return (a.name || "").localeCompare(b.name || "");
+ });
+
+ return results.slice(0, 10);
+ }
+
+ // ── Launch app ────────────────────────────────────────────────────────────
+ function launchApp(app) {
+ const path = app.fileName || app.path || app.filePath;
+ if (path && path.toString().endsWith('.desktop')) {
+ const escapedPath = path.toString().replace(/'/g, "'\\''");
+ runDetached("gio launch '" + escapedPath + "'");
+ return;
+ }
+ if (app.command && app.command.length > 0) {
+ const safeArgs = [];
+ for (let i = 0; i < app.command.length; i++) {
+ const arg = app.command[i];
+ if (/^%[fFuUijkc]$/.test(arg)) continue;
+ safeArgs.push("'" + arg.replace(/'/g, "'\\''") + "'");
+ }
+ if (safeArgs.length > 0) {
+ const cmd = safeArgs.join(" ");
+ const wrapped = app.runInTerminal ? "xdg-terminal-exec " + cmd : cmd;
+ runDetached(wrapped);
+ return;
+ }
+ }
+ app.execute();
+ }
+
+ property Process _detachedProc: Process {
+ command: []
+ running: false
+ }
+
+ function runDetached(command) {
+ _detachedProc.command = ["bash", "-c", "cd ~ && setsid " + command + " > /dev/null 2>&1 &"]
+ _detachedProc.running = false
+ _detachedProc.running = true
+ }
+}
diff --git a/src/services/AudioControl.qml b/src/services/AudioControl.qml
new file mode 100644
index 0000000..a65bf19
--- /dev/null
+++ b/src/services/AudioControl.qml
@@ -0,0 +1,393 @@
+import QtQuick
+import Quickshell.Services.Pipewire
+import "../components"
+import "../"
+
+Item {
+ id: root
+
+ readonly property var sink: Pipewire.defaultAudioSink
+ readonly property var source: Pipewire.defaultAudioSource
+
+ function reset() { switcher.reset() }
+
+ PwObjectTracker {
+ objects: Pipewire.nodes.values
+ }
+
+ readonly property var sinkNodes: {
+ var result = []
+ var nodes = Pipewire.nodes.values
+ for (var i = 0; i < nodes.length; i++) {
+ var n = nodes[i]
+ if (n.audio !== null && !n.isStream && n.isSink)
+ result.push(n)
+ }
+ return result
+ }
+
+ readonly property var sourceNodes: {
+ var result = []
+ var nodes = Pipewire.nodes.values
+ for (var i = 0; i < nodes.length; i++) {
+ var n = nodes[i]
+ if (n.audio !== null && !n.isStream && !n.isSink)
+ result.push(n)
+ }
+ return result
+ }
+
+ function deviceName(node) {
+ if (!node) return "Unknown"
+ return node.nickname || node.description || node.name || "Unknown"
+ }
+
+ property string page: Popups.audioPage
+
+ Connections {
+ target: Popups
+ function onAudioPageChanged() {
+ root.page = Popups.audioPage
+ }
+ }
+
+ Row {
+ anchors.fill: parent
+ spacing: 8
+
+ // ── Page content ──────────────────────────────────────────────────────
+ Item {
+ width: parent.width - switcher.implicitWidth - parent.spacing - 1 - parent.spacing
+ height: parent.height
+ clip: true
+
+ // Output
+ PopupPage {
+ anchors.fill: parent
+ visible: root.page === "output"
+
+ ChannelColumn {
+ width: parent.width
+ label: (root.sink && root.sink.ready) ? root.deviceName(root.sink) : "Output"
+ icon: {
+ if (!root.sink || !root.sink.ready) return ""
+ if (root.sink.audio.muted) return ""
+ if (root.sink.audio.volume > 0.6) return ""
+ if (root.sink.audio.volume > 0.2) return ""
+ return ""
+ }
+ value: (root.sink && root.sink.ready) ? root.sink.audio.volume : 0
+ muted: (root.sink && root.sink.audio) ? root.sink.audio.muted : false
+ active: (root.sink && root.sink.ready) || false
+ onVolumeChanged: function(v) {
+ if (root.sink && root.sink.ready) root.sink.audio.volume = v
+ }
+ onMuteToggled: {
+ if (root.sink && root.sink.ready)
+ root.sink.audio.muted = !root.sink.audio.muted
+ }
+ }
+ }
+
+ // Input
+ PopupPage {
+ anchors.fill: parent
+ visible: root.page === "input"
+
+ ChannelColumn {
+ width: parent.width
+ label: (root.source && root.source.ready) ? root.deviceName(root.source) : "Input"
+ icon: (root.source && root.source.audio && root.source.audio.muted) ? "" : ""
+ value: (root.source && root.source.ready) ? root.source.audio.volume : 0
+ muted: (root.source && root.source.audio) ? root.source.audio.muted : false
+ active: (root.source && root.source.ready) || false
+ onVolumeChanged: function(v) {
+ if (root.source && root.source.ready) root.source.audio.volume = v
+ }
+ onMuteToggled: {
+ if (root.source && root.source.ready)
+ root.source.audio.muted = !root.source.audio.muted
+ }
+ }
+ }
+
+ // Mixer
+ PopupPage {
+ anchors.fill: parent
+ visible: root.page === "mixer"
+
+ SectionLabel { text: "Output Devices" }
+
+ Repeater {
+ model: root.sinkNodes
+ delegate: DeviceRow {
+ width: parent.width
+ label: root.deviceName(modelData)
+ isDefault: (root.sink && root.sink.ready && modelData.name === root.sink.name) || false
+ onClicked: Pipewire.preferredDefaultAudioSink = modelData
+ }
+ }
+
+ Text {
+ visible: root.sinkNodes.length === 0
+ text: "No output devices"
+ color: Qt.rgba(1,1,1,0.2)
+ font.pixelSize: 11
+ leftPadding: 10
+ }
+
+ Rectangle {
+ width: parent.width; height: 1
+ color: Qt.rgba(1, 1, 1, 0.06)
+ }
+
+ SectionLabel { text: "Input Devices" }
+
+ Repeater {
+ model: root.sourceNodes
+ delegate: DeviceRow {
+ width: parent.width
+ label: root.deviceName(modelData)
+ isDefault: (root.source && root.source.ready && modelData.name === root.source.name) || false
+ onClicked: Pipewire.preferredDefaultAudioSource = modelData
+ }
+ }
+
+ Text {
+ visible: root.sourceNodes.length === 0
+ text: "No input devices"
+ color: Qt.rgba(1,1,1,0.2)
+ font.pixelSize: 11
+ leftPadding: 10
+ }
+ }
+ }
+
+ // Divider
+ Rectangle {
+ width: 1; height: parent.height
+ color: Qt.rgba(1, 1, 1, 0.1)
+ }
+
+ // Tab switcher — right side
+ TabSwitcher {
+ id: switcher
+ orientation: "vertical"
+ height: (parent.height - 17)
+ anchors.verticalCenter: parent.verticalCenter
+ model: [
+ { key: "output", icon: "" },
+ { key: "input", icon: "" },
+ { key: "mixer", icon: "" },
+ ]
+ currentPage: root.page
+ onPageChanged: function(key) { Popups.audioPage = key }
+ }
+ }
+
+ // ── ChannelColumn ─────────────────────────────────────────────────────────
+ component ChannelColumn: Item {
+ id: col
+
+ property string label: ""
+ property string icon: ""
+ property real value: 0.0
+ property bool muted: false
+ property bool active: false
+
+ readonly property int trackHeight: 160
+ readonly property int barW: 22
+ readonly property int thumbD: barW - 6
+
+ signal volumeChanged(real value)
+ signal muteToggled()
+
+ // Expose size so PopupPage Flickable can measure content
+ implicitWidth: inner.implicitWidth
+ implicitHeight: inner.implicitHeight
+
+ readonly property string pctText:
+ active ? Math.round(value * 100) + "%" : "--%"
+
+ Column {
+ id: inner
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 8
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: col.pctText
+ color: col.muted ? Qt.rgba(1,1,1,0.25) : Theme.text
+ font.pixelSize: 13
+ font.bold: true
+ Behavior on color { ColorAnimation { duration: 150 } }
+ }
+
+ Item {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: col.barW
+ height: col.trackHeight
+
+ Rectangle {
+ id: track
+ anchors.fill: parent
+ radius: width / 2
+ color: Qt.rgba(1,1,1,0.08)
+
+ // Fill bar
+ Rectangle {
+ anchors { bottom: parent.bottom; left: parent.left; right: parent.right }
+ height: Math.max(parent.radius * 2, parent.height * col.value)
+ radius: parent.radius
+ color: col.muted ? Qt.rgba(1,1,1,0.15) : Theme.active
+ Behavior on color { ColorAnimation { duration: 150 } }
+ Behavior on height { NumberAnimation { duration: 80; easing.type: Easing.OutCubic } }
+ }
+
+ // Thumb
+ Rectangle {
+ id: thumb
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: col.thumbD
+ height: width
+ radius: width / 2
+ color: col.muted ? Qt.rgba(1,1,1,0.3) : "#ffffff"
+ y: {
+ var travel = track.height - height
+ return Math.max(0, Math.min(travel, (1.0 - col.value) * travel))
+ }
+ Behavior on color { ColorAnimation { duration: 150 } }
+ }
+
+ // Drag to change volume
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.SizeVerCursor
+ function calc(my) {
+ var travel = track.height - thumb.height
+ return Math.max(0.0, Math.min(1.0,
+ 1.0 - (my - thumb.height / 2) / travel))
+ }
+ onPressed: col.volumeChanged(calc(mouseY))
+ onPositionChanged: if (pressed) col.volumeChanged(calc(mouseY))
+ }
+
+ // Scroll wheel to change volume
+ WheelHandler {
+ acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
+ onWheel: function(event) {
+ var step = 0.05
+ var delta = event.angleDelta.y > 0 ? step : -step
+ col.volumeChanged(Math.max(0.0, Math.min(1.0, col.value + delta)))
+ }
+ }
+ }
+ }
+
+ // Mute button
+ Rectangle {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: col.barW + 32
+ height: 28
+ radius: Theme.cornerRadius
+ color: col.muted
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.2)
+ : Qt.rgba(1,1,1,0.06)
+ Behavior on color { ColorAnimation { duration: 150 } }
+
+ Row {
+ anchors.centerIn: parent
+ spacing: 5
+ Text {
+ text: col.icon
+ font.pixelSize: 13
+ color: col.muted ? Theme.active : Qt.rgba(1,1,1,0.55)
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 150 } }
+ }
+ Text {
+ text: col.muted ? "Muted" : "Mute"
+ font.pixelSize: 11
+ color: col.muted ? Theme.active : Qt.rgba(1,1,1,0.4)
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 150 } }
+ }
+ }
+ Rectangle {
+ anchors.fill: parent; radius: parent.radius
+ color: muteHov.hovered ? Qt.rgba(1,1,1,0.05) : "transparent"
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ HoverHandler { id: muteHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: col.muteToggled() }
+ }
+
+ // Label
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: col.label
+ color: Qt.rgba(1,1,1,0.3)
+ font.pixelSize: 10
+ font.capitalization: Font.AllUppercase
+ font.letterSpacing: 1
+ elide: Text.ElideRight
+ width: col.barW + 60
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+ }
+
+ // ── SectionLabel ──────────────────────────────────────────────────────────
+ component SectionLabel: Text {
+ color: Qt.rgba(1, 1, 1, 0.35)
+ font.pixelSize: 10
+ font.capitalization: Font.AllUppercase
+ font.letterSpacing: 0.8
+ leftPadding: 4
+ topPadding: 2
+ }
+
+ // ── DeviceRow ─────────────────────────────────────────────────────────────
+ component DeviceRow: Item {
+ id: row
+ implicitHeight: 28
+
+ property string label: ""
+ property bool isDefault: false
+ signal clicked()
+
+ Rectangle {
+ anchors.fill: parent
+ radius: Theme.cornerRadius - 4
+ color: row.isDefault
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.12)
+ : (rowHov.hovered ? Qt.rgba(1,1,1,0.05) : "transparent")
+ Behavior on color { ColorAnimation { duration: 120 } }
+ }
+
+ Row {
+ anchors { left: parent.left; leftMargin: 8; right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ spacing: 6
+
+ Rectangle {
+ width: 6; height: 6; radius: 3
+ anchors.verticalCenter: parent.verticalCenter
+ color: row.isDefault ? Theme.active : Qt.rgba(1,1,1,0.2)
+ Behavior on color { ColorAnimation { duration: 150 } }
+ }
+
+ Text {
+ text: row.label
+ color: row.isDefault ? Theme.text : Qt.rgba(1,1,1,0.5)
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ width: parent.width - 14 - parent.spacing
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 150 } }
+ }
+ }
+
+ HoverHandler { id: rowHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: row.clicked() }
+ }
+}
diff --git a/src/services/BatteryStatus.qml b/src/services/BatteryStatus.qml
new file mode 100644
index 0000000..6121d81
--- /dev/null
+++ b/src/services/BatteryStatus.qml
@@ -0,0 +1,159 @@
+import QtQuick
+import Quickshell.Services.UPower
+import "../"
+
+// Config:
+//showPercentage: bool — always show % beside icon (default: false = hover only)
+
+Item {
+ id: root
+
+ property bool showPercentage: false
+
+ // ── UPower data ──────────────────────────────────────────────────────────
+ readonly property var bat: UPower.displayDevice
+ // Auto-hide on desktops (no battery hardware). Only show when
+ // UPower confirms a laptop battery exists.
+ visible: bat.ready && bat.isLaptopBattery
+ readonly property real pct: bat.ready ? Math.round(bat.percentage * 100) : 0
+ readonly property bool charging: bat.ready
+ ? (bat.state === UPowerDeviceState.Charging ||
+ bat.state === UPowerDeviceState.PendingCharge ||
+ bat.state === UPowerDeviceState.FullyCharged)
+ : false
+ readonly property bool full: bat.ready
+ ? bat.state === UPowerDeviceState.FullyCharged
+ : false
+
+ implicitWidth: statusRow.implicitWidth + 6
+ implicitHeight: statusRow.implicitHeight
+
+ // ── Warning tracker ──────────────────────────────────────────────────────
+ // warnedLevels stores which thresholds have fired this discharge cycle.
+ // Resets when charging begins.
+ property var warnedLevels: []
+
+ function checkWarning() {
+ if (charging) {
+ warnedLevels = []
+ return
+ }
+ var thresholds = [5, 10, 20,30]
+ for (var i = 0; i < thresholds.length; i++) {
+ var lvl = thresholds[i]
+ if (pct <= lvl && warnedLevels.indexOf(lvl) < 0) {
+ warnedLevels = warnedLevels.concat([lvl])
+ warningWindow.warnLevel = lvl
+ warningWindow.visible = true
+ break
+ }
+ }
+ }
+
+ onPctChanged: checkWarning()
+ onChargingChanged: {
+ if (charging) warnedLevels = []
+ checkWarning()
+ }
+
+ // ── Nerd Font icons ──────────────────────────────────────────────────────
+ function staticIcon(p) {
+ if (p > 90) return ""
+ if (p > 80) return ""
+ if (p > 70) return ""
+ if (p > 60) return ""
+ if (p > 50) return ""
+ if (p > 40) return ""
+ if (p > 30) return ""
+ if (p > 20) return ""
+ if (p > 10) return ""
+ return ""
+ }
+
+ // Charging animation frames (low → full)
+ readonly property var chargeFrames: ["","","","","","","",""]
+ property int chargeFrame: 0
+
+ Timer {
+ interval: 650
+ running: root.charging && !root.full
+ repeat: true
+ onTriggered: root.chargeFrame = (root.chargeFrame + 1) % root.chargeFrames.length
+ }
+
+ readonly property string icon: {
+ if (full) return ""
+ if (charging) return chargeFrames[chargeFrame % chargeFrames.length]
+ return staticIcon(pct)
+ }
+
+ // ── Color ─────────────────────────────────────────────────────────────────
+ readonly property color iconColor: {
+ if (full) return Theme.active
+ if (charging) return Theme.active
+ if (pct <= 5) return "#ff4444"
+ if (pct <= 10) return "#ff6b00"
+ if (pct <= 20) return "#ffcc00"
+ if (pct <= 30) return "#ff9900"
+ return Theme.text
+ }
+
+ // ── Display ───────────────────────────────────────────────────────────────
+ Row {
+ id: statusRow
+ spacing: 4
+ anchors.centerIn: parent
+
+ Text {
+ id: iconText
+ text: root.icon
+ color: root.iconColor
+ font.pixelSize: 16
+ anchors.verticalCenter: parent.verticalCenter
+
+ // Pulse when critically low and discharging
+ SequentialAnimation on opacity {
+ id: pulseAnim
+ running: root.pct <= 10 && !root.charging
+ loops: Animation.Infinite
+ NumberAnimation { to: 0.2; duration: 600; easing.type: Easing.InOutSine }
+ NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutSine }
+ }
+
+ // Snap back when animation stops
+ Connections {
+ target: pulseAnim
+ function onRunningChanged() {
+ if (!pulseAnim.running) iconText.opacity = 1.0
+ }
+ }
+ }
+
+ Item {
+ id: pctWrapper
+ property bool show: root.showPercentage || hov.hovered
+ implicitWidth: show ? pctText.implicitWidth + 2 : 0
+ implicitHeight: pctText.implicitHeight
+ clip: true
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on implicitWidth { NumberAnimation { duration: Anim.standardNormal; easing: Anim.standard } }
+
+ Text {
+ id: pctText
+ text: root.pct + "%"
+ color: hov.hovered ? Theme.active : Theme.text
+ font.pixelSize: 12
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 120 } }
+ }
+ }
+ }
+
+ HoverHandler { id: hov }
+
+ // ── Warning window ────────────────────────────────────────────────────────
+ BatteryWarning {
+ id: warningWindow
+ visible: false
+ }
+}
diff --git a/src/services/BatteryWarning.qml b/src/services/BatteryWarning.qml
new file mode 100644
index 0000000..a2e0847
--- /dev/null
+++ b/src/services/BatteryWarning.qml
@@ -0,0 +1,116 @@
+import QtQuick
+import Quickshell
+import "../"
+
+// Low battery warning — FloatingWindow, centered by the WM (Hyprland floats center).
+// Auto-dismisses after `timeout` ms. Click anywhere to dismiss early.
+
+FloatingWindow {
+ id: root
+
+ property int warnLevel: 30
+ property int timeout: 8000
+
+ minimumSize: Qt.size(320, 100)
+ maximumSize: Qt.size(320, 100)
+
+ color: "transparent"
+
+ // Auto-dismiss timer
+ Timer {
+ id: autoClose
+ interval: root.timeout
+ running: root.visible
+ onTriggered: root.visible = false
+ }
+
+ onVisibleChanged: if (visible) autoClose.restart()
+
+ // ── Severity helpers ─────────────────────────────────────────────────────
+ readonly property color accentColor: warnLevel <= 5 ? "#ff4444" :
+ warnLevel <= 10 ? "#ff6b00" : "#ffcc00"
+ readonly property string title: warnLevel <= 5 ? "Critical Battery" :
+ warnLevel <= 10 ? "Very Low Battery" : "Low Battery"
+ readonly property string message: warnLevel <= 5
+ ? "Battery at " + warnLevel + "% — plug in now!"
+ : "Battery at " + warnLevel + "% — consider charging." // for testing visuals without changing actual battery level
+
+ // ── Visuals ───────────────────────────────────────────────────────────────
+ Rectangle {
+ anchors.fill: parent
+ radius: Theme.cornerRadius + 4
+ color: Theme.background
+
+ // Left accent bar
+ Rectangle {
+ width: 4
+ height: parent.height - 20
+ radius: 2
+ anchors.left: parent.left
+ anchors.leftMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+ color: root.accentColor
+ }
+
+ Column {
+ anchors {
+ left: parent.left
+ leftMargin: 22
+ right: parent.right
+ rightMargin: 12
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 5
+
+ Text {
+ text: "⚠ " + root.title
+ color: root.accentColor
+ font.pixelSize: 13
+ font.bold: true
+ }
+
+ Text {
+ text: root.message
+ color: Theme.text
+ font.pixelSize: 12
+ width: parent.width
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ // Countdown progress bar
+ Rectangle {
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 3
+ radius: 2
+ color: Qt.rgba(1, 1, 1, 0.08)
+
+ Rectangle {
+ id: countdown
+ height: parent.height
+ radius: parent.radius
+ color: root.accentColor
+ width: parent.width
+
+ NumberAnimation on width {
+ running: root.visible
+ from: countdown.parent.width
+ to: 0
+ duration: root.timeout
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.visible = false
+ }
+ Item {
+ anchors.fill: parent
+ Keys.onEscapePressed: root.visible = false
+ }
+ }
+}
diff --git a/src/services/CavaService.qml b/src/services/CavaService.qml
new file mode 100644
index 0000000..a39c475
--- /dev/null
+++ b/src/services/CavaService.qml
@@ -0,0 +1,58 @@
+pragma Singleton
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Services.Mpris
+import "../"
+
+// Single cava process shared by CenterContent and PlayerCard.
+// 32 bars at 30fps.
+
+QtObject {
+ id: root
+
+ readonly property int barCount: 32
+
+ property var bars: (function() {
+ var a = []; for (var i = 0; i < 32; i++) a.push(0); return a
+ })()
+
+ // isPlaying is true if ANY MPRIS player is currently playing.
+ // This ensures bars flow regardless of which player index is active in PlayerCard.
+ readonly property bool isPlaying: {
+ var vals = Mpris.players.values
+ for (var i = 0; i < vals.length; i++) {
+ if (vals[i].playbackState === MprisPlaybackState.Playing) return true
+ }
+ return false
+ }
+
+ property var _proc: Process {
+ command: [
+ "bash", "-c",
+ "mkdir -p /tmp/brain_shell && " +
+ "printf '[general]\\nbars = 32\\nframerate = 30\\nnoise_reduction = 77\\n\\n" +
+ "[output]\\nmethod = raw\\nraw_target = /dev/stdout\\n" +
+ "data_format = ascii\\nascii_max_range = 100\\n" +
+ "bar_delimiter = 59\\nframe_delimiter = 10\\n' " +
+ "> /tmp/brain_shell/cava_shared.ini && " +
+ "exec cava -p /tmp/brain_shell/cava_shared.ini 2>/dev/null"
+ ]
+ // Run when any media is playing — cava bars appear in both
+ // the dashboard PlayerCard and the center notch.
+ running: root.isPlaying
+ stdout: SplitParser {
+ onRead: function(line) {
+ var t = line.trim()
+ if (t === "") return
+ if (t.endsWith(";")) t = t.slice(0, -1)
+ var parts = t.split(";")
+ if (parts.length !== root.barCount) return
+ var arr = []
+ for (var i = 0; i < parts.length; i++)
+ arr.push(parseInt(parts[i]) || 0)
+ root.bars = arr
+ }
+ }
+ }
+}
diff --git a/src/services/ClipboardService.qml b/src/services/ClipboardService.qml
new file mode 100644
index 0000000..6850cb7
--- /dev/null
+++ b/src/services/ClipboardService.qml
@@ -0,0 +1,242 @@
+pragma Singleton
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import "../"
+
+QtObject {
+ id: root
+
+ property var entries: []
+ property var pinned: []
+ property bool loading: false
+
+ readonly property string _pinsPath:
+ Quickshell.env("HOME") + "/.config/Brain_Shell/src/user_data/clipboard_pins.json"
+
+ // ── Pins: load ─────────────────────────────────────────────────────────────
+ property var _loadPinsProc: Process {
+ command: ["bash", "-c",
+ "[ -f '" + root._pinsPath + "' ] && cat '" + root._pinsPath + "' || " +
+ "(mkdir -p \"$(dirname '" + root._pinsPath + "')\" && echo '[]')"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try { root.pinned = JSON.parse(text.trim()) }
+ catch (e) { root.pinned = [] }
+ }
+ }
+ }
+
+ // ── Pins: save ─────────────────────────────────────────────────────────────
+ property var _savePinsProc: Process { command: []; running: false }
+
+ function _savePins() {
+ var json = JSON.stringify(root.pinned)
+ _savePinsProc.command = ["bash", "-c",
+ "mkdir -p \"$(dirname '" + root._pinsPath + "')\" && " +
+ "printf '%s' '" + json.replace(/'/g, "'\\''") + "' > '" + root._pinsPath + "'"]
+ _savePinsProc.running = false
+ _savePinsProc.running = true
+ }
+
+ // ── History: list ──────────────────────────────────────────────────────────
+ property var _partialEntries: []
+
+ property var _listProc: Process {
+ command: ["bash", "-c", "cliphist list 2>/dev/null"]
+ running: false
+
+ stdout: SplitParser {
+ onRead: function(line) {
+ if (line.trim() === "") return
+ var tabIdx = line.indexOf("\t")
+ if (tabIdx < 0) return
+ var id = line.substring(0, tabIdx).trim()
+ var preview = line.substring(tabIdx + 1).trim()
+ if (id === "" || preview === "") return
+ var isImage = preview.indexOf("binary data") >= 0 ||
+ preview.startsWith("[[") ||
+ preview.indexOf("image/") >= 0
+ root._partialEntries.push({ id: id, preview: preview, isImage: isImage })
+ }
+ }
+
+ onRunningChanged: {
+ if (running) {
+ root._partialEntries = []
+ root.loading = true
+ } else {
+ root.entries = root._partialEntries.slice()
+ root._partialEntries = []
+ root.loading = false
+ }
+ }
+ }
+
+ function load() {
+ if (_listProc.running) _listProc.running = false
+ _listProc.running = true
+ }
+
+ // ── Copy ───────────────────────────────────────────────────────────────────
+ property var _copyProc: Process { command: []; running: false }
+
+ function copyEntry(id) {
+ _copyProc.command = ["bash", "-c",
+ "cliphist decode '" + id + "' 2>/dev/null | wl-copy"]
+ _copyProc.running = false
+ _copyProc.running = true
+ }
+
+ function copyText(t) {
+ _copyProc.command = ["bash", "-c",
+ "printf '%s' '" + t.replace(/\\/g, "\\\\").replace(/'/g, "'\\''") + "' | wl-copy"]
+ _copyProc.running = false
+ _copyProc.running = true
+ }
+
+ // ── Type: copy first, then wtype after popup closes ────────────────────────
+ property var _wtypeProc: Process { command: []; running: false }
+
+ function typeFromClipboard() {
+ _wtypeProc.command = ["bash", "-c", "sleep 0.35 && wl-paste -n | wtype -"]
+ _wtypeProc.running = false
+ _wtypeProc.running = true
+ }
+
+ // ── Delete ─────────────────────────────────────────────────────────────────
+ property var _deleteProc: Process {
+ command: []
+ running: false
+ onRunningChanged: if (!running) root.load()
+ }
+
+ function deleteEntry(id) {
+ if (!id || id === "") return
+ _deleteProc.command = ["bash", "-c",
+ "cliphist list 2>/dev/null | awk -F'\\t' -v id='" + id +
+ "' '$1==id' | cliphist delete 2>/dev/null"]
+ _deleteProc.running = false
+ _deleteProc.running = true
+ }
+
+ // ── Pin ────────────────────────────────────────────────────────────────────
+ // Pin data format: { text, preview, id, timestamp }
+ // - text: full decoded content (used for copyText)
+ // - preview: cliphist list preview (used for dedup in the flat model)
+ // - id: cliphist row ID at pin time (used for deleteEntry; may be stale after wipe)
+ // - timestamp: epoch ms
+
+ property var _pinQueue: []
+ property bool _isDecodingPin: false
+
+ property var _decodeProc: Process {
+ command: []
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var decoded = text.trim()
+ if (decoded !== "" && root._pinQueue.length > 0) {
+ var currentItem = root._pinQueue[0]
+ var list = root.pinned.filter(function(p) { return p.text !== decoded })
+ list.unshift({
+ text: decoded,
+ preview: currentItem.preview,
+ id: currentItem.id,
+ timestamp: new Date().getTime()
+ })
+ root.pinned = list
+ root._savePins()
+ }
+
+ if (root._pinQueue.length > 0) {
+ root._pinQueue.shift()
+ }
+
+ root._isDecodingPin = false
+ root._processNextPin()
+ }
+ }
+ }
+
+ function pinEntry(id, preview) {
+ root._pinQueue.push({ id: id, preview: preview })
+ root._processNextPin()
+ }
+
+ function _processNextPin() {
+ if (root._isDecodingPin || root._pinQueue.length === 0) return
+
+ root._isDecodingPin = true
+ var nextItem = root._pinQueue[0]
+
+ _decodeProc.command = ["bash", "-c", "cliphist decode '" + nextItem.id + "' 2>/dev/null"]
+ _decodeProc.running = false
+ _decodeProc.running = true
+ }
+
+ function unpinAt(index) {
+ var list = root.pinned.slice()
+ list.splice(index, 1)
+ root.pinned = list
+ root._savePins()
+ }
+
+ // ── Wipe unpinned history ──────────────────────────────────────────────────
+ property var _wipeProc: Process {
+ command: ["bash", "-c", "cliphist wipe 2>/dev/null"]
+ running: false
+ onRunningChanged: if (!running) root.load()
+ }
+
+ function wipeHistory() {
+ _wipeProc.running = false
+ _wipeProc.running = true
+ }
+
+ // ── Wipe on Quickshell close ───────────────────────────────────────────────
+ property var _shutdownWipeProc: Process {
+ command: ["bash", "-c", "cliphist wipe 2>/dev/null"]
+ running: false
+ }
+
+ Component.onDestruction: {
+ _shutdownWipeProc.running = true
+ }
+
+ // ── Install a systemd user service that wipes on OS shutdown/reboot ────────
+ property var _setupServiceProc: Process {
+ command: ["bash", "-c",
+ "mkdir -p ~/.config/systemd/user && " +
+ "printf '[Unit]\\nDescription=Wipe cliphist history on logout/shutdown\\n\\n" +
+ "[Service]\\nType=oneshot\\nRemainAfterExit=true\\nExecStart=/usr/bin/true\\nExecStop=/usr/bin/cliphist wipe\\n\\n" +
+ "[Install]\\nWantedBy=default.target\\n' " +
+ "> ~/.config/systemd/user/cliphist-wipe.service && " +
+ "systemctl --user daemon-reload && " +
+ "systemctl --user enable --now cliphist-wipe.service 2>/dev/null || true"]
+ running: false
+ }
+
+ // ── Init ───────────────────────────────────────────────────────────────────
+ Component.onCompleted: {
+ _loadPinsProc.running = true
+ _setupServiceProc.running = true
+ load()
+ }
+
+ // Reload when popup opens; poll every 5 s while open
+ property var _popupConnection: Connections {
+ target: Popups
+ function onClipboardOpenChanged() {
+ if (Popups.clipboardOpen) root.load()
+ }
+ }
+
+ property var _pollTimer: Timer {
+ interval: 5000
+ running: Popups.clipboardOpen
+ repeat: true
+ onTriggered: root.load()
+ }
+}
diff --git a/src/services/FocusGrabManager.qml b/src/services/FocusGrabManager.qml
new file mode 100644
index 0000000..4d8561a
--- /dev/null
+++ b/src/services/FocusGrabManager.qml
@@ -0,0 +1,52 @@
+pragma Singleton
+import QtQuick
+
+/*!
+ FocusGrabManager — centralized input focus coordination for popups.
+
+ Fixes the "Input Focus Delays" known issue in Brain_Shell by ensuring
+ only one popup grabs focus at a time and releases it cleanly on close.
+
+ Usage:
+ // When opening a popup
+ FocusGrabManager.requestGrab(myPopupContent)
+
+ // When closing
+ FocusGrabManager.releaseGrab(myPopupContent)
+*/
+QtObject {
+ id: root
+
+ property var _currentGrab: null
+
+ function requestGrab(item) {
+ if (!item) return;
+ if (_currentGrab && _currentGrab !== item && _currentGrab.visible) {
+ // Release previous grab gracefully
+ _currentGrab.focus = false;
+ }
+ _currentGrab = item;
+ // Defer to avoid race with popup open animation
+ Qt.callLater(function() {
+ if (_currentGrab === item && item.visible) {
+ item.forceActiveFocus();
+ }
+ });
+ }
+
+ function releaseGrab(item) {
+ if (_currentGrab === item) {
+ _currentGrab = null;
+ }
+ if (item) {
+ item.focus = false;
+ }
+ }
+
+ function releaseAll() {
+ if (_currentGrab) {
+ _currentGrab.focus = false;
+ _currentGrab = null;
+ }
+ }
+}
diff --git a/src/services/GpuDetector.qml b/src/services/GpuDetector.qml
new file mode 100644
index 0000000..bc54518
--- /dev/null
+++ b/src/services/GpuDetector.qml
@@ -0,0 +1,77 @@
+pragma Singleton
+import QtQuick
+import Quickshell
+import Quickshell.Io
+
+/*!
+ GpuDetector.qml — GPU vendor detection singleton.
+
+ Detects GPU vendor synchronously at initialization time so that
+ VideoWallpaperService and other consumers can rely on the vendor
+ being already detected when they first query it.
+
+ Ported from NothingLess.
+*/
+QtObject {
+ id: root
+
+ // Synchronous vendor detection. Set during component initialization.
+ property string vendor: "unknown"
+
+ readonly property bool hasHardwareDecoder: root.vendor !== "unknown" && root.vendor !== ""
+ readonly property bool isNvidia: root.vendor === "nvidia"
+ readonly property bool isAmd: root.vendor === "amd"
+ readonly property bool isIntel: root.vendor === "intel"
+
+ // Run detection at component creation time
+ Component.onCompleted: {
+ // Use Process instead of XMLHttpRequest (blocked in Qt 6)
+ // The async bash fallback reads all card*/device/vendor files
+ gpuDetect.running = true
+ }
+
+ // GPU vendor detection via /sys/class/drm
+ property Process gpuDetect: Process {
+ command: ["bash", "-c",
+ "v=$(for f in /sys/class/drm/card*/device/vendor; do cat \"$f\" 2>/dev/null && break; done); " +
+ "case $v in " +
+ " 0x10de) echo nvidia;; " +
+ " 0x1002) echo amd;; " +
+ " 0x8086) echo intel;; " +
+ " *) echo unknown;; " +
+ "esac"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var result = String(text).trim()
+ if (result && result.length > 0) {
+ root.vendor = result
+ console.log("[GpuDetector] vendor:", result)
+ }
+ }
+ }
+ }
+
+ function getBestDecoder(codec) {
+ var c = codec || "h264"
+ switch (root.vendor) {
+ case "nvidia":
+ return { hardware: true, decoder: c+"_cuvid", encoder: c+"_nvenc", device: "cuda", maxThreads: 2 }
+ case "amd":
+ return { hardware: true, decoder: c+"_vaapi", encoder: c+"_amf", device: "vaapi", maxThreads: 2 }
+ case "intel":
+ return { hardware: true, decoder: c+"_qsv", encoder: c+"_qsv", device: "qsv", maxThreads: 2 }
+ default:
+ return { hardware: false, decoder: c, encoder: null, device: "cpu", maxThreads: 4 }
+ }
+ }
+
+ function detectCodecFromPath(path) {
+ var ext = String(path).toLowerCase().split(".").pop()
+ switch (ext) {
+ case "mp4": case "mov": case "avi": return "h264"
+ case "webm": case "mkv": return "vp9"
+ default: return "h264"
+ }
+ }
+}
diff --git a/src/services/HyprlandService.qml b/src/services/HyprlandService.qml
new file mode 100644
index 0000000..c7234cb
--- /dev/null
+++ b/src/services/HyprlandService.qml
@@ -0,0 +1,201 @@
+pragma Singleton
+import QtQuick
+import Quickshell.Io
+import Quickshell
+
+/*!
+ HyprlandService — centralized Hyprland compositor integration.
+
+ Replaces all scattered hyprctl calls across the codebase with
+ a single typed singleton. Provides:
+
+ - Submap management (for keybind interception)
+ - Gap read/write
+ - Active border color
+ - Shader management
+ - Client/monitor listing (for screenshots, recording)
+ - Config provider detection (lua vs .conf)
+ - Workspace event socket (future: real-time events)
+
+ Usage:
+ HyprlandService.setGaps(5, 10)
+ HyprlandService.setBorderColor("rgb(255,0,0)")
+ HyprlandService.enterSubmap("BrainShell_clean")
+*/
+QtObject {
+ id: root
+
+ // ── Config provider ───────────────────────────────────────────────────────
+ // "lua" (Hyprland >= 0.48) or "conf" (legacy)
+ property string configProvider: "lua"
+
+ property var _providerFile: FileView {
+ id: providerFile
+ path: Quickshell.env("HOME") + "/.config/Brain_Shell/src/user_data/config_Provider.json"
+ watchChanges: true
+ onFileChanged: reload()
+ onLoaded: _parseProvider(text())
+ }
+
+ Component.onCompleted: _parseProvider(providerFile.text())
+
+ function _parseProvider(raw) {
+ if (!raw || raw.trim() === "") return
+ try {
+ var data = JSON.parse(raw)
+ if (data.configProvider) root.configProvider = data.configProvider
+ } catch (e) {
+ console.warn("HyprlandService: failed to parse config_Provider.json")
+ }
+ }
+
+ // ── Reusable process (avoids Qt.createQmlObject overhead for simple dispatches) ──
+ property Process _hyprProc: Process {
+ }
+ // For JSON queries we use throwaway processes to avoid blocking the reusable one.
+ function _runJson(args, callback) {
+ var p = Qt.createQmlObject('import Quickshell.Io; Process {}', root)
+ p.command = ["hyprctl"].concat(args)
+ var collector = Qt.createQmlObject('import Quickshell.Io; StdioCollector {}', p)
+ collector.onStreamFinished = function() {
+ try {
+ var data = JSON.parse(collector.text().trim())
+ if (callback) callback(data)
+ } catch (e) {
+ console.warn("HyprlandService: JSON parse failed for", args.join(" "))
+ }
+ }
+ p.stdout = collector
+ p.running = true
+ }
+
+ // ── Submap ────────────────────────────────────────────────────────────────
+ function enterSubmap(name) {
+ if (configProvider === "lua")
+ _hyprProc.command = ["hyprctl", "dispatch", "hl.dsp.submap('" + name + "')"]
+ else
+ _hyprProc.command = ["hyprctl", "dispatch", "submap", name]
+ _hyprProc.running = false
+ _hyprProc.running = true
+ }
+
+ function exitSubmap() {
+ if (configProvider === "lua")
+ _hyprProc.command = ["hyprctl", "dispatch", "hl.dsp.submap('reset')"]
+ else
+ _hyprProc.command = ["hyprctl", "dispatch", "submap", "reset"]
+ _hyprProc.running = false
+ _hyprProc.running = true
+ }
+
+ // ── Gaps ──────────────────────────────────────────────────────────────────
+ function getGaps(callback) {
+ // Returns { gapsIn: int, gapsOut: int }
+ _runJson(["-j", "getoption", "general:gaps_in"], function(data) {
+ var gapsIn = data.int || data.custom || "5"
+ _runJson(["-j", "getoption", "general:gaps_out"], function(data2) {
+ var gapsOut = data2.int || data2.custom || "10"
+ if (callback) callback({ gapsIn: parseInt(gapsIn), gapsOut: parseInt(gapsOut) })
+ })
+ })
+ }
+
+ function setGaps(gapsIn, gapsOut) {
+ if (configProvider === "lua") {
+ _hyprProc.command = ["hyprctl", "keyword", "general:gaps_in", String(gapsIn),
+ "&&", "hyprctl", "keyword", "general:gaps_out", String(gapsOut)]
+ } else {
+ _hyprProc.command = ["bash", "-c",
+ "hyprctl keyword general:gaps_in " + gapsIn +
+ " && hyprctl keyword general:gaps_out " + gapsOut]
+ }
+ _hyprProc.running = false
+ _hyprProc.running = true
+ }
+
+ // ── Border color ──────────────────────────────────────────────────────────
+ function setBorderColor(rgbString) {
+ if (configProvider === "lua") {
+ _hyprProc.command = ["hyprctl", "eval",
+ "'hl.config({ general = { [\"col.active_border\"] = { colors = { \"" + rgbString + "\" } } } })'"]
+ } else {
+ _hyprProc.command = ["hyprctl", "keyword", "general:col.active_border", "\"" + rgbString + "\""]
+ }
+ _hyprProc.running = false
+ _hyprProc.running = true
+ }
+
+ // ── Shaders ───────────────────────────────────────────────────────────────
+ function getCurrentShader(callback) {
+ _runJson(["-j", "getoption", "decoration:screen_shader"], function(data) {
+ if (callback) callback(data.str || data.custom || "")
+ })
+ }
+
+ function setShader(shaderPath) {
+ if (configProvider === "lua") {
+ _hyprProc.command = ["hyprctl", "keyword", "decoration:screen_shader", shaderPath]
+ } else {
+ _hyprProc.command = ["hyprctl", "keyword", "decoration:screen_shader", shaderPath]
+ }
+ _hyprProc.running = false
+ _hyprProc.running = true
+ }
+
+ // ── Client listing (for screenshots, recording) ──────────────────────────
+ function getClients(callback) {
+ _runJson(["-j", "clients"], callback)
+ }
+
+ function getMonitors(callback) {
+ _runJson(["-j", "monitors"], callback)
+ }
+
+ // ── Workspace ─────────────────────────────────────────────────────────────
+ function getActiveWorkspace(callback) {
+ _runJson(["-j", "activeworkspace"], callback)
+ }
+
+ function dispatchWorkspace(ws) {
+ _hyprProc.command = ["hyprctl", "dispatch", "workspace", String(ws)]
+ _hyprProc.running = false
+ _hyprProc.running = true
+ }
+
+ function moveToWorkspace(ws) {
+ _hyprProc.command = ["hyprctl", "dispatch", "movetoworkspace", String(ws)]
+ _hyprProc.running = false
+ _hyprProc.running = true
+ }
+
+ // ── Dynamic keybind injection ─────────────────────────────────────────────
+ // Foundation for Task 10: register/unregister keybinds at runtime
+ function registerKeybind(keycombo, dispatcher, args) {
+ var cmd = configProvider === "lua"
+ ? "hyprctl keyword bind " + keycombo + "," + dispatcher + "," + args
+ : "hyprctl keyword bind " + keycombo + "," + dispatcher + "," + args
+ _hyprProc.command = ["bash", "-c", cmd]
+ _hyprProc.running = false
+ _hyprProc.running = true
+ }
+
+ function unregisterKeybind(keycombo) {
+ var cmd = configProvider === "lua"
+ ? "hyprctl keyword unbind " + keycombo
+ : "hyprctl keyword unbind " + keycombo
+ _hyprProc.command = ["bash", "-c", cmd]
+ _hyprProc.running = false
+ _hyprProc.running = true
+ }
+
+ // ── Raw dispatch (escape hatch for uncommon operations) ───────────────────
+ function dispatch(action) {
+ _hyprProc.command = ["hyprctl", "dispatch", action]
+ _hyprProc.running = false
+ _hyprProc.running = true
+ }
+
+ function rawJson(args, callback) {
+ _runJson(args, callback)
+ }
+}
diff --git a/src/services/HyprlandSyncService.qml b/src/services/HyprlandSyncService.qml
new file mode 100644
index 0000000..a6ee254
--- /dev/null
+++ b/src/services/HyprlandSyncService.qml
@@ -0,0 +1,252 @@
+pragma Singleton
+import QtQuick
+import Quickshell.Io
+import Quickshell
+import "../"
+
+/*!
+ HyprlandSyncService — regenerates hyprland.lua when keybinds change.
+
+ Reads Brain_Shell's keybinds.json and writes a complete hyprland.lua
+ to ~/.local/share/Brain_Shell/ with autostart + hl.bind() entries.
+ Triggered automatically when KeybindService saves changes.
+*/
+QtObject {
+ id: root
+
+ readonly property string _dataDir: Quickshell.env("HOME") + "/.local/share/Brain_Shell"
+ readonly property string _luaPath: _dataDir + "/hyprland.lua"
+ readonly property string _confPath: _dataDir + "/hyprland.conf"
+
+ // ── Keybind definitions (same targets as IpcManager) ─────────────────────
+ readonly property var _bindMap: ([
+ { target: "dashboard-home", combo: "SUPER + D", cli: "dashboard-home" },
+ { target: "dashboard-stats", combo: "CTRL + SHIFT + ESCAPE", cli: "dashboard-stats" },
+ { target: "dashboard-kanban", combo: "SUPER + Z", cli: "dashboard-kanban" },
+ { target: "dashboard-launcher", combo: "SUPER + Q", cli: "dashboard-launcher" },
+ { target: "dashboard-config", combo: "SUPER + C", cli: "dashboard-config" },
+ { target: "PowerMenu-toggle", combo: "SUPER + ESCAPE", cli: "arch-menu" },
+ { target: "notification-toggle", combo: "SUPER + N", cli: "notification-toggle" },
+ { target: "wallpaper-toggle", combo: "SUPER + W", cli: "wallpaper" },
+ { target: "clipboard-toggle", combo: "SUPER + V", cli: "clipboard" },
+ { target: "wifi-toggle", combo: "SUPER + ALT + W", cli: "wifi-toggle" },
+ { target: "bluetooth-toggle", combo: "SUPER + ALT + B", cli: "bluetooth-toggle" },
+ { target: "vpn-toggle", combo: "SUPER + ALT + G", cli: "vpn-toggle" },
+ { target: "hotspot-toggle", combo: "SUPER + ALT + H", cli: "hotspot-toggle" },
+ { target: "audioOut-toggle", combo: "SUPER + A", cli: "audioOut-toggle" },
+ { target: "audioIn-toggle", combo: "SUPER + ALT + I", cli: "audioIn-toggle" },
+ { target: "audioMix-toggle", combo: "SUPER + M", cli: "audioMix-toggle" },
+ { target: "focus-toggle", combo: "SUPER + F", cli: "focus" },
+ { target: "screenrec-on", combo: "SUPER + R", cli: "screen-record" },
+ ])
+
+ // ── Load user overrides from keybinds.json ────────────────────────────────
+ // Returns a map: target → combo string, or target → "" (disabled).
+ // Targets NOT in overrides use their default combo.
+ function _loadOverrides() {
+ var overrides = {};
+ try {
+ var raw = KeybindService.keybinds;
+ if (raw && typeof raw === "object") {
+ for (var target in raw) {
+ if (!raw[target]) continue;
+ var mods = raw[target].mods || "";
+ var key = raw[target].key || "";
+ if (mods !== "" && key !== "") {
+ // User set a custom combo
+ overrides[target] = mods + " + " + key;
+ } else {
+ // User explicitly disabled this bind — mark as empty
+ overrides[target] = "";
+ }
+ }
+ }
+ } catch (e) {
+ console.warn("HyprlandSyncService: failed to load keybinds:", e);
+ }
+ return overrides;
+ }
+
+ // ── Generate hyprland.lua content ─────────────────────────────────────────
+ function _generateLua() {
+ var overrides = _loadOverrides();
+ var lines = [];
+
+ lines.push("-- ═══════════════════════════════════════════════════════════════════════════");
+ lines.push("-- Brain_Shell — generated by HyprlandSyncService");
+ lines.push("-- Regenerated on keybind changes. Do not edit manually.");
+ lines.push("-- ═══════════════════════════════════════════════════════════════════════════");
+ lines.push("");
+ lines.push("-- ── Autostart ───────────────────────────────────────────────────────────────");
+ lines.push('hl.on("hyprland.start", function ()');
+ lines.push(' hl.exec_cmd("brain-shell")');
+ lines.push("end)");
+ lines.push("");
+ lines.push("-- ── Window rules (quickshell integration) ──────────────────────────────────");
+ lines.push('hl.window_rule({ match = { class = "^(quickshell)$" }, no_blur = true })');
+ lines.push('hl.window_rule({ match = { class = "^(quickshell)$" }, border_size = 0 })');
+ lines.push('hl.window_rule({ match = { class = "^(quickshell)$" }, no_anim = true })');
+ lines.push('hl.window_rule({ match = { class = "^(quickshell)$" }, rounding = 0 })');
+ lines.push('hl.window_rule({ match = { class = "^(quickshell)$" }, stay_focused = true })');
+ lines.push('hl.window_rule({ match = { class = "^(quickshell)$" }, no_max_size = true })');
+ lines.push("");
+ lines.push("-- ── Layer rules (quickshell integration) ────────────────────────────────────");
+ lines.push('hl.layer_rule({ match = { namespace = "^(quickshell)$" }, blur = true })');
+ lines.push('hl.layer_rule({ match = { namespace = "^(quickshell)$" }, ignore_alpha = 0 })');
+ lines.push('hl.layer_rule({ match = { namespace = "^(quickshell)$" }, no_anim = true })');
+ lines.push("");
+ lines.push("-- ── Submap for keybind interception (ShellState / KeybindService) ───────────");
+ lines.push('hl.define_submap("BrainShell_clean", function()');
+ lines.push(' hl.bind("Escape", hl.dsp.submap("reset"))');
+ lines.push("end)");
+ lines.push("");
+ lines.push("-- ── Keybinds ────────────────────────────────────────────────────────────────");
+
+ for (var i = 0; i < _bindMap.length; i++) {
+ var b = _bindMap[i];
+ // Check if user explicitly disabled this bind
+ if (overrides.hasOwnProperty(b.target) && overrides[b.target] === "") {
+ continue; // user disabled this bind — skip
+ }
+ var combo = overrides[b.target] || b.combo;
+ lines.push('hl.bind("' + combo + '", hl.dsp.exec_cmd("brain-shell run ' + b.cli + '"))');
+ }
+
+ lines.push("");
+ return lines.join("\n") + "\n";
+ }
+
+ // ── Generate hyprland.conf content ────────────────────────────────────────
+ function _generateConf() {
+ var overrides = _loadOverrides();
+ var lines = [];
+
+ lines.push("# Brain_Shell — generated by HyprlandSyncService");
+ lines.push("# Regenerated on keybind changes. Do not edit manually.");
+ lines.push("");
+ lines.push("$mainMod = SUPER");
+ lines.push("");
+ lines.push("exec-once = brain-shell");
+ lines.push("");
+
+ // ── Window rules (quickshell integration) ────────────────────────────
+ lines.push("windowrule = no_blur on, match:class ^(quickshell)$");
+ lines.push("windowrule = border_size 0, match:class ^(quickshell)$");
+ lines.push("windowrule = no_anim on, match:class ^(quickshell)$");
+ lines.push("windowrule = rounding 0, match:class ^(quickshell)$");
+ lines.push("windowrule = stay_focused on, match:class ^(quickshell)$");
+ lines.push("windowrule = no_max_size on, match:class ^(quickshell)$");
+ lines.push("");
+
+ // ── Layer rules (quickshell integration) ─────────────────────────────
+ lines.push("layerrule = blur on, match:namespace ^(quickshell)$");
+ lines.push("layerrule = ignore_alpha 0, match:namespace ^(quickshell)$");
+ lines.push("layerrule = no_anim on, match:namespace ^(quickshell)$");
+ lines.push("");
+
+ // ── Keybinds ─────────────────────────────────────────────────────────
+ for (var i = 0; i < _bindMap.length; i++) {
+ var b = _bindMap[i];
+ // Skip disabled binds
+ if (overrides.hasOwnProperty(b.target) && overrides[b.target] === "") {
+ continue;
+ }
+ var combo = overrides[b.target] || b.combo;
+ // Split combo into modifiers (space-separated) and key
+ // "SUPER + D" → mods="SUPER", key="D"
+ // "CTRL + SHIFT + ESCAPE" → mods="CTRL SHIFT", key="ESCAPE"
+ var parts = combo.split(" + ");
+ var key = parts.pop();
+ var mods = parts.join(" ");
+ // Convert SUPER → $mainMod (consistent with user config conventions)
+ mods = mods.replace(/SUPER/g, "$mainMod");
+ // Handle single-modifier SUPER-as-only-modifier case
+ if (mods === "" && key === "") continue;
+
+ var bindLine;
+ if (mods) {
+ bindLine = "bind = " + mods + ", " + key + ", exec, brain-shell run " + b.cli;
+ } else {
+ bindLine = "bind = , " + key + ", exec, brain-shell run " + b.cli;
+ }
+ lines.push(bindLine);
+ }
+
+ lines.push("");
+ return lines.join("\n") + "\n";
+ }
+
+ // ── Write to disk ─────────────────────────────────────────────────────────
+ property Process _writeProc: Process {
+ command: []
+ running: false
+ }
+
+ function sync() {
+ var lua = _generateLua();
+ var conf = _generateConf();
+
+ // Escape backslashes and single quotes for safe printf '%s' usage
+ var le = lua.replace(/\\/g, "\\\\").replace(/'/g, "'\\''");
+ var ce = conf.replace(/\\/g, "\\\\").replace(/'/g, "'\\''");
+
+ _writeProc.command = ["bash", "-c",
+ "mkdir -p '" + _dataDir + "' && " +
+ "printf '%s' '" + le + "' > '" + _luaPath + "' && " +
+ "printf '%s' '" + ce + "' > '" + _confPath + "'"
+ ];
+ _writeProc.running = false;
+ _writeProc.running = true;
+
+ console.log("HyprlandSyncService: synced", _bindMap.length, "binds to", _luaPath);
+ }
+
+ property Process _reloadProc: Process {
+ command: ["hyprctl", "reload"]
+ running: false
+ }
+
+ // ── Auto-sync on keybind changes ──────────────────────────────────────────
+ property Timer _debounce: Timer {
+ interval: 150
+ repeat: false
+ onTriggered: {
+ root.sync()
+ // Reload Hyprland so the new bind file takes effect immediately
+ root._reloadAfterSync.restart()
+ }
+ }
+
+ property Timer _reloadAfterSync: Timer {
+ interval: 200
+ repeat: false
+ onTriggered: {
+ root._reloadProc.running = false
+ root._reloadProc.running = true
+ }
+ }
+
+ // KeybindService is a singleton — available when this object instantiates.
+ property var _keybindWatcher: Connections {
+ target: KeybindService
+ function onKeybindsChanged() {
+ root._debounce.restart()
+ }
+ }
+
+ // ── Initial sync on startup ───────────────────────────────────────────────
+ property Timer _initTimer: Timer {
+ interval: 500
+ running: true
+ onTriggered: root.sync()
+ }
+
+ // ── IPC handler for manual sync (brain-shell sync) ───────────────────────
+ property var _ipcHandler: IpcHandler {
+ target: "sync-binds"
+ function toggle() {
+ root.sync();
+ console.log("HyprlandSyncService: manual sync triggered via IPC");
+ }
+ }
+}
diff --git a/src/services/IdleService.qml b/src/services/IdleService.qml
new file mode 100644
index 0000000..96976e4
--- /dev/null
+++ b/src/services/IdleService.qml
@@ -0,0 +1,117 @@
+pragma Singleton
+import QtQuick
+import Quickshell.Io
+import Quickshell
+
+/*!
+ IdleService — monitors user idle time.
+
+ Uses xprintidle ( Wayland-compatible via XWayland ) for accurate idle
+ detection. Falls back to a simple timer-based estimate if unavailable.
+
+ Signals:
+ idleTimeout(seconds) — emitted when idle crosses lockTimeout
+ activityResumed() — emitted when activity detected after idle
+*/
+QtObject {
+ id: root
+
+ // Milliseconds since last user activity
+ property int idleTime: 0
+
+ // Emitted when idleTime crosses thresholds
+ signal idleTimeout(int seconds)
+ signal activityResumed()
+
+ // Configurable thresholds
+ property int lockTimeout: 300000 // 5 min
+ property int suspendTimeout: 600000 // 10 min
+
+ property bool _locked: false
+ property bool _suspended: false
+ property bool _hasXprintidle: false
+
+ // Check if xprintidle is available once at startup
+ property Process _detectProc: Process {
+ command: ["bash", "-c", "command -v xprintidle >/dev/null 2>&1 && echo yes || echo no"]
+ running: true
+ stdout: SplitParser {
+ onRead: function(line) {
+ root._hasXprintidle = line.trim() === "yes"
+ console.log("[IdleService] xprintidle:", root._hasXprintidle ? "available" : "unavailable")
+ if (root._hasXprintidle) {
+ _pollTimer.interval = 2000
+ } else {
+ // No xprintidle — increase interval to reduce CPU
+ _pollTimer.interval = 5000
+ }
+ _pollTimer.running = true
+ }
+ }
+ }
+
+ // Poll idle state
+ property Timer _pollTimer: Timer {
+ interval: 2000
+ repeat: true
+ running: false
+ onTriggered: root._checkIdle()
+ }
+
+ property Process _idleProc: Process {
+ command: ["xprintidle"]
+ running: false
+ stdout: SplitParser {
+ onRead: function(line) {
+ var ms = parseInt(line.trim())
+ if (isNaN(ms)) return
+ root.idleTime = ms
+
+ if (root.idleTime < 1000) {
+ // Activity detected
+ if (root._locked || root._suspended) {
+ root._locked = false
+ root._suspended = false
+ root.activityResumed()
+ }
+ }
+
+ if (!root._locked && root.idleTime >= root.lockTimeout) {
+ root._locked = true
+ root.idleTimeout(Math.floor(root.idleTime / 1000))
+ }
+ if (!root._suspended && root.idleTime >= root.suspendTimeout) {
+ root._suspended = true
+ root.idleTimeout(Math.floor(root.idleTime / 1000))
+ }
+ }
+ }
+ }
+
+ function _checkIdle() {
+ if (root._hasXprintidle) {
+ _idleProc.running = false
+ _idleProc.running = true
+ } else {
+ // Fallback: without xprintidle, keep idleTime at 0 so video
+ // wallpapers don't get stuck paused. hypridle handles actual
+ // lock/suspend independently.
+ if (root.idleTime !== 0) {
+ root.idleTime = 0
+ if (root._locked || root._suspended) {
+ root._locked = false
+ root._suspended = false
+ root.activityResumed()
+ }
+ }
+ }
+ }
+
+ // Reset idle counter (called when activity is detected externally)
+ function resetActivity() {
+ root.idleTime = 0
+ root._locked = false
+ root._suspended = false
+ root.activityResumed()
+ }
+}
diff --git a/src/services/KanbanBoard.qml b/src/services/KanbanBoard.qml
new file mode 100644
index 0000000..fb35ecd
--- /dev/null
+++ b/src/services/KanbanBoard.qml
@@ -0,0 +1,1087 @@
+import QtQuick
+import Quickshell.Io
+import "../"
+import "../components"
+
+// KanbanBoard — three columns, JSON at $HOME/.config/Brain_Shell/src/user_data/tasks.json.
+//
+// Key behaviours:
+// • Draft: task only saved when Enter pressed or focus lost with text.
+// • Arrow move: card appears in new column offset by ±dir*36px, springs
+// to 0 with OutBack overshoot (rubber-band feel).
+// • Due date: mini calendar + optional time picker, both optional.
+// Time-only → today's date prepended automatically.
+// • Delete confirm: Enter = confirm, Esc = cancel; only active when overlay
+// is showing; dashboard close cancels automatically.
+
+Item {
+ id: root
+ focus: true
+
+ // ── Persistent state ──────────────────────────────────────────────────────
+ property var _tasks: []
+ property int _nextId: 0
+ property string _filePath: ""
+
+ // ── Animation tracking ────────────────────────────────────────────────────
+ // Plain objects — mutated in-place, no signal needed, checked once per card.
+ property var _newCardIds: ({}) // id → true (y slide-in on creation)
+ property var _entryDirections: ({}) // id → dir (x spring on column move)
+
+ // ── Delete confirm ────────────────────────────────────────────────────────
+ property int delConfirmId: -1 // task id with overlay open, -1 = none
+
+ // ── Due date picker state ─────────────────────────────────────────────────
+ property int pickerTaskId: -1
+ property int pickerYear: 0
+ property int pickerMonth: 0
+ property int pickerDay: 0 // 0 = date not selected
+ property bool pickerHasTime: false
+ property int pickerTimeH: 12
+ property int pickerTimeM: 0
+ property var pickerDays: []
+
+ readonly property var _monthNames: [
+ "January","February","March","April","May","June",
+ "July","August","September","October","November","December"
+ ]
+ readonly property var _dowNames: ["Su","Mo","Tu","We","Th","Fr","Sa"]
+
+ // ── Boot: resolve $HOME → create file if missing → load ──────────────────
+ Process {
+ command: ["bash", "-c", "echo $HOME"]
+ running: true
+ stdout: SplitParser {
+ onRead: function(line) {
+ var h = line.trim()
+ if (h === "") return
+ root._filePath = h + "/.config/Brain_Shell/src/user_data/tasks.json"
+ mkProc.command = [
+ "bash", "-c",
+ "[ -f '" + root._filePath + "' ] || " +
+ "(mkdir -p \"$HOME/.config/Brain_Shell/src/user_data\" && " +
+ "printf '%s' '{\"tasks\":[],\"nextId\":0}' > '" + root._filePath + "')"
+ ]
+ mkProc.running = false; mkProc.running = true
+ }
+ }
+ }
+
+ Process {
+ id: mkProc; command: []; running: false
+ onRunningChanged: {
+ if (!running && root._filePath !== "") {
+ rdProc.command = ["cat", root._filePath]
+ rdProc.running = false; rdProc.running = true
+ }
+ }
+ }
+
+ Process {
+ id: rdProc; command: []; running: false
+ stdout: StdioCollector {
+ id: rdBuf
+ onStreamFinished: {
+ try {
+ var o = JSON.parse(rdBuf.text)
+ root._tasks = o.tasks || []
+ root._nextId = o.nextId || 0
+ } catch(e) { root._tasks = []; root._nextId = 0 }
+ }
+ }
+ }
+
+ // ── Save ──────────────────────────────────────────────────────────────────
+ function _save() {
+ if (_filePath === "") return
+ var s = JSON.stringify({ tasks: _tasks, nextId: _nextId })
+ wrProc.command = [
+ "bash", "-c",
+ "printf '%s' '" + s.replace(/'/g, "'\\''") + "' > '" + _filePath + "'"
+ ]
+ wrProc.running = false; wrProc.running = true
+ }
+ Process { id: wrProc; command: []; running: false }
+
+ // ── Reset state when dashboard closes ─────────────────────────────────────
+ Connections {
+ target: Popups
+ function onDashboardOpenChanged() {
+ if (!Popups.dashboardOpen) {
+ root.delConfirmId = -1
+ root.pickerTaskId = -1
+ }
+ }
+ }
+
+ // ── Mutations ─────────────────────────────────────────────────────────────
+ function _addTask(col, title) {
+ var id = root._nextId++
+ var list = root._tasks.slice()
+ list.unshift({ id: id, title: title, column: col, urgency: "", dueDate: "" })
+ root._newCardIds = Object.assign({}, root._newCardIds, { [id]: true })
+ root._tasks = list
+ _save()
+ }
+
+ function _moveTask(id, dir) {
+ var list = root._tasks.slice()
+ for (var i = 0; i < list.length; i++) {
+ if (list[i].id !== id) continue
+ var nc = list[i].column + dir
+ if (nc < 0 || nc > 2) return
+ // Record direction before model changes so new card can read it
+ root._entryDirections = Object.assign({}, root._entryDirections, { [id]: dir })
+ list[i] = Object.assign({}, list[i], { column: nc })
+ break
+ }
+ root._tasks = list
+ _save()
+ }
+
+ function _removeTask(id) {
+ root._tasks = root._tasks.filter(function(t) { return t.id !== id })
+ if (root.delConfirmId === id) root.delConfirmId = -1
+ _save()
+ }
+
+ function _patchTask(id, key, val) {
+ var list = root._tasks.slice()
+ for (var i = 0; i < list.length; i++) {
+ if (list[i].id !== id) continue
+ var t = Object.assign({}, list[i]); t[key] = val; list[i] = t; break
+ }
+ root._tasks = list
+ _save()
+ }
+
+ // ── Urgency helpers ───────────────────────────────────────────────────────
+ function _urgColor(u) {
+ if (u === "high") return "#f38ba8"
+ if (u === "medium") return "#f9e2af"
+ if (u === "low") return "#a6e3a1"
+ return "transparent"
+ }
+ function _urgLabel(u) {
+ if (u === "high") return "High"
+ if (u === "medium") return "Med"
+ if (u === "low") return "Low"
+ return ""
+ }
+
+ // ── Due date helpers ──────────────────────────────────────────────────────
+ function _zp2(n) { return n < 10 ? "0" + n : "" + n }
+
+ function _formatDue(s) {
+ if (!s || s === "") return ""
+ var months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
+ var now = new Date()
+ var todayStr = now.getFullYear() + "-" + _zp2(now.getMonth()+1) + "-" + _zp2(now.getDate())
+ var datePart = s.length >= 10 ? s.substring(0, 10) : ""
+ var timePart = s.length >= 16 ? s.substring(11, 16) : ""
+ var dateLabel = ""
+ if (datePart !== "") {
+ if (datePart === todayStr) dateLabel = "Today"
+ else {
+ var dp = datePart.split("-")
+ dateLabel = months[parseInt(dp[1]) - 1] + " " + parseInt(dp[2])
+ }
+ }
+ if (dateLabel !== "" && timePart !== "") return dateLabel + " " + timePart
+ if (dateLabel !== "") return dateLabel
+ if (timePart !== "") return "Today " + timePart
+ return ""
+ }
+
+ // ── Picker helpers ────────────────────────────────────────────────────────
+ function _openPicker(taskId) {
+ root.pickerTaskId = taskId
+ var now = new Date()
+ root.pickerYear = now.getFullYear()
+ root.pickerMonth = now.getMonth()
+ root.pickerDay = 0
+ root.pickerHasTime = false
+ root.pickerTimeH = 12
+ root.pickerTimeM = 0
+
+ for (var i = 0; i < root._tasks.length; i++) {
+ if (root._tasks[i].id !== taskId) continue
+ var s = root._tasks[i].dueDate || ""
+ if (s === "") break
+ var dp2 = s.length >= 10 ? s.substring(0, 10) : ""
+ var tp = s.length >= 16 ? s.substring(11, 16) : ""
+ if (dp2 !== "") {
+ var p = dp2.split("-")
+ root.pickerYear = parseInt(p[0])
+ root.pickerMonth = parseInt(p[1]) - 1
+ root.pickerDay = parseInt(p[2])
+ }
+ if (tp !== "") {
+ var tParts = tp.split(":")
+ root.pickerTimeH = parseInt(tParts[0]) || 0
+ root.pickerTimeM = parseInt(tParts[1]) || 0
+ root.pickerHasTime = true
+ }
+ break
+ }
+ _rebuildPickerDays()
+ }
+
+ function _rebuildPickerDays() {
+ var firstDow = new Date(root.pickerYear, root.pickerMonth, 1).getDay()
+ var daysInMon = new Date(root.pickerYear, root.pickerMonth + 1, 0).getDate()
+ var daysInPrev = new Date(root.pickerYear, root.pickerMonth, 0).getDate()
+ var days = []
+ for (var p = firstDow - 1; p >= 0; p--)
+ days.push({ n: daysInPrev - p, cur: false })
+ for (var d = 1; d <= daysInMon; d++)
+ days.push({ n: d, cur: true })
+ var tail = 42 - days.length
+ for (var t = 1; t <= tail; t++)
+ days.push({ n: t, cur: false })
+ root.pickerDays = days
+ }
+
+ onPickerYearChanged: _rebuildPickerDays()
+ onPickerMonthChanged: _rebuildPickerDays()
+
+ function _commitPicker() {
+ if (root.pickerTaskId < 0) return
+ var datePart = "", timePart = ""
+ if (root.pickerDay > 0) {
+ var m = root.pickerMonth + 1
+ datePart = root.pickerYear + "-" + _zp2(m) + "-" + _zp2(root.pickerDay)
+ }
+ if (root.pickerHasTime)
+ timePart = _zp2(root.pickerTimeH) + ":" + _zp2(root.pickerTimeM)
+
+ var result = ""
+ if (datePart !== "" && timePart !== "") result = datePart + " " + timePart
+ else if (datePart !== "") result = datePart
+ else if (timePart !== "") {
+ var now = new Date()
+ result = now.getFullYear() + "-" + _zp2(now.getMonth()+1) + "-" + _zp2(now.getDate())
+ + " " + timePart
+ }
+ _patchTask(root.pickerTaskId, "dueDate", result)
+ root.pickerTaskId = -1
+ }
+
+ // ── Delete keyboard handler ───────────────────────────────────────────────
+ // Grabs focus when delete confirm opens; Enter = delete, Esc = cancel.
+ Item {
+ id: deleteKeyHandler
+ Keys.onReturnPressed: function(ev) {
+ if (root.delConfirmId >= 0) {
+ root._removeTask(root.delConfirmId)
+ ev.accepted = true
+ }
+ }
+ Keys.onEscapePressed: function(ev) {
+ if (root.delConfirmId >= 0) {
+ root.delConfirmId = -1
+ ev.accepted = true
+ }
+ }
+ }
+
+ onDelConfirmIdChanged: {
+ if (delConfirmId >= 0) deleteKeyHandler.forceActiveFocus()
+ }
+
+ // ── Column defs ───────────────────────────────────────────────────────────
+ readonly property var colDefs: [
+ { idx: 0, label: "To Do" },
+ { idx: 1, label: "Ongoing" },
+ { idx: 2, label: "Done" }
+ ]
+
+ // ── Column layout ─────────────────────────────────────────────────────────
+ Row {
+ id: mainRow
+ anchors.fill: parent
+ anchors.topMargin: 8
+ spacing: 8
+
+ Repeater {
+ model: root.colDefs
+
+ delegate: Item {
+ id: colItem
+ required property var modelData
+
+ readonly property int cIdx: modelData.idx
+ readonly property string cLabel: modelData.label
+ readonly property var cTasks: {
+ var ci = cIdx
+ return root._tasks.filter(function(t) { return t.column === ci })
+ }
+
+ property bool draftOpen: false
+
+ width: (mainRow.width - mainRow.spacing * 2) / 3
+ height: parent.height
+
+ Rectangle {
+ anchors.fill: parent; radius: Theme.cornerRadius
+ color: Qt.rgba(1, 1, 1, 0.03)
+ border.color: Qt.rgba(1, 1, 1, 0.07); border.width: 1
+ }
+
+ Column {
+ anchors { fill: parent; margins: 10 }
+ spacing: 8
+
+ // Header
+ Item {
+ width: parent.width; height: 26
+
+ Row {
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ spacing: 7
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: colItem.cLabel; color: Theme.active
+ font.pixelSize: 12; font.weight: Font.DemiBold
+ }
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: cntT.implicitWidth + 10; height: 16; radius: 8
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.12)
+ Text {
+ id: cntT; anchors.centerIn: parent
+ text: colItem.cTasks.length
+ color: Theme.active; font.pixelSize: 9; font.weight: Font.Bold
+ }
+ }
+ }
+
+ // Add (+) button
+ Rectangle {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ width: 22; height: 22; radius: 6
+ color: addH.hovered
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18)
+ : Qt.rgba(1, 1, 1, 0.05)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.20)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text { anchors.centerIn: parent; text: "+"; color: Theme.active; font.pixelSize: 15 }
+ HoverHandler { id: addH; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: { colItem.draftOpen = true; draftTimer.restart() }
+ }
+ }
+ }
+
+ Rectangle { width: parent.width; height: 1; color: Qt.rgba(1,1,1,0.07) }
+
+ Timer {
+ id: draftTimer; interval: 50
+ onTriggered: if (colItem.draftOpen) draftInput.forceActiveFocus()
+ }
+
+ // Content area
+ Item {
+ width: parent.width
+ height: parent.height - 26 - 1 - parent.spacing * 2
+
+ // Draft card — slides in from top
+ Item {
+ id: draftWrap; z: 2; width: parent.width
+ height: colItem.draftOpen ? draftRect.implicitHeight + 6 : 0
+ clip: true
+ Behavior on height { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } }
+
+ Rectangle {
+ id: draftRect
+ anchors { left: parent.left; right: parent.right; top: parent.top; topMargin: 3 }
+ radius: 8
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.06)
+ border.color: draftInput.activeFocus
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.50)
+ : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.22)
+ border.width: 1
+ implicitHeight: draftInput.contentHeight + 24
+ Behavior on border.color { ColorAnimation { duration: 100 } }
+
+ Text {
+ anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ visible: draftInput.text === ""
+ text: "Task title…"; color: Qt.rgba(1,1,1,0.25); font.pixelSize: 12
+ }
+
+ TextInput {
+ id: draftInput
+ anchors { left: parent.left; right: parent.right; leftMargin: 10; rightMargin: 10; verticalCenter: parent.verticalCenter }
+ color: Theme.text; font.pixelSize: 12
+ wrapMode: TextInput.WordWrap
+ selectionColor: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.35)
+
+ function commit() {
+ var t = text.trim()
+ if (t !== "") root._addTask(colItem.cIdx, t)
+ colItem.draftOpen = false; text = ""
+ root.forceActiveFocus()
+ }
+
+ Keys.onReturnPressed: function(ev) { commit(); ev.accepted = true }
+ Keys.onEscapePressed: function(ev) { colItem.draftOpen = false; text = ""; ev.accepted = true; root.forceActiveFocus() }
+ onActiveFocusChanged: if (!activeFocus && colItem.draftOpen) commit()
+ }
+ }
+ }
+
+ // Task list
+ Flickable {
+ anchors.top: draftWrap.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.topMargin: colItem.draftOpen ? 4 : 0
+ height: parent.height - draftWrap.height - (colItem.draftOpen ? 4 : 0)
+ contentWidth: width
+ contentHeight: taskCol.implicitHeight + 4
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+
+ Column {
+ id: taskCol
+ width: parent.width; spacing: 6
+
+ Repeater {
+ model: colItem.cTasks
+ delegate: TaskCard {
+ required property var modelData
+ width: parent.width
+ taskData: modelData
+ colIdx: colItem.cIdx
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // ── Picker dim overlay ────────────────────────────────────────────────────
+ Rectangle {
+ anchors.fill: parent; z: 29
+ visible: root.pickerTaskId >= 0
+ color: Qt.rgba(0, 0, 0, 0.35)
+ MouseArea { anchors.fill: parent; onClicked: root.pickerTaskId = -1 }
+ }
+
+ // ── Date / time picker popup ──────────────────────────────────────────────
+ Rectangle {
+ id: datePicker
+ z: 30
+ anchors.centerIn: parent
+ visible: root.pickerTaskId >= 0
+
+ width: 230
+ height: pickerCol.implicitHeight + 24
+ radius: Theme.cornerRadius
+ color: Qt.rgba(
+ Math.min(1, Theme.background.r + 0.06),
+ Math.min(1, Theme.background.g + 0.06),
+ Math.min(1, Theme.background.b + 0.06), 0.98)
+ border.color: Qt.rgba(1,1,1,0.15); border.width: 1
+
+ // Swallow clicks so they don't reach the dim overlay below
+ MouseArea { anchors.fill: parent; onClicked: {} }
+
+ Column {
+ id: pickerCol
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 12 }
+ spacing: 8
+
+ // ── Month nav ──────────────────────────────────────────────────────
+ Item {
+ width: parent.width; height: 22
+ Text {
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ text: "‹"; font.pixelSize: 17
+ color: pmH.hovered ? Qt.rgba(1,1,1,0.85) : Qt.rgba(1,1,1,0.30)
+ Behavior on color { ColorAnimation { duration: 80 } }
+ HoverHandler { id: pmH; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ if (root.pickerMonth === 0) { root.pickerMonth = 11; root.pickerYear-- }
+ else root.pickerMonth--
+ }
+ }
+ }
+ Text {
+ anchors.centerIn: parent
+ text: root._monthNames[root.pickerMonth].substring(0,3) + " " + root.pickerYear
+ color: Theme.text; font.pixelSize: 11; font.weight: Font.DemiBold
+ }
+ Text {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: "›"; font.pixelSize: 17
+ color: nmH.hovered ? Qt.rgba(1,1,1,0.85) : Qt.rgba(1,1,1,0.30)
+ Behavior on color { ColorAnimation { duration: 80 } }
+ HoverHandler { id: nmH; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ if (root.pickerMonth === 11) { root.pickerMonth = 0; root.pickerYear++ }
+ else root.pickerMonth++
+ }
+ }
+ }
+ }
+
+ // ── Day-of-week headers ────────────────────────────────────────────
+ Item {
+ width: parent.width; height: 14
+ Row {
+ anchors.fill: parent
+ Repeater {
+ model: root._dowNames
+ delegate: Text {
+ width: Math.floor(parent.parent.width / 7)
+ horizontalAlignment: Text.AlignHCenter
+ text: modelData; font.pixelSize: 8; font.weight: Font.Bold
+ color: Qt.rgba(1,1,1,0.18)
+ }
+ }
+ }
+ }
+
+ // ── Day grid ──────────────────────────────────────────────────────
+ Grid {
+ id: dayGrid
+ width: parent.width; columns: 7; rows: 6
+ readonly property real cW: width / 7
+ readonly property real cH: 24
+
+ Repeater {
+ model: root.pickerDays
+ delegate: Item {
+ required property var modelData
+ required property int index
+ width: dayGrid.cW; height: dayGrid.cH
+
+ readonly property bool isSel: modelData.cur && modelData.n === root.pickerDay
+ readonly property bool isNow: {
+ var n = new Date()
+ return modelData.cur &&
+ modelData.n === n.getDate() &&
+ root.pickerMonth === n.getMonth() &&
+ root.pickerYear === n.getFullYear()
+ }
+
+ Rectangle {
+ anchors.centerIn: parent
+ width: Math.min(dayGrid.cW, dayGrid.cH) - 2; height: width; radius: width / 2
+ color: isSel
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.82)
+ : isNow ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.12)
+ : dayH.hovered && modelData.cur ? Qt.rgba(1,1,1,0.08) : "transparent"
+ border.color: isNow && !isSel ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.35) : "transparent"
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 80 } }
+
+ Text {
+ anchors.centerIn: parent
+ text: modelData.n
+ font.pixelSize: 9; font.weight: isSel ? Font.Bold : Font.Normal
+ color: isSel ? Theme.background
+ : modelData.cur ? Qt.rgba(1,1,1,0.78) : Qt.rgba(1,1,1,0.14)
+ }
+ }
+ HoverHandler { id: dayH; enabled: modelData.cur; cursorShape: Qt.PointingHandCursor }
+ TapHandler { enabled: modelData.cur; onTapped: root.pickerDay = modelData.n }
+ }
+ }
+ }
+
+ Rectangle { width: parent.width; height: 1; color: Qt.rgba(1,1,1,0.08) }
+
+ // ── Time row ──────────────────────────────────────────────────────
+ Item {
+ width: parent.width; height: timeInner.implicitHeight
+
+ Row {
+ id: timeInner
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 10
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: "⏰"; font.pixelSize: 13
+ }
+
+ // Controls when time is set
+ Row {
+ spacing: 4; visible: root.pickerHasTime
+ anchors.verticalCenter: parent.verticalCenter
+
+ // ── Hour col ──────────────────────────────────────────
+ Column {
+ spacing: 2; anchors.verticalCenter: parent.verticalCenter
+ Rectangle {
+ width: 26; height: 18; radius: 4
+ color: hUpH.hovered ? Qt.rgba(1,1,1,0.12) : Qt.rgba(1,1,1,0.05)
+ border.color: Qt.rgba(1,1,1,0.10); border.width: 1
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "▲"; font.pixelSize: 7; color: Qt.rgba(1,1,1,0.50) }
+ HoverHandler { id: hUpH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root.pickerTimeH = (root.pickerTimeH + 1) % 24 }
+ }
+ Rectangle {
+ width: 26; height: 24; radius: 4
+ color: Qt.rgba(1,1,1,0.07); border.color: Qt.rgba(1,1,1,0.10); border.width: 1
+ Text {
+ anchors.centerIn: parent
+ text: root._zp2(root.pickerTimeH)
+ font.pixelSize: 13; font.family: "JetBrains Mono"; font.weight: Font.Bold
+ color: Theme.active
+ }
+ }
+ Rectangle {
+ width: 26; height: 18; radius: 4
+ color: hDnH.hovered ? Qt.rgba(1,1,1,0.12) : Qt.rgba(1,1,1,0.05)
+ border.color: Qt.rgba(1,1,1,0.10); border.width: 1
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "▼"; font.pixelSize: 7; color: Qt.rgba(1,1,1,0.50) }
+ HoverHandler { id: hDnH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root.pickerTimeH = (root.pickerTimeH + 23) % 24 }
+ }
+ }
+
+ Text { anchors.verticalCenter: parent.verticalCenter; text: ":"; font.pixelSize: 15; font.weight: Font.Bold; color: Theme.text }
+
+ // ── Minute col ────────────────────────────────────────
+ Column {
+ spacing: 2; anchors.verticalCenter: parent.verticalCenter
+ Rectangle {
+ width: 26; height: 18; radius: 4
+ color: mUpH.hovered ? Qt.rgba(1,1,1,0.12) : Qt.rgba(1,1,1,0.05)
+ border.color: Qt.rgba(1,1,1,0.10); border.width: 1
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "▲"; font.pixelSize: 7; color: Qt.rgba(1,1,1,0.50) }
+ HoverHandler { id: mUpH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root.pickerTimeM = (root.pickerTimeM + 5) % 60 }
+ }
+ Rectangle {
+ width: 26; height: 24; radius: 4
+ color: Qt.rgba(1,1,1,0.07); border.color: Qt.rgba(1,1,1,0.10); border.width: 1
+ Text {
+ anchors.centerIn: parent
+ text: root._zp2(root.pickerTimeM)
+ font.pixelSize: 13; font.family: "JetBrains Mono"; font.weight: Font.Bold
+ color: Theme.active
+ }
+ }
+ Rectangle {
+ width: 26; height: 18; radius: 4
+ color: mDnH.hovered ? Qt.rgba(1,1,1,0.12) : Qt.rgba(1,1,1,0.05)
+ border.color: Qt.rgba(1,1,1,0.10); border.width: 1
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "▼"; font.pixelSize: 7; color: Qt.rgba(1,1,1,0.50) }
+ HoverHandler { id: mDnH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root.pickerTimeM = (root.pickerTimeM + 55) % 60 }
+ }
+ }
+
+ // Clear time ✕
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 18; height: 18; radius: 9
+ color: clrTH.hovered ? Qt.rgba(1,1,1,0.14) : Qt.rgba(1,1,1,0.05)
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "✕"; font.pixelSize: 8; color: Qt.rgba(1,1,1,0.40) }
+ HoverHandler { id: clrTH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root.pickerHasTime = false }
+ }
+ }
+
+ // "Add time" pill (shown when no time set)
+ Rectangle {
+ visible: !root.pickerHasTime
+ anchors.verticalCenter: parent.verticalCenter
+ width: addTL.implicitWidth + 18; height: 24; radius: 12
+ color: addTH.hovered
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.15)
+ : Qt.rgba(1,1,1,0.06)
+ border.color: Qt.rgba(1,1,1,0.10); border.width: 1
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text {
+ id: addTL; anchors.centerIn: parent
+ text: "Add time"; font.pixelSize: 10
+ color: addTH.hovered ? Theme.active : Qt.rgba(1,1,1,0.45)
+ Behavior on color { ColorAnimation { duration: 80 } }
+ }
+ HoverHandler { id: addTH; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: { root.pickerHasTime = true; root.pickerTimeH = 12; root.pickerTimeM = 0 }
+ }
+ }
+ }
+ }
+
+ Rectangle { width: parent.width; height: 1; color: Qt.rgba(1,1,1,0.08) }
+
+ // ── Clear / Done ───────────────────────────────────────────────────
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 10; bottomPadding: 0
+
+ Rectangle {
+ width: 86; height: 28; radius: 8
+ color: clrH.hovered ? Qt.rgba(1,1,1,0.10) : Qt.rgba(1,1,1,0.05)
+ border.color: Qt.rgba(1,1,1,0.10); border.width: 1
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "Clear"; font.pixelSize: 11; color: Qt.rgba(1,1,1,0.50) }
+ HoverHandler { id: clrH; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: { root._patchTask(root.pickerTaskId, "dueDate", ""); root.pickerTaskId = -1 }
+ }
+ }
+
+ Rectangle {
+ width: 86; height: 28; radius: 8
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, doneH.hovered ? 0.28 : 0.16)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.38); border.width: 1
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text {
+ anchors.centerIn: parent; text: "Done"
+ font.pixelSize: 11; font.weight: Font.Medium; color: Theme.active
+ }
+ HoverHandler { id: doneH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._commitPicker() }
+ }
+ }
+ }
+ }
+
+ // ── TaskCard ──────────────────────────────────────────────────────────────
+ component TaskCard: Item {
+ id: card
+
+ property var taskData
+ property int colIdx
+
+ property bool showExtra: false
+ property real xEntry: 0
+ property real dragX: 0
+
+ readonly property bool isDelConfirm: root.delConfirmId === taskData.id
+
+ height: cardBg.implicitHeight + 2
+
+ transform: [
+ Translate { x: card.dragX + card.xEntry },
+ // 3D Tilt: card physically tilts in the direction you drag it
+ Rotation {
+ origin.x: card.width / 2; origin.y: card.height / 2
+ axis { x: 0; y: 1; z: 0 }
+ angle: (card.dragX / 65.0) * -12
+ Behavior on angle { SpringAnimation { spring: 2.5; damping: 0.3 } }
+ }
+ ]
+
+ // Lift Effect: card scales down slightly when grabbed
+ scale: dragHandler.active ? 0.94 : 1.0
+ Behavior on scale { NumberAnimation { duration: 300; easing.type: Easing.OutBack; easing.overshoot: 1.5 } }
+
+ // Fluid Column Jumps: smooth gliding instead of teleporting
+ Behavior on x { SpringAnimation { spring: 2.0; damping: 0.25 } }
+ Behavior on y { SpringAnimation { spring: 2.0; damping: 0.25 } }
+
+ // ── Entry animation ────────────────────────────────────────────────────
+ // New cards: no x offset (draft gives enough visual feedback).
+ // Moved cards: appear at ±36px in move direction, spring to 0.
+ Component.onCompleted: {
+ var id = card.taskData.id
+ if (root._newCardIds[id]) {
+ delete root._newCardIds[id]
+ // No extra animation — draft open/close is the creation affordance
+ } else if (root._entryDirections[id] !== undefined) {
+ var dir = root._entryDirections[id]
+ delete root._entryDirections[id]
+ // dir=1 (moved right) → xEntry=+36 (card overshoots right, bounces left to 0)
+ // dir=-1 (moved left) → xEntry=-36 (card overshoots left, bounces right to 0)
+ card.xEntry = dir * 36
+ xSpringAnim.restart()
+ }
+ }
+
+ // Spring to 0 with OutBack rubber-band feel
+ NumberAnimation {
+ id: xSpringAnim
+ target: card; property: "xEntry"; to: 0
+ duration: 400
+ easing.type: Easing.OutBack
+ easing.overshoot: 1.6
+ }
+
+ // ── Swipe to move ─────────────────────────────────────────────────────
+ DragHandler {
+ id: dragHandler
+ target: null
+ xAxis.enabled: true; yAxis.enabled: false; dragThreshold: 12
+
+ onActiveChanged: {
+ if (!active) {
+ var d = card.dragX; snapAnim.start()
+ var dir = d > 0 ? 1 : -1
+ if (Math.abs(d) > 50 && card.colIdx + dir >= 0 && card.colIdx + dir <= 2)
+ root._moveTask(card.taskData.id, dir)
+ }
+ }
+ onTranslationChanged: {
+ if (active) card.dragX = Math.max(-65, Math.min(65, translation.x))
+ }
+ }
+
+ // Elastic Snap: wobbles slightly when snapping back to place
+ NumberAnimation {
+ id: snapAnim; target: card; property: "dragX"; to: 0
+ duration: 600
+ easing.type: Easing.OutElastic
+ easing.amplitude: 1.2
+ easing.period: 0.6
+ }
+
+ // Dynamic Glow: stronger, smoother color tinting
+ readonly property real dragAmt: Math.abs(dragX) / 65.0
+ readonly property color dragTint: {
+ if (dragX > 2) return Qt.rgba(166/255, 227/255, 161/255, dragAmt * 0.35)
+ if (dragX < -2) return Qt.rgba(243/255, 139/255, 168/255, dragAmt * 0.35)
+ return "transparent"
+ }
+
+ // ── Card body ─────────────────────────────────────────────────────────
+ Rectangle {
+ id: cardBg
+ width: parent.width; radius: 8
+ color: Qt.rgba(1, 1, 1, 0.05)
+ border.color: {
+ var u = card.taskData.urgency
+ if (u === "high") return Qt.rgba(243/255, 139/255, 168/255, 0.45)
+ if (u === "medium") return Qt.rgba(249/255, 226/255, 175/255, 0.35)
+ if (u === "low") return Qt.rgba(166/255, 227/255, 161/255, 0.35)
+ return Qt.rgba(1, 1, 1, 0.10)
+ }
+ border.width: 1
+ Behavior on border.color { ColorAnimation { duration: 150 } }
+ implicitHeight: body.implicitHeight + 18
+
+ // Drag direction tint
+ Rectangle {
+ anchors.fill: parent; radius: parent.radius; color: card.dragTint
+ Behavior on color { ColorAnimation { duration: 60 } }
+ }
+
+ Column {
+ id: body
+ anchors { left: parent.left; right: parent.right; leftMargin: 10; rightMargin: 10; top: parent.top; topMargin: 9 }
+ spacing: 6
+
+ // Title (inline edit)
+ TextInput {
+ width: parent.width
+ text: card.taskData.title
+ color: Theme.text; font.pixelSize: 12; font.weight: Font.Medium
+ wrapMode: TextInput.WordWrap
+ selectionColor: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.35)
+ onEditingFinished: { var t = text.trim(); if (t !== "") root._patchTask(card.taskData.id, "title", t) }
+ }
+
+ // Badges row
+ Row {
+ visible: card.taskData.urgency !== "" || (card.taskData.dueDate || "") !== ""
+ spacing: 6
+ Rectangle {
+ visible: card.taskData.urgency !== ""
+ anchors.verticalCenter: parent.verticalCenter
+ width: urgL.implicitWidth + 12; height: 16; radius: 8
+ color: root._urgColor(card.taskData.urgency); opacity: 0.85
+ Text { id: urgL; anchors.centerIn: parent; text: root._urgLabel(card.taskData.urgency); font.pixelSize: 9; font.weight: Font.Bold; color: "#1e1e2e" }
+ }
+ Row {
+ visible: (card.taskData.dueDate || "") !== ""
+ anchors.verticalCenter: parent.verticalCenter; spacing: 3
+ Text { text: "📅"; font.pixelSize: 9 }
+ Text { text: root._formatDue(card.taskData.dueDate || ""); font.pixelSize: 9; color: Qt.rgba(1,1,1,0.50) }
+ }
+ }
+
+ // Expandable extra fields
+ Column {
+ visible: card.showExtra; width: parent.width; spacing: 6
+
+ // Urgency picker
+ Row {
+ spacing: 5
+ Text { anchors.verticalCenter: parent.verticalCenter; text: "Urgency"; font.pixelSize: 9; color: Qt.rgba(1,1,1,0.35) }
+ Repeater {
+ model: ["", "low", "medium", "high"]
+ delegate: Rectangle {
+ required property string modelData
+ property bool sel: card.taskData.urgency === modelData
+ width: uT.implicitWidth + 12; height: 17; radius: 9
+ color: sel ? (modelData === "" ? Qt.rgba(1,1,1,0.15) : root._urgColor(modelData))
+ : (uH.hovered ? Qt.rgba(1,1,1,0.10) : Qt.rgba(1,1,1,0.05))
+ border.color: Qt.rgba(1,1,1, sel ? 0.20 : 0.08); border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text {
+ id: uT; anchors.centerIn: parent; font.pixelSize: 9
+ text: modelData === "" ? "None" : modelData.charAt(0).toUpperCase() + modelData.slice(1)
+ color: (sel && modelData !== "") ? "#1e1e2e" : Qt.rgba(1,1,1,0.65)
+ }
+ HoverHandler { id: uH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._patchTask(card.taskData.id, "urgency", modelData) }
+ }
+ }
+ }
+
+ // Due date button row
+ Row {
+ spacing: 6
+ Text { anchors.verticalCenter: parent.verticalCenter; text: "Due"; font.pixelSize: 9; color: Qt.rgba(1,1,1,0.35) }
+
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: dueLbl.implicitWidth + 20; height: 20; radius: 10
+ color: dueBH.hovered
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.15)
+ : Qt.rgba(1,1,1,0.07)
+ border.color: Qt.rgba(1,1,1,0.12); border.width: 1
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text {
+ id: dueLbl; anchors.centerIn: parent; font.pixelSize: 9
+ text: (card.taskData.dueDate || "") !== "" ? root._formatDue(card.taskData.dueDate) : "Set due date"
+ color: (card.taskData.dueDate || "") !== "" ? Theme.active : Qt.rgba(1,1,1,0.40)
+ Behavior on color { ColorAnimation { duration: 80 } }
+ }
+ HoverHandler { id: dueBH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._openPicker(card.taskData.id) }
+ }
+
+ // Clear ✕
+ Rectangle {
+ visible: (card.taskData.dueDate || "") !== ""
+ anchors.verticalCenter: parent.verticalCenter
+ width: 16; height: 16; radius: 8
+ color: clrDH.hovered ? Qt.rgba(1,1,1,0.12) : Qt.rgba(1,1,1,0.05)
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "✕"; font.pixelSize: 8; color: Qt.rgba(1,1,1,0.35) }
+ HoverHandler { id: clrDH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._patchTask(card.taskData.id, "dueDate", "") }
+ }
+ }
+ }
+
+ // Action bar
+ Item {
+ width: parent.width; height: 22
+
+ // ▾/▴ extra fields
+ Rectangle {
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 20; height: 20; radius: 5
+ color: optH.hovered ? Qt.rgba(1,1,1,0.10) : Qt.rgba(1,1,1,0.04)
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: card.showExtra ? "▴" : "▾"; font.pixelSize: 9; color: Qt.rgba(1,1,1, optH.hovered ? 0.70 : 0.30) }
+ HoverHandler { id: optH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: card.showExtra = !card.showExtra }
+ }
+
+ Row {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ spacing: 4
+
+ // ← left
+ Rectangle {
+ visible: card.colIdx > 0
+ width: 20; height: 20; radius: 5
+ color: lH.hovered ? Qt.rgba(1,1,1,0.10) : Qt.rgba(1,1,1,0.04)
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "←"; font.pixelSize: 10; color: Qt.rgba(1,1,1, lH.hovered ? 0.80 : 0.40) }
+ HoverHandler { id: lH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._moveTask(card.taskData.id, -1) }
+ }
+
+ // → right
+ Rectangle {
+ visible: card.colIdx < 2
+ width: 20; height: 20; radius: 5
+ color: rH.hovered ? Qt.rgba(1,1,1,0.10) : Qt.rgba(1,1,1,0.04)
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "→"; font.pixelSize: 10; color: Qt.rgba(1,1,1, rH.hovered ? 0.80 : 0.40) }
+ HoverHandler { id: rH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._moveTask(card.taskData.id, 1) }
+ }
+
+ // ✕ delete
+ Rectangle {
+ width: 20; height: 20; radius: 5
+ color: dH.hovered ? Qt.rgba(248/255,113/255,113/255,0.20) : Qt.rgba(1,1,1,0.04)
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text {
+ anchors.centerIn: parent; text: "✕"; font.pixelSize: 10
+ color: Qt.rgba(248/255,113/255,113/255, dH.hovered ? 1.0 : 0.60)
+ Behavior on color { ColorAnimation { duration: 80 } }
+ }
+ HoverHandler { id: dH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root.delConfirmId = card.taskData.id }
+ }
+ }
+ }
+ }
+
+ // ── Delete confirmation overlay ────────────────────────────────────
+ Rectangle {
+ anchors.fill: parent; radius: parent.radius
+ visible: card.isDelConfirm
+ color: Qt.rgba(
+ Math.min(1, Theme.background.r + 0.05),
+ Math.min(1, Theme.background.g + 0.05),
+ Math.min(1, Theme.background.b + 0.05), 0.96)
+
+ Column {
+ anchors.centerIn: parent; spacing: 10
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "Delete task?"; color: Theme.text; font.pixelSize: 12; font.weight: Font.Medium
+ }
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter; spacing: 8
+ Rectangle {
+ width: 64; height: 24; radius: 6
+ color: cnH.hovered ? Qt.rgba(1,1,1,0.10) : Qt.rgba(1,1,1,0.05)
+ border.color: Qt.rgba(1,1,1,0.10); border.width: 1
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "Cancel"; font.pixelSize: 11; color: Qt.rgba(1,1,1,0.60) }
+ HoverHandler { id: cnH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root.delConfirmId = -1 }
+ }
+ Rectangle {
+ width: 64; height: 24; radius: 6
+ color: cfH.hovered ? "#cc3a3a" : "#993030"
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text { anchors.centerIn: parent; text: "Delete"; font.pixelSize: 11; font.weight: Font.Bold; color: "white" }
+ HoverHandler { id: cfH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._removeTask(card.taskData.id) }
+ }
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "↵ confirm · ⎋ cancel"; font.pixelSize: 9
+ color: Qt.rgba(1,1,1,0.20)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/services/PowerMenu.qml b/src/services/PowerMenu.qml
new file mode 100644
index 0000000..fc326ec
--- /dev/null
+++ b/src/services/PowerMenu.qml
@@ -0,0 +1,129 @@
+import QtQuick
+import Quickshell.Io
+import "../"
+
+// Power menu — vertical list of power action buttons.
+
+Column {
+ id: root
+ spacing: 4
+ width: parent.width
+
+ readonly property var actions: [
+ {
+ label: "Shutdown",
+ icon: "⏻",
+ danger: true,
+ confirm: true,
+ title: "Shut Down?",
+ message: "Your computer will power off. Save your work before continuing.",
+ label2: "Shut Down",
+ action: "shutdown"
+ },
+ {
+ label: "Reboot ",
+ icon: "↺",
+ danger: true,
+ confirm: true,
+ title: "Reboot?",
+ message: "Your computer will restart. Save your work before continuing.",
+ label2: "Reboot",
+ action: "reboot"
+ },
+ {
+ label: "Log Out ",
+ icon: "",
+ danger: true,
+ confirm: true,
+ title: "Log Out?",
+ message: "You will be logged out of your session. Save your work before continuing.",
+ label2: "Log Out",
+ action: "logout"
+ },
+ {
+ label: "Lock ",
+ icon: "",
+ danger: false,
+ confirm: false,
+ action: "lock"
+ },
+ {
+ label: "Suspend ",
+ icon: "⏾",
+ danger: false,
+ confirm: false,
+ action: "suspend"
+ },
+ ]
+
+ // Direct runner for non-confirm actions
+ Process {
+ id: runner
+ property var pendingCmd: []
+ command: pendingCmd
+ onRunningChanged: if (!running) pendingCmd = []
+ }
+
+ function runDirect(action) {
+ switch (action) {
+ case "lock": runner.pendingCmd = ["loginctl", "lock-session"]; break
+ case "suspend": runner.pendingCmd = ["systemctl", "suspend"]; break
+ }
+ runner.running = true
+ Popups.archMenuOpen = false
+ }
+
+ Repeater {
+ model: root.actions
+
+ delegate: Rectangle {
+ width: root.width
+ height: 44
+ radius: Theme.cornerRadius
+ color: hov.hovered
+ ? (modelData.danger ? "#4d2020" : Theme.active)
+ : "transparent"
+
+ Behavior on color { ColorAnimation { duration: 120 } }
+
+ Row {
+ anchors.centerIn: parent
+ spacing: 10
+
+ Text {
+ text: modelData.icon
+ font.pixelSize: 16
+ color: modelData.danger && hov.hovered ? "#ff6b6b" : hov.hovered?"#000000":Theme.text
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Text {
+ text: modelData.label
+ font.pixelSize: 13
+ color: modelData.danger && hov.hovered ? "#ff6b6b" : hov.hovered?"#000000":Theme.text
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ HoverHandler { id: hov; cursorShape: Qt.PointingHandCursor }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ if (modelData.confirm) {
+ // Close menu first, then show confirm dialog
+ Popups.closeAll()
+ Popups.showConfirm(
+ modelData.title,
+ modelData.message,
+ modelData.label2,
+ modelData.action
+ )
+ } else {
+ root.runDirect(modelData.action)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/services/ScreenRecService.qml b/src/services/ScreenRecService.qml
new file mode 100644
index 0000000..4aaaa04
--- /dev/null
+++ b/src/services/ScreenRecService.qml
@@ -0,0 +1,450 @@
+pragma Singleton
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import "../"
+
+// ScreenRecService — owns all screen recording state.
+//
+// Audio routing (fixed):
+// none → no -a flag; launch directly
+// mic only → pactl get-default-source → pass directly to wf-recorder
+// system only → BrainShellMixer null sink + loopback from sink.monitor
+// both → BrainShellMixer null sink + loopback from sink.monitor
+// + loopback from default source (mic)
+//
+// The null sink is torn down after every recording (saved or discarded).
+// Notifications are sent via notify-send with action buttons (requires
+// libnotify ≥ 0.8 and a daemon that supports actions, e.g. dunst/mako).
+
+QtObject {
+ id: root
+
+ // ── Persisted options ─────────────────────────────────────────────────────
+ property string captureTarget: "screen"
+ property bool audioMic: false
+ property bool audioSystem: false
+
+ // ── Display helpers ───────────────────────────────────────────────────────
+ readonly property var _captureIcons: ({ screen: "", window: "", region: "" })
+ readonly property var _captureLabels: ({ screen: "Screen", window: "Window", region: "Region" })
+ readonly property string captureIcon: _captureIcons[captureTarget] ?? ""
+ readonly property string captureLabel: _captureLabels[captureTarget] ?? "Screen"
+
+ readonly property string audioLabel: {
+ if (audioMic && audioSystem) return "Mic + Sys"
+ if (audioMic) return "Mic"
+ if (audioSystem) return "Sys"
+ return "Non"
+ }
+
+ // ── Strip hover (open = "capture" | "audio" | "") ─────────────────────────
+ property string openStrip: ""
+ property real popupTargetX: 0
+ property real popupTargetWidth: 0
+
+ property var _stripTimer: Timer {
+ interval: 280
+ onTriggered: root.openStrip = ""
+ }
+ function keepStripOpen() { _stripTimer.stop() }
+ function scheduleStripClose() { _stripTimer.restart() }
+
+ // ── Recording state ───────────────────────────────────────────────────────
+ property bool recording: false
+ property int elapsed: 0
+ property string _currentFile: "" // tracked so discard can delete it
+ property bool _discarding: false // true when discardRecording() was called
+ property bool _usingNullSink: false // true while BrainShellMixer is active
+
+ readonly property string elapsedDisplay: {
+ var m = Math.floor(elapsed / 60)
+ var s = elapsed % 60
+ return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s
+ }
+
+ property var _elapsedTimer: Timer {
+ interval: 1000
+ running: root.recording
+ repeat: true
+ onTriggered: root.elapsed++
+ }
+
+ // ── Audio bars — 6 bars, always active during recording ───────────────────
+ property var audioBars: [0, 0, 0, 0, 0, 0]
+
+ // ── Config ────────────────────────────────────────────────────────────────
+ property var _configView: FileView {
+ id: configView
+ watchChanges: false
+ onLoaded: root._parseConfig(configView.text())
+ }
+
+ property var _initConfig: Process {
+ command: []
+ running: false
+ onExited: function() { configView.reload() }
+ }
+
+ Component.onCompleted: {
+ var path = Quickshell.env("HOME") + "/.config/Brain_Shell/src/user_data/screenrec.json"
+ configView.path = path
+ _initConfig.command = [
+ "bash", "-c",
+ "[ -f '" + path + "' ] || " +
+ "(mkdir -p \"$(dirname '" + path + "')\" && " +
+ "printf '{\"captureTarget\":\"screen\",\"audioMic\":false,\"audioSystem\":false}\\n'" +
+ " > '" + path + "')"
+ ]
+ _initConfig.running = true
+ }
+
+ function _parseConfig(raw) {
+ if (!raw || raw.trim() === "") return
+ try {
+ var o = JSON.parse(raw)
+ if (o.captureTarget) root.captureTarget = o.captureTarget
+ if (typeof o.audioMic === "boolean") root.audioMic = o.audioMic
+ if (typeof o.audioSystem === "boolean") root.audioSystem = o.audioSystem
+ } catch(e) {}
+ }
+
+ function saveConfig() {
+ var path = Quickshell.env("HOME") + "/.config/Brain_Shell/src/user_data/screenrec.json"
+ var data = JSON.stringify({
+ captureTarget: root.captureTarget,
+ audioMic: root.audioMic,
+ audioSystem: root.audioSystem
+ })
+ _saveProc.command = ["bash", "-c",
+ "printf '%s' '" + data.replace(/'/g, "'\\''") + "' > '" + path + "'"]
+ _saveProc.running = false
+ _saveProc.running = true
+ }
+
+ property var _saveProc: Process { command: []; running: false }
+
+ // ── Recording process ─────────────────────────────────────────────────────
+ property string _pendingGeometry: ""
+ property string _resolvedAudioDevice: ""
+
+ property var _windowPickerProc: Process {
+ command: []
+ running: false
+ stdout: StdioCollector {
+ id: windowPickerOut
+ onStreamFinished: {
+ var g = windowPickerOut.text.trim()
+ if (g !== "") root._pendingGeometry = g
+ }
+ }
+ onExited: function(exitCode, exitStatus) {
+ if (exitCode === 0 && root._pendingGeometry !== "")
+ root._resolveAudio()
+ else
+ root._pendingGeometry = ""
+ }
+ }
+
+ property var _regionPickerProc: Process {
+ command: []
+ running: false
+ stdout: StdioCollector {
+ id: regionPickerOut
+ onStreamFinished: {
+ var g = regionPickerOut.text.trim()
+ if (g !== "") root._pendingGeometry = g
+ }
+ }
+ onExited: function(exitCode, exitStatus) {
+ if (exitCode === 0 && root._pendingGeometry !== "")
+ root._resolveAudio()
+ else
+ root._pendingGeometry = ""
+ }
+ }
+
+ // Step 1: resolve/set up audio device, then launch.
+ // Reads the resolved device name from stdout (last non-empty line).
+ property var _audioDeviceProc: Process {
+ command: []
+ running: false
+ stdout: SplitParser {
+ onRead: function(line) {
+ var s = line.trim()
+ if (s !== "") root._resolvedAudioDevice = s
+ }
+ }
+ onExited: function(exitCode, exitStatus) {
+ root._launch()
+ }
+ }
+
+ // ── Audio resolution ──────────────────────────────────────────────────────
+ //
+ // none: call _launch() immediately — no audio device, no null sink.
+ // mic only: get default PulseAudio source; pass straight to wf-recorder.
+ // system only: create BrainShellMixer null sink, route sink.monitor into it.
+ // both: create BrainShellMixer null sink, route sink.monitor AND
+ // default source (mic) into it via two loopback modules.
+ //
+ function _resolveAudio() {
+ root._resolvedAudioDevice = ""
+ root._usingNullSink = false
+
+ if (!root.audioMic && !root.audioSystem) {
+ // No audio — skip device resolution entirely
+ root._launch()
+ return
+ }
+
+ if (root.audioMic && !root.audioSystem) {
+ // Mic only — use the default source directly; no null sink needed
+ _audioDeviceProc.command = ["bash", "-c",
+ "printf '%s\\n' \"$(pactl get-default-source)\""]
+ _audioDeviceProc.running = false
+ _audioDeviceProc.running = true
+ return
+ }
+
+ // System audio (alone or combined with mic) — needs BrainShellMixer
+ root._usingNullSink = true
+
+ var script =
+ // Clean up any stale modules from a previous crashed session
+ "pactl unload-module module-loopback 2>/dev/null; " +
+ "pactl unload-module module-null-sink 2>/dev/null; " +
+ "sleep 0.3; " +
+ // Create the virtual mixer sink
+ "pactl load-module module-null-sink sink_name=BrainShellMixer >/dev/null; " +
+ "sleep 0.3; " +
+ // Route system audio (default sink monitor) into BrainShellMixer
+ "pactl load-module module-loopback " +
+ "sink=BrainShellMixer source=$(pactl get-default-sink).monitor >/dev/null"
+
+ if (root.audioMic && root.audioSystem) {
+ // Also route mic into BrainShellMixer
+ script += "; pactl load-module module-loopback " +
+ "sink=BrainShellMixer source=$(pactl get-default-source) >/dev/null"
+ }
+
+ // Emit the recording device name as the final stdout line
+ script += "; printf 'BrainShellMixer.monitor\\n'"
+
+ _audioDeviceProc.command = ["bash", "-c", script]
+ _audioDeviceProc.running = false
+ _audioDeviceProc.running = true
+ }
+
+ // ── Null-sink teardown — run after every recording ends ───────────────────
+ property var _cleanupAudioProc: Process { command: []; running: false }
+
+ function _teardownNullSink() {
+ if (!root._usingNullSink) return
+ root._usingNullSink = false
+ _cleanupAudioProc.command = ["bash", "-c",
+ "pactl unload-module module-loopback 2>/dev/null; " +
+ "pactl unload-module module-null-sink 2>/dev/null"]
+ _cleanupAudioProc.running = false
+ _cleanupAudioProc.running = true
+ }
+
+ // ── Notification process ──────────────────────────────────────────────────
+ // Uses notify-send --wait + --action (libnotify ≥ 0.8 required).
+ // Saved recording: shows "View Folder" (xdg-open dir) and "Open in MPV".
+ property var _notifyProc: Process { command: []; running: false }
+
+ // ── wf-recorder process ───────────────────────────────────────────────────
+ property var _recProc: Process {
+ command: []
+ running: false
+ onExited: function(exitCode, exitStatus) {
+ var savedFile = root._currentFile // capture before it is cleared
+
+ root.recording = false
+ root.elapsed = 0
+ root._pendingGeometry = ""
+ root._currentFile = ""
+ root._cavaRecProc.running = false
+ root.audioBars = [0, 0, 0, 0, 0, 0]
+ ShellState.screenRecord = false
+
+ // Always tear down the null sink (no-op when mic-only or no-audio)
+ root._teardownNullSink()
+
+ if (!root._discarding && savedFile !== "") {
+ // Normal stop — notify with interactive action buttons.
+ // FILE/"$FILE" expands $HOME correctly inside bash.
+ _notifyProc.command = ["bash", "-c",
+ "FILE=\"" + savedFile + "\"; " +
+ "DIR=\"$(dirname \"$FILE\")\"; " +
+ "ACTION=$(notify-send" +
+ " --app-name 'ScreenRec'" +
+ " --icon 'video-x-generic'" +
+ " --action 'view=View Folder'" +
+ " --action 'open=Open in MPV'" +
+ " --wait" +
+ " 'Recording Saved' \"$FILE\"); " +
+ "case \"$ACTION\" in" +
+ " view) xdg-open \"$DIR\" ;;" +
+ " open) mpv \"$FILE\" ;;" +
+ "esac"]
+ _notifyProc.running = false
+ _notifyProc.running = true
+ }
+ // Discard path: _discardTimer handles file deletion + notification
+ }
+ }
+
+ function _buildCmd() {
+ var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss")
+ root._currentFile = "$HOME/Videos/screen_recordings/" + ts + ".mp4"
+
+ var cmd = "mkdir -p $HOME/Videos/screen_recordings && " +
+ "wf-recorder -c libx264" +
+ " -x yuv420p" +
+ " -r 30" + // Limit FPS to 30
+ " -p preset=fast" + // Faster encoding speed
+ " -p crf=26" + // Lower quality/smaller size
+ " -p profile=main" + // Maximum web/Discord compatibility
+ " -p color_range=tv" + // Fixes washed out blacks/whites
+ " -p colorspace=bt709" + // Tags the correct HD color matrix
+ " -p color_primaries=bt709" +
+ " -p color_trc=bt709" +
+ " -f " + root._currentFile
+
+ if (root._pendingGeometry !== "")
+ cmd += " -g '" + root._pendingGeometry + "'"
+
+ // Use --audio=DEVICE (matches wf-recorder working script convention) [cite: 48]
+ if ((root.audioMic || root.audioSystem) && root._resolvedAudioDevice !== "")
+ cmd += " --audio=" + root._resolvedAudioDevice
+
+ return cmd
+}
+
+ function _launch() {
+ _recProc.command = ["bash", "-c", root._buildCmd()]
+ _recProc.running = false
+ _recProc.running = true
+ root.recording = true
+ root.elapsed = 0
+ root.openStrip = ""
+ if (root._resolvedAudioDevice !== "")
+ _startCavaWithSource(root._resolvedAudioDevice)
+ }
+
+ function startRecording() {
+ root._pendingGeometry = ""
+ root._discarding = false
+ saveConfig()
+ if (root.captureTarget === "screen") {
+ root._resolveAudio()
+ } else if (root.captureTarget === "window") {
+ _windowPickerProc.command = [
+ "bash", "-c",
+ "hyprctl clients -j | python3 -c \"" +
+ "import sys,json; ws=json.load(sys.stdin); " +
+ "[print(str(w['at'][0])+','+str(w['at'][1])+' '+str(w['size'][0])+'x'+str(w['size'][1])) " +
+ "for w in ws if w['mapped']]\" | slurp"
+ ]
+ _windowPickerProc.running = false
+ _windowPickerProc.running = true
+ } else {
+ _regionPickerProc.command = [
+ "bash", "-c",
+ "hyprctl monitors -j | python3 -c \"" +
+ "import sys,json; ms=json.load(sys.stdin); " +
+ "[print(str(m['x'])+','+str(m['y'])+' '+str(m['width'])+'x'+str(m['height'])) for m in ms]\" | slurp"
+ ]
+ _regionPickerProc.running = false
+ _regionPickerProc.running = true
+ }
+ }
+
+ function stopRecording() {
+ _sigProc.command = ["bash", "-c", "pkill -INT wf-recorder"]
+ _sigProc.running = false
+ _sigProc.running = true
+ }
+
+ function discardRecording() {
+ root._discarding = true
+ var fileToDelete = root._currentFile
+ // Kill wf-recorder; _recProc.onExited will see _discarding=true and skip
+ // the saved notification. The timer below handles delete + notify.
+ _sigProc.command = ["bash", "-c", "pkill -INT wf-recorder"]
+ _sigProc.running = false
+ _sigProc.running = true
+ _discardTimer.fileToDelete = fileToDelete
+ _discardTimer.restart()
+ }
+
+ property var _discardTimer: Timer {
+ property string fileToDelete: ""
+ interval: 800
+ onTriggered: {
+ if (fileToDelete !== "") {
+ var f = fileToDelete
+ _discardDeleteProc.command = ["bash", "-c",
+ "rm -f \"" + f + "\" && " +
+ "notify-send" +
+ " --app-name 'ScreenRec'" +
+ " --icon 'video-x-generic'" +
+ " 'Recording Discarded'" +
+ " 'The recording was deleted.'"]
+ _discardDeleteProc.running = false
+ _discardDeleteProc.running = true
+ fileToDelete = ""
+ root._discarding = false
+ }
+ }
+ }
+
+ property var _discardDeleteProc: Process { command: []; running: false }
+
+ function cancelSetup() {
+ root.openStrip = ""
+ ShellState.screenRecord = false
+ }
+
+ property var _sigProc: Process { command: []; running: false }
+
+ // ── Cava — runs during recording, source mirrors wf-recorder's audio ──────
+ property var _cavaRecProc: Process {
+ command: []
+ running: false
+ stdout: SplitParser {
+ onRead: function(line) {
+ if (!root.recording) return
+ var t = line.trim()
+ if (t === "") return
+ if (t.endsWith(";")) t = t.slice(0, -1)
+ var parts = t.split(";")
+ if (parts.length !== 12) return
+ var bars = []
+ for (var i = 0; i < 12; i++) bars.push(parseInt(parts[i]) || 0)
+ root.audioBars = bars
+ }
+ }
+ }
+
+ function _startCavaWithSource(src) {
+ var config =
+ "[general]\nbars = 12\nframerate = 20\nnoise_reduction = 77\n\n" +
+ "[output]\nmethod = raw\nraw_target = /dev/stdout\n" +
+ "data_format = ascii\nascii_max_range = 100\n" +
+ "bar_delimiter = 59\nframe_delimiter = 10\n\n" +
+ "[input]\nmethod = pulse\nsource = " + src + "\n"
+
+ _cavaRecProc.command = [
+ "bash", "-c",
+ "mkdir -p /tmp/brain_shell && printf '%s\\n' '" +
+ config.replace(/'/g, "'\\''") +
+ "' > /tmp/brain_shell/cava_rec.ini && " +
+ "exec cava -p /tmp/brain_shell/cava_rec.ini 2>/dev/null"
+ ]
+ _cavaRecProc.running = false
+ _cavaRecProc.running = true
+ }
+}
diff --git a/src/services/ScreenshotTool.qml b/src/services/ScreenshotTool.qml
new file mode 100644
index 0000000..9b04ec5
--- /dev/null
+++ b/src/services/ScreenshotTool.qml
@@ -0,0 +1,114 @@
+pragma Singleton
+import QtQuick
+import Quickshell.Io
+import Quickshell
+import "../"
+
+/*!
+ ScreenshotTool — screenshot utility using grim + slurp.
+
+ Modes: screen, window, region.
+ Saves to ~/Pictures/Screenshots/ with timestamp.
+ Supports copy-to-clipboard.
+*/
+QtObject {
+ id: root
+
+ property bool capturing: false
+
+ property var _notifyProc: Process { command: []; running: false }
+ property var _captureProc: Process {
+ command: []
+ running: false
+ onExited: function(exitCode) {
+ root.capturing = false
+ if (exitCode === 0 && root._lastFile !== "") {
+ _notifyProc.command = ["bash", "-c",
+ "FILE=\"" + root._lastFile + "\"; " +
+ "DIR=\"$(dirname \"$FILE\")\"; " +
+ "ACTION=$(notify-send" +
+ " --app-name 'Screenshot'" +
+ " --icon 'camera-photo-symbolic'" +
+ " --action 'view=View Folder'" +
+ " --action 'copy=Copy to Clipboard'" +
+ " --wait" +
+ " 'Screenshot Saved' \"$FILE\"); " +
+ "case \"$ACTION\" in" +
+ " view) xdg-open \"$DIR\" ;;" +
+ " copy) wl-copy < \"$FILE\" ;;" +
+ "esac"]
+ _notifyProc.running = true
+ }
+ }
+ }
+
+ property string _lastFile: ""
+ property var _windowPickerProc: Process {
+ command: []
+ running: false
+ stdout: StdioCollector {
+ id: windowPickerOut
+ onStreamFinished: {
+ var g = windowPickerOut.text.trim()
+ if (g !== "") root._doCapture(g)
+ else root.capturing = false
+ }
+ }
+ }
+
+ property var _regionPickerProc: Process {
+ command: []
+ running: false
+ stdout: StdioCollector {
+ id: regionPickerOut
+ onStreamFinished: {
+ var g = regionPickerOut.text.trim()
+ if (g !== "") root._doCapture(g)
+ else root.capturing = false
+ }
+ }
+ }
+
+ function captureScreen() {
+ root._doCapture("")
+ }
+
+ function captureWindow() {
+ root.capturing = true
+ _windowPickerProc.command = [
+ "bash", "-c",
+ "hyprctl clients -j | python3 -c \"" +
+ "import sys,json; ws=json.load(sys.stdin); " +
+ "[print(str(w['at'][0])+','+str(w['at'][1])+' '+str(w['size'][0])+'x'+str(w['size'][1])) " +
+ "for w in ws if w['mapped']]\" | slurp"
+ ]
+ _windowPickerProc.running = false
+ _windowPickerProc.running = true
+ }
+
+ function captureRegion() {
+ root.capturing = true
+ _regionPickerProc.command = [
+ "bash", "-c",
+ "hyprctl monitors -j | python3 -c \"" +
+ "import sys,json; ms=json.load(sys.stdin); " +
+ "[print(str(m['x'])+','+str(m['y'])+' '+str(m['width'])+'x'+str(m['height'])) for m in ms]\" | slurp"
+ ]
+ _regionPickerProc.running = false
+ _regionPickerProc.running = true
+ }
+
+ function _doCapture(geometry) {
+ root.capturing = true
+ var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss")
+ root._lastFile = Quickshell.env("HOME") + "/Pictures/Screenshots/" + ts + ".png"
+
+ var cmd = "mkdir -p $HOME/Pictures/Screenshots && grim"
+ if (geometry !== "") cmd += " -g '" + geometry + "'"
+ cmd += " '" + root._lastFile + "'"
+
+ _captureProc.command = ["bash", "-c", cmd]
+ _captureProc.running = false
+ _captureProc.running = true
+ }
+}
diff --git a/src/services/ShellConfigService.qml b/src/services/ShellConfigService.qml
new file mode 100644
index 0000000..f91d0af
--- /dev/null
+++ b/src/services/ShellConfigService.qml
@@ -0,0 +1,102 @@
+pragma Singleton
+import QtQuick
+import Quickshell.Io
+import Quickshell
+import "."
+
+/*!
+ ShellConfigService — reactive JSON configuration for Brain_Shell.
+
+ Persists to ~/.config/Brain_Shell/src/user_data/shell_config.json
+*/
+QtObject {
+ id: root
+
+ // ── Config values ─────────────────────────────────────────────────────────
+ property real animationSpeed: 1.0
+ property bool barEnabled: true
+ property int dashboardWidth: 900
+ property int dashboardHeight: 520
+ property bool focusModeOnStartup: false
+ property bool autoUpdateCheck: true
+
+ readonly property string _filePath: Quickshell.env("HOME") + "/.config/Brain_Shell/src/user_data/shell_config.json"
+
+ // ── Load / Save ───────────────────────────────────────────────────────────
+ property var _file: FileView {
+ id: configFile
+ path: root._filePath
+ watchChanges: true
+ onFileChanged: root._parse(text())
+ onLoaded: root._parse(text())
+ }
+
+ property Process _ensureProc: Process {
+ command: ["bash", "-c",
+ "[ -f '" + root._filePath + "' ] || " +
+ "(mkdir -p \"$(dirname '" + root._filePath + "')\" && " +
+ "printf '%s' '{}' > '" + root._filePath + "')"]
+ running: false
+ }
+
+ property Process _saveProc: Process {
+ command: []
+ running: false
+ }
+
+ Component.onCompleted: {
+ _ensureProc.running = false
+ _ensureProc.running = true
+ }
+
+ function _parse(raw) {
+ if (!raw || raw.trim() === "") return
+ try {
+ var obj = JSON.parse(raw)
+
+ if (typeof obj.animationSpeed === "number") root.animationSpeed = obj.animationSpeed
+ if (typeof obj.barEnabled === "boolean") root.barEnabled = obj.barEnabled
+ if (typeof obj.dashboardWidth === "number") root.dashboardWidth = obj.dashboardWidth
+ if (typeof obj.dashboardHeight === "number") root.dashboardHeight = obj.dashboardHeight
+ if (typeof obj.focusModeOnStartup === "boolean") root.focusModeOnStartup = obj.focusModeOnStartup
+ if (typeof obj.autoUpdateCheck === "boolean") root.autoUpdateCheck = obj.autoUpdateCheck
+ } catch (e) {
+ console.warn("ShellConfigService: failed to parse config:", e)
+ }
+ }
+
+ function save() {
+ var data = JSON.stringify({
+ animationSpeed: root.animationSpeed,
+ barEnabled: root.barEnabled,
+ dashboardWidth: root.dashboardWidth,
+ dashboardHeight: root.dashboardHeight,
+ focusModeOnStartup: root.focusModeOnStartup,
+ autoUpdateCheck: root.autoUpdateCheck
+ }, null, 2)
+ _saveProc.command = ["bash", "-c",
+ "mkdir -p \"$(dirname '" + _filePath + "')\" && " +
+ "printf '%s' '" + data.replace(/'/g, "'\\''") + "' > '" + _filePath + "'"]
+ _saveProc.running = false
+ _saveProc.running = true
+ }
+
+ function resetToDefaults() {
+ var defaults = { animationSpeed: 1.0, barEnabled: true, dashboardWidth: 900, dashboardHeight: 520, focusModeOnStartup: false, autoUpdateCheck: true };
+
+ animationSpeed = defaults.animationSpeed || 1.0;
+ barEnabled = defaults.barEnabled !== false;
+ dashboardWidth = defaults.dashboardWidth || 900;
+ dashboardHeight = defaults.dashboardHeight || 520;
+ focusModeOnStartup = defaults.focusModeOnStartup || false;
+ autoUpdateCheck = defaults.autoUpdateCheck !== false;
+ save()
+ }
+
+ function clearAllData() {
+ NotificationService.clearHistory()
+ ClipboardService.wipeHistory()
+ UsageTracker._scores = {}
+ UsageTracker._save()
+ }
+}
diff --git a/src/services/UpdateService.qml b/src/services/UpdateService.qml
new file mode 100644
index 0000000..ddce043
--- /dev/null
+++ b/src/services/UpdateService.qml
@@ -0,0 +1,307 @@
+pragma Singleton
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import "../"
+
+// UpdateService — startup update checker (30s delay).
+// Persistent autoUpdate preference stored in src/user_data/update_prefs.json.
+QtObject {
+ id: root
+
+ // ── Persistent preference ──────────────────────────────────────────────
+ property bool autoUpdate: true
+
+ // ── Live state (drives UpdatePopup) ───────────────────────────────────
+ property bool checking: false
+ property bool updating: false
+ property bool updateAvailable: false
+ property bool hasConflict: false
+ property bool updateSuccess: false
+
+ property int commitsBehind: 0
+ property var commitMessages: []
+ property string lastError: ""
+ property int _pingAttempts: 0
+ property int _pingMaxAttempts: 12
+
+ property var _pingRetryTimer: Timer {
+ interval: 5000
+ repeat: false
+ onTriggered: root._pingCheck()
+ }
+
+ property var _pingProc: Process {
+ command: ["ping", "-c", "1", "-W", "3", "1.1.1.1"]
+ running: false
+ onExited: function(code) {
+ if (code === 0) {
+ root._pingAttempts = 0
+ root.check()
+ } else {
+ root._pingAttempts++
+ console.log("Ping attempt " + root._pingAttempts + " failed, retrying...")
+ if (root._pingAttempts < root._pingMaxAttempts) {
+ root._pingRetryTimer.restart()
+ } else {
+ root._pingAttempts = 0 // silent cancel
+ console.log("Max ping attempts reached. Update check aborted.")
+ }
+ }
+ }
+ }
+
+ function _startConnectivityCheck() {
+ root._pingAttempts = 0
+ root._pingCheck()
+ console.log("Started connectivity check for updates.")
+ }
+
+ function _pingCheck() {
+ root._pingProc.running = false
+ root._pingProc.running = true
+ }
+
+ // Popup is only shown when autoUpdate is enabled
+ readonly property bool showPopup:
+ autoUpdate && (
+ updateAvailable ||
+ updating ||
+ hasConflict ||
+ updateSuccess ||
+ (lastError !== "" && !checking)
+ )
+
+ // ── Paths ──────────────────────────────────────────────────────────────
+ readonly property string _dir: Quickshell.env("HOME") + "/.local/src/Brain_Shell"
+ readonly property string _cfgPath: Quickshell.env("HOME") + "/.config/Brain_Shell/src/user_data/update_prefs.json"
+
+ // ── Startup: 30s delay ─────────────────────────────────────────────────
+ property var _startTimer: Timer {
+ interval: 30000
+ repeat: false
+ running: false
+ onTriggered: root._startConnectivityCheck()
+ }
+
+ // ── Config: init → read → arm timer ───────────────────────────────────
+ property var _initProc: Process {
+ command: ["bash", "-c",
+ // Ensure pref file exists
+ "[ -f '" + root._cfgPath + "' ] || " +
+ "(mkdir -p \"$(dirname '" + root._cfgPath + "')\" && " +
+ "printf '%s' '{\"autoUpdate\":true}' > '" + root._cfgPath + "');\n" +
+ // Emit prefs
+ "cat '" + root._cfgPath + "'"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ var o = JSON.parse(text.trim())
+ if (typeof o.autoUpdate === "boolean")
+ root.autoUpdate = o.autoUpdate
+ console.log("UpdateService: Loaded config. autoUpdate=" + root.autoUpdate)
+ } catch(e) {
+ console.log("UpdateService: Failed to parse config JSON:", e)
+ }
+
+ if (root.autoUpdate) {
+ console.log("UpdateService: Auto-update enabled. Starting 30s delay timer.")
+ root._startTimer.start()
+ } else {
+ console.log("UpdateService: Auto-update disabled.")
+ }
+ }
+ }
+ }
+
+ // ── Config: save ──────────────────────────────────────────────────────
+ property var _saveProc: Process { command: []; running: false }
+
+ function _saveConfig() {
+ console.log("UpdateService: Saving config. autoUpdate=" + root.autoUpdate)
+ var json = JSON.stringify({ autoUpdate: root.autoUpdate })
+ _saveProc.command = ["bash", "-c",
+ "printf '%s' '" + json.replace(/'/g, "'\\''") +
+ "' > '" + root._cfgPath + "'"]
+ _saveProc.running = false
+ _saveProc.running = true
+ }
+
+ // ── Step 1: fetch origin/main ──────────────────────────────────────────
+ property var _fetchProc: Process {
+ command: ["git", "-C", root._dir, "fetch", "origin", "main", "--quiet"]
+ running: false
+ onExited: function(code) {
+ if (code !== 0) {
+ console.log("UpdateService: git fetch failed with code " + code)
+ root.checking = false
+ root.lastError = "Could not reach remote. Check your connection."
+ return
+ }
+ console.log("UpdateService: git fetch successful.")
+ _countProc.running = false
+ _countProc.running = true
+ }
+ }
+
+ // ── Step 2: count commits behind ──────────────────────────────────────
+ property var _countProc: Process {
+ command: ["bash", "-c",
+ "git -C '" + root._dir + "' rev-list --count HEAD..origin/main 2>/dev/null"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var n = parseInt(text.trim())
+ root.commitsBehind = isNaN(n) ? 0 : n
+ console.log("UpdateService: Commits behind origin/main: " + root.commitsBehind)
+ if (root.commitsBehind > 0) {
+ _logProc.running = false
+ _logProc.running = true
+ } else {
+ root.checking = false
+ console.log("UpdateService: Up to date.")
+ }
+ }
+ }
+ }
+
+ // ── Step 3: read commit log ────────────────────────────────────────────
+ property var _logProc: Process {
+ command: ["bash", "-c",
+ "git -C '" + root._dir +
+ "' log HEAD..origin/main --oneline --no-decorate 2>/dev/null"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var lines = text.trim()
+ .split("\n")
+ .filter(function(l) { return l.trim() !== "" })
+ root.commitMessages = lines
+ root.checking = false
+ root.updateAvailable = true
+ }
+ }
+ }
+
+ // ── Git pull ───────────────────────────────────────────────────────────
+ property var _pullProc: Process {
+ command: ["git", "-C", root._dir, "pull", "origin", "main"]
+ running: false
+ onExited: function(code) {
+ root.updating = false
+ if (code === 0) {
+ console.log("UpdateService: git pull successful.")
+ root.updateAvailable = false
+ root.hasConflict = false
+ root.lastError = ""
+ root.updateSuccess = true
+ } else {
+ console.log("UpdateService: git pull failed with code " + code + ". (Likely local conflict)")
+ // fetch succeeded earlier, so failure = local changes conflict
+ root.hasConflict = true
+ root.lastError = ""
+ }
+ }
+ }
+
+ // ── Stash local changes, then pull ────────────────────────────────────
+ // stash pop is intentionally omitted — shell reloads after update anyway.
+ // User can `git stash pop` manually if they want changes back.
+ property var _stashPullProc: Process {
+ command: ["bash", "-c",
+ // stash with || true so an empty worktree doesn't abort the whole chain
+ "git -C '" + root._dir + "' stash push -m 'brain-shell-pre-update' 2>/dev/null || true; " +
+ "git -C '" + root._dir + "' pull origin main 2>&1"]
+ running: false
+ onExited: function(code) {
+ root.updating = false
+ if (code === 0) {
+ console.log("UpdateService: git stash + pull successful.")
+ root.updateAvailable = false
+ root.hasConflict = false
+ root.lastError = ""
+ root.updateSuccess = true
+ } else {
+ console.log("UpdateService: git stash + pull failed with code " + code)
+ root.hasConflict = false
+ root.lastError = "Stash + pull failed. Try manually: git pull origin main"
+ }
+ }
+ }
+
+ // ── Public API ─────────────────────────────────────────────────────────
+
+ function check() {
+ console.log("UpdateService: check() triggered")
+ if (root.checking || root.updating) return
+ root.checking = true
+ root.lastError = ""
+ root.updateAvailable = false
+ root.updateSuccess = false
+ root.hasConflict = false
+
+ // Pre-check: verify git repo exists before attempting fetch
+ _preCheckProc.running = false
+ _preCheckProc.running = true
+ }
+
+ property var _preCheckProc: Process {
+ command: ["bash", "-c",
+ "test -d '" + root._dir + "/.git' && git -C '" + root._dir + "' remote get-url origin 2>/dev/null"]
+ running: false
+ onExited: function(code) {
+ if (code !== 0) {
+ console.log("UpdateService: Not a valid git repo — skipping update check")
+ root.checking = false
+ root.lastError = ""
+ return
+ }
+ _fetchProc.running = false
+ _fetchProc.running = true
+ }
+ }
+
+ function applyUpdate() {
+ console.log("UpdateService: applyUpdate() triggered")
+ if (root.updating) return
+ root.updating = true
+ root.hasConflict = false
+ root.lastError = ""
+ root.updateSuccess = false
+ _pullProc.running = false
+ _pullProc.running = true
+ }
+
+ function stashAndUpdate() {
+ console.log("UpdateService: stashAndUpdate() triggered")
+ if (root.updating) return
+ root.updating = true
+ root.hasConflict = false
+ root.lastError = ""
+ root.updateSuccess = false
+ _stashPullProc.running = false
+ _stashPullProc.running = true
+ }
+
+ function dismiss() {
+ console.log("UpdateService: dismiss() triggered")
+ root.updateAvailable = false
+ root.hasConflict = false
+ root.lastError = ""
+ root.updateSuccess = false
+ }
+
+ function disableAutoUpdate() {
+ console.log("UpdateService: disableAutoUpdate() triggered")
+ root.autoUpdate = false
+ root.updateAvailable = false
+ root.hasConflict = false
+ root.lastError = ""
+ root.updateSuccess = false
+ root._startTimer.stop()
+ _saveConfig()
+ }
+
+ Component.onCompleted: _initProc.running = true
+}
\ No newline at end of file
diff --git a/src/services/UsageTracker.qml b/src/services/UsageTracker.qml
new file mode 100644
index 0000000..846376b
--- /dev/null
+++ b/src/services/UsageTracker.qml
@@ -0,0 +1,76 @@
+pragma Singleton
+import QtQuick
+import Quickshell.Io
+import Quickshell
+
+/*!
+ UsageTracker — tracks app launch frequency for sorting + recent apps.
+
+ Persists to ~/.config/Brain_Shell/src/user_data/usage.json
+*/
+QtObject {
+ id: root
+
+ property var _scores: ({})
+ readonly property string _filePath: Quickshell.env("HOME") + "/.config/Brain_Shell/src/user_data/usage.json"
+
+ // ── Declarative processes (reusable, no Qt.createQmlObject overhead) ──
+
+ property Process _loadProc: Process {
+ command: ["bash", "-c",
+ "[ -f '" + root._filePath + "' ] && cat '" + root._filePath + "' || echo '{}'"]
+ running: false
+ stdout: SplitParser {
+ onRead: function(line) {
+ try {
+ root._scores = JSON.parse(line.trim());
+ } catch (e) {
+ root._scores = {};
+ }
+ }
+ }
+ }
+
+ property Process _saveProc: Process {
+ command: []
+ running: false
+ }
+
+ property var _initTimer: Timer {
+ interval: 0
+ running: true
+ onTriggered: {
+ root._loadProc.running = false
+ root._loadProc.running = true
+ }
+ }
+
+ function _save() {
+ var data = JSON.stringify(_scores);
+ _saveProc.command = ["bash", "-c",
+ "mkdir -p \"$(dirname '" + _filePath + "')\" && " +
+ "printf '%s' '" + data.replace(/'/g, "'\\''") + "' > '" + _filePath + "'"];
+ _saveProc.running = false;
+ _saveProc.running = true;
+ }
+
+ function recordLaunch(appId) {
+ var s = _scores[appId] || 0;
+ _scores[appId] = s + 1;
+ _save();
+ }
+
+ function getUsageScore(appId) {
+ return _scores[appId] || 0;
+ }
+
+ function getRecentApps(limit) {
+ limit = limit || 10;
+ var entries = [];
+ for (var id in _scores) {
+ entries.push({ id: id, score: _scores[id] });
+ }
+ entries.sort((a, b) => b.score - a.score);
+ return entries.slice(0, limit).map(e => e.id);
+ }
+}
diff --git a/src/services/VideoWallpaperService.qml b/src/services/VideoWallpaperService.qml
new file mode 100644
index 0000000..d781889
--- /dev/null
+++ b/src/services/VideoWallpaperService.qml
@@ -0,0 +1,187 @@
+pragma Singleton
+import QtQuick
+import Quickshell.Io
+import Quickshell
+
+/*!
+ VideoWallpaperService — hardware-accelerated video wallpaper optimizer.
+ Ported from NothingLess. Handles:
+
+ - GPU-aware FFmpeg downscaling (4K → 1080p/720p)
+ - Cached low-res versions (~/.cache/Brain_Shell/video-cache/)
+ - Frame skipping to target FPS
+ - Multi-screen dedup (same cache for same video)
+ - Pause/resume on screen lock (via IdleService)
+*/
+QtObject {
+ id: root
+
+ readonly property string cacheDir: Quickshell.env("HOME") + "/.cache/Brain_Shell/video-cache"
+
+ // Target FPS for video wallpapers (lower = less GPU)
+ property int targetFps: {
+ if (GpuDetector.hasHardwareDecoder) return 24
+ return 15
+ }
+
+ // Max resolution height (0 = native, no downscale)
+ property int targetHeight: 1080
+
+ // ── Video detection ───────────────────────────────────────────────────────
+ function isVideo(path) {
+ if (!path) return false;
+ return /\.(mp4|webm|mkv|mov|avi|gif)$/i.test(path);
+ }
+
+ // ── HW encoder per GPU ────────────────────────────────────────────────────
+ readonly property string hwEncoder: {
+ if (GpuDetector.isIntel) return "h264_qsv";
+ if (GpuDetector.isAmd) return "h264_vaapi";
+ if (GpuDetector.isNvidia) return "h264_nvenc";
+ return "";
+ }
+
+ readonly property string hwScaleFilter: {
+ if (GpuDetector.isIntel) return "scale_qsv";
+ if (GpuDetector.isAmd) return "scale_vaapi";
+ if (GpuDetector.isNvidia) return "scale_cuda";
+ return "scale";
+ }
+
+ // ── File hash for cache names ─────────────────────────────────────────────
+ function _hashPath(path) {
+ var hash = 5381;
+ for (var i = 0; i < path.length; i++) {
+ hash = ((hash << 5) + hash) + path.charCodeAt(i);
+ hash = hash & hash;
+ }
+ return Math.abs(hash).toString(16);
+ }
+
+ function getCachePath(originalPath) {
+ if (!originalPath || targetHeight === 0) return originalPath;
+ if (!isVideo(originalPath)) return originalPath;
+
+ var hash = _hashPath(originalPath);
+ // Always output H.264 MP4 — ensures hardware decode on all GPUs.
+ // VP9/WebM would fall back to CPU decode on Intel (no VAAPI VP9).
+ return cacheDir + "/" + hash + "-" + targetHeight + "p.mp4";
+ }
+
+ // ── Get effective path (synchronous — returns cache path if exists, else compute and return) ─
+ function getEffectivePath(originalPath) {
+ if (!originalPath || targetHeight === 0) return originalPath;
+ if (!isVideo(originalPath)) return originalPath;
+ var hash = _hashPath(originalPath);
+ return cacheDir + "/" + hash + "-" + targetHeight + "p.mp4";
+ }
+
+ // ── Generate cached H.264 MP4 version (hardware-decodable on all GPUs) ───
+ function _generateCache(originalPath, cachePath, callback) {
+ if (genProc.running && root._generatingPath === cachePath) {
+ root._genCallbacks.push(callback);
+ return;
+ }
+
+ mkdirProc.running = false;
+ mkdirProc.running = true;
+
+ // Detect source codec to decide if transcoding is needed
+ var srcCodec = GpuDetector.detectCodecFromPath(originalPath);
+ var needsTranscode = (srcCodec !== "h264");
+
+ if (!needsTranscode && targetHeight === 0) {
+ if (callback) callback(originalPath);
+ return;
+ }
+
+ var cmd = "ffmpeg -y -loglevel error -hwaccel none -i '" + originalPath + "'";
+ cmd += " -filter:v fps=" + targetFps;
+ if (targetHeight > 0) {
+ cmd += ",scale=-2:" + targetHeight + ":flags=lanczos";
+ }
+ cmd += ",format=yuv420p";
+
+ if (GpuDetector.hasHardwareDecoder && hwEncoder !== "") {
+ cmd += " -c:v " + hwEncoder + " -preset fast -b:v 2M -maxrate 4M -bufsize 4M";
+ } else {
+ cmd += " -c:v libx264 -preset veryfast -crf 26";
+ }
+
+ cmd += " -an -movflags +faststart '" + cachePath + "'";
+
+ root._generatingPath = cachePath;
+ root._genCallbacks = callback ? [callback] : [];
+ root._genOriginal = originalPath;
+
+ genProc.command = ["bash", "-c", cmd];
+ genProc.running = false;
+ genProc.running = true;
+ }
+
+ property string _genOriginal: ""
+
+ property Process genProc: Process {
+ running: false
+ stdout: StdioCollector {}
+ stderr: StdioCollector {}
+ onExited: function(code) {
+ var success = code === 0;
+ var cachePath = root._generatingPath;
+
+ if (!success) {
+ console.warn("[VideoWallpaperService] cache generation failed (exit", code + ")");
+ } else {
+ console.log("[VideoWallpaperService] cached →", cachePath);
+ }
+
+ var cbs = root._genCallbacks;
+ root._genCallbacks = [];
+ root._generatingPath = "";
+ var original = root._genOriginal;
+ root._genOriginal = "";
+
+ for (var i = 0; i < cbs.length; i++) {
+ if (cbs[i]) cbs[i](success ? cachePath : original);
+ }
+ }
+ }
+
+ property var _genCallbacks: []
+ property string _generatingPath: ""
+
+ property Process mkdirProc: Process {
+ command: ["mkdir", "-p", root.cacheDir]
+ running: false
+ }
+
+ // ── Clear cache ───────────────────────────────────────────────────────────
+ function clearCache() {
+ clearProc.running = false;
+ clearProc.running = true;
+ }
+
+ property Process clearProc: Process {
+ command: ["bash", "-c", "rm -rf '" + root.cacheDir + "'/* 2>/dev/null; true"]
+ running: false
+ }
+
+ // ── Optimize query ────────────────────────────────────────────────────────
+ function optimize(wallpaperPath) {
+ return {
+ fps: root.targetFps,
+ useHardware: GpuDetector.hasHardwareDecoder,
+ isVideo: isVideo(wallpaperPath)
+ };
+ }
+
+ // Check if source needs transcoding for hardware decode
+ function needsTranscode(path) {
+ if (!isVideo(path)) return false;
+ return GpuDetector.detectCodecFromPath(path) !== "h264";
+ }
+
+ Component.onDestruction: {
+ if (genProc.running !== undefined) genProc.running = false;
+ }
+}
diff --git a/src/services/WallpaperService.qml b/src/services/WallpaperService.qml
new file mode 100644
index 0000000..a8517bf
--- /dev/null
+++ b/src/services/WallpaperService.qml
@@ -0,0 +1,446 @@
+pragma Singleton
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import "../"
+
+/*!
+ WallpaperService — wallpaper list + apply pipeline + thumbnail cache.
+
+ Replicates NothingLess' approach:
+ - Wallpaper change is INSTANT (sets property, no shell blocking)
+ - Matugen runs as fire-and-forget background process
+ - Lockscreen frame generation is fire-and-forget
+ - Symlink + static frame generated in background
+*/
+QtObject {
+ id: root
+
+ // ── Config paths ─────────────────────────────────────────────────────────
+ readonly property string configPath:
+ Quickshell.env("HOME") + "/.config/Brain_Shell/src/user_data/wallpaper.json"
+ readonly property string cacheDir:
+ Quickshell.env("HOME") + "/.cache/Brain_Shell"
+ readonly property string thumbDir: cacheDir + "/thumbnails"
+ readonly property string lockscreenDir: cacheDir + "/lockscreen"
+
+ readonly property string fallbackDir:
+ decodeURIComponent(Qt.resolvedUrl("../assets/wallpapers").toString().replace("file://", ""))
+
+ // ── State ─────────────────────────────────────────────────────────────────
+ property var wallpapers: []
+ property string currentWall: ""
+ property string previewWall: ""
+ property string scheme: "content"
+ property bool applying: false
+ property string wallpaperDir: "~/Pictures/Wallpapers"
+ property var perScreenWallpapers: ({})
+
+ // Subfolder / filter support
+ property var activeFilters: []
+ property var subfolderFilters: []
+ property var allSubdirs: []
+
+ // Thumbnails — deterministic proxy paths (NothingLess approach, no hash map)
+ property bool thumbsReady: false
+ property string _thumbDir: thumbDir
+
+ // Dedup matugen runs
+ property string _lastMatugenWall: ""
+ property string _lastMatugenScheme: ""
+
+ readonly property var schemes: [
+ "content", "tonal-spot", "fidelity", "fruit-salad", "neutral", "monochrome",
+ "expressive", "rainbow"
+ ]
+
+ signal wallpaperApplied(string path)
+ signal thumbnailsReady()
+ signal wallpapersRefreshed()
+ signal lockscreenFrameReady(string framePath)
+
+ // ── File type detection ──────────────────────────────────────────────────
+ function getFileType(path) {
+ if (!path) return "unknown"
+ var ext = path.toLowerCase().split('.').pop()
+ if (['jpg','jpeg','png','webp','tif','tiff','bmp'].includes(ext)) return 'image'
+ if (['gif'].includes(ext)) return 'gif'
+ if (['mp4','webm','mkv','mov','avi'].includes(ext)) return 'video'
+ return 'unknown'
+ }
+
+ function getColorSource(filePath) {
+ var ft = root.getFileType(filePath)
+ if (ft === 'video' || ft === 'gif') return root.thumbnailFor(filePath) || filePath
+ return filePath
+ }
+
+ function getLockscreenFramePath(filePath) {
+ if (!filePath) return ""
+ var ft = root.getFileType(filePath)
+ if (ft === 'image') return filePath
+ if (ft === 'video' || ft === 'gif')
+ return root.lockscreenDir + "/" + filePath.split('/').pop() + ".jpg"
+ return filePath
+ }
+
+ // ── Deterministic thumbnail path (NothingLess proxy structure) ──────────
+ function thumbnailFor(filePath) {
+ if (!filePath) return ""
+ // Normalize wallpaperDir to absolute (expand ~ if present)
+ var base = root.wallpaperDir
+ if (base.startsWith("~/"))
+ base = Quickshell.env("HOME") + base.substring(1)
+ if (!base.endsWith("/")) base += "/"
+ if (!filePath.startsWith(base)) return ""
+ var rel = filePath.substring(base.length)
+ return root.thumbDir + "/" + rel + ".jpg"
+ }
+
+ // ── Filter utilities ─────────────────────────────────────────────────────
+ readonly property var filteredWallpapers: {
+ var all = root.wallpapers
+ if (!all || !all.length) return []
+ var sf = root.subfolderFilters
+ if (sf && sf.length) {
+ var base = root.wallpaperDir; if (!base.endsWith("/")) base += "/"
+ var f = []; for (var i = 0; i < all.length; i++) {
+ var rel = all[i]; if (rel.startsWith(base)) rel = rel.substring(base.length)
+ if (sf.includes(rel.split("/")[0])) f.push(all[i])
+ }; all = f
+ }
+ var af = root.activeFilters
+ if (af && af.length) {
+ var r = []; for (var j = 0; j < all.length; j++) {
+ if (af.includes(root.getFileType(all[j]))) r.push(all[j])
+ }; return r
+ }
+ return all
+ }
+
+ function toggleFilter(type) {
+ var c = root.activeFilters.slice(); var i = c.indexOf(type)
+ if (i >= 0) c.splice(i, 1); else c.push(type); root.activeFilters = c
+ }
+ function toggleSubfolder(f) {
+ var c = root.subfolderFilters.slice(); var i = c.indexOf(f)
+ if (i >= 0) c.splice(i, 1); else c.push(f); root.subfolderFilters = c
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // NAVIGATION — NothingLess style: set property, matugen in background
+ // ═══════════════════════════════════════════════════════════════════════════
+ function nextWallpaper() {
+ if (!root.wallpapers.length) return
+ var idx = root.wallpapers.indexOf(root.currentWall)
+ idx = (idx < 0 || idx >= root.wallpapers.length - 1) ? 0 : idx + 1
+ root._setWallpaper(root.wallpapers[idx])
+ }
+
+ function previousWallpaper() {
+ if (!root.wallpapers.length) return
+ var idx = root.wallpapers.indexOf(root.currentWall)
+ idx = (idx <= 0) ? root.wallpapers.length - 1 : idx - 1
+ root._setWallpaper(root.wallpapers[idx])
+ }
+
+ function setWallpaperByIndex(index) {
+ if (index >= 0 && index < root.wallpapers.length)
+ root._setWallpaper(root.wallpapers[index])
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // CORE: Instant wallpaper change + fire-and-forget side effects
+ //
+ // NothingLess pattern:
+ // 1. Set currentWall → triggers renderer crossfade (INSTANT)
+ // 2. Spawn background processes (matugen, symlink, frame) — non-blocking
+ // 3. Config saved after background work finishes
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ // Public API — user clicked "Apply" in the popup
+ function apply(path, screen) {
+ if (root.applying || path === "") return
+ root.applying = true
+
+ if (screen !== undefined && screen !== null && screen !== "") {
+ var ps = Object.assign({}, root.perScreenWallpapers)
+ ps[screen] = path; root.perScreenWallpapers = ps
+ }
+
+ // 1. INSTANT: set the wallpaper — crossfade starts immediately
+ root.currentWall = path
+ root.wallpaperApplied(path)
+
+ // 2. Background: symlink + static frame + matugen + lockscreen
+ root._spawnSideEffects(path)
+
+ // 3. Save config
+ root.saveConfig()
+ root.applying = false
+ }
+
+ // Internal — navigation uses this, skips config save
+ function _setWallpaper(path) {
+ if (path === root.currentWall || path === "") return
+
+ // INSTANT
+ root.currentWall = path
+ root.wallpaperApplied(path)
+
+ // Background
+ root._spawnSideEffects(path)
+ root.saveConfig()
+ }
+
+ // Fire-and-forget background work
+ function _spawnSideEffects(path) {
+ // NothingLess approach: use THUMBNAIL as matugen source (fast, already generated)
+ // Symlinks for external consumers (lockscreen, etc.)
+ // No ffmpeg extraction — thumbnail IS the static frame
+ var colorSource = root.getColorSource(path)
+ var matugenCfg = decodeURIComponent(Qt.resolvedUrl("../config/matugen.toml").toString().replace("file://", ""))
+
+ // Symlink current wallpaper
+ var linkCmd = "ln -sf '" + path + "' ~/.curr_wall 2>/dev/null; " +
+ "ln -sf '" + (colorSource || path) + "' ~/.curr_wall_static.jpg 2>/dev/null"
+ _linkProc.command = ["bash", "-c", linkCmd]
+ _linkProc.running = false
+ _linkProc.running = true
+
+ // Matugen — separate, lighter process (NothingLess style: direct command, no bash pipe)
+ var needMatugen = (root._lastMatugenWall !== path || root._lastMatugenScheme !== root.scheme)
+ root._lastMatugenWall = path
+ root._lastMatugenScheme = root.scheme
+ if (needMatugen) {
+ var source = colorSource || path
+ _matugenWithCfg.command = ["matugen", "image", source, "--source-color-index", "0",
+ "-c", matugenCfg, "-t", "scheme-" + root.scheme]
+ _matugenWithCfg.running = false
+ _matugenWithCfg.running = true
+
+ _matugenPlain.command = ["matugen", "image", source, "--source-color-index", "0",
+ "-t", "scheme-" + root.scheme]
+ _matugenPlain.running = false
+ _matugenPlain.running = true
+ }
+
+ // Lockscreen frame (parallel, independent)
+ root._generateLockscreenFrame(path)
+ }
+
+ // ── Symlink only ─────────────────────────────────────────────────────────
+ property Process _linkProc: Process {}
+
+ // ── Matugen with Brain_Shell config ──────────────────────────────────────
+ property Process _matugenWithCfg: Process {
+ onExited: { root.updateBorders() }
+ }
+
+ // ── Matugen plain (updates system-level cache) ───────────────────────────
+ property Process _matugenPlain: Process {}
+
+ // ── Lockscreen frame ─────────────────────────────────────────────────────
+ function _generateLockscreenFrame(filePath) {
+ if (!filePath) return
+ var ft = root.getFileType(filePath)
+ if (ft !== 'video' && ft !== 'gif') return
+
+ var scriptPath = decodeURIComponent(
+ Qt.resolvedUrl("../scripts/lockwall.py").toString().replace("file://", ""))
+ _lockwallProc.cmd = ["python3", scriptPath, filePath, root.cacheDir]
+ _lockwallProc.proc.running = false
+ _lockwallProc.proc.running = true
+ }
+
+ property var _lockwallProc: QtObject {
+ property var cmd: []
+ property Process proc: Process {
+ command: _lockwallProc.cmd
+ onExited: function(code) {
+ if (code === 0) root.lockscreenFrameReady(root.getLockscreenFramePath(root.currentWall))
+ }
+ }
+ }
+
+ // ── Scheme setter ────────────────────────────────────────────────────────
+ function setScheme(newScheme) {
+ if (root.scheme === newScheme) return
+ root.scheme = newScheme
+ root.saveConfig()
+ if (root.currentWall) root._spawnSideEffects(root.currentWall)
+ }
+
+ // ── Per-screen clear ─────────────────────────────────────────────────────
+ function clearPerScreenWallpaper(screen) {
+ var ps = Object.assign({}, root.perScreenWallpapers)
+ delete ps[screen]; root.perScreenWallpapers = ps
+ root.saveConfig()
+ }
+
+ // ── Border update ────────────────────────────────────────────────────────
+ function updateBorders() {
+ var primary = String(Theme.active).replace('#', '')
+ if (primary === "") return
+ var cmd = "hyprctl keyword general:col.active_border \"rgb(" + primary + ")\""
+ try {
+ if (ShellState && ShellState.configProvider === "lua")
+ cmd = "hyprctl eval 'hl.config({ general = { [\"col.active_border\"] = { colors = { \"rgb(" + primary + ")\" } } } })'"
+ } catch(e) {}
+ _borderProc.command = ["bash", "-c", cmd]
+ _borderProc.running = false; _borderProc.running = true
+ }
+ property Process _borderProc: Process {}
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // FILE SCANNING & WATCHERS
+ // ═══════════════════════════════════════════════════════════════════════════
+ function refresh() {
+ if (_listProc.running) return
+ root.wallpapers = []; root.thumbsReady = false
+ _listProc.running = true
+ root.scanSubfolders()
+ }
+
+ function scanSubfolders() {
+ if (!root.wallpaperDir) return
+ _scanSubProc.command = [
+ "find", root.wallpaperDir, "-mindepth", "1",
+ "-name", ".*", "-prune", "-o", "-type", "d", "-print"
+ ]
+ _scanSubProc.running = false; _scanSubProc.running = true
+ }
+
+ property Process _scanSubProc: Process {
+ stdout: SplitParser { onRead: function(line) {
+ var t = line.trim(); if (!t) return
+ var base = root.wallpaperDir; if (!base.endsWith("/")) base += "/"
+ var rel = t; if (t.startsWith(base)) rel = t.substring(base.length)
+ if (rel.indexOf("/") === -1 && !rel.startsWith(".")) {
+ var c = root.allSubdirs.slice()
+ if (!c.includes(rel)) { c.push(rel); c.sort(); root.allSubdirs = c }
+ }
+ }}
+ onExited: { root.subfolderFilters = root.allSubdirs.slice() }
+ }
+
+ property var _listTemp: []
+ property Process _listProc: Process {
+ command: ["bash", "-c",
+ "find " + root.wallpaperDir + " -type f " +
+ "\\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' " +
+ "-o -iname '*.gif' -o -iname '*.webp' -o -iname '*.tif' -o -iname '*.tiff' " +
+ "-o -iname '*.mp4' -o -iname '*.webm' -o -iname '*.mkv' " +
+ "-o -iname '*.mov' -o -iname '*.avi' \\) | sort"
+ ]
+ stdout: SplitParser { onRead: function(line) { var t = line.trim(); if (t) _listTemp.push(t) } }
+ onExited: {
+ var files = _listTemp; _listTemp = []
+ if (!files.length) { root._scanFallback(); return }
+ root.wallpapers = files
+ root.wallpapersRefreshed()
+ // Only auto-assign on first load when currentWall is empty
+ if (!root.currentWall && files.length) {
+ root.currentWall = files[0]
+ root.saveConfig()
+ }
+ root._startThumbGen()
+ if (root.currentWall) root._spawnSideEffects(root.currentWall)
+ }
+ }
+
+ // ── Fallback ─────────────────────────────────────────────────────────────
+ property var _fbTemp: []
+ function _scanFallback() {
+ _fbProc.command = ["bash", "-c",
+ "find " + root.fallbackDir + " -type f " +
+ "\\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\) | sort"
+ ]
+ _fbProc.running = false; _fbProc.running = true
+ }
+ property Process _fbProc: Process {
+ stdout: SplitParser { onRead: function(line) { var t = line.trim(); if (t) _fbTemp.push(t) } }
+ onExited: {
+ var files = _fbTemp; _fbTemp = []
+ root.wallpapers = files; root.wallpapersRefreshed()
+ if (files.length && !root.currentWall) {
+ root.currentWall = files[0]
+ root.saveConfig()
+ }
+ }
+ }
+
+ // ── Watchers ─────────────────────────────────────────────────────────────
+ property var _dirWatcher: FileView {
+ path: root.wallpaperDir; watchChanges: true; printErrors: false
+ onFileChanged: { root.refresh() }
+ }
+ property var _subWatchers: []
+ onAllSubdirsChanged: {
+ for (var i = 0; i < root._subWatchers.length; i++)
+ if (root._subWatchers[i]) root._subWatchers[i].destroy()
+ root._subWatchers = []
+ var base = root.wallpaperDir; if (!base.endsWith("/")) base += "/"
+ for (var j = 0; j < root.allSubdirs.length; j++) {
+ var w = Qt.createQmlObject(
+ 'import Quickshell; FileView { watchChanges: true; printErrors: false }', root)
+ w.path = base + root.allSubdirs[j]
+ w.fileChanged.connect(function() { root.refresh() })
+ root._subWatchers.push(w)
+ }
+ }
+ onWallpaperDirChanged: { _dirWatcher.path = root.wallpaperDir; root.refresh() }
+
+ // ── Thumbnails (NothingLess approach: proxy dirs, run immediately) ───────
+ property Process _thumbGen: Process {
+ command: ["python3",
+ decodeURIComponent(Qt.resolvedUrl("../scripts/thumbgen_batch.py").toString().replace("file://", "")),
+ root.wallpaperDir, "--cache", root.thumbDir, "--size", "256", "--workers", "4", "--quiet"
+ ]
+ stdout: StdioCollector { onStreamFinished: { if (text.length > 0) console.log("[WallpaperService]", text) } }
+ stderr: StdioCollector { onStreamFinished: { if (text.length > 0) console.warn("[WallpaperService]", text) } }
+ onExited: {
+ root.thumbsReady = true
+ root.thumbnailsReady()
+ }
+ }
+ function _startThumbGen() {
+ if (_thumbGen.running) return
+ _thumbGen.running = true
+ }
+
+ // ── Config ───────────────────────────────────────────────────────────────
+ property string _cfgBuf: ""
+ property Process _readCfg: Process {
+ command: ["bash", "-c", "cat '" + root.configPath + "' 2>/dev/null"]
+ stdout: SplitParser { onRead: function(line) { root._cfgBuf += line } }
+ onExited: {
+ if (root._cfgBuf) { try {
+ var o = JSON.parse(root._cfgBuf)
+ if (o.currentWall && o.currentWall !== "") root.currentWall = o.currentWall
+ if (o.wallpaperDir && o.wallpaperDir !== "") root.wallpaperDir = o.wallpaperDir
+ if (o.scheme && o.scheme !== "") root.scheme = o.scheme
+ if (o.perScreen && typeof o.perScreen === "object") root.perScreenWallpapers = o.perScreen
+ } catch(e) {} }
+ root._cfgBuf = ""
+ if (!root.currentWall)
+ root.currentWall = root.fallbackDir + "/brain-shell-default-0.png"
+ root.refresh()
+ }
+ }
+
+ function saveConfig() {
+ var json = JSON.stringify({
+ currentWall: root.currentWall, wallpaperDir: root.wallpaperDir,
+ scheme: root.scheme, perScreen: root.perScreenWallpapers
+ })
+ _saveCfg.command = ["bash", "-c",
+ "mkdir -p \"$(dirname '" + root.configPath.replace(/'/g, "'\\''") + "')\" && " +
+ "printf '%s' '" + json.replace(/'/g, "'\\''") + "' > '" + root.configPath.replace(/'/g, "'\\''") + "'"
+ ]
+ _saveCfg.running = false; _saveCfg.running = true
+ }
+ property Process _saveCfg: Process {}
+
+ Component.onCompleted: { _readCfg.running = true }
+}
diff --git a/src/services/config_tab/KeybindService.qml b/src/services/config_tab/KeybindService.qml
new file mode 100644
index 0000000..804a2db
--- /dev/null
+++ b/src/services/config_tab/KeybindService.qml
@@ -0,0 +1,416 @@
+pragma Singleton
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import "../"
+import "../../"
+
+QtObject {
+ id: root
+
+ readonly property string _shellDir: Quickshell.shellDir
+ readonly property string _configDir: Quickshell.env("HOME") + "/.config/Brain_Shell"
+ readonly property string _luaPath: _configDir + "/Brain_ShellKeybinds.lua"
+ readonly property string _confPath: _configDir + "/Brain_ShellKeybinds.conf"
+ readonly property string _jsonPath: _configDir + "/src/user_data/keybinds.json"
+
+ property string configProvider: ShellState.configProvider
+
+ // ── Capture gate ──────────────────────────────────────────────────────────
+ // Set true by KeybindsPage while a combo is being recorded.
+ // In your state handler (ShellState / Popups), observe this and dispatch:
+ // true → hyprctl dispatch submap, clean
+ // false → hyprctl dispatch submap, reset
+ property bool isCapturing: false
+
+ // ── Defaults ──────────────────────────────────────────────────────────────
+ readonly property var _defaults: ({
+ "dashboard-home": { mods: "SUPER", key: "D", label: "Dashboard: System", group: "Dashboard" },
+ "dashboard-stats": { mods: "CTRL + SHIFT", key: "ESCAPE", label: "Dashboard: Home", group: "Dashboard" },
+ "dashboard-kanban": { mods: "SUPER", key: "Z", label: "Dashboard: Tasks", group: "Dashboard" },
+ "dashboard-launcher": { mods: "SUPER", key: "Q", label: "Dashboard: Apps", group: "Dashboard" },
+ "dashboard-config": { mods: "SUPER", key: "C", label: "Dashboard: Config", group: "Dashboard" },
+ "PowerMenu-toggle": { mods: "SUPER", key: "ESCAPE", label: "Arch Menu", group: "Popups" },
+ "notification-toggle":{ mods: "SUPER", key: "N", label: "Notifications", group: "Popups" },
+ "wallpaper-toggle": { mods: "SUPER", key: "W", label: "Wallpaper", group: "Popups" },
+ "clipboard-toggle": { mods: "SUPER", key: "V", label: "Clipboard", group: "Popups" },
+ "wifi-toggle": { mods: "SUPER + ALT", key: "W", label: "Network: Wi-Fi", group: "Network Tabs" },
+ "bluetooth-toggle": { mods: "SUPER + ALT", key: "B", label: "Network: Bluetooth", group: "Network Tabs" },
+ "vpn-toggle": { mods: "SUPER + ALT", key: "G", label: "Network: VPN", group: "Network Tabs" },
+ "hotspot-toggle": { mods: "SUPER + ALT", key: "H", label: "Network: Hotspot", group: "Network Tabs" },
+ "audioOut-toggle": { mods: "SUPER", key: "A", label: "Audio: Output", group: "Audio Tabs" },
+ "audioIn-toggle": { mods: "SUPER + ALT", key: "I", label: "Audio: Input", group: "Audio Tabs" },
+ "audioMix-toggle": { mods: "SUPER", key: "M", label: "Audio: Mixer", group: "Audio Tabs" },
+ "focus-toggle": { mods: "SUPER", key: "B", label: "Focus Mode", group: "Quick Settings" },
+ "screenrec-on": { mods: "ALT", key: "F9", label: "Screen Record", group: "Quick Settings" },
+ })
+
+ property var keybinds: ({})
+
+ // ── Hyprland binds cache ──────────────────────────────────────────────────
+ // Refreshed each time a BindRow enters capture mode.
+ property var _hyprBinds: []
+
+ property var _hyprBindsProc: Process {
+ command: ["hyprctl", "binds", "-j"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try { root._hyprBinds = JSON.parse(text.trim()) }
+ catch (e) { root._hyprBinds = [] }
+ }
+ }
+ }
+
+ function loadHyprBinds() {
+ _hyprBindsProc.running = false
+ _hyprBindsProc.running = true
+ }
+
+ // Converts "SUPER + SHIFT" → Hyprland modmask integer
+ function _modsToMask(modsStr) {
+ var mask = 0
+ var parts = modsStr.toUpperCase().split("+")
+ for (var i = 0; i < parts.length; i++) {
+ var p = parts[i].trim()
+ if (p === "SUPER") mask |= 64
+ else if (p === "SHIFT") mask |= 1
+ else if (p === "CTRL") mask |= 4
+ else if (p === "ALT") mask |= 8
+ }
+ return mask
+ }
+
+ // Returns a short description of the conflicting Hyprland bind, or "".
+ // Own shell binds (arg contains "qs ipc") are filtered out.
+ function wouldConflictHypr(action, mods, key) {
+ var mask = _modsToMask(mods)
+ var k = key.toLowerCase()
+ for (var i = 0; i < root._hyprBinds.length; i++) {
+ var b = root._hyprBinds[i]
+ if (b.submap !== "") continue // ignore submaps
+ if (b.mouse) continue // ignore mouse binds
+ if (b.arg && b.arg.indexOf("qs ipc") >= 0) continue // our own shell binds
+ if (b.modmask === mask && (b.key || "").toLowerCase() === k) {
+ var desc = b.dispatcher || ""
+ if (b.arg) desc += ": " + b.arg.substring(0, 36)
+ return desc || "Hyprland bind"
+ }
+ }
+ return ""
+ }
+
+ // ── Internal duplicate detection ──────────────────────────────────────────
+ readonly property var _comboMap: {
+ var m = {}
+ var ks = Object.keys(root.keybinds)
+ for (var i = 0; i < ks.length; i++) {
+ var b = root.keybinds[ks[i]]
+ if (!b || !b.mods || !b.key) continue
+ var combo = b.mods + "+" + b.key
+ if (!m[combo]) m[combo] = [ks[i]]
+ else m[combo] = m[combo].concat([ks[i]])
+ }
+ return m
+ }
+
+ function isDuplicate(action) {
+ var b = root.keybinds[action]
+ if (!b || !b.mods || !b.key) return false
+ var combo = b.mods + "+" + b.key
+ return !!(root._comboMap[combo] && root._comboMap[combo].length > 1)
+ }
+
+ function conflictsWith(action) {
+ var b = root.keybinds[action]
+ if (!b || !b.mods || !b.key) return ""
+ var list = root._comboMap[b.mods + "+" + b.key]
+ if (!list || list.length < 2) return ""
+ for (var i = 0; i < list.length; i++) {
+ if (list[i] !== action) {
+ var o = root.keybinds[list[i]]
+ return o ? o.label : list[i]
+ }
+ }
+ return ""
+ }
+
+ function wouldConflict(action, mods, key) {
+ var combo = mods + "+" + key
+ var ks = Object.keys(root.keybinds)
+ for (var i = 0; i < ks.length; i++) {
+ if (ks[i] === action) continue
+ var b = root.keybinds[ks[i]]
+ if (b && b.mods + "+" + b.key === combo)
+ return b.label || ks[i]
+ }
+ return ""
+ }
+
+ // ── Load ──────────────────────────────────────────────────────────────────
+ property var _loadProc: Process {
+ command: ["bash", "-c",
+ "[ -f '" + root._jsonPath + "' ] && cat '" + root._jsonPath + "' || echo '{}'"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var merged = {}
+ var defs = root._defaults
+ var dkeys = Object.keys(defs)
+ for (var i = 0; i < dkeys.length; i++) {
+ var dk = dkeys[i]
+ merged[dk] = { mods: defs[dk].mods, key: defs[dk].key,
+ label: defs[dk].label, group: defs[dk].group }
+ }
+ try {
+ var saved = JSON.parse(text.trim())
+ var sk = Object.keys(saved)
+ for (var j = 0; j < sk.length; j++) {
+ var s = sk[j]
+ if (!merged[s]) continue
+ if (saved[s].mods !== undefined) merged[s].mods = saved[s].mods
+ if (saved[s].key !== undefined) merged[s].key = saved[s].key
+ }
+ } catch(e) {}
+ root.keybinds = merged
+ root._writeFiles()
+ // _ensureInclude() disabled — HyprlandSyncService handles
+ // the unified bind file at ~/.local/share/Brain_Shell/hyprland.lua
+ // to avoid duplicate bind registrations.
+ }
+ }
+ }
+
+ // ── Save / Reload ─────────────────────────────────────────────────────────
+ function save() {
+ var out = {}
+ var defs = root._defaults
+ var ks = Object.keys(root.keybinds)
+ for (var i = 0; i < ks.length; i++) {
+ var k = ks[i]
+ if (!defs[k]) continue
+ if (root.keybinds[k].mods !== defs[k].mods || root.keybinds[k].key !== defs[k].key)
+ out[k] = { mods: root.keybinds[k].mods, key: root.keybinds[k].key }
+ }
+ var json = JSON.stringify(out, null, 2)
+ _saveProc.command = ["bash", "-c",
+ "mkdir -p \"$(dirname '" + root._jsonPath + "')\" && " +
+ "printf '%s' '" + json.replace(/'/g, "'\\''") + "' > '" + root._jsonPath + "'"]
+ _saveProc.running = false
+ _saveProc.running = true
+ root._writeFiles()
+ }
+
+ property var _saveProc: Process { command: []; running: false }
+
+ property var _reloadProc: Process {
+ command: ["hyprctl", "reload"]
+ running: false
+ }
+
+ // Faster reload — 100ms is enough for filesystem flush on modern SSDs
+ property var _reloadTimer: Timer {
+ interval: 100
+ repeat: false
+ onTriggered: {
+ root._reloadProc.running = false
+ root._reloadProc.running = true
+ }
+ }
+
+ function reload() {
+ _reloadTimer.restart()
+ }
+
+ // Persist to disk. HyprlandSyncService handles the hyprctl reload
+ // after regenerating the unified bind file (debounce 150ms + write 200ms).
+ // Calling reload() here would race — the sync hasn't happened yet.
+ function saveAndReload() {
+ save()
+ // Reload removed: HyprlandSyncService reloads after syncing the new binds.
+ // If you need an immediate reload, call reload() separately.
+ }
+
+ // Updates in-memory only — does NOT persist.
+ // Callers responsible for invoking saveAndReload() when ready.
+ function updateBinding(action, newMods, newKey) {
+ var old = root.keybinds[action]
+ if (!old) return
+ var m = newMods.toUpperCase().trim()
+ var k = newKey.toUpperCase().trim()
+ if (m === "" || k === "") return
+ if (root.wouldConflict(action, m, k) !== "") return
+ // Direct mutation instead of full copy-replace — faster for keybind page responsiveness
+ var ks = Object.keys(root.keybinds)
+ var copy = {}
+ for (var i = 0; i < ks.length; i++) {
+ var key = ks[i]
+ copy[key] = root.keybinds[key]
+ }
+ copy[action] = { mods: m, key: k, label: old.label, group: old.group }
+ root.keybinds = copy
+ }
+
+ // Reset is always immediate — reverts to default and reloads right away
+ function resetBinding(action) {
+ var def = root._defaults[action]
+ if (!def) return
+ updateBinding(action, def.mods, def.key)
+ saveAndReload()
+ }
+
+ // Allows the UI to explicitly unbind an action (or preserve installer unbinds)
+ function unbindBinding(action) {
+ var old = root.keybinds[action]
+ if (!old) return
+
+ var copy = Object.assign({}, root.keybinds)
+ copy[action] = { mods: "", key: "", label: old.label, group: old.group }
+ root.keybinds = copy
+
+ saveAndReload()
+ }
+
+ // ── File generation ───────────────────────────────────────────────────────
+ property var _writeProc: Process { command: []; running: false }
+
+ function _writeFiles() {
+ var lua = _genLua()
+ var conf = _genConf()
+ var le = lua.replace(/\\/g, "\\\\").replace(/'/g, "'\\''")
+ var ce = conf.replace(/\\/g, "\\\\").replace(/'/g, "'\\''")
+
+ // Write both files so the user has them regardless of what they switch to
+ _writeProc.command = ["bash", "-c",
+ "printf '%s' '" + le + "' > '" + root._luaPath + "' && " +
+ "printf '%s' '" + ce + "' > '" + root._confPath + "'"]
+
+ _writeProc.running = false
+ _writeProc.running = true
+ }
+
+ function _grouped() {
+ var groups = {}; var order = []
+ var ks = Object.keys(root.keybinds)
+ for (var i = 0; i < ks.length; i++) {
+ var k = ks[i]; var b = root.keybinds[k]
+ if (!b || !b.mods || !b.key) continue
+ var g = b.group || "Other"
+ if (!groups[g]) { groups[g] = []; order.push(g) }
+ groups[g].push({ k: k, mods: b.mods, key: b.key, label: b.label })
+ }
+ return { groups: groups, order: order }
+ }
+
+ function _genLua() {
+ var sd = root._shellDir.replace(/"/g, "\\\"")
+ var data = _grouped()
+
+ var lines = [
+ "-- ==============================================================================",
+ "-- Brain Shell Keybinds",
+ "-- Auto-generated by Quickshell. Do not edit manually.",
+ "-- ==============================================================================",
+ "",
+ "local shell = \"" + sd + "\"",
+ "",
+ "-- ==============================================================================",
+ "-- BrainShell Capture Submap (Disables all normal binds during recording)",
+ "-- ==============================================================================",
+ "hl.define_submap(\"BrainShell_clean\", function()",
+ " -- Emergency exit in case the shell crashes during capture",
+ " hl.bind(\"CTRL + ESCAPE\", function()",
+ " hl.dispatch(hl.dsp.exec_cmd(\"notify-send 'BrainShell' 'Emergency Exit: Keybinds re-enabled.'\"))",
+ " hl.dispatch(hl.dsp.submap(\"reset\"))",
+ " end, { description = \"Emergency return to global submap\" })",
+ "end)",
+ "",
+ "-- ==============================================================================",
+ "-- User Defined Bindings",
+ "-- ==============================================================================",
+ ""
+ ]
+
+ for (var gi = 0; gi < data.order.length; gi++) {
+ var g = data.order[gi]
+ lines.push("-- " + g)
+ var entries = data.groups[g]
+ for (var ei = 0; ei < entries.length; ei++) {
+ var e = entries[ei]
+ lines.push("hl.bind(\"" + e.mods + " + " + e.key + "\", hl.dsp.exec_cmd(\"qs ipc -c \" .. shell .. \" call " + e.k + " toggle\"))")
+ }
+ lines.push("")
+ }
+ return lines.join("\n")
+ }
+
+ function _genConf() {
+ var data = _grouped()
+
+ var lines = [
+ "# ==============================================================================",
+ "# Brain Shell Keybinds",
+ "# Auto-generated by Quickshell. Do not edit manually.",
+ "# ==============================================================================",
+ "",
+ "# ==============================================================================",
+ "# BrainShell Capture Submap (Disables all normal binds during recording)",
+ "# ==============================================================================",
+ "submap = BrainShell_clean",
+ "bind = CTRL, ESCAPE, exec, notify-send 'BrainShell' 'Emergency Exit: Keybinds re-enabled.'",
+ "bind = CTRL, ESCAPE, submap, reset",
+ "submap = reset",
+ "",
+ "# ==============================================================================",
+ "# User Defined Bindings",
+ "# ==============================================================================",
+ ""
+ ]
+
+ for (var gi = 0; gi < data.order.length; gi++) {
+ var g = data.order[gi]
+ lines.push("# " + g)
+ var entries = data.groups[g]
+ for (var ei = 0; ei < entries.length; ei++) {
+ var e = entries[ei]
+ // Hyprland .conf format drops the '+' symbol between modifiers
+ var confMods = e.mods.replace(/\s*\+\s*/g, " ")
+ var cmd = "qs ipc -c " + root._shellDir + " call " + e.k + " toggle"
+ lines.push("bind = " + confMods + ", " + e.key + ", exec, " + cmd)
+ }
+ lines.push("")
+ }
+ return lines.join("\n")
+ }
+
+ // ── Auto-include in hyprland configs ──────────────────────────────────────
+ property var _includeProc: Process { command: []; running: false }
+
+ function _ensureInclude() {
+ var lp = root._luaPath.replace(/"/g, "\\\"")
+ var cp = root._confPath.replace(/"/g, "\\\"")
+
+ if (configProvider === "lua") {
+ _includeProc.command = ["bash", "-c", [
+ "MARKER='Brain_ShellKeybinds'",
+ "LUA=\"$HOME/.config/hypr/hyprland.lua\"",
+ "if [ -f \"$LUA\" ] && ! grep -qF \"$MARKER\" \"$LUA\"; then",
+ " printf '\\n-- Brain_ShellKeybinds\\ndofile(\"" + lp + "\")\\n' >> \"$LUA\"",
+ "fi",
+ ].join("\n")]
+ } else {
+ _includeProc.command = ["bash", "-c", [
+ "MARKER='Brain_ShellKeybinds'",
+ "CONF=\"$HOME/.config/hypr/hyprland.conf\"",
+ "if [ -f \"$CONF\" ] && ! grep -qF \"$MARKER\" \"$CONF\"; then",
+ " printf '\\n# Brain_ShellKeybinds\\nsource = " + cp + "\\n' >> \"$CONF\"",
+ "fi",
+ ].join("\n")]
+ }
+
+ _includeProc.running = false
+ _includeProc.running = true
+ }
+
+ Component.onCompleted: _loadProc.running = true
+}
diff --git a/src/services/config_tab/KeybindsPage.qml b/src/services/config_tab/KeybindsPage.qml
new file mode 100644
index 0000000..ec23b58
--- /dev/null
+++ b/src/services/config_tab/KeybindsPage.qml
@@ -0,0 +1,609 @@
+import QtQuick
+import QtQuick.Controls
+import "../"
+import "../../"
+
+Item {
+ id: root
+
+ // ── Capture state ─────────────────────────────────────────────────────────
+ property string _capturing: ""
+ readonly property bool anyCapturing: _capturing !== ""
+
+ // Keep KeybindService in sync so external handlers can observe it.
+ // In ShellState / Popups, watch KeybindService.isCapturing and dispatch:
+ // true → hyprctl dispatch submap, clean
+ // false → hyprctl dispatch submap, reset
+ onAnyCapturingChanged: KeybindService.isCapturing = anyCapturing
+
+ // ── Pending changes ───────────────────────────────────────────────────────
+ property var _pending: ({})
+ readonly property bool hasPending: Object.keys(_pending).length > 0
+
+ function _addPending(action, mods, key) {
+ var copy = Object.assign({}, _pending)
+ copy[action] = { mods: mods, key: key }
+ _pending = copy
+ }
+
+ function _clearPending(action) {
+ var copy = Object.assign({}, _pending)
+ delete copy[action]
+ _pending = copy
+ }
+
+ function _applyPending() {
+ var ks = Object.keys(_pending)
+ for (var i = 0; i < ks.length; i++) {
+ var m = _pending[ks[i]].mods
+ var k = _pending[ks[i]].key
+ if (m === "" && k === "") {
+ KeybindService.unbindBinding(ks[i])
+ } else {
+ KeybindService.updateBinding(ks[i], m, k)
+ }
+ }
+ _pending = {}
+ KeybindService.saveAndReload()
+ }
+
+ // ── Groups ────────────────────────────────────────────────────────────────
+ readonly property var _groups: {
+ var groups = {}; var order = []
+ var defs = KeybindService._defaults
+ var ks = Object.keys(defs)
+ for (var i = 0; i < ks.length; i++) {
+ var g = defs[ks[i]].group
+ if (!groups[g]) { groups[g] = []; order.push(g) }
+ groups[g].push(ks[i])
+ }
+ return order.map(function(g) { return { name: g, actions: groups[g] } })
+ }
+
+ // ── Save banner ───────────────────────────────────────────────────────────
+ Rectangle {
+ id: _saveBanner
+ anchors { top: parent.top; left: parent.left; right: parent.right }
+ height: root.hasPending ? 44 : 0
+ clip: true
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.07)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.20)
+ border.width: root.hasPending ? 1 : 0
+ radius: 8
+
+ Behavior on height { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } }
+
+ Row {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter; rightMargin: 10 }
+ spacing: 8
+ visible: root.hasPending
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: {
+ var n = Object.keys(root._pending).length
+ return n + " unsaved change" + (n > 1 ? "s" : "")
+ }
+ font.pixelSize: 11
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.70)
+ }
+
+ // Discard
+ Rectangle {
+ width: 62; height: 26; radius: 7
+ color: _discardH.hovered ? Qt.rgba(1,1,1,0.08) : Qt.rgba(1,1,1,0.04)
+ border.color: Qt.rgba(1,1,1,0.13); border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text { anchors.centerIn: parent; text: "Discard"; font.pixelSize: 10
+ color: Qt.rgba(1,1,1,0.48) }
+ HoverHandler { id: _discardH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._pending = {} }
+ }
+
+ // Save
+ Rectangle {
+ width: 62; height: 26; radius: 7
+ color: _saveH.hovered
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.28)
+ : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.16)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.42)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text { anchors.centerIn: parent; text: "Save"; font.pixelSize: 10
+ font.weight: Font.Medium; color: Theme.active }
+ HoverHandler { id: _saveH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._applyPending() }
+ }
+ }
+ }
+
+ // ── Scrollable list ───────────────────────────────────────────────────────
+ Flickable {
+ anchors {
+ top: _saveBanner.bottom
+ left: parent.left
+ right: parent.right
+ bottom: parent.bottom
+ leftMargin: 12
+ rightMargin: 12
+ bottomMargin: 12
+ topMargin: 6
+ }
+ contentWidth: width
+ contentHeight: _col.implicitHeight + 16
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+
+ ScrollBar.vertical: ScrollBar {
+ policy: ScrollBar.AsNeeded
+ contentItem: Rectangle {
+ implicitWidth: 3; implicitHeight: 40; radius: 1.5
+ color: Qt.rgba(1, 1, 1, 0.22)
+ }
+ background: Item {}
+ }
+
+ Column {
+ id: _col
+ width: parent.width - 12
+ spacing: 2
+
+ Repeater {
+ model: root._groups
+ delegate: Column {
+ required property var modelData
+ required property int index
+ width: _col.width
+ spacing: 2
+
+ Item {
+ width: parent.width
+ height: index > 0 ? 30 : 16
+ Text {
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 4
+ text: modelData.name
+ font.pixelSize: 9
+ font.weight: Font.Bold
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.55)
+ }
+ }
+
+ Repeater {
+ model: modelData.actions
+ delegate: BindRow {
+ id: _br
+ required property string modelData
+ width: _col.width
+ action: modelData
+ isCapturing: root._capturing === modelData
+ pendingCombo: root._pending[modelData] || null
+ onRequestCapture: root._capturing = modelData
+ onReleaseCapture: root._capturing = ""
+ onCaptureAccepted: function(newMods, newKey) {
+ root._addPending(_br.action, newMods, newKey)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ // ── BindRow ───────────────────────────────────────────────────────────────
+ component BindRow: Item {
+ id: br
+
+ property string action: ""
+ property bool isCapturing: false
+ property var pendingCombo: null // { mods, key } or null
+
+ signal requestCapture()
+ signal releaseCapture()
+ signal captureAccepted(string newMods, string newKey)
+
+ // Capture internals
+ property int _pressedMods: 0
+ property string _liveMods: ""
+ property string capturedMods: ""
+ property string capturedKey: ""
+
+ // Derived from service + pending
+ readonly property var _b: KeybindService.keybinds[action]
+ readonly property var _def: KeybindService._defaults[action]
+ readonly property bool _isUnbound: br._pillText === "Unbound"
+ readonly property bool _isPending: br.pendingCombo !== null && br.pendingCombo !== undefined
+ readonly property bool _isDefault: {
+ if (!br._def) return true
+ var m = br._isPending ? br.pendingCombo.mods : (br._b ? br._b.mods : "")
+ var k = br._isPending ? br.pendingCombo.key : (br._b ? br._b.key : "")
+ return m === br._def.mods && k === br._def.key
+ }
+ readonly property string _bindText: {
+ if (!br._b || br._b.key === "") return "Unbound"
+ return br._b.mods ? br._b.mods + " + " + br._b.key : br._b.key
+ }
+ // Show pending value in the pill when set
+ readonly property string _pillText: {
+ if (br._isPending) {
+ if (br.pendingCombo.key === "") return "Unbound"
+ return br.pendingCombo.mods ? br.pendingCombo.mods + " + " + br.pendingCombo.key : br.pendingCombo.key
+ }
+ return br._bindText
+ }
+ readonly property bool _savedDupe: KeybindService.isDuplicate(action)
+
+ readonly property bool _interactive: root._capturing === "" || br.isCapturing
+
+ // Live conflict: service binds → pending map → Hyprland binds (in that order)
+ readonly property string _conflictLabel: {
+ if (!br.capturedKey) return ""
+ var c = KeybindService.wouldConflict(br.action, br.capturedMods, br.capturedKey)
+ if (c !== "") return c
+ // Cross-check against other pending entries
+ var combo = br.capturedMods + "+" + br.capturedKey
+ var pkeys = Object.keys(root._pending)
+ for (var i = 0; i < pkeys.length; i++) {
+ if (pkeys[i] === br.action) continue
+ var p = root._pending[pkeys[i]]
+ if (p.mods + "+" + p.key === combo) {
+ var lbl = KeybindService.keybinds[pkeys[i]]
+ return (lbl ? lbl.label : pkeys[i]) + " (pending)"
+ }
+ }
+ return KeybindService.wouldConflictHypr(br.action, br.capturedMods, br.capturedKey)
+ }
+ readonly property bool _hasConflict: _conflictLabel !== ""
+
+ height: isCapturing ? 58 : 36
+ clip: true
+ Behavior on height { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } }
+
+ onIsCapturingChanged: {
+ if (isCapturing) {
+ br._pressedMods = 0
+ br._liveMods = ""
+ br.capturedMods = ""
+ br.capturedKey = ""
+ KeybindService.loadHyprBinds() // refresh for conflict detection
+ Qt.callLater(function() { _captureArea.forceActiveFocus() })
+ }
+ }
+
+ // ── Background ────────────────────────────────────────────────────────
+ Rectangle {
+ anchors.fill: parent
+ radius: 8
+ color: br.isCapturing
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.07)
+ : _rH.hovered ? Qt.rgba(1, 1, 1, 0.04) : "transparent"
+ border.color: br._savedDupe
+ ? Qt.rgba(248/255, 113/255, 113/255, 0.35)
+ : br.isCapturing
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.20)
+ : "transparent"
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
+ }
+
+ // ── Invisible focus target for key capture ────────────────────────────
+ Item {
+ id: _captureArea
+ anchors.fill: parent
+ focus: br.isCapturing
+ visible: br.isCapturing
+
+ Keys.onPressed: function(event) {
+ event.accepted = true
+ if (_isMod(event.key)) {
+ br._liveMods = _mods(event.modifiers)
+ return
+ }
+ br._pressedMods = event.modifiers
+ br._liveMods = _mods(event.modifiers)
+ }
+
+ Keys.onReleased: function(event) {
+ event.accepted = true
+ if (_isMod(event.key)) {
+ if (!br.capturedKey) br._liveMods = _mods(event.modifiers)
+ return
+ }
+ // Bare Escape = cancel without saving
+ if (event.key === Qt.Key_Escape && br._pressedMods === Qt.NoModifier) {
+ br.releaseCapture()
+ return
+ }
+ var k = _keyName(event.key)
+ if (k !== "") {
+ var m = _mods(br._pressedMods)
+ br.capturedMods = m
+ br.capturedKey = k
+ // Auto-accept when valid: has mods, no service conflict,
+ // no pending-map conflict, no Hyprland conflict
+ if (m !== ""
+ && KeybindService.wouldConflict(br.action, m, k) === ""
+ && KeybindService.wouldConflictHypr(br.action, m, k) === ""
+ && !_hasPendingConflict(m, k)) {
+ br.captureAccepted(m, k)
+ br.releaseCapture()
+ }
+ // else: stay open, show conflict warning
+ }
+ }
+
+ // Returns true if mods+key collides with any OTHER pending entry
+ function _hasPendingConflict(mods, key) {
+ var combo = mods + "+" + key
+ var pkeys = Object.keys(root._pending)
+ for (var i = 0; i < pkeys.length; i++) {
+ if (pkeys[i] === br.action) continue
+ var p = root._pending[pkeys[i]]
+ if (p.mods + "+" + p.key === combo) return true
+ }
+ return false
+ }
+ }
+
+ // ── Normal display ────────────────────────────────────────────────────
+ Item {
+ anchors { top: parent.top; left: parent.left; right: parent.right
+ leftMargin: 10; rightMargin: 8 }
+ height: 36
+ visible: !br.isCapturing
+
+ Text {
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ text: br._b ? br._b.label : br.action
+ font.pixelSize: 12
+ color: br._savedDupe ? "#f87171" : (br._isUnbound ? Qt.rgba(1, 1, 1, 0.35) : Qt.rgba(1, 1, 1, 0.68))
+ Behavior on color { ColorAnimation { duration: 120 } }
+ }
+
+ Row {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ spacing: 6
+
+ // Saved duplicate warning
+ Text {
+ visible: br._savedDupe
+ anchors.verticalCenter: parent.verticalCenter
+ text: "⚠ " + KeybindService.conflictsWith(br.action)
+ font.pixelSize: 9
+ color: Qt.rgba(248/255, 113/255, 113/255, 0.75)
+ }
+
+ // Clear bind
+ Rectangle {
+ visible: br._pillText !== "Unbound"
+ width: 22; height: 22; radius: 6
+ color: _clrH.hovered ? Qt.rgba(1,1,1,0.09) : "transparent"
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text { anchors.centerIn: parent; text: ""; font.pixelSize: 11
+ color: _clrH.hovered ? "#ff4444" : Qt.rgba(1,1,1,0.28) }
+ HoverHandler { id: _clrH; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ enabled: br._interactive
+ onClicked: {
+ root._addPending(br.action, "", "")
+ }
+ }
+ }
+
+ // Reset to default
+ Rectangle {
+ visible: !br._isDefault
+ width: 22; height: 22; radius: 6
+ color: _rstH.hovered ? Qt.rgba(1,1,1,0.09) : "transparent"
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text { anchors.centerIn: parent; text: "↺"; font.pixelSize: 11
+ color: _rstH.hovered ? Theme.active : Qt.rgba(1,1,1,0.28) }
+ HoverHandler { id: _rstH; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ enabled: br._interactive
+ onClicked: {
+ if (br._isPending) {
+ // If the saved value is already default, just drop pending
+ var def = KeybindService._defaults[br.action]
+ if (br._b && br._b.mods === def.mods && br._b.key === def.key)
+ root._clearPending(br.action)
+ else
+ root._addPending(br.action, def.mods, def.key)
+ } else {
+ // No pending → immediate save (reset is always safe)
+ KeybindService.resetBinding(br.action)
+ }
+ }
+ }
+ }
+
+ // Binding pill — amber tint when a pending change is staged
+ Rectangle {
+ height: 24; radius: 6
+ width: _pillT.implicitWidth + 18
+
+ color: br._isUnbound
+ ? Qt.rgba(1, 1, 1, 0.04)
+ : ((_pillH.hovered && br._interactive)
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.16)
+ : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.08))
+
+ border.color: br._isUnbound
+ ? Qt.rgba(1, 1, 1, 0.1)
+ : (br._isPending
+ ? Qt.rgba(1.0, 0.74, 0.22, 0.55)
+ : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.24))
+
+ border.width: 1
+
+ opacity: br._interactive ? (br._isUnbound ? 0.7 : 1.0) : 0.4
+
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Behavior on border.color { ColorAnimation { duration: 150 } }
+ Behavior on opacity { NumberAnimation { duration: 120 } }
+
+ Text {
+ id: _pillT
+ anchors.centerIn: parent
+ text: br._pillText
+ font.pixelSize: 10; font.family: "JetBrains Mono"
+ font.italic: br._isUnbound
+
+ color: br._isUnbound
+ ? Qt.rgba(1, 1, 1, 0.45)
+ : (br._isPending ? Qt.rgba(1.0, 0.74, 0.22, 1.0) : Theme.active)
+
+ Behavior on color { ColorAnimation { duration: 150 } }
+ }
+ HoverHandler { id: _pillH; cursorShape: br._interactive ? Qt.PointingHandCursor : Qt.ArrowCursor }
+ MouseArea {
+ anchors.fill: parent
+ enabled: br._interactive
+ onClicked: br.requestCapture()
+ }
+ }
+ }
+ }
+
+ // ── Capture display ───────────────────────────────────────────────────
+ Column {
+ anchors { top: parent.top; left: parent.left; right: parent.right
+ leftMargin: 10; rightMargin: 8 }
+ spacing: 0
+ visible: br.isCapturing
+
+ // Row 1: label + live capture pill + cancel
+ Item {
+ width: parent.width; height: 36
+
+ Text {
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ text: br._b ? br._b.label : br.action
+ font.pixelSize: 12
+ color: Qt.rgba(1, 1, 1, 0.68)
+ }
+
+ Row {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ spacing: 6
+
+ // Live capture pill
+ Rectangle {
+ height: 24; radius: 6
+ width: Math.max(120, _capT.implicitWidth + 18)
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.08)
+ border.color: br._hasConflict
+ ? Qt.rgba(248/255, 113/255, 113/255, 0.55)
+ : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,
+ br.capturedKey !== "" ? 0.40 : 0.18)
+ border.width: 1
+ Behavior on border.color { ColorAnimation { duration: 120 } }
+
+ Text {
+ id: _capT
+ anchors.centerIn: parent
+ font.pixelSize: 10; font.family: "JetBrains Mono"
+ color: br._hasConflict
+ ? "#f87171"
+ : br.capturedKey !== ""
+ ? Theme.active
+ : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.45)
+ text: {
+ if (br.capturedKey !== "")
+ return (br.capturedMods ? br.capturedMods + " + " : "") + br.capturedKey
+ if (br._liveMods !== "")
+ return br._liveMods + " + ?"
+ return "Press a key..."
+ }
+ }
+ }
+
+ // Cancel — Escape also cancels
+ Rectangle {
+ width: 28; height: 24; radius: 6
+ color: _cnH.hovered ? Qt.rgba(1,1,1,0.09) : "transparent"
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text { anchors.centerIn: parent; text: "✕"; font.pixelSize: 10
+ color: Qt.rgba(1,1,1,0.38) }
+ HoverHandler { id: _cnH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: br.releaseCapture() }
+ }
+ }
+ }
+
+ // Row 2: conflict warning (fades in when there's a conflict)
+ Item {
+ width: parent.width; height: 22
+ opacity: (br.capturedKey !== "" && br._hasConflict) ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 140 } }
+
+ Text {
+ anchors { left: parent.left; leftMargin: 2; verticalCenter: parent.verticalCenter }
+ text: "⚠ Conflicts with: " + br._conflictLabel
+ font.pixelSize: 10
+ color: "#f87171"
+ }
+ }
+ }
+
+ // ── Key helpers ───────────────────────────────────────────────────────
+ function _isMod(k) {
+ return k === Qt.Key_Shift || k === Qt.Key_Control ||
+ k === Qt.Key_Meta || k === Qt.Key_Alt ||
+ k === Qt.Key_Super_L || k === Qt.Key_Super_R ||
+ k === Qt.Key_Hyper_L || k === Qt.Key_Hyper_R ||
+ k === Qt.Key_AltGr || k === Qt.Key_CapsLock ||
+ k === Qt.Key_NumLock || k === Qt.Key_ScrollLock
+ }
+
+ function _mods(flags) {
+ var p = []
+ if (flags & Qt.MetaModifier) p.push("SUPER")
+ if (flags & Qt.ShiftModifier) p.push("SHIFT")
+ if (flags & Qt.ControlModifier) p.push("CTRL")
+ if (flags & Qt.AltModifier) p.push("ALT")
+ return p.join(" + ")
+ }
+
+ function _keyName(k) {
+ if (_isMod(k)) return ""
+ if (k >= Qt.Key_A && k <= Qt.Key_Z) return String.fromCharCode(k)
+ if (k >= Qt.Key_0 && k <= Qt.Key_9) return String.fromCharCode(k)
+ if (k >= Qt.Key_F1 && k <= Qt.Key_F35) return "F" + (k - Qt.Key_F1 + 1)
+ var m = {}
+ m[Qt.Key_Escape] = "Escape"
+ m[Qt.Key_Return] = "Return"
+ m[Qt.Key_Enter] = "KP_Enter"
+ m[Qt.Key_Tab] = "Tab"
+ m[Qt.Key_Backspace] = "BackSpace"
+ m[Qt.Key_Delete] = "Delete"
+ m[Qt.Key_Insert] = "Insert"
+ m[Qt.Key_Home] = "Home"
+ m[Qt.Key_End] = "End"
+ m[Qt.Key_PageUp] = "Prior"
+ m[Qt.Key_PageDown] = "Next"
+ m[Qt.Key_Left] = "Left"
+ m[Qt.Key_Right] = "Right"
+ m[Qt.Key_Up] = "Up"
+ m[Qt.Key_Down] = "Down"
+ m[Qt.Key_Space] = "Space"
+ m[Qt.Key_Print] = "Print"
+ m[Qt.Key_Pause] = "Pause"
+ m[Qt.Key_Minus] = "minus"
+ m[Qt.Key_Equal] = "equal"
+ m[Qt.Key_BracketLeft] = "bracketleft"
+ m[Qt.Key_BracketRight] = "bracketright"
+ m[Qt.Key_Backslash] = "backslash"
+ m[Qt.Key_Semicolon] = "semicolon"
+ m[Qt.Key_Apostrophe] = "apostrophe"
+ m[Qt.Key_Comma] = "comma"
+ m[Qt.Key_Period] = "period"
+ m[Qt.Key_Slash] = "slash"
+ m[Qt.Key_QuoteLeft] = "grave"
+ return m[k] || ""
+ }
+
+ HoverHandler { id: _rH; enabled: !br.isCapturing }
+ }
+}
diff --git a/src/services/config_tab/ShellConfig.qml b/src/services/config_tab/ShellConfig.qml
new file mode 100644
index 0000000..145ff47
--- /dev/null
+++ b/src/services/config_tab/ShellConfig.qml
@@ -0,0 +1,85 @@
+import QtQuick
+import "../"
+import "../../"
+import "../../components"
+
+Item {
+ id: root
+
+ property string _page: "appearance"
+
+ readonly property var _tabs: [
+ { key: "appearance", icon: "", label: "Appearance" },
+ { key: "layout", icon: "", label: "Layout & Behavior" },
+ { key: "data", icon: "", label: "Data & Storage" },
+ { key: "keybinds", icon: "", label: "Keybinds" },
+ { key: "misc", icon: "", label: "Misc" },
+ ]
+
+ Row {
+ anchors {
+ fill: parent
+ margins: 8
+ }
+ spacing: 12
+
+ // Left: tab column (30%)
+ Rectangle {
+ width: Math.floor((parent.width - parent.spacing) * 0.30)
+ height: parent.height
+ radius: Theme.cornerRadius
+ color: Qt.rgba(1, 1, 1, 0.04)
+ border.color: Qt.rgba(1, 1, 1, 0.07)
+ border.width: 1
+
+ TabSwitcher {
+ orientation: "vertical"
+ anchors {
+ top: parent.top
+ bottom: parent.bottom
+ left: parent.left
+ right: parent.right
+ topMargin: 8
+ bottomMargin: 8
+ leftMargin: 6
+ rightMargin: 6
+ }
+ currentPage: root._page
+ model: root._tabs
+ onPageChanged: function(key) { root._page = key }
+ }
+ }
+
+ // Right: content area (70%)
+ Item {
+ width: parent.width - Math.floor((parent.width - parent.spacing) * 0.30) - parent.spacing
+ height: parent.height
+
+ Item {
+ anchors.fill: parent
+ visible: root._page === "appearance"
+ Text { anchors.centerIn: parent; text: "Appearance Coming Soon!"; font.pixelSize: 13; color: Qt.rgba(1,1,1,0.12) }
+ }
+ Item {
+ anchors.fill: parent
+ visible: root._page === "layout"
+ Text { anchors.centerIn: parent; text: "Layout & Behavior Coming Soon!"; font.pixelSize: 13; color: Qt.rgba(1,1,1,0.12) }
+ }
+ Item {
+ anchors.fill: parent
+ visible: root._page === "data"
+ Text { anchors.centerIn: parent; text: "Data & Storage Coming Soon!"; font.pixelSize: 13; color: Qt.rgba(1,1,1,0.12) }
+ }
+ Item {
+ anchors.fill: parent
+ visible: root._page === "keybinds"
+ KeybindsPage { anchors.fill: parent }
+ }
+ Item {
+ anchors.fill: parent
+ visible: root._page === "misc"
+ Text { anchors.centerIn: parent; text: "Misc Coming Soon!"; font.pixelSize: 13; color: Qt.rgba(1,1,1,0.12) }
+ }
+ }
+ }
+}
diff --git a/src/services/home/CalendarCard.qml b/src/services/home/CalendarCard.qml
new file mode 100644
index 0000000..006233f
--- /dev/null
+++ b/src/services/home/CalendarCard.qml
@@ -0,0 +1,171 @@
+import QtQuick
+import "../../"
+import "../../components"
+
+// Calendar card — month grid with prev/next navigation.
+// Self-contained: owns all calendar state.
+
+StatCard {
+ id: root
+ padding: 0
+
+ // ── State ─────────────────────────────────────────────────────────────────
+ property int _year: 0
+ property int _month: 0
+ property int _today: 0
+ property var _days: []
+ property string _label: ""
+
+ readonly property var _monthNames: [
+ "January","February","March","April","May","June",
+ "July","August","September","October","November","December"
+ ]
+ readonly property var _dowNames: ["Su","Mo","Tu","We","Th","Fr","Sa"]
+
+ Component.onCompleted: {
+ var now = new Date()
+ _year = now.getFullYear()
+ _month = now.getMonth()
+ _today = now.getDate()
+ _rebuild()
+ }
+
+ function _rebuild() {
+ _label = _monthNames[_month].substring(0,3).toUpperCase() + " " + _year
+ var firstDow = new Date(_year, _month, 1).getDay()
+ var daysInMon = new Date(_year, _month + 1, 0).getDate()
+ var daysInPrev = new Date(_year, _month, 0).getDate()
+ var days = []
+ for (var p = firstDow - 1; p >= 0; p--)
+ days.push({ n: daysInPrev - p, cur: false })
+ for (var d = 1; d <= daysInMon; d++)
+ days.push({ n: d, cur: true })
+ var tail = 42 - days.length
+ for (var t = 1; t <= tail; t++)
+ days.push({ n: t, cur: false })
+ _days = days
+ }
+
+ function _prev() {
+ if (_month === 0) { _month = 11; _year-- } else _month--
+ _rebuild()
+ }
+ function _next() {
+ if (_month === 11) { _month = 0; _year++ } else _month++
+ _rebuild()
+ }
+
+ Timer {
+ running: true
+ Component.onCompleted: {
+ var now = new Date()
+ var tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
+ interval = tomorrow - now // Set interval to exact time until midnight
+ }
+ onTriggered: {
+ var now = new Date()
+ root._year = now.getFullYear()
+ root._month = now.getMonth()
+ root._today = now.getDate()
+ root._rebuild()
+
+ interval = 86400000 // Reset to 24 hours for the next day
+ restart()
+ }
+ }
+
+ // ── UI ────────────────────────────────────────────────────────────────────
+ Item {
+ anchors { fill: parent; margins: 12 }
+
+ // Header
+ Item {
+ id: hdr
+ anchors { left: parent.left; right: parent.right; top: parent.top }
+ height: 22
+
+ Text {
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ text: "‹"; font.pixelSize: 15
+ color: pH.hovered ? Qt.rgba(1,1,1,0.7) : Qt.rgba(1,1,1,0.25)
+ Behavior on color { ColorAnimation { duration: 100 } }
+ HoverHandler { id: pH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._prev() }
+ }
+ Text {
+ anchors.centerIn: parent
+ text: root._label; font.pixelSize: 10; font.weight: Font.Bold
+ color: Theme.text
+ }
+ Text {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: "›"; font.pixelSize: 15
+ color: nH.hovered ? Qt.rgba(1,1,1,0.7) : Qt.rgba(1,1,1,0.25)
+ Behavior on color { ColorAnimation { duration: 100 } }
+ HoverHandler { id: nH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._next() }
+ }
+ }
+
+ // DOW row
+ Item {
+ id: dow
+ anchors { left: parent.left; right: parent.right; top: hdr.bottom; topMargin: 3 }
+ height: 16
+ Row {
+ anchors.fill: parent
+ Repeater {
+ model: root._dowNames
+ delegate: Text {
+ width: Math.floor(dow.width / 7)
+ horizontalAlignment: Text.AlignHCenter
+ text: modelData; font.pixelSize: 8; font.weight: Font.Bold
+ color: Qt.rgba(1,1,1,0.2)
+ }
+ }
+ }
+ }
+
+ // Day grid
+ Grid {
+ id: grid
+ anchors { left: parent.left; right: parent.right; top: dow.bottom; topMargin: 2; bottom: parent.bottom }
+ columns: 7; rows: 6
+
+ readonly property real cW: width / 7
+ readonly property real cH: height / 6
+
+ Repeater {
+ model: root._days
+ delegate: Item {
+ required property var modelData
+ required property int index
+ width: grid.cW; height: grid.cH
+
+ readonly property bool isToday:
+ modelData.cur && modelData.n === root._today
+
+ Rectangle {
+ anchors.centerIn: parent
+ width: Math.min(parent.width, parent.height) - 4
+ height: width; radius: width / 2
+ color: isToday ? Qt.rgba(166/255,208/255,247/255,0.15)
+ : dH.hovered && modelData.cur ? Qt.rgba(1,1,1,0.07) : "transparent"
+ border.color: isToday ? Qt.rgba(166/255,208/255,247/255,0.3) : "transparent"
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 80 } }
+ Text {
+ anchors.centerIn: parent; text: modelData.n
+ font.pixelSize: 9; font.family: "JetBrains Mono"
+ font.weight: isToday ? Font.Bold : Font.Normal
+ color: isToday ? Theme.active
+ : modelData.cur ? Qt.rgba(205/255,214/255,244/255,0.55)
+ : Qt.rgba(1,1,1,0.13)
+ }
+ }
+ HoverHandler { id: dH; enabled: modelData.cur; cursorShape: Qt.PointingHandCursor }
+ }
+ }
+ }
+ }
+}
diff --git a/src/services/home/ClockCard.qml b/src/services/home/ClockCard.qml
new file mode 100644
index 0000000..c6ba941
--- /dev/null
+++ b/src/services/home/ClockCard.qml
@@ -0,0 +1,784 @@
+import QtQuick
+import Quickshell.Io
+import "../../"
+import "../../components"
+
+// ClockCard — Clock / Timer / Alarm / Stopwatch
+
+StatCard {
+ id: root
+ padding: 0
+
+ // ── Mode ──────────────────────────────────────────────────────────────────
+ property string _mode: "clock"
+
+ // ── Clock ─────────────────────────────────────────────────────────────────
+ property string _hm: "00:00"
+ property string _hStr: "00"
+ property string _mStr: "00"
+ property string _sec: "00"
+ property int _currentH: 0
+ property int _currentM: 0
+
+ // ── Timer ─────────────────────────────────────────────────────────────────
+ property int _timerTotal: 10 * 60
+ property int _timerLeft: 10 * 60
+ property bool _timerRunning: false
+ property bool _timerStarted: false
+ property bool _timerFired: false
+ property bool _addTimerOpen: false
+
+ // ── Stopwatch ─────────────────────────────────────────────────────────────
+ property int _swMs: 0
+ property bool _swRunning: false
+ property bool _swStarted: false
+
+ // ── Alarms ────────────────────────────────────────────────────────────────
+ property var _alarms: []
+ property int _alarmIdSeq: 0
+ property bool _addOpen: false
+ property int _addHour: 7
+ property int _addMinute: 0
+
+ // ── Notification ─────────────────────────────────────────────────────────
+ Process {
+ id: notifyProc
+ command: []
+ running: false
+ }
+
+ function _notify(title, body) {
+ notifyProc.command = ["notify-send", "-a", "Brain Shell", "-i", "alarm", title, body]
+ notifyProc.running = false
+ notifyProc.running = true
+ }
+
+ // ── Master tick ───────────────────────────────────────────────────────────
+ Timer {
+ interval: 1000; running: true; repeat: true
+ onTriggered: {
+ root._tick()
+ if (root._swRunning) root._swMs += 1000
+ if (root._timerRunning && root._timerLeft > 0) {
+ root._timerLeft--
+ if (root._timerLeft === 0) {
+ root._timerRunning = false
+ if (!root._timerFired) {
+ root._timerFired = true
+ root._notify("Timer finished",
+ "Your " + root._timerTotalLabel() + " timer is done.")
+ }
+ }
+ }
+ if (root._sec === "00") root._checkAlarms()
+ // Repaint ring only when on timer page and timer is running
+ if (root._mode === "timer") timerCanvas.requestPaint()
+ root._syncState()
+ }
+ }
+
+ Component.onCompleted: { _tick(); _syncState() }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+ function _zp(n) { return n < 10 ? "0"+n : ""+n }
+
+ function _tick() {
+ var d = new Date()
+ var h = d.getHours(), m = d.getMinutes(), s = d.getSeconds()
+ _currentH = h; _currentM = m
+ _hm = _zp(h) + ":" + _zp(m) + ":" + _zp(s)
+ _hStr = _zp(h)
+ _mStr = _zp(m)
+ _sec = _zp(s)
+ }
+
+ function _timerDisplay() {
+ var h = Math.floor(_timerLeft / 3600)
+ var m = Math.floor((_timerLeft % 3600) / 60)
+ var s = _timerLeft % 60
+ return h > 0
+ ? _zp(h) + ":" + _zp(m) + ":" + _zp(s)
+ : _zp(m) + ":" + _zp(s)
+ }
+
+ function _timerTotalLabel() {
+ var h = Math.floor(_timerTotal / 3600)
+ var m = Math.floor((_timerTotal % 3600) / 60)
+ return h > 0 ? h + "h " + _zp(m) + "m" : m + "m"
+ }
+
+ function _timerProgress() {
+ return _timerTotal > 0 ? (_timerTotal - _timerLeft) / _timerTotal : 0
+ }
+
+ function _swDisplay() {
+ var t = Math.floor(_swMs / 1000)
+ return _zp(Math.floor(t / 60)) + ":" + _zp(t % 60)
+ }
+
+ function _checkAlarms() {
+ var list = _alarms.slice(), changed = false
+ for (var i = 0; i < list.length; i++) {
+ var a = list[i]
+ if (!a.enabled) continue
+ if (a.hour === _currentH && a.minute === _currentM && !a.firedToday) {
+ list[i] = Object.assign({}, a, { firedToday: true })
+ changed = true
+ _notify("Alarm", (a.label !== "" ? a.label : "Alarm") +
+ " — " + _zp(a.hour) + ":" + _zp(a.minute))
+ }
+ if (a.firedToday && !(a.hour === _currentH && a.minute === _currentM)) {
+ list[i] = Object.assign({}, a, { firedToday: false })
+ changed = true
+ }
+ }
+ if (changed) _alarms = list
+ }
+
+ function _addAlarm() {
+ var list = _alarms.slice()
+ list.push({
+ id: _alarmIdSeq++,
+ hour: _addHour,
+ minute: _addMinute,
+ label: "",
+ enabled: true,
+ firedToday: false
+ })
+ _alarms = list
+ _addOpen = false
+ _syncState()
+ }
+
+ function _toggleAlarm(id) {
+ var list = _alarms.slice()
+ for (var i = 0; i < list.length; i++) {
+ if (list[i].id === id) {
+ list[i] = Object.assign({}, list[i], { enabled: !list[i].enabled })
+ break
+ }
+ }
+ _alarms = list
+ _syncState()
+ }
+
+ function _deleteAlarm(id) {
+ _alarms = _alarms.filter(function(a) { return a.id !== id })
+ _syncState()
+ }
+
+ function _syncState() {
+ ClockState.timerRunning = _timerRunning
+ ClockState.timerLeft = _timerLeft
+ ClockState.timerTotal = _timerTotal
+ ClockState.timerDisplay = _timerDisplay()
+ ClockState.swRunning = _swRunning
+ ClockState.swDisplay = _swDisplay()
+ ClockState.alarms = _alarms
+ ClockState.swStarted = _swStarted
+ ClockState.timerStarted = _timerStarted
+
+ var now = _currentH * 60 + _currentM, best = null
+ for (var i = 0; i < _alarms.length; i++) {
+ var a = _alarms[i]
+ if (!a.enabled) continue
+ var t = a.hour * 60 + a.minute
+ var diff = t >= now ? t - now : t + 1440 - now
+ if (best === null || diff < best.minsUntil)
+ best = { hour: a.hour, minute: a.minute, label: a.label, minsUntil: diff }
+ }
+ ClockState.nextAlarm = best
+ }
+
+ Connections {
+ target: ClockState
+
+ function onSwRunningChanged() {
+ // Sync internal state if the singleton is changed externally (e.g., from the notch)
+ if (root._swRunning !== ClockState.swRunning) {
+ root._swRunning = ClockState.swRunning
+ }
+ }
+
+ function onRequestStopwatchReset() {
+ root._swMs = 0
+ root._swRunning = false
+ root._swStarted = false
+ root._syncState()
+ }
+
+ function onTimerRunningChanged() {
+ if (root._timerRunning !== ClockState.timerRunning) {
+ root._timerRunning = ClockState.timerRunning
+ }
+ }
+
+ function onRequestTimerReset() {
+ root._timerLeft = root._timerTotal
+ root._timerRunning = false
+ root._timerStarted = false
+ root._syncState()
+ timerCanvas.requestPaint()
+ }
+ }
+
+ // ── UI ────────────────────────────────────────────────────────────────────
+ Item {
+ anchors.fill: parent
+
+ // ── CLOCK ─────────────────────────────────────────────────────────────
+ Item {
+ anchors { left: parent.left; right: parent.right; top: parent.top; bottom: tabs.top }
+ visible: root._mode === "clock"
+
+ Row {
+ anchors.centerIn: parent
+ spacing: 10
+
+ // HH stacked above MM with diagonal offset
+ Item {
+ anchors.verticalCenter: parent.verticalCenter
+ // Width fits both texts plus the one-char offset
+ readonly property int charOffset: 40
+ width: hhText.implicitWidth + charOffset
+ height: hhText.implicitHeight + mmText.implicitHeight - 8
+
+ Text {
+ id: hhText
+ anchors.left: parent.left
+ anchors.top: parent.top
+ text: root._hStr
+ font.pixelSize: 72; font.weight: Font.Bold
+ font.family: "JetBrains Mono"; font.letterSpacing: -4
+ color: Theme.text
+ }
+ Text {
+ id: mmText
+ anchors.left: parent.left
+ anchors.leftMargin: parent.charOffset
+ anchors.top: hhText.bottom
+ anchors.topMargin: -8
+ text: root._mStr
+ font.pixelSize: 72; font.weight: Font.Bold
+ font.family: "JetBrains Mono"; font.letterSpacing: -4
+ color: Theme.active
+ }
+ }
+
+ // Seconds — vertically centered beside the stack
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: root._sec
+ font.pixelSize: 22; font.weight: Font.Medium
+ font.family: "JetBrains Mono"
+ color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.45)
+ }
+ }
+ }
+
+ // ── TIMER ─────────────────────────────────────────────────────────────
+ Item {
+ anchors { left: parent.left; right: parent.right; top: parent.top; bottom: tabs.top }
+ visible: root._mode === "timer"
+
+ // "+" / "x" toggle — top-right corner
+ Item {
+ id: addTimerBtn
+ anchors { top: parent.top; right: parent.right; topMargin: 8; rightMargin: 8 }
+ width: 24; height: 24
+
+ Rectangle {
+ anchors.fill: parent; radius: 7
+ color: _addTimerHov.hovered
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.15)
+ : Qt.rgba(1,1,1,0.06)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.2); border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text {
+ anchors.centerIn: parent
+ text: root._addTimerOpen ? "x" : "+"
+ font.pixelSize: 14; color: Theme.active
+ }
+ }
+ HoverHandler { id: _addTimerHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: root._addTimerOpen = !root._addTimerOpen
+ }
+ }
+
+ Column {
+ anchors.centerIn: parent; spacing: 10
+
+ // ── Ring — hidden while add-timer panel is open ────────────────
+ Item {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: 100; height: 100
+ visible: !root._addTimerOpen
+
+ Canvas {
+ id: timerCanvas
+ anchors.fill: parent
+ width: Math.round(parent.width)
+ height: Math.round(parent.height)
+ renderStrategy: Canvas.Cooperative
+ renderTarget: Canvas.Image
+ onPaint: {
+ var ctx = getContext("2d")
+ ctx.clearRect(0, 0, width, height)
+ var cx = width/2, cy = height/2, r = 44
+ ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2)
+ ctx.strokeStyle = Qt.rgba(1,1,1,0.08)
+ ctx.lineWidth = 5; ctx.stroke()
+ var p = root._timerProgress()
+ if (p > 0) {
+ ctx.beginPath()
+ ctx.arc(cx, cy, r, -Math.PI/2, -Math.PI/2 + Math.PI*2*p)
+ ctx.strokeStyle = Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.85)
+ ctx.lineWidth = 5; ctx.lineCap = "round"; ctx.stroke()
+ }
+ }
+ Connections {
+ target: Theme
+ function onActiveChanged() { timerCanvas.requestPaint() }
+ }
+ }
+
+ Column {
+ anchors.centerIn: parent; spacing: 1
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: root._timerDisplay()
+ font.pixelSize: root._timerLeft >= 3600 ? 16 : 22
+ font.weight: Font.Bold; font.family: "JetBrains Mono"
+ color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.9)
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "remaining"; font.pixelSize: 8
+ color: Qt.rgba(1,1,1,0.25)
+ }
+ }
+ }
+
+ // ── Presets — hidden while add-timer panel is open ─────────────
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 5
+ visible: !root._addTimerOpen && !root._timerRunning
+ Repeater {
+ model: [5, 10, 15, 30]
+ delegate: Rectangle {
+ required property int modelData
+ required property int index
+ width: 36; height: 22; radius: 6
+ color: _pH.hovered ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.1) : Qt.rgba(1,1,1,0.05)
+ border.color: Qt.rgba(1,1,1,0.1); border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text {
+ anchors.centerIn: parent
+ text: modelData < 60 ? modelData+"m" : "1h"
+ font.pixelSize: 9; font.family: "JetBrains Mono"; font.weight: Font.Bold
+ color: Qt.rgba(1,1,1,0.45)
+ }
+ HoverHandler { id: _pH; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ root._timerTotal = modelData * 60
+ root._timerLeft = modelData * 60
+ root._timerRunning = false
+ root._timerFired = false
+ root._syncState()
+ timerCanvas.requestPaint()
+ }
+ }
+ }
+ }
+ }
+
+ // ── Custom duration — visible only when add-timer panel is open
+ Item {
+ anchors.horizontalCenter: parent.horizontalCenter
+ visible: root._addTimerOpen
+ width: _timerInputCol.implicitWidth
+ height: _timerInputCol.implicitHeight
+
+ Column {
+ id: _timerInputCol
+ anchors.centerIn: parent
+ spacing: 10
+
+ TimeInput {
+ id: timerTimeInput
+ anchors.horizontalCenter: parent.horizontalCenter
+ minuteStep: 1
+ }
+
+ Rectangle {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: 58; height: 26; radius: 8
+ color: _setTimerHov.hovered
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.18)
+ : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.1)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.25); border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text {
+ anchors.centerIn: parent; text: "Set Timer"
+ font.pixelSize: 11; font.weight: Font.Medium
+ color: Theme.active
+ }
+ HoverHandler { id: _setTimerHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ var total = timerTimeInput.hours * 3600
+ + timerTimeInput.minutes * 60
+ root._addTimerOpen = false
+ if (total > 0) {
+ root._timerTotal = total
+ root._timerLeft = total
+ root._timerRunning = false
+ root._timerFired = false
+ root._syncState()
+ timerCanvas.requestPaint()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // ── Start / Pause + Reset ─────────────────────
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 6
+ visible: !root._addTimerOpen
+
+ // Start / Pause
+ Rectangle {
+ width: 58; height: 26; radius: 8
+ color: _startHov.hovered
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.2)
+ : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.12)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.22); border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text {
+ anchors.centerIn: parent
+ text: root._timerRunning ? "Pause" : "Start"
+ font.pixelSize: 10; font.weight: Font.Medium
+ color: Theme.active
+ }
+ HoverHandler { id: _startHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ root._timerRunning = !root._timerRunning
+ root._timerStarted = true
+ root._timerFired = false
+ root._syncState()
+ }
+ }
+ }
+
+ // Reset
+ Rectangle {
+ width: 58; height: 26; radius: 8
+ color: _resetHov.hovered
+ ? Qt.rgba(1,1,1,0.1)
+ : Qt.rgba(1,1,1,0.05)
+ border.color: Qt.rgba(1,1,1,0.1); border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text {
+ anchors.centerIn: parent; text: "Reset"
+ font.pixelSize: 10; font.weight: Font.Medium
+ color: Qt.rgba(1,1,1,0.4)
+ }
+ HoverHandler { id: _resetHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ root._timerLeft = root._timerTotal
+ root._timerRunning = false
+ root._timerFired = false
+ root._timerStarted = false
+ root._syncState()
+ timerCanvas.requestPaint()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // ── ALARM ─────────────────────────────────────────────────────────────
+ Item {
+ anchors { left: parent.left; right: parent.right; top: parent.top; bottom: tabs.top }
+ visible: root._mode === "alarm"
+ clip: true
+
+ Item {
+ anchors { fill: parent; margins: 10 }
+
+ // ── Header — Item, not Row, so right-anchor on + button works ──
+ Item {
+ id: alarmHeader
+ anchors { left: parent.left; right: parent.right; top: parent.top }
+ height: 28
+
+ Text {
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ text: "Alarms"; font.pixelSize: 12; font.weight: Font.DemiBold
+ color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.7)
+ }
+
+ Item {
+ id: addAlarmBtn
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ width: 24; height: 24
+
+ Rectangle {
+ anchors.fill: parent; radius: 7
+ color: _addHov.hovered
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.15)
+ : Qt.rgba(1,1,1,0.06)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.2); border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text {
+ anchors.centerIn: parent
+ text: root._addOpen ? "✕" : "+"
+ font.pixelSize: 14; color: Theme.active
+ }
+ }
+ HoverHandler { id: _addHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ var opening = !root._addOpen
+ root._addOpen = opening
+ if (opening) {
+ // Snap to next nearest 5-min mark from now
+ var d = new Date()
+ var totalMins = d.getHours() * 60 + d.getMinutes() + 1
+ var snapped = Math.ceil(totalMins / 5) * 5
+ var h = Math.floor(snapped / 60) % 24
+ var m = snapped % 60
+ root._addHour = h
+ root._addMinute = m
+ alarmTimeInput.initialize(h, m)
+ }
+ }
+ }
+ }
+ }
+
+ // ── Add alarm panel ────────────────────────────────────────────
+ Rectangle {
+ id: addPanel
+ anchors { left: parent.left; right: parent.right; top: alarmHeader.bottom; topMargin: 6 }
+ height: root._addOpen ? 140 : 0
+ clip: true
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.05)
+ radius: 8
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.1); border.width: 1
+ opacity: root._addOpen ? 1 : 0
+ Behavior on height { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } }
+ Behavior on opacity { NumberAnimation { duration: 150 } }
+
+ Column {
+ anchors.centerIn: parent
+ spacing: 10
+
+ TimeInput {
+ id: alarmTimeInput
+ anchors.horizontalCenter: parent.horizontalCenter
+ minuteStep: 1
+ }
+
+ Rectangle {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: 58; height: 26; radius: 8
+ color: _setAlarmHov.hovered
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.18)
+ : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.1)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.25); border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text {
+ anchors.centerIn: parent; text: "Set Alarm"
+ font.pixelSize: 11; font.weight: Font.Medium
+ color: Theme.active
+ }
+ HoverHandler { id: _setAlarmHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ root._addHour = alarmTimeInput.hours
+ root._addMinute = alarmTimeInput.minutes
+ root._addAlarm()
+ }
+ }
+ }
+ }
+ }
+
+ // ── Alarm list ─────────────────────────────────────────────────
+ ListView {
+ id: alarmList
+ anchors {
+ left: parent.left; right: parent.right
+ top: addPanel.bottom; topMargin: 6
+ bottom: parent.bottom
+ }
+ model: root._alarms
+ clip: true; spacing: 4
+ boundsBehavior: Flickable.StopAtBounds
+ cacheBuffer: 500
+
+ delegate: Rectangle {
+ required property var modelData
+ required property int index
+ width: alarmList.width; height: 36; radius: 8
+ color: Qt.rgba(1,1,1,0.04)
+ border.color: modelData.enabled
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.15)
+ : Qt.rgba(1,1,1,0.07)
+ border.width: 1
+
+ // Time label
+ Text {
+ anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: root._zp(modelData.hour) + ":" + root._zp(modelData.minute)
+ font.pixelSize: 15; font.weight: Font.Bold; font.family: "JetBrains Mono"
+ color: modelData.enabled
+ ? Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.9)
+ : Qt.rgba(1,1,1,0.3)
+ }
+
+ // Toggle
+ Rectangle {
+ id: toggleBtn
+ anchors { right: deleteBtn.left; rightMargin: 6; verticalCenter: parent.verticalCenter }
+ width: 28; height: 18; radius: 9
+ color: modelData.enabled
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.25)
+ : Qt.rgba(1,1,1,0.1)
+ Behavior on color { ColorAnimation { duration: 130 } }
+ Rectangle {
+ width: 12; height: 12; radius: 6
+ anchors.verticalCenter: parent.verticalCenter
+ x: modelData.enabled ? parent.width - width - 3 : 3
+ color: modelData.enabled ? Theme.active : Qt.rgba(1,1,1,0.3)
+ Behavior on x { NumberAnimation { duration: 130; easing.type: Easing.OutCubic } }
+ Behavior on color { ColorAnimation { duration: 130 } }
+ }
+ MouseArea {
+ anchors.fill: parent; cursorShape: Qt.PointingHandCursor
+ onClicked: root._toggleAlarm(modelData.id)
+ }
+ }
+
+ // Delete
+ Rectangle {
+ id: deleteBtn
+ anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter }
+ width: 22; height: 22; radius: 6
+ color: _delH.hovered ? Qt.rgba(248/255,113/255,113/255,0.18) : "transparent"
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text { anchors.centerIn: parent; text: "✕"; font.pixelSize: 10; color: Qt.rgba(248/255,113/255,113/255,0.6) }
+ HoverHandler { id: _delH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root._deleteAlarm(modelData.id) }
+ }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ visible: root._alarms.length === 0 && !root._addOpen
+ text: "No alarms set\nTap + to add one"
+ horizontalAlignment: Text.AlignHCenter
+ font.pixelSize: 11; color: Qt.rgba(1,1,1,0.2)
+ lineHeight: 1.5
+ }
+ }
+ }
+ }
+
+ // ── STOPWATCH ─────────────────────────────────────────────────────────
+ Item {
+ anchors { left: parent.left; right: parent.right; top: parent.top; bottom: tabs.top }
+ visible: root._mode === "stopwatch"
+
+ Column {
+ anchors.centerIn: parent; spacing: 12
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: root._swDisplay()
+ font.pixelSize: 52; font.weight: Font.Bold
+ font.family: "JetBrains Mono"; font.letterSpacing: -1
+ color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.9)
+ }
+
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 6
+
+ // Start / Stop
+ Rectangle {
+ width: 58; height: 26; radius: 8
+ color: _swStartHov.hovered
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.2)
+ : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.12)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b,0.22); border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text {
+ anchors.centerIn: parent
+ text: root._swRunning ? "Stop" : "Start"
+ font.pixelSize: 10; font.weight: Font.Medium
+ color: Theme.active
+ }
+ HoverHandler { id: _swStartHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: { root._swRunning = !root._swRunning; root._swStarted = true; root._syncState();}
+
+ }
+ }
+ // Reset
+ Rectangle {
+ width: 58; height: 26; radius: 8
+ color: _swResetHov.hovered
+ ? Qt.rgba(1,1,1,0.1)
+ : Qt.rgba(1,1,1,0.05)
+ border.color: Qt.rgba(1,1,1,0.1); border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text {
+ anchors.centerIn: parent; text: "Reset"
+ font.pixelSize: 10; font.weight: Font.Medium
+ color: Qt.rgba(1,1,1,0.4)
+ }
+ HoverHandler { id: _swResetHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: { root._swMs = 0; root._swRunning = false; root._swStarted = false; root._syncState() }
+ }
+ }
+ }
+ }
+ }
+
+ // ── Tab bar ───────────────────────────────────────────────────────────
+ TabSwitcher {
+ id: tabs
+ anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
+ orientation: "horizontal"; width: parent.width
+ currentPage: root._mode
+ model: [
+ { key: "clock", icon: "", label: "Clock" },
+ { key: "timer", icon: "", label: "Timer" },
+ { key: "alarm", icon: "", label: "Alarm" },
+ { key: "stopwatch", icon: "", label: "Stopwatch" }
+ ]
+ onPageChanged: function(key) { root._mode = key }
+ }
+ }
+}
diff --git a/src/services/home/DashHome.qml b/src/services/home/DashHome.qml
new file mode 100644
index 0000000..4244642
--- /dev/null
+++ b/src/services/home/DashHome.qml
@@ -0,0 +1,115 @@
+import QtQuick
+import Quickshell.Io
+import "../"
+import "../../components"
+
+// Dashboard Home tab — layout only.
+//
+// ┌──────────────┬───────────────────────────┬──────────────┐
+// │ ProfileCard │ ClockCard │ │
+// ├──────────────┤ │ QuickSettings│
+// │ CalendarCard │ PlayerCard │ (brightness │
+// │ │ │ + toggles) │
+// └──────────────┴───────────────────────────┴──────────────┘
+
+Item {
+ id: root
+
+ readonly property int colW: 210
+ readonly property int gap: 8
+ readonly property int profileH: 160
+ readonly property int clockH: 220
+
+ // ── Avatar path ───────────────────────────────────────────────────────────
+ property string _avatarPath: ""
+ property string _staticJpg: "" // resolved once: $HOME/.curr_wall_static.jpg
+
+ // Resolve $HOME once, then set the fixed path.
+ // Both gif (magick frame) and non-gif (symlink) cases now land at the
+ // same ~/.curr_wall_static.jpg so no readlink resolution is needed.
+ Process {
+ command: ["bash", "-c", "echo $HOME"]
+ running: true
+ stdout: SplitParser {
+ onRead: function(line) {
+ var h = line.trim()
+ if (h === "") return
+ root._staticJpg = h + "/.curr_wall_static.jpg"
+ root._avatarPath = root._staticJpg
+ }
+ }
+ }
+
+ // Re-arm the image on every successful apply.
+ // Because the path never changes, Qt's image cache would serve the old
+ // texture. Clearing _avatarPath for one frame then restoring it forces
+ // the Image to re-read the file from disk.
+ Connections {
+ target: WallpaperService
+ function onWallpaperApplied(path) {
+ root._avatarPath = ""
+ reloadTimer.restart()
+ }
+ }
+
+ Timer {
+ id: reloadTimer
+ interval: 0
+ repeat: false
+ onTriggered: root._avatarPath = root._staticJpg
+ }
+
+ // ── Left column ───────────────────────────────────────────────────────────
+ Item {
+ id: leftCol
+ anchors { left: parent.left; top: parent.top; bottom: parent.bottom; topMargin: root.gap }
+ width: root.colW
+
+ ProfileCard {
+ id: profileCard
+ anchors { left: parent.left; right: parent.right; top: parent.top }
+ height: root.profileH
+ avatarPath: root._avatarPath
+ }
+
+ CalendarCard {
+ anchors {
+ left: parent.left; right: parent.right
+ top: profileCard.bottom; topMargin: root.gap
+ bottom: parent.bottom
+ }
+ }
+ }
+
+ // ── Right column — QuickSettings fills full height ────────────────────────
+ QuickSettings {
+ id: rightCard
+ anchors { right: parent.right; top: parent.top; bottom: parent.bottom; topMargin: root.gap }
+ width: root.colW
+ }
+
+ // ── Center column ─────────────────────────────────────────────────────────
+ Item {
+ id: centerCol
+ anchors {
+ left: leftCol.right; leftMargin: root.gap
+ right: rightCard.left; rightMargin: root.gap
+ top: parent.top; bottom: parent.bottom
+ topMargin: root.gap
+ }
+
+ ClockCard {
+ id: clockCard
+ anchors { left: parent.left; right: parent.right; top: parent.top }
+ height: root.clockH
+ }
+
+ PlayerCard {
+ anchors {
+ left: parent.left; right: parent.right
+ top: clockCard.bottom; topMargin: root.gap
+ bottom: parent.bottom
+ }
+ }
+ }
+}
diff --git a/src/services/home/PlayerCard.qml b/src/services/home/PlayerCard.qml
new file mode 100644
index 0000000..20adde1
--- /dev/null
+++ b/src/services/home/PlayerCard.qml
@@ -0,0 +1,525 @@
+import QtQuick
+import QtQuick.Effects
+import Quickshell.Io
+import Quickshell.Services.Mpris
+import "../../"
+import "../../components"
+
+/*!
+ PlayerCard v2 — media player card with organic animations.
+ Ported improvements from upstream/dev:
+ - Blocklist instead of allowlist (catches more players)
+ - Marquee scroll for long track names
+ - Rearranged source picker (top-right)
+ - Nerd Font Unicode icons
+*/
+Item {
+ id: root
+
+ // ── Source blocklist ──────────────────────────────────────────────────────
+ readonly property var _blocked: [
+ "kdeconnect",
+ "gsconnect",
+ "playerctld",
+ "plasma-browser-integration"
+ ]
+
+ // Explicit count tracker — forces filteredPlayers to re-evaluate whenever
+ // a player joins or leaves the MPRIS list.
+ property int _mprisCount: Mpris.players.values.length
+
+ readonly property var filteredPlayers: {
+ var _dep = root._mprisCount // explicit dependency on list size changes
+ var result = []
+ var vals = Mpris.players.values
+ for (var i = 0; i < vals.length; i++) {
+ var id = (vals[i].identity || "").toLowerCase()
+ var isBlocked = false
+ for (var j = 0; j < root._blocked.length; j++) {
+ if (id.indexOf(root._blocked[j]) !== -1) {
+ isBlocked = true
+ break
+ }
+ }
+ if (!isBlocked) result.push(vals[i])
+ }
+ return result
+ }
+
+ property int selectedPlayerIndex: 0
+ property bool _dropdownOpen: false
+
+ onVisibleChanged: if (!visible) root._dropdownOpen = false
+
+ onFilteredPlayersChanged: {
+ var oldPlayer = root.player
+ if (oldPlayer) {
+ for (var i = 0; i < root.filteredPlayers.length; i++) {
+ if (root.filteredPlayers[i] === oldPlayer) {
+ root.selectedPlayerIndex = i
+ return
+ }
+ }
+ }
+ if (root.selectedPlayerIndex >= root.filteredPlayers.length)
+ root.selectedPlayerIndex = Math.max(0, root.filteredPlayers.length - 1)
+ }
+
+ // ── MPRIS ─────────────────────────────────────────────────────────────────
+ readonly property var player: root.filteredPlayers.length > 0
+ ? root.filteredPlayers[root.selectedPlayerIndex] : null
+
+ readonly property bool isPlaying: root.player?.playbackState === MprisPlaybackState.Playing ?? false
+ readonly property string artUrl: root.player?.trackArtUrl ?? ""
+
+ readonly property string title: {
+ var t = root.player?.trackTitle
+ return (t && t !== "") ? t : "Nothing Playing"
+ }
+ readonly property string artist: {
+ var a = root.player?.trackArtists
+ if (!a) return ""
+ if (typeof a === "string") return a
+ if (typeof a.join === "function") return a.join(", ")
+ return a.toString()
+ }
+
+ readonly property real length: root.player?.length ?? 0
+ readonly property real position: root.player?.position ?? 0
+
+ property real _pos: 0
+ onPositionChanged: root._pos = position
+
+ Timer {
+ interval: 1000; running: root.isPlaying; repeat: true
+ onTriggered: {
+ if (root.length > 0)
+ root._pos = Math.min(root._pos + 1, root.length)
+ }
+ }
+
+ function _fmt(sec) {
+ var s = Math.floor(sec)
+ return Math.floor(s / 60) + ":" + (s % 60 < 10 ? "0" : "") + (s % 60)
+ }
+
+ readonly property real _progress: root.length > 0 ? root._pos / root.length : 0
+
+ // ── Shared cava bars from CavaService ─────────────────────────────────────
+ readonly property int _cavaBars: 32
+ readonly property var _bars: CavaService.bars
+
+ // ── Player icon helper ────────────────────────────────────────────────────
+ function _playerIcon(player) {
+ if (!player) return "♪"
+ var id = (player.identity || "").toLowerCase()
+ if (id.indexOf("spotify") !== -1) return ""
+ if (id.indexOf("firefox") !== -1) return ""
+ if (id.indexOf("chromium") !== -1) return ""
+ if (id.indexOf("chrome") !== -1) return ""
+ if (id.indexOf("brave") !== -1) return ""
+ if (id.indexOf("youtube") !== -1) return ""
+ return "♪"
+ }
+
+ function _playerLabel(player) {
+ if (!player) return "—"
+ var id = (player.identity || "").toLowerCase()
+ if (id.indexOf("spotify") !== -1) return "Spotify"
+ if (id.indexOf("firefox") !== -1) return "Firefox"
+ if (id.indexOf("chromium") !== -1) return "Chromium"
+ if (id.indexOf("chrome") !== -1) return "Chrome"
+ if (id.indexOf("brave") !== -1) return "Brave"
+ if (id.indexOf("youtube") !== -1) return "YouTube"
+ if (id.indexOf("edge") !== -1) return "Edge"
+ if (id.indexOf("opera") !== -1) return "Opera"
+ if (id.indexOf("vivaldi") !== -1) return "Vivaldi"
+ return player.identity || "Player"
+ }
+
+ // ── Background visuals (blurred + darkened album art) ─────────────────────
+ Item {
+ id: bgSource
+ anchors.fill: parent
+ opacity: 0
+ layer.enabled: true
+
+ Item {
+ id: artSource
+ anchors.fill: parent
+ layer.enabled: true
+ Image {
+ anchors.fill: parent
+ source: root.artUrl
+ fillMode: Image.PreserveAspectCrop
+ smooth: true
+ }
+ }
+
+ MultiEffect {
+ source: artSource
+ anchors.fill: parent
+ visible: root.artUrl !== ""
+ opacity: root.artUrl !== "" ? 1 : 0
+ blurEnabled: true
+ blur: 0.5
+ blurMax: 32
+ saturation: 0.2
+ Behavior on opacity { NumberAnimation { duration: 400 } }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ gradient: Gradient {
+ GradientStop { position: 0.0; color: Qt.rgba(0,0,0,0.38) }
+ GradientStop { position: 0.4; color: Qt.rgba(0,0,0,0.50) }
+ GradientStop { position: 1.0; color: Qt.rgba(0,0,0,0.88) }
+ }
+ }
+ }
+
+ Rectangle {
+ id: bgMask
+ anchors.fill: parent
+ radius: Theme.cornerRadius
+ visible: false
+ layer.enabled: true
+ }
+
+ MultiEffect {
+ source: bgSource
+ anchors.fill: parent
+ maskEnabled: true
+ maskSource: bgMask
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1.0
+ }
+
+ // ── Track name + artist (with marquee scroll for long titles) ─────────────
+ Column {
+ anchors {
+ left: parent.left; leftMargin: 120
+ right: parent.right; rightMargin: 120
+ top: parent.top; topMargin: 16
+ }
+ spacing: 4
+ clip: true
+
+ // Title with marquee scroll
+ Item {
+ width: parent.width
+ height: 22
+ clip: true
+ TextMetrics {
+ id: titleMetrics
+ font: titleText.font
+ text: root.title
+ }
+ Text {
+ id: titleText
+ text: root.title
+ font.pixelSize: 18; font.weight: Font.Bold
+ color: "#ffffff"
+ anchors.horizontalCenter: titleMetrics.width <= parent.width ? parent.horizontalCenter : undefined
+ NumberAnimation on x {
+ id: marqueeAnim
+ running: titleMetrics.width > titleText.parent.width && root.isPlaying
+ from: titleText.parent.width
+ to: -titleMetrics.width
+ duration: Math.max(0, (titleMetrics.width + titleText.parent.width) * 20)
+ loops: Animation.Infinite
+ }
+ onTextChanged: marqueeAnim.restart()
+ }
+ }
+ Text {
+ width: parent.width
+ text: root.artist
+ visible: root.artist !== ""
+ font.pixelSize: 13
+ color: Qt.rgba(1,1,1,0.55)
+ maximumLineCount: 1
+ elide: Text.ElideRight
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+
+ // ── Bottom stack: controls + progress ────────────────────────────────────
+ Column {
+ anchors {
+ left: parent.left; leftMargin: 14
+ right: parent.right; rightMargin: 14
+ bottom: parent.bottom; bottomMargin: 54
+ }
+ spacing: 6
+
+ // Controls
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 28
+ Repeater {
+ model: [ { key: "prev" }, { key: "play" }, { key: "next" } ]
+ delegate: Rectangle {
+ required property var modelData
+ required property int index
+ readonly property bool isPlay: modelData.key === "play"
+ readonly property string dispIcon: {
+ if (modelData.key === "prev") return ""
+ if (modelData.key === "next") return ""
+ return !root.isPlaying ? "" : ""
+ }
+ width: 36; height: 36
+ radius: height / 2
+ color: isPlay
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.18)
+ : cH.hovered ? Qt.rgba(1,1,1,0.14) : Qt.rgba(1,1,1,0.06)
+ border.color: isPlay ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.3) : "transparent"
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 100 } }
+ Text {
+ anchors.centerIn: parent
+ text: parent.dispIcon
+ font.pixelSize: isPlay ? 18 : 14
+ color: isPlay ? Theme.active : Qt.rgba(1,1,1,0.7)
+ }
+ HoverHandler { id: cH; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ if (!root.player) return
+ switch (modelData.key) {
+ case "play":
+ if (root.player.canTogglePlaying)
+ root.player.isPlaying = !root.player.isPlaying
+ break
+ case "prev":
+ if (root.player.canGoPrevious) root.player.previous()
+ break
+ case "next":
+ if (root.player.canGoNext) root.player.next()
+ break
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Progress bar + timestamps
+ Column {
+ width: parent.width; spacing: 3
+ Item {
+ width: parent.width; height: 6
+ Rectangle {
+ anchors.fill: parent; radius: height / 2
+ color: Qt.rgba(1,1,1,0.2)
+ MouseArea {
+ anchors.fill: parent; cursorShape: Qt.PointingHandCursor
+ onClicked: function(mouse) {
+ if (root.player && root.length > 0) {
+ var f = mouse.x / width
+ root.player.position = f * root.length
+ root._pos = f * root.length
+ }
+ }
+ }
+ Rectangle {
+ anchors { left: parent.left; top: parent.top; bottom: parent.bottom }
+ width: Math.max(radius * 2, parent.width * root._progress)
+ radius: parent.radius; color: Theme.active
+ Behavior on width { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
+ }
+ }
+ }
+ Item {
+ width: parent.width; height: 14
+
+ Text {
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ text: root._fmt(root._pos)
+ font.pixelSize: 9; font.family: "JetBrains Mono"
+ color: Qt.rgba(1,1,1,0.4)
+ }
+
+ Text {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: root._fmt(root.length)
+ font.pixelSize: 9; font.family: "JetBrains Mono"
+ color: Qt.rgba(1,1,1,0.4)
+ }
+ }
+ }
+ }
+
+ // ── Source picker — top-right pill, expands downward ──────────────────────
+ Item {
+ id: sourcePicker
+ anchors {
+ top: parent.top
+ right: parent.right
+ topMargin: 12
+ rightMargin: 12
+ }
+ visible: root.filteredPlayers.length > 1
+ z: 30
+ width: pill.width
+ height: pill.height
+
+ Rectangle {
+ id: pill
+ anchors.top: parent.top
+ anchors.right: parent.right
+
+ width: activeRow.implicitWidth + 24
+
+ readonly property int _rowH: 26
+ height: root._dropdownOpen
+ ? (_rowH * root.filteredPlayers.length)
+ : _rowH
+ Behavior on height { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
+
+ radius: _rowH / 2
+ clip: true
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.15)
+ border.color: root._dropdownOpen
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.30)
+ : "transparent"
+ border.width: 1
+ Behavior on border.color { ColorAnimation { duration: 150 } }
+
+ // Stacks downward from the top
+ Column {
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: 0
+
+ // ── Active player row (always at the top) ─────────────
+ Item {
+ height: pill._rowH
+ width: parent.width
+
+ Row {
+ id: activeRow
+ anchors.centerIn: parent
+ spacing: 5
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: root._playerIcon(root.player)
+ font.pixelSize: 11
+ color: Theme.active
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: root._playerLabel(root.player)
+ font.pixelSize: 10
+ font.weight: Font.Medium
+ color: Qt.rgba(1,1,1,0.92)
+ }
+ }
+
+ HoverHandler { cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: root._dropdownOpen = !root._dropdownOpen
+ }
+ }
+
+ // ── Other player rows (appear below the active row) ─
+ Repeater {
+ model: root.filteredPlayers
+
+ delegate: Item {
+ required property var modelData
+ required property int index
+ readonly property bool isCurrent: index === root.selectedPlayerIndex
+
+ width: pill.width
+ height: isCurrent ? 0 : (root._dropdownOpen ? pill._rowH : 0)
+ visible: !isCurrent
+ opacity: root._dropdownOpen ? 1 : 0
+ Behavior on height { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } }
+ Behavior on opacity { NumberAnimation { duration: 140 } }
+
+ Row {
+ anchors.centerIn: parent
+ spacing: 5
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: root._playerIcon(modelData)
+ font.pixelSize: 11
+ color: rowH.hovered
+ ? Qt.rgba(1,1,1,0.90)
+ : Qt.rgba(1,1,1,0.55)
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: root._playerLabel(modelData)
+ font.pixelSize: 10
+ color: rowH.hovered
+ ? Qt.rgba(1,1,1,0.90)
+ : Qt.rgba(1,1,1,0.55)
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ }
+
+ HoverHandler { id: rowH; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ root.selectedPlayerIndex = index
+ root._dropdownOpen = false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // ── Cava bars — flush with card bottom ────────────────────────────────────
+ Item {
+ anchors { left: parent.left; right: parent.right; bottom: parent.bottom; leftMargin: 7; rightMargin: 7; bottomMargin: 4 }
+ height: 32
+ Row {
+ anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
+ spacing: 2
+ readonly property real barW: Math.max(1, (parent.width - spacing * (root._cavaBars - 1)) / root._cavaBars)
+ Repeater {
+ model: root._bars
+ delegate: Item {
+ required property int modelData
+ required property int index
+ width: parent.barW; height: 32
+ Rectangle {
+ anchors.bottom: parent.bottom
+ width: parent.width
+ readonly property real _amp: root.isPlaying ? (modelData / 100) : 0
+ height: Math.max(2, _amp * 32)
+ radius: width / 2
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.25 + _amp * 0.65)
+ Behavior on height { NumberAnimation { duration: 50; easing.type: Easing.OutCubic } }
+ }
+ }
+ }
+ }
+ }
+
+ // Border
+ Rectangle {
+ anchors.fill: parent
+ radius: Theme.cornerRadius
+ color: "transparent"
+ border.color: Qt.rgba(1,1,1,0.08)
+ border.width: 1
+ }
+
+ // Close dropdown on click outside
+ TapHandler {
+ enabled: root._dropdownOpen
+ onTapped: root._dropdownOpen = false
+ }
+}
diff --git a/src/services/home/ProfileCard.qml b/src/services/home/ProfileCard.qml
new file mode 100644
index 0000000..d923b2a
--- /dev/null
+++ b/src/services/home/ProfileCard.qml
@@ -0,0 +1,157 @@
+import QtQuick
+import QtQuick.Effects
+import Quickshell.Io
+import "../../"
+import "../../components"
+
+// Profile card — circular avatar, username, window manager, uptime.
+
+StatCard {
+ id: root
+ padding: 0
+
+ property string avatarPath: ""
+
+ property string _user: ""
+ property string _wm: ""
+ property string _uptime: ""
+
+ Process {
+ command: ["bash", "-c", "echo $USER"]
+ running: true
+ stdout: SplitParser {
+ onRead: function(line) {
+ if (line.trim() !== "") root._user = line.trim()
+ }
+ }
+ }
+
+ Process {
+ command: ["bash", "-c", "echo ${XDG_CURRENT_DESKTOP:-Hyprland}"]
+ running: true
+ stdout: SplitParser {
+ onRead: function(line) {
+ if (line.trim() !== "") root._wm = line.trim()
+ }
+ }
+ }
+
+ Process {
+ id: uptimeProc
+ command: ["bash", "-c",
+ "uptime -p | sed 's/up //' | sed 's/ hours\\?/h/' | " +
+ "sed 's/ minutes\\?/m/' | sed 's/ days\\?/d/' | sed 's/, / /g'"]
+ running: false
+ stdout: SplitParser {
+ onRead: function(line) {
+ if (line.trim() !== "") root._uptime = line.trim()
+ }
+ }
+ }
+
+ Timer {
+ interval: 60000; running: true; repeat: true
+ onTriggered: { uptimeProc.running = false; uptimeProc.running = true }
+ }
+
+ Component.onCompleted: uptimeProc.running = true
+
+ Row {
+ anchors {
+ left: parent.left; leftMargin: 16
+ right: parent.right; rightMargin: 16
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 18
+
+ // Circular avatar
+ Item {
+ width: 72; height: 72
+ anchors.verticalCenter: parent.verticalCenter
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ gradient: Gradient {
+ GradientStop { position: 0.0; color: Qt.rgba(166/255,208/255,247/255,0.22) }
+ GradientStop { position: 1.0; color: Qt.rgba(80/255,130/255,190/255,0.14) }
+ }
+ border.color: Qt.rgba(166/255,208/255,247/255,0.22)
+ border.width: 1
+ }
+
+ Rectangle {
+ id: photoMask
+ anchors.fill: parent
+ radius: width / 2
+ visible: false
+ layer.enabled: true
+ }
+
+ Image {
+ anchors.fill: parent
+ source: root.avatarPath !== "" ? ("file://" + root.avatarPath) : ""
+ fillMode: Image.PreserveAspectCrop
+ smooth: true
+ visible: root.avatarPath !== ""
+ sourceSize.width: 128
+ sourceSize.height: 128
+ asynchronous: true
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ maskEnabled: true
+ maskSource: photoMask
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1.0
+ }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ text: ""
+ font.pixelSize: 28
+ color: Theme.active
+ visible: root.avatarPath === ""
+ }
+ }
+
+ // Text stats
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 10
+
+ Text {
+ text: root._user
+ font.pixelSize: 17; font.weight: Font.DemiBold
+ color: Theme.active
+ }
+
+ Row {
+ spacing: 8
+ Text {
+ text: ""; font.pixelSize: 12; color: Theme.active
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Text {
+ text: root._wm; font.pixelSize: 12
+ color: Qt.rgba(205/255,214/255,244/255,0.55)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Row {
+ spacing: 8
+ Text {
+ text: ""; font.pixelSize: 12; color: Theme.active
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Text {
+ text: root._uptime; font.pixelSize: 12
+ font.family: "JetBrains Mono"
+ color: Qt.rgba(205/255,214/255,244/255,0.55)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ }
+ }
+}
diff --git a/src/services/home/QuickSettings.qml b/src/services/home/QuickSettings.qml
new file mode 100644
index 0000000..72d9ec1
--- /dev/null
+++ b/src/services/home/QuickSettings.qml
@@ -0,0 +1,1035 @@
+import Quickshell
+import QtQuick
+import QtQuick.Controls
+import Quickshell.Io
+import "../../"
+import "../../components"
+import "../"
+
+// Right column — brightness slider + scrollable quick-settings grid.
+
+StatCard {
+ id: root
+ padding: 0
+ focus: true
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Brightness
+ // ─────────────────────────────────────────────────────────────────────────
+ property real _brightVal: 0.72
+ property int _brightMax: 100
+ property bool _brightBusy: false
+
+ Process {
+ id: brightRead; command: ["bash", "-c", "brightnessctl -m"]; running: false
+ stdout: SplitParser {
+ onRead: function(line) {
+ var p = line.split(",")
+ if (p.length >= 5) {
+ var cur = parseInt(p[2]); var max = parseInt(p[4])
+ if (max > 0) { root._brightMax = max; root._brightVal = cur / max }
+ }
+ }
+ }
+ }
+ Process {
+ id: brightWrite
+ command: ["bash", "-c", "brightnessctl set " +
+ (Math.round(root._brightVal * root._brightMax) <= 0
+ ? 2 : Math.round(root._brightVal * root._brightMax))]
+ running: false
+ onRunningChanged: if (!running) root._brightBusy = false
+ }
+ Timer { id: brightDebounce; interval: 50; repeat: false
+ onTriggered: { root._brightBusy = true; brightWrite.running = true } }
+ // Brightness read — only when dashboard is open (was: every 1s always = 60/min)
+ Timer { interval: 1000; running: Popups.dashboardOpen; repeat: true
+ onTriggered: if (!root._brightBusy) brightRead.running = true }
+ function _setBright(v) {
+ root._brightVal = Math.max(0.0, Math.min(1.0, v))
+ brightDebounce.restart()
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Wi-Fi
+ // ─────────────────────────────────────────────────────────────────────────
+ property bool wifiOn: false
+ property string wifiSSID: ""
+
+ Process { id: wifiRadioRead; command: ["bash", "-c", "nmcli radio wifi"]; running: false
+ stdout: SplitParser { onRead: function(l) {
+ root.wifiOn = l.trim() === "enabled"
+ // Expose to ShellState — suppressed while hotspot owns the interface
+ ShellState.wifiOn = root.wifiOn && !ShellState.hotspot
+ } } }
+ Process { id: wifiSSIDRead
+ command: ["bash", "-c",
+ "nmcli -t -f ACTIVE,SSID dev wifi 2>/dev/null | grep '^yes:' | head -1 | cut -d: -f2"]
+ running: false
+ stdout: SplitParser { onRead: function(l) { root.wifiSSID = l.trim() } } }
+ Process { id: wifiToggleProc; command: []; running: false
+ onRunningChanged: if (!running) _wifiPoll() }
+ function _wifiPoll() {
+ wifiRadioRead.running = false; wifiRadioRead.running = true
+ wifiSSIDRead.running = false; wifiSSIDRead.running = true
+ }
+ function _wifiToggle() {
+ // Do not allow wifi toggle while hotspot is using the interface
+ if (root.hotspotOn || root.hotspotBusy) return
+ root.wifiOn = !root.wifiOn // optimistic — tile updates now
+ ShellState.wifiOn = root.wifiOn
+ wifiToggleProc.command = ["bash", "-c",
+ "nmcli radio wifi " + (root.wifiOn ? "on" : "off")]
+ wifiToggleProc.running = false
+ wifiToggleProc.running = true
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Bluetooth
+ // ─────────────────────────────────────────────────────────────────────────
+ property bool btOn: false
+ property string btDevice: ""
+
+ Process { id: btPowerRead
+ command: ["bash", "-c",
+ "bluetoothctl show 2>/dev/null | grep '^\\s*Powered:' | awk '{print $2}'"]
+ running: false
+ stdout: SplitParser { onRead: function(l) { root.btOn = l.trim() === "yes" } } }
+ Process { id: btDeviceRead
+ command: ["bash", "-c",
+ "bluetoothctl devices Connected 2>/dev/null | head -1 | cut -d' ' -f3-"]
+ running: false
+ stdout: SplitParser { onRead: function(l) { root.btDevice = l.trim() } } }
+ Process { id: btToggleProc; command: []; running: false
+ onRunningChanged: if (!running) {
+ _btPoll()
+ ShellState.btPowered = root.btOn
+ if (!root.btOn) ShellState.btConnected = false
+ }
+ }
+ function _btPoll() {
+ btPowerRead.running = false; btPowerRead.running = true
+ btDeviceRead.running = false; btDeviceRead.running = true
+ }
+ function _btToggle() {
+ var turningOn = !root.btOn
+ root.btOn = turningOn // optimistic
+ // Mirror to ShellState immediately so Network.qml bar icon reacts
+ ShellState.btPowered = turningOn
+ if (!turningOn) ShellState.btConnected = false
+
+ btToggleProc.command = ["bash", "-c",
+ "bluetoothctl power " + (turningOn ? "on" : "off")]
+ btToggleProc.running = false
+ btToggleProc.running = true
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Night Light (hyprsunset)
+ // ─────────────────────────────────────────────────────────────────────────
+ property bool nightLightOn: false
+
+ Process { id: nlCheck; command: ["bash", "-c", "pgrep -x hyprsunset"]; running: false
+ stdout: SplitParser { onRead: function(l) { if (l.trim() !== "") root.nightLightOn = true } } }
+ Process { id: nlProc; command: ["hyprsunset", "-t", "5600"]; running: false }
+ Process { id: nlKill; command: ["bash", "-c", "pkill hyprsunset"]; running: false }
+ function _nightLightToggle() {
+ if (root.nightLightOn) {
+ nlProc.running = false; nlKill.running = false; nlKill.running = true
+ root.nightLightOn = false
+ } else { nlProc.running = true; root.nightLightOn = true }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Caffeine (systemd-inhibit)
+ // ─────────────────────────────────────────────────────────────────────────
+ property bool caffeineOn: false
+
+ Process { id: caffeineCheck
+ command: ["bash", "-c", "pgrep -f 'systemd-inhibit.*Caffeine'"]; running: false
+ stdout: SplitParser { onRead: function(l) { if (l.trim() !== "") root.caffeineOn = true } } }
+ Process { id: caffeineProc
+ command: ["systemd-inhibit","--what=idle:sleep",
+ "--who=Brain Shell","--why=Caffeine mode","sleep","infinity"]
+ running: false }
+ Process { id: caffeineKill
+ command: ["bash", "-c", "pkill -f 'systemd-inhibit.*Caffeine'"]; running: false
+ onRunningChanged: if (!running) root.caffeineOn = false }
+ function _caffeineToggle() {
+ if (root.caffeineOn) {
+ caffeineProc.running = false
+ caffeineKill.running = false; caffeineKill.running = true
+ } else { caffeineProc.running = true; root.caffeineOn = true }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Do Not Disturb
+ // ─────────────────────────────────────────────────────────────────────────
+ function _dndToggle() {
+ ShellState.dnd = !ShellState.dnd
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Hotspot — direct toggle; requires ethernet connection
+ // ─────────────────────────────────────────────────────────────────────────
+ property bool hotspotOn: false
+ property bool hotspotBusy: false
+ property bool _hsWifiWasOff: false // wifi radio was off when hotspot started; restore on stop
+ property string hotspotLabel: "" // sublabel: "Active" | "Not on ethernet" | ""
+ property string _hsSSID: "BrainShell"
+ property string _hsPassword: "changeme1"
+ property string _hsWifiIface: "wlan0"
+
+ readonly property string _hsCfgPath:
+ Quickshell.env("HOME") + "/.config/Brain_Shell/src/user_data/hotspot.json"
+
+ // Load config on startup
+ Process {
+ id: hsCfgLoadProc
+ command: ["bash", "-c",
+ "[ -f '" + root._hsCfgPath + "' ] || " +
+ "(mkdir -p \"$(dirname '" + root._hsCfgPath + "')\" && " +
+ "printf '%s' '{\"ssid\":\"BrainShell\",\"password\":\"changeme1\"}' > '" + root._hsCfgPath + "'); " +
+ "cat '" + root._hsCfgPath + "'"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ var o = JSON.parse(text.trim())
+ if (o.ssid) root._hsSSID = o.ssid
+ if (o.password) root._hsPassword = o.password
+ } catch(e) {}
+ }
+ }
+ }
+
+ // Detect WiFi interface name
+ Process {
+ id: hsIfaceProc
+ command: ["bash", "-c",
+ "nmcli -g DEVICE,TYPE dev 2>/dev/null | awk -F: '$2==\"wifi\"{print $1; exit}'"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var n = text.trim()
+ if (n !== "") root._hsWifiIface = n
+ }
+ }
+ }
+
+ // Check if ethernet is connected
+ Process {
+ id: hsEthernetCheck
+ command: ["bash", "-c",
+ "nmcli -t -f TYPE,STATE dev 2>/dev/null | grep -c 'ethernet:connected'"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var hasEth = parseInt(text.trim()) > 0
+ if (!hasEth) {
+ root.hotspotLabel = "Not on ethernet"
+ root.hotspotBusy = false
+ return
+ }
+ // Remember whether wifi radio was off so we can restore it on stop
+ root._hsWifiWasOff = !root.wifiOn
+ // Ethernet confirmed — start hotspot
+ root._hsDoStart()
+ }
+ }
+ }
+
+ // Check hotspot status
+ Process {
+ id: hotspotCheck
+ command: ["bash", "-c",
+ "nmcli -t -f TYPE,STATE dev 2>/dev/null | grep -c 'wifi:connected'"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ // If WiFi device shows 'connected' in AP mode that's our hotspot
+ // Cross-check with ap-like connection
+ hsActiveCheckProc.running = false; hsActiveCheckProc.running = true
+ }
+ }
+ }
+
+ Process {
+ id: hsActiveCheckProc
+ command: ["bash", "-c",
+ "nmcli -t -f NAME,STATE,DEVICE con show --active 2>/dev/null" +
+ " | awk -F: '$1~/[Hh]otspot/{found=1} END{print found+0}'"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.hotspotOn = parseInt(text.trim()) > 0
+ ShellState.hotspot = root.hotspotOn
+ root.hotspotLabel = root.hotspotOn ? "Active" : ""
+ // WiFi interface is owned by hotspot — suppress from ShellState
+ if (root.hotspotOn) ShellState.wifiOn = false
+ }
+ }
+ }
+
+ // Start hotspot
+ Process {
+ id: hsStartProc
+ command: []
+ running: false
+ stderr: StdioCollector { id: hsStartErr }
+ onRunningChanged: if (!running) {
+ root.hotspotBusy = false
+ // Re-check actual state after nmcli exits
+ hsActiveCheckProc.running = false; hsActiveCheckProc.running = true
+ }
+ onExited: function(code, status) {
+ if (code === 0) {
+ root.hotspotOn = true
+ root.hotspotLabel = "Active"
+ ShellState.hotspot = true
+ // WiFi interface now owned by hotspot
+ ShellState.wifiOn = false
+ } else {
+ root.hotspotOn = false
+ root.hotspotLabel = "Failed"
+ ShellState.hotspot = false
+ hsLabelResetTimer.restart()
+ }
+ }
+ }
+
+ // Stop hotspot
+ Process {
+ id: hsStopProc
+ // Disconnect by interface — works regardless of what nmcli named the connection
+ command: ["bash", "-c",
+ "nmcli device disconnect " + root._hsWifiIface + " 2>/dev/null; " +
+ "nmcli con delete BrainShellHotspot 2>/dev/null; true"]
+ running: false
+ onRunningChanged: if (!running) {
+ root.hotspotBusy = false
+ root.hotspotOn = false
+ ShellState.hotspot = false
+ if (root._hsWifiWasOff) {
+ // Wifi radio was off before hotspot started — restore that state.
+ // Turn the radio back off so the interface cycle is clean.
+ root.wifiOn = false
+ ShellState.wifiOn = false
+ wifiToggleProc.command = ["bash", "-c", "nmcli radio wifi off"]
+ wifiToggleProc.running = false
+ wifiToggleProc.running = true
+ root._hsWifiWasOff = false
+ } else {
+ // Radio was already on — just re-expose wifi state and re-poll for SSID
+ ShellState.wifiOn = root.wifiOn
+ _wifiPoll()
+ }
+ }
+ }
+
+ Timer { id: hsLabelResetTimer; interval: 3000; repeat: false
+ onTriggered: { if (root.hotspotLabel === "Failed") root.hotspotLabel = "" } }
+
+ // Ethernet disconnect watcher — runs during polling when hotspot is active
+ Process {
+ id: hsEthernetLiveCheck
+ command: ["bash", "-c",
+ "nmcli -t -f TYPE,STATE dev 2>/dev/null | grep -c 'ethernet:connected'"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (!root.hotspotOn || root.hotspotBusy) return
+ var hasEth = parseInt(text.trim()) > 0
+ if (!hasEth) {
+ // Ethernet lost — tear down hotspot automatically
+ root.hotspotLabel = "Ethernet lost"
+ root.hotspotBusy = true
+ // Rebuild stop command with current iface before running
+ hsStopProc.command = ["bash", "-c",
+ "nmcli device disconnect " + root._hsWifiIface + " 2>/dev/null; " +
+ "nmcli con delete BrainShellHotspot 2>/dev/null; true"]
+ hsStopProc.running = false; hsStopProc.running = true
+ hsLabelResetTimer.restart()
+ }
+ }
+ }
+ }
+
+ function _hsDoStart() {
+ var ssid = root._hsSSID
+ var pass = root._hsPassword
+ var iface = root._hsWifiIface
+ hsStartProc.command = ["bash", "-c",
+ // Silently bring the wifi radio up if it was off (ethernet-only scenario).
+ // nmcli needs the radio enabled before it can create an AP connection.
+ "nmcli radio wifi on 2>/dev/null; " +
+ "sleep 1; " +
+ // Disconnect whatever is currently on the interface
+ "nmcli device disconnect \"" + iface + "\" 2>/dev/null; " +
+ "nmcli con delete BrainShellHotspot 2>/dev/null; " +
+ "nmcli device wifi hotspot " +
+ "ifname \"" + iface + "\" " +
+ "ssid \"" + ssid + "\" " +
+ "password \"" + pass + "\" " +
+ "con-name BrainShellHotspot 2>&1"]
+ hsStartProc.running = false; hsStartProc.running = true
+ }
+
+ function _hotspotToggle() {
+ if (root.hotspotBusy) return
+ if (root.hotspotOn) {
+ root.hotspotBusy = true
+ root.hotspotLabel = ""
+ // Rebuild with current iface (detected after startup)
+ hsStopProc.command = ["bash", "-c",
+ "nmcli device disconnect \"" + root._hsWifiIface + "\" 2>/dev/null; " +
+ "nmcli con delete BrainShellHotspot 2>/dev/null; true"]
+ hsStopProc.running = false; hsStopProc.running = true
+ } else {
+ root.hotspotBusy = true
+ root.hotspotLabel = ""
+ // Check ethernet first, then start
+ hsEthernetCheck.running = false; hsEthernetCheck.running = true
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Airplane Mode (rfkill)
+ // ─────────────────────────────────────────────────────────────────────────
+ property bool airplaneOn: false
+
+ Process { id: airplaneCheck
+ command: ["bash", "-c",
+ // Airplane = ALL radios soft-blocked.
+ // nmcli radio wifi off blocks only wifi via rfkill — not bluetooth/wwan.
+ // So count devices that are NOT blocked; if zero, airplane mode is on.
+ "notBlocked=$(rfkill list all 2>/dev/null | grep -c 'Soft blocked: no');" +
+ " total=$(rfkill list all 2>/dev/null | grep -c 'Soft blocked:');" +
+ " [ \"$total\" -gt 0 ] && [ \"$notBlocked\" -eq 0 ] && echo yes || echo no"]
+ running: false
+ stdout: SplitParser {
+ onRead: function(l) { root.airplaneOn = l.trim() === "yes" }
+ }
+ }
+ Process { id: airplaneOn_proc
+ command: ["bash", "-c", "rfkill block all"]; running: false
+ onRunningChanged: if (!running) root.airplaneOn = true }
+ Process { id: airplaneOff_proc
+ command: ["bash", "-c", "rfkill unblock all"]; running: false
+ onRunningChanged: if (!running) root.airplaneOn = false }
+ function _airplaneToggle() {
+ if (root.airplaneOn) {
+ airplaneOff_proc.running = false; airplaneOff_proc.running = true
+ } else {
+ airplaneOn_proc.running = false; airplaneOn_proc.running = true
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Focus Mode (hyprctl gaps)
+ // ─────────────────────────────────────────────────────────────────────────
+ property int _savedGapsIn: 5; property int _savedGapsOut: 10
+
+ Process { id: readGapsIn
+ command: ["bash", "-c",
+ "hyprctl getoption general:gaps_in -j | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get('int',5))\""]
+ running: false
+ stdout: SplitParser { onRead: function(l) { var v=parseInt(l.trim()); if(!isNaN(v)) root._savedGapsIn=v } }
+ onRunningChanged: if (!running) readGapsOut.running = true }
+ Process { id: readGapsOut
+ command: ["bash", "-c",
+ "hyprctl getoption general:gaps_out -j | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get('int',10))\""]
+ running: false
+ stdout: SplitParser { onRead: function(l) { var v=parseInt(l.trim()); if(!isNaN(v)) root._savedGapsOut=v } }
+ onRunningChanged: if (!running) applyFocusGaps.running = true }
+ Process { id: applyFocusGaps
+ command: ["bash", "-c",
+ "hyprctl keyword general:gaps_in 0 && hyprctl keyword general:gaps_out 10"]
+ running: false; onRunningChanged: if (!running) ShellState.focusMode = true }
+ Process { id: restoreGaps; command: []; running: false
+ onRunningChanged: if (!running) ShellState.focusMode = false }
+ function _focusToggle() {
+ if (ShellState.focusMode) {
+ restoreGaps.command = ["bash", "-c",
+ "hyprctl keyword general:gaps_in " + root._savedGapsIn +
+ " && hyprctl keyword general:gaps_out " + root._savedGapsOut]
+ restoreGaps.running = false; restoreGaps.running = true
+ } else { readGapsIn.running = false; readGapsIn.running = true }
+ }
+
+ Connections {
+ target: IpcManager
+ function onFocusToggleRequested() {
+ root._focusToggle()
+ }
+ }
+
+// ─────────────────────────────────────────────────────────────────────────
+ // Filter (Native Hyprland Lua)
+ //
+ // Tile click: runs bash `find`, opens picker popup above the tile.
+ // Picker has "Off" at top + all available shaders.
+ // Selecting a shader: resolves absolute path and uses `hyprctl eval hl.config()`
+ // Selecting the active shader or "Off": clears the shader in Hyprland.
+ // ─────────────────────────────────────────────────────────────────────────
+ property string currentFilter: ""
+ property var filterList: []
+ property bool filterPickerOpen: false
+
+ // Add your standard shader directories here (space-separated)
+ property string shaderPaths: "~/.config/hypr/shaders ~/.local/share/hypr/shaders /usr/share/hyprshade/shaders ~/.local/src/Brain_Shell/src/config/shaders ~/.config/quickshell/src/config/shaders"
+
+ // Check process stays exactly the same — it already reads cleanly from Hyprland!
+ Process {
+ id: filterCheckProc
+ command: ["bash", "-c",
+ "hyprctl getoption decoration:screen_shader -j 2>/dev/null" +
+ " | python3 -c \"" +
+ "import sys,json,os;" +
+ "d=json.load(sys.stdin);" +
+ "s=d.get('str','').strip();" +
+ "print('' if s in ('','[[EMPTY]]') else os.path.splitext(os.path.basename(s))[0])\""]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.currentFilter = text.trim()
+ }
+ }
+ }
+
+ Process {
+ id: filterApplyProc
+ command: []
+ running: false
+ onRunningChanged: if (!running) {
+ filterCheckProc.running = false
+ filterCheckProc.running = true
+ }
+ }
+
+ function _filterApply(name) {
+ var turningOff = (name === "" || name === root.currentFilter)
+ root.currentFilter = turningOff ? "" : name
+
+ var isLua = ShellState.configProvider === "lua"
+
+ // Handle DPMS toggling based on provider
+ var damageCmd = isLua
+ ? ` && hyprctl dispatch 'hl.dsp.dpms({ action = "disable" })' && hyprctl dispatch 'hl.dsp.dpms({ action = "enable" })'`
+ : ` && hyprctl dispatch dpms off && hyprctl dispatch dpms on`
+
+ if (turningOff) {
+ var offCmd = isLua
+ ? "hyprctl eval \"hl.config({ decoration = { screen_shader = '' } })\""
+ : "hyprctl keyword decoration:screen_shader '[[EMPTY]]'"
+
+ filterApplyProc.command = ["bash", "-c", offCmd + damageCmd]
+ } else {
+ var resolveCmd =
+ "TARGET=$(find " + root.shaderPaths +
+ " -maxdepth 1 -type f \\( -name '" + name + ".glsl' -o -name '" + name + ".frag' \\)" +
+ " 2>/dev/null | head -n 1); "
+
+ var onCmd = isLua
+ ? "if [ -n \"$TARGET\" ]; then hyprctl eval \"hl.config({ decoration = { screen_shader = '$TARGET' } })\"" + damageCmd + "; fi"
+ : "if [ -n \"$TARGET\" ]; then hyprctl keyword decoration:screen_shader \"$TARGET\"" + damageCmd + "; fi"
+
+ filterApplyProc.command = ["bash", "-c", resolveCmd + onCmd]
+ }
+
+ filterApplyProc.running = false
+ filterApplyProc.running = true
+ root.filterPickerOpen = false
+ }
+
+ Connections {
+ target: WallpaperService
+ function onWallpaperApplied(path) {
+ filterCheckProc.running = false
+ filterCheckProc.running = true
+ }
+ }
+
+ Connections {
+ target: Popups
+ function onDashboardOpenChanged() {
+ if (!Popups.dashboardOpen) root.filterPickerOpen = false
+ }
+ }
+
+ Process {
+ id: filterListProc
+ // Replaces `hyprshade ls` by searching your directories and stripping the file extensions
+ command: ["bash", "-c", "find " + root.shaderPaths + " -maxdepth 1 -type f \\( -name '*.glsl' -o -name '*.frag' \\) 2>/dev/null | rev | cut -d/ -f1 | rev | sed 's/\\.[^.]*$//' | sort -u"]
+ running: false
+ stdout: SplitParser {
+ onRead: function(l) {
+ var n = l.trim()
+ if (n !== "") root.filterList = root.filterList.concat([n])
+ }
+ }
+ }
+
+ function _filterOpen() {
+ root.filterList = []
+ filterListProc.running = false
+ filterListProc.running = true
+ root.filterPickerOpen = true
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Polling timer — only when dashboard is open (was: always ≈ 48 spawns/min)
+ // ─────────────────────────────────────────────────────────────────────────
+ Timer {
+ interval: 5000; running: Popups.dashboardOpen; repeat: true
+ onTriggered: {
+ _wifiPoll(); _btPoll()
+ hsActiveCheckProc.running = false; hsActiveCheckProc.running = true
+ airplaneCheck.running = false; airplaneCheck.running = true
+ // Monitor ethernet while hotspot is active
+ if (root.hotspotOn && !root.hotspotBusy) {
+ hsEthernetLiveCheck.running = false; hsEthernetLiveCheck.running = true
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ brightRead.running = true
+ _wifiPoll(); _btPoll()
+ nlCheck.running = true
+ caffeineCheck.running = true
+ hotspotCheck.running = true
+ airplaneCheck.running = true
+ filterCheckProc.running = true
+ hsCfgLoadProc.running = true
+ hsIfaceProc.running = true
+ hsActiveCheckProc.running = true
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // UI
+ // ─────────────────────────────────────────────────────────────────────────
+ Column {
+ anchors { fill: parent; margins: 12 }
+ spacing: 0
+
+ // ── Brightness ────────────────────────────────────────────────────────
+ Item {
+ width: parent.width
+ height: 52
+
+ Text {
+ id: brightLbl
+ anchors { left: parent.left; top: parent.top }
+ text: "BRIGHTNESS"; font.pixelSize: 9; font.weight: Font.Bold
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.55)
+ }
+ Text {
+ anchors { right: parent.right; top: parent.top }
+ text: Math.round(root._brightVal * 100) + "%"
+ font.pixelSize: 9; font.family: "JetBrains Mono"; font.weight: Font.Bold
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.7)
+ }
+
+ Row {
+ anchors { left: parent.left; right: parent.right; top: brightLbl.bottom; topMargin: 8 }
+ spacing: 8
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: ""; font.pixelSize: 13
+ color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.35)
+ }
+
+ Item {
+ id: btw
+ width: parent.width - 13 - 13 - parent.spacing * 2
+ height: 30; anchors.verticalCenter: parent.verticalCenter
+ anchors.bottomMargin: 30
+ readonly property int thumbD: 14
+
+ Rectangle {
+ id: btrack
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width; height: 5; radius: height / 2
+ color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.12)
+ Rectangle {
+ anchors { left: parent.left; top: parent.top; bottom: parent.bottom }
+ width: Math.max(parent.radius * 2, parent.width * root._brightVal)
+ radius: parent.radius; color: Theme.active
+ Behavior on width { NumberAnimation { duration: 80; easing.type: Easing.OutCubic } }
+ }
+ MouseArea {
+ anchors.fill: parent; cursorShape: Qt.PointingHandCursor
+ function _c(mx) {
+ return Math.max(0.0, Math.min(1.0,
+ (mx - btw.thumbD/2) / (btrack.width - btw.thumbD)))
+ }
+ onPressed: root._setBright(_c(mouseX))
+ onPositionChanged: if (pressed) root._setBright(_c(mouseX))
+ }
+ }
+
+ WheelHandler {
+ acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
+ onWheel: function(e) {
+ root._setBright(root._brightVal + (e.angleDelta.y > 0 ? 0.05 : -0.05))
+ }
+ }
+ Rectangle {
+ width: btw.thumbD; height: btw.thumbD; radius: btw.thumbD / 2
+ color: "#ffffff"; anchors.verticalCenter: parent.verticalCenter
+ x: Math.max(0, Math.min(btw.width - width, root._brightVal * (btw.width - width)))
+ Behavior on x { NumberAnimation { duration: 80; easing.type: Easing.OutCubic } }
+ }
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: ""; font.pixelSize: 13
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.75)
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width; height: 1
+ color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.08)
+ }
+ Item { width: parent.width; height: 8 }
+
+ Text {
+ id: qsLbl; width: parent.width
+ text: "QUICK SETTINGS"; font.pixelSize: 9; font.weight: Font.Bold
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.55)
+ }
+ Item { width: parent.width; height: 8 }
+
+ // ── Tile grid ─────────────────────────────────────────────────────────
+ Item {
+ width: parent.width
+ height: root.height - 12 - 52 - 1 - 8 - qsLbl.height - 8
+
+ Flickable {
+ id: flick
+ anchors.fill: parent
+ contentWidth: width
+ contentHeight: tileGrid.implicitHeight + 8
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+
+ component TglBtn: Rectangle {
+ id: btn
+ required property bool on
+ required property string icon
+ required property string label
+ property string sublabel: ""
+ signal toggled()
+
+ radius: 10
+ color: on
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.14)
+ : bH.hovered
+ ? Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.08)
+ : Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.04)
+ border.color: on
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.30)
+ : Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.10)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 130 } }
+ Behavior on border.color { ColorAnimation { duration: 130 } }
+
+ Rectangle {
+ anchors { top: parent.top; right: parent.right; margins: 8 }
+ width: 6; height: 6; radius: 3
+ color: btn.on ? Theme.active : Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.18)
+ Behavior on color { ColorAnimation { duration: 130 } }
+ }
+
+ Column {
+ anchors { left: parent.left; bottom: parent.bottom; margins: 9 }
+ spacing: 2
+ Text {
+ text: btn.icon; font.pixelSize: 17
+ color: btn.on ? Theme.active : Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.40)
+ Behavior on color { ColorAnimation { duration: 130 } }
+ }
+ Text {
+ text: btn.label; font.pixelSize: 9; font.weight: Font.Medium
+ color: btn.on ? Theme.text : Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.45)
+ Behavior on color { ColorAnimation { duration: 130 } }
+ }
+ Text {
+ visible: btn.sublabel !== ""
+ text: btn.sublabel
+ font.pixelSize: 8; font.family: "JetBrains Mono"
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.65)
+ width: btn.width - 18; elide: Text.ElideRight
+ }
+ }
+ HoverHandler { id: bH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: btn.toggled() }
+ }
+
+ Grid {
+ id: tileGrid
+ width: flick.width
+ columns: 2; spacing: 6
+
+ readonly property real btnW: (width - spacing) / 2
+ readonly property real btnH: btnW * 0.85
+
+ TglBtn {
+ width: tileGrid.btnW; height: tileGrid.btnH
+ on: root.wifiOn && !root.hotspotOn
+ icon: (root.wifiOn && !root.hotspotOn) ? "" : ""; label: "Wi-Fi"
+ sublabel: (root.wifiOn && !root.hotspotOn) && root.wifiSSID !== "" ? root.wifiSSID : (root.hotspotOn ? "Used by Hotspot" : "")
+ onToggled: root._wifiToggle()
+ }
+ TglBtn {
+ width: tileGrid.btnW; height: tileGrid.btnH
+ on: root.btOn; icon: root.btOn ? "" : ""; label: "Bluetooth"
+ sublabel: root.btOn && root.btDevice !== "" ? root.btDevice : ""
+ onToggled: root._btToggle()
+ }
+ TglBtn {
+ width: tileGrid.btnW; height: tileGrid.btnH
+ on: root.airplaneOn; icon: ""; label: "Airplane Mode"
+ onToggled: root._airplaneToggle()
+ }
+ TglBtn {
+ width: tileGrid.btnW; height: tileGrid.btnH
+ on: root.hotspotOn || root.hotspotBusy
+ icon: ""
+ label: "Hotspot"
+ sublabel: root.hotspotLabel
+ onToggled: root._hotspotToggle()
+ }
+ TglBtn {
+ width: tileGrid.btnW; height: tileGrid.btnH
+ on: root.nightLightOn; icon: ""; label: "Night Light"
+ onToggled: root._nightLightToggle()
+ }
+ TglBtn {
+ width: tileGrid.btnW; height: tileGrid.btnH
+ on: root.caffeineOn; icon: ""; label: "Caffeine"
+ onToggled: root._caffeineToggle()
+ }
+ TglBtn {
+ width: tileGrid.btnW; height: tileGrid.btnH
+ on: ShellState.focusMode
+ icon: ShellState.focusMode ? "" : ""; label: "Focus Mode"
+ onToggled: root._focusToggle()
+ }
+ TglBtn {
+ width: tileGrid.btnW; height: tileGrid.btnH
+ on: ShellState.dnd; icon: ShellState.dnd ? "" : ""
+ label: "Do Not Disturb"
+ onToggled: root._dndToggle()
+ }
+ TglBtn {
+ width: tileGrid.btnW; height: tileGrid.btnH
+ on: ShellState.screenRecord || ScreenRecService.recording
+ icon: ScreenRecService.recording ? "⏹" : ""
+ label: ScreenRecService.recording ? "Recording" : "Screen Capture"
+ onToggled: {
+ if (ScreenRecService.recording) {
+ ScreenRecService.stopRecording()
+ } else if (ShellState.screenRecord) {
+ ScreenRecService.cancelSetup()
+ } else {
+ Popups.closeAll()
+ ShellState.screenRecord = true
+ }
+ }
+ }
+ // Filter tile — opens picker, does not toggle directly
+ TglBtn {
+ width: tileGrid.btnW; height: tileGrid.btnH
+ on: root.currentFilter !== ""
+ icon: ""
+ label: "Filter"
+ sublabel: root.currentFilter !== "" ? root.currentFilter : ""
+ onToggled: root._filterOpen()
+ }
+ }
+ }
+ }
+ }
+
+ // ── Filter picker popup ───────────────────────────────────────────────────
+ // Floats above the bottom-right tile. z:20 renders it over the grid.
+ // Anchored bottom-right of the StatCard's inner area.
+ Rectangle {
+ id: filterPicker
+ visible: root.filterPickerOpen
+ z: 20
+
+ onVisibleChanged: {
+ if (visible) {
+ forceActiveFocus()
+ } else {
+ root.forceActiveFocus()
+ }
+ }
+
+ Keys.onEscapePressed: function(event) {
+ root.filterPickerOpen = false
+ event.accepted = true // <--- Prevents the dashboard from closing
+ }
+
+ anchors {
+ right: parent.right
+ bottom: parent.bottom
+ rightMargin: 12
+ bottomMargin: 12
+ }
+
+ width: 180
+ // Height fits "Off" row + all shader rows, capped at 280
+ height: Math.min(280, pickerCol.implicitHeight + 16)
+ radius: Theme.cornerRadius
+
+ color: Qt.rgba(
+ Math.min(1, Theme.background.r + 0.05),
+ Math.min(1, Theme.background.g + 0.05),
+ Math.min(1, Theme.background.b + 0.05),
+ 0.98)
+ border.color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.10)
+ border.width: 1
+
+ // Subtle entrance scale + fade
+ opacity: root.filterPickerOpen ? 1 : 0
+ scale: root.filterPickerOpen ? 1 : 0.95
+ Behavior on opacity { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } }
+ Behavior on scale { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } }
+ transformOrigin: Item.BottomRight
+
+ // Dismiss when clicking outside the picker
+ MouseArea {
+ anchors.fill: parent
+ // Swallow clicks so they don't fall through to tiles below
+ onClicked: {} // intentionally empty — keeps picker open on internal clicks
+ }
+
+ Flickable {
+ anchors { fill: parent; margins: 8 }
+ contentWidth: width
+ contentHeight: pickerCol.implicitHeight
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+
+ Column {
+ id: pickerCol
+ width: parent.width
+ spacing: 2
+
+ // Header label
+ Text {
+ width: parent.width
+ text: "SHADER"
+ font.pixelSize: 9; font.weight: Font.Bold
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.55)
+ leftPadding: 4
+ bottomPadding: 4
+ }
+
+ // "Off" row — always first
+ Rectangle {
+ width: parent.width
+ height: 28
+ radius: 6
+ property bool isActive: root.currentFilter === ""
+ color: isActive
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.14)
+ : offH.hovered ? Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.07) : "transparent"
+ Behavior on color { ColorAnimation { duration: 100 } }
+
+ Row {
+ anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ spacing: 8
+ Text {
+ text: parent.parent.isActive ? "●" : "○"
+ font.pixelSize: 9
+ color: parent.parent.isActive ? Theme.active : Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.30)
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ Text {
+ text: "Off"
+ font.pixelSize: 12
+ color: parent.parent.isActive ? Theme.active : Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.65)
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ }
+ HoverHandler { id: offH; cursorShape: Qt.PointingHandCursor }
+ TapHandler { onTapped: root._filterApply("") }
+ }
+
+ // Divider
+ Rectangle {
+ width: parent.width; height: 1
+ color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.07)
+ }
+
+ // Shader rows — populated by hyprshade ls
+ Repeater {
+ model: root.filterList
+ delegate: Rectangle {
+ required property string modelData
+ property bool isActive: root.currentFilter === modelData
+
+ width: pickerCol.width
+ height: 28
+ radius: 6
+ color: isActive
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.14)
+ : itemH.hovered ? Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.07) : "transparent"
+ Behavior on color { ColorAnimation { duration: 100 } }
+
+ Row {
+ anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ spacing: 8
+ Text {
+ text: parent.parent.isActive ? "●" : "○"
+ font.pixelSize: 9
+ color: parent.parent.isActive ? Theme.active : Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.30)
+ anchors.verticalCenter: parent.verticalCenter
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ Text {
+ text: modelData
+ font.pixelSize: 12
+ color: parent.parent.isActive ? Theme.active : Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.65)
+ anchors.verticalCenter: parent.verticalCenter
+ elide: Text.ElideRight
+ width: pickerCol.width - 38
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ }
+ HoverHandler { id: itemH; cursorShape: Qt.PointingHandCursor }
+ TapHandler { onTapped: root._filterApply(modelData) }
+ }
+ }
+
+ // Empty state — shown while hyprshade ls is still running
+ Text {
+ width: parent.width
+ visible: root.filterList.length === 0
+ text: "Loading…"
+ font.pixelSize: 11
+ color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.25)
+ horizontalAlignment: Text.AlignHCenter
+ topPadding: 4
+ }
+ }
+ }
+ }
+
+ // Tap outside the picker to close it
+ TapHandler {
+ enabled: root.filterPickerOpen
+ onTapped: root.filterPickerOpen = false
+ }
+}
diff --git a/src/services/home/qmldir b/src/services/home/qmldir
new file mode 100644
index 0000000..0e80263
--- /dev/null
+++ b/src/services/home/qmldir
@@ -0,0 +1,5 @@
+ProfileCard ProfileCard.qml
+CalendarCard CalendarCard.qml
+ClockCard ClockCard.qml
+PlayerCard PlayerCard.qml
+QuickSettings QuickSettings.qml
diff --git a/src/services/notifications/NotificationList.qml b/src/services/notifications/NotificationList.qml
new file mode 100644
index 0000000..2319403
--- /dev/null
+++ b/src/services/notifications/NotificationList.qml
@@ -0,0 +1,366 @@
+import QtQuick
+import Quickshell.Services.Notifications
+import "../"
+import "../../"
+
+// ─────────────────────────────────────────────────────────────
+// NotificationList — content panel for NotificationsPopup
+// ─────────────────────────────────────────────────────────────
+Item {
+ id: root
+
+ width: 360
+
+ // Total height: header + list area (or empty state)
+ height: header.height
+ + (NotificationService.count > 0 ? listArea.height : emptyState.height)
+
+ // ── Header ─────────────────────────────────────────────────
+ Item {
+ id: header
+ anchors { top: parent.top; left: parent.left; right: parent.right }
+ height: 44
+
+ Text {
+ anchors { horizontalCenter: parent.horizontalCenter; leftMargin: 16; verticalCenter: parent.verticalCenter }
+ text: "Notifications"
+ color: Theme.text
+ font.pixelSize: 14
+ font.bold: true
+ }
+
+ // Clear-all — only visible when there are notifications
+ Item {
+ anchors { right: parent.right; rightMargin: 12; verticalCenter: parent.verticalCenter }
+ width: clearLabel.width + 16
+ height: 26
+ visible: NotificationService.count > 0
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 13
+ color: clearHover.containsMouse ? Qt.rgba(1,1,1,0.10) : "transparent"
+ Behavior on color { ColorAnimation { duration: 120 } }
+ }
+ Text {
+ id: clearLabel
+ anchors.centerIn: parent
+ text: "Clear all"
+ color: Theme.subtext
+ font.pixelSize: 12
+ }
+ HoverHandler { id: clearHover }
+ TapHandler { onTapped: NotificationService.dismissAll() }
+ }
+ }
+
+ // Divider — only when list is non-empty
+ Rectangle {
+ id: divider
+ anchors { top: header.bottom; left: parent.left; right: parent.right }
+ height: 1
+ color: Qt.rgba(1, 1, 1, 0.06)
+ visible: NotificationService.count > 0
+ }
+
+ // ── Scrollable list ─────────────────────────────────────────
+ // Uses groupedModel for Android/iOS-style app grouping (Task 12).
+ // Falls back to flat list if grouping is disabled or unavailable.
+ Item {
+ id: listArea
+ anchors { top: divider.bottom; left: parent.left; right: parent.right }
+ // Clamp to maxListHeight — ListView scrolls inside
+ height: Math.min(contentList.contentHeight, maxListHeight)
+ visible: NotificationService.count > 0
+
+ readonly property int maxListHeight: 440
+
+ ListView {
+ id: contentList
+ anchors.fill: parent
+ model: NotificationService.list
+ clip: true
+ spacing: 1
+ boundsBehavior: Flickable.StopAtBounds
+ cacheBuffer: 2000
+
+ // ── Group by app name ───────────────────────────────────────
+ section.property: "appName"
+ section.criteria: ViewSection.FullString
+ section.delegate: Component {
+ Item {
+ required property string section
+ width: ListView.view.width
+ height: 32
+
+ Rectangle {
+ anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
+ height: 1
+ color: Qt.rgba(1, 1, 1, 0.05)
+ }
+
+ Row {
+ anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter }
+ spacing: 8
+
+ // App icon (small)
+ Item {
+ width: 16; height: 16
+ anchors.verticalCenter: parent.verticalCenter
+ Rectangle {
+ anchors.fill: parent; radius: 4
+ color: Qt.rgba(1,1,1,0.08)
+ Text {
+ anchors.centerIn: parent
+ text: (section || "?").charAt(0).toUpperCase()
+ color: Theme.subtext
+ font.pixelSize: 9; font.bold: true
+ }
+ }
+ }
+
+ Text {
+ text: section || "Unknown"
+ color: Theme.subtext
+ font.pixelSize: 11
+ font.bold: true
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ }
+ }
+
+ delegate: NotificationCard {
+ required property var modelData
+ width: ListView.view.width
+ notification: modelData
+ }
+ }
+
+ // Fade overlay when clipped
+ Rectangle {
+ anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
+ height: 28
+ visible: contentList.contentHeight > listArea.maxListHeight
+ gradient: Gradient {
+ orientation: Gradient.Vertical
+ GradientStop { position: 0.0; color: "transparent" }
+ GradientStop { position: 1.0; color: Qt.rgba(0.09, 0.11, 0.13, 1.0) }
+ }
+ }
+ }
+
+ // ── Empty state ─────────────────────────────────────────────
+ Item {
+ id: emptyState
+ anchors { top: header.bottom; left: parent.left; right: parent.right }
+ height: 80
+ visible: NotificationService.count === 0
+
+ Column {
+ anchors.centerIn: parent
+ spacing: 6
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: ""
+ color: Qt.rgba(1, 1, 1, 0.15)
+ font.pixelSize: 28
+ font.family: Theme.iconFont
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "No notifications"
+ color: Theme.subtext
+ font.pixelSize: 12
+ }
+ }
+ }
+
+ // ── NotificationCard ── inline component ────────────────────
+ component NotificationCard: Item {
+ id: card
+
+ // notification is required — guard every access with ?. and ?? fallback
+ required property var notification
+
+ // Urgency accent color — guard against undefined notification/urgency
+ readonly property color urgencyColor: {
+ if (!notification) return Theme.active
+ switch (notification.urgency) {
+ case NotificationUrgency.Critical: return "#e06c75"
+ case NotificationUrgency.Low: return Qt.rgba(1, 1, 1, 0.25)
+ default: return Theme.active
+ }
+ }
+
+ height: cardRow.height + 20
+
+ // Hover background
+ Rectangle {
+ anchors.fill: parent
+ color: cardHover.containsMouse ? Qt.rgba(1, 1, 1, 0.05) : "transparent"
+ Behavior on color { ColorAnimation { duration: 120 } }
+ }
+
+ // Left urgency accent bar
+ Rectangle {
+ anchors { left: parent.left; top: parent.top; bottom: parent.bottom }
+ width: 3
+ color: card.urgencyColor
+ opacity: 0.85
+ }
+
+ // Content row
+ Row {
+ id: cardRow
+ anchors {
+ left: parent.left; leftMargin: 12
+ right: parent.right; rightMargin: 8
+ top: parent.top; topMargin: 10
+ }
+ spacing: 10
+ height: Math.max(iconArea.height, textCol.implicitHeight)
+
+ // App icon
+ Item {
+ id: iconArea
+ width: 32
+ height: 32
+
+ Image {
+ id: iconImg
+ anchors.fill: parent
+ source: {
+ var ic = card.notification?.appIcon ?? ""
+ if (ic === "") return ""
+ if (ic.startsWith("/")) return "file://" + ic
+ return "image://icon/" + ic
+ }
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ visible: status === Image.Ready
+ sourceSize.width: 32
+ sourceSize.height: 32
+ }
+
+ // Letter fallback
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: Qt.rgba(1, 1, 1, 0.08)
+ visible: iconImg.status !== Image.Ready
+
+ Text {
+ anchors.centerIn: parent
+ text: (card.notification?.appName ?? "?").charAt(0).toUpperCase()
+ color: Theme.text
+ font.pixelSize: 14
+ font.bold: true
+ }
+ }
+ }
+
+ // Text column
+ Column {
+ id: textCol
+ // Leave room for dismiss button
+ width: cardRow.width - iconArea.width - dismissBtn.width - (cardRow.spacing * 2)
+ spacing: 3
+
+ // App name
+ Text {
+ width: parent.width
+ text: card.notification?.appName ?? ""
+ color: Theme.subtext
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ visible: text !== ""
+ }
+
+ // Summary
+ Text {
+ width: parent.width
+ text: card.notification?.summary ?? ""
+ color: Theme.text
+ font.pixelSize: 13
+ font.bold: true
+ wrapMode: Text.WordWrap
+ maximumLineCount: 2
+ elide: Text.ElideRight
+ visible: text !== ""
+ }
+
+ // Body
+ Text {
+ width: parent.width
+ text: card.notification?.body ?? ""
+ color: Theme.subtext
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ maximumLineCount: 3
+ elide: Text.ElideRight
+ textFormat: Text.StyledText
+ visible: text !== ""
+ }
+
+ // Action buttons
+ Row {
+ spacing: 6
+ visible: (card.notification?.actions?.length ?? 0) > 0
+
+ Repeater {
+ model: card.notification?.actions ?? []
+ delegate: Item {
+ required property var modelData
+ width: actionLbl.width + 20
+ height: 22
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 3
+ color: actHover.containsMouse
+ ? Qt.rgba(1,1,1,0.15)
+ : Qt.rgba(1,1,1,0.07)
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ Text {
+ id: actionLbl
+ anchors.centerIn: parent
+ text: modelData?.text ?? ""
+ color: Theme.text
+ font.pixelSize: 11
+ }
+ HoverHandler { id: actHover }
+ TapHandler { onTapped: modelData?.invoke() }
+ }
+ }
+ }
+ }
+
+ // Dismiss ✕
+ Item {
+ id: dismissBtn
+ width: 24
+ height: 24
+
+ Rectangle {
+ anchors.fill: parent
+ radius: width / 2
+ color: xHover.containsMouse ? Qt.rgba(1,1,1,0.12) : "transparent"
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ Text {
+ anchors.centerIn: parent
+ text: "✕"
+ color: Theme.subtext
+ font.pixelSize: 10
+ }
+ HoverHandler { id: xHover }
+ TapHandler { onTapped: card.notification?.dismiss() }
+ }
+ }
+
+ HoverHandler { id: cardHover }
+ }
+}
diff --git a/src/services/notifications/NotificationService.qml b/src/services/notifications/NotificationService.qml
new file mode 100644
index 0000000..0fc1ba2
--- /dev/null
+++ b/src/services/notifications/NotificationService.qml
@@ -0,0 +1,162 @@
+pragma Singleton
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Services.Notifications
+import "../../"
+
+// ─────────────────────────────────────────────────────────────
+// NotificationService — global singleton with bounded history,
+// app-grouped model, and JSON persistence across restarts.
+// ─────────────────────────────────────────────────────────────
+
+NotificationServer {
+ id: root
+
+ bodyMarkupSupported: true
+ bodySupported: true
+ actionsSupported: true
+ keepOnReload: true
+
+ signal notificationAdded(var notification)
+
+ property var list: []
+ readonly property int count: list.length
+
+ // Maximum notifications to keep in history (prevents unbounded growth)
+ property int maxHistory: 50
+
+ // ── Grouped model (Task 12) — app-grouped for Android/iOS-style display ──
+ // [{ appName, appIcon, count, notifications: [...], mostRecent }]
+ property var groupedModel: []
+
+ function _rebuildGroupedModel() {
+ var groups = {};
+ var order = [];
+ for (var i = 0; i < root.list.length; i++) {
+ var n = root.list[i];
+ var key = n.appName || "Unknown";
+ if (!groups[key]) {
+ groups[key] = { appName: key, appIcon: n.appIcon || "", notifications: [], mostRecent: n };
+ order.push(key);
+ }
+ groups[key].notifications.push(n);
+ groups[key].count = groups[key].notifications.length;
+ }
+ var result = [];
+ for (var j = 0; j < order.length; j++) {
+ result.push(groups[order[j]]);
+ }
+ root.groupedModel = result;
+ }
+
+ // ── Persistent history (Task 13) — JSON file ────────────────────────────
+ readonly property string _historyPath:
+ Quickshell.env("HOME") + "/.config/Brain_Shell/src/user_data/notification_history.json"
+
+ property var _historySaveProc: Process { command: []; running: false }
+
+ function _saveHistory() {
+ var data = [];
+ for (var i = 0; i < root.list.length; i++) {
+ var n = root.list[i];
+ data.push({
+ appName: n.appName || "",
+ appIcon: n.appIcon || "",
+ summary: n.summary || "",
+ body: n.body || "",
+ urgency: n.urgency !== undefined ? n.urgency : 1,
+ timestamp: Date.now()
+ });
+ }
+ var json = JSON.stringify(data);
+ _historySaveProc.command = ["bash", "-c",
+ "mkdir -p \"$(dirname '" + root._historyPath + "')\" && " +
+ "printf '%s' '" + json.replace(/'/g, "'\\''") + "' > '" + root._historyPath + "'"];
+ _historySaveProc.running = false;
+ _historySaveProc.running = true;
+ }
+
+ property Timer _saveDebounce: Timer {
+ interval: 2000
+ repeat: false
+ onTriggered: root._saveHistory()
+ }
+
+ // Load persisted history on startup (cosmetic only — these are NOT live notifications)
+ property var _historyLoadProc: Process {
+ command: ["bash", "-c",
+ "[ -f '" + root._historyPath + "' ] && cat '" + root._historyPath + "' || echo '[]'"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ var data = JSON.parse(text.trim());
+ // History is read-only display; real notifications come via DBus.
+ // We store the count for the bar indicator but don't hydrate live objects.
+ root._restoredHistoryCount = Array.isArray(data) ? data.length : 0;
+ } catch (e) {
+ root._restoredHistoryCount = 0;
+ }
+ }
+ }
+ }
+
+ property int _restoredHistoryCount: 0
+
+ property bool _ready: false
+
+ property Timer _startupTimer: Timer {
+ interval: 500
+ running: true
+ onTriggered: {
+ root._ready = true
+ root._historyLoadProc.running = false
+ root._historyLoadProc.running = true
+ }
+ }
+
+ onNotification: function(n) {
+ n.tracked = true
+
+ if (root.list.includes(n)) return
+
+ var newList = [n, ...root.list]
+ // Enforce history limit
+ if (newList.length > root.maxHistory) {
+ var excess = newList.length - root.maxHistory
+ for (var i = excess - 1; i >= 0; i--) {
+ var old = newList[newList.length - 1]
+ if (old && old.dismiss) old.dismiss()
+ }
+ newList = newList.slice(0, root.maxHistory)
+ }
+ root.list = newList
+ root._rebuildGroupedModel()
+ root._saveDebounce.restart()
+
+ if (ShellState.dnd) return
+
+ if (root._ready) {
+ root.notificationAdded(n)
+ }
+
+ n.onClosed.connect(function() {
+ root.list = root.list.filter(function(x) { return x !== n })
+ root._rebuildGroupedModel()
+ root._saveDebounce.restart()
+ })
+ }
+
+ function dismissAll() {
+ if (!root.list) return
+ const list = [...root.list]
+ for (const n of list) n.dismiss()
+ }
+
+ function clearHistory() {
+ root.list = []
+ root.groupedModel = []
+ root._restoredHistoryCount = 0
+ }
+}
diff --git a/src/services/qmldir b/src/services/qmldir
new file mode 100644
index 0000000..6959e0f
--- /dev/null
+++ b/src/services/qmldir
@@ -0,0 +1,30 @@
+PowerMenu PowerMenu.qml
+SystemStats ./system/SystemStats.qml
+BatteryStatus BatteryStatus.qml
+BatteryWarning BatteryWarning.qml
+EnvyControl ./system/EnvyControl.qml
+AudioControl AudioControl.qml
+MemService ./system/MemService.qml
+NetService ./system/NetService.qml
+GpuService ./system/GpuService.qml
+EnvyControlService ./system/EnvyControlService.qml
+CpuService ./system/CpuService.qml
+CpuFreqService ./system/CpuFreqService.qml
+DiskService ./system/DiskService.qml
+ThermalService ./system/ThermalService.qml
+FanControl ./system/FanControl.qml
+DashHome home/DashHome.qml
+singleton NotificationService 1.0 ./notifications/NotificationService.qml
+NotificationList ./notifications/NotificationList.qml
+singleton WallpaperService 1.0 WallpaperService.qml
+singleton ShellConfigService 1.0 ShellConfigService.qml
+singleton ScreenRecService 1.0 ScreenRecService.qml
+KanbanBoard KanbanBoard.qml
+AppLauncher AppLauncher.qml
+ShellConfig ./config_tab/ShellConfig.qml
+KeybindsPage ./config_tab/KeybindsPage.qml
+singleton HyprlandSyncService 1.0 HyprlandSyncService.qml
+singleton AppSearch 1.0 AppSearch.qml
+singleton UsageTracker 1.0 UsageTracker.qml
+singleton IdleService 1.0 IdleService.qml
+singleton GpuDetector 1.0 GpuDetector.qml
\ No newline at end of file
diff --git a/src/services/system/CpuFreqService.qml b/src/services/system/CpuFreqService.qml
new file mode 100644
index 0000000..b0c0775
--- /dev/null
+++ b/src/services/system/CpuFreqService.qml
@@ -0,0 +1,140 @@
+import QtQuick
+import Quickshell.Io
+
+// Tracks auto-cpufreq daemon status, the kernel governor, and current freq.
+//
+// Reading strategy:
+// 1. systemctl is-active auto-cpufreq → daemonActive
+// 2. cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
+// → reads ALL cores, picks the dominant governor
+// → activeProfile: "performance" | "powersave"
+// 3. cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq
+// → averages kHz across all cores → curFreqStr
+//
+// Exposes:
+// string governor — dominant sysfs value e.g. "powersave"
+// string activeProfile — "performance" | "powersave"
+// bool daemonActive — true if auto-cpufreq.service is running
+// bool busy
+// string curFreqStr — average frequency e.g. "2.40 GHz"
+// function setActiveProfile(profile) — "performance" | "powersave"
+
+QtObject {
+ id: root
+
+ property string governor: "—"
+ property string activeProfile: "powersave"
+ property bool daemonActive: false
+ property bool busy: false
+ property string curFreqStr: "— GHz"
+
+ property string _pendingProfile: ""
+
+ // ── Daemon status check ───────────────────────────────────────────────────
+ property var _daemonProc: Process {
+ command: ["systemctl", "is-active", "auto-cpufreq"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.daemonActive = text.trim() === "active"
+ }
+ }
+ }
+
+ // ── Governor reader (all cores) ───────────────────────────────────────────
+ // Picks dominant governor, then maps it to "performance" or "powersave".
+ property var _govProc: Process {
+ command: ["sh", "-c", "cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var lines = text.trim().split("\n").filter(function(l) { return l !== "" })
+ if (lines.length === 0) return
+
+ var counts = {}
+ for (var i = 0; i < lines.length; i++) {
+ var g = lines[i].trim()
+ counts[g] = (counts[g] || 0) + 1
+ }
+
+ var dominant = lines[0].trim()
+ var max = 0
+ var keys = Object.keys(counts)
+ for (var j = 0; j < keys.length; j++) {
+ if (counts[keys[j]] > max) {
+ max = counts[keys[j]]
+ dominant = keys[j]
+ }
+ }
+
+ root.governor = dominant
+ root.activeProfile = (dominant === "performance") ? "performance" : "powersave"
+ }
+ }
+ }
+
+ // ── Current frequency reader (all cores) ─────────────────────────────────
+ // scaling_cur_freq is in kHz. Average across all cores → format as GHz.
+ property var _freqProc: Process {
+ command: ["sh", "-c", "cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var lines = text.trim().split("\n").filter(function(l) { return l !== "" })
+ if (lines.length === 0) return
+
+ var sum = 0
+ for (var i = 0; i < lines.length; i++)
+ sum += parseFloat(lines[i].trim())
+
+ var avgGhz = (sum / lines.length) / 1e6
+ root.curFreqStr = avgGhz.toFixed(2) + " GHz"
+ }
+ }
+ }
+
+ // ── Set profile ───────────────────────────────────────────────────────────
+ // Writes the requested governor to all cores via pkexec tee.
+ property var _setProc: Process {
+ command: []
+ running: false
+ onRunningChanged: {
+ if (!running) {
+ root.busy = false
+ root._poll()
+ }
+ }
+ }
+
+ function setActiveProfile(profile) {
+ if (root.busy) return
+ root.busy = true
+
+ var gov = (profile === "performance") ? "performance" : "powersave"
+ _setProc.command = [
+ "sh", "-c",
+ "echo " + gov + " | tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor"
+ ]
+ _setProc.running = false
+ _setProc.running = true
+ }
+
+ // ── Poll timer ────────────────────────────────────────────────────────────
+ property var _pollTimer: Timer {
+ interval: Popups.dashboardOpen ? 2000 : 30000
+ running: true
+ repeat: true
+ onTriggered: root._poll()
+ }
+
+ function _poll() {
+ _govProc.running = false
+ _govProc.running = true
+ _freqProc.running = false
+ _freqProc.running = true
+ _daemonProc.running = false
+ _daemonProc.running = true
+ }
+
+ Component.onCompleted: _poll()
+}
diff --git a/src/services/system/CpuService.qml b/src/services/system/CpuService.qml
new file mode 100644
index 0000000..c1b26d8
--- /dev/null
+++ b/src/services/system/CpuService.qml
@@ -0,0 +1,68 @@
+import QtQuick
+import Quickshell.Io
+
+// Reads /proc/stat every second via cat and computes CPU usage %.
+// Exposes:
+// real usagePercent — 0.0 to 100.0
+
+QtObject {
+ id: root
+
+ property bool active: true
+ property real usagePercent: 0.0
+
+ property real _prevIdle: 0
+ property real _prevTotal: 0
+ property bool _firstRead: true
+
+ property var _proc: Process {
+ command: ["cat", "/proc/stat"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: root._parse(text)
+ }
+ }
+
+ property var _timer: Timer {
+ interval: Popups.dashboardOpen ? 2000 : 30000
+ running: root.active
+ repeat: true
+ onTriggered: {
+ _proc.running = false
+ _proc.running = true
+ }
+ }
+
+ function _parse(text) {
+ var line = text.split("\n")[0]
+ var parts = line.trim().split(/\s+/)
+ if (parts.length < 8 || parts[0] !== "cpu") return
+
+ var user = parseFloat(parts[1])
+ var nice = parseFloat(parts[2])
+ var system = parseFloat(parts[3])
+ var idle = parseFloat(parts[4])
+ var iowait = parseFloat(parts[5])
+ var irq = parseFloat(parts[6])
+ var softirq = parseFloat(parts[7])
+ var steal = parts.length > 8 ? parseFloat(parts[8]) : 0
+
+ var totalIdle = idle + iowait
+ var total = user + nice + system + totalIdle + irq + softirq + steal
+
+ if (!root._firstRead) {
+ var dTotal = total - root._prevTotal
+ var dIdle = totalIdle - root._prevIdle
+ if (dTotal > 0)
+ root.usagePercent = Math.round((1 - dIdle / dTotal) * 100)
+ }
+
+ root._firstRead = false
+ root._prevTotal = total
+ root._prevIdle = totalIdle
+ }
+
+ Component.onCompleted: {
+ _proc.running = true
+ }
+}
diff --git a/src/services/system/DiskService.qml b/src/services/system/DiskService.qml
new file mode 100644
index 0000000..f86b89b
--- /dev/null
+++ b/src/services/system/DiskService.qml
@@ -0,0 +1,79 @@
+import QtQuick
+import Quickshell.Io
+
+// Runs df every 15s and exposes real block devices as a list model.
+//
+// Exposes:
+// var disks — ListModel with objects:
+// { source, mount, usedPct, usedStr, totalStr }
+
+QtObject {
+ id: root
+
+ property bool active: true
+ property var disks: []
+
+ property var _proc: Process {
+ command: [
+ "sh", "-c",
+ "df -BM --output=source,size,used,pcent,target 2>/dev/null" +
+ " | grep '^/dev/' | grep -v 'tmpfs\\|loop'"
+ ]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: root._parse(text)
+ }
+ }
+
+ property var _timer: Timer {
+ interval: Popups.dashboardOpen ? 15000 : 60000
+ running: root.active
+ repeat: true
+ onTriggered: root._run()
+ }
+
+ function _run() {
+ _proc.running = false
+ _proc.running = true
+ }
+
+ function _parse(text) {
+ var lines = text.trim().split("\n")
+ var result = []
+
+ for (var i = 0; i < lines.length; i++) {
+ var parts = lines[i].trim().split(/\s+/)
+ // source size used pcent target
+ if (parts.length < 5) continue
+
+ var source = parts[0]
+ var total = parts[1] // e.g. "230004M"
+ var used = parts[2] // e.g. "180000M"
+ var pct = parts[3] // e.g. "78%"
+ var mount = parts[4]
+
+ // Shorten source: /dev/nvme0n1p2 → nvme0n1p2, /dev/sda1 → sda1
+ var shortSource = source.replace("/dev/", "")
+
+ result.push({
+ source: shortSource,
+ mount: mount,
+ usedPct: parseInt(pct) || 0,
+ usedStr: _fmt(used),
+ totalStr: _fmt(total)
+ })
+ }
+
+ root.disks = result
+ }
+
+ // Convert "180000M" → "175 GB" or keep as MB
+ function _fmt(mibStr) {
+ var n = parseInt(mibStr)
+ if (isNaN(n)) return mibStr
+ if (n >= 1024) return (n / 1024).toFixed(0) + " GB"
+ return n + " MB"
+ }
+
+ Component.onCompleted: _run()
+}
diff --git a/src/services/system/EnvyControl.qml b/src/services/system/EnvyControl.qml
new file mode 100644
index 0000000..62a3041
--- /dev/null
+++ b/src/services/system/EnvyControl.qml
@@ -0,0 +1,172 @@
+import QtQuick
+import Quickshell.Io
+import Quickshell.Services.UPower
+import "../"
+
+// Graphics & power status panel.
+
+Column {
+ id: root
+ spacing: 12
+ width: parent.width
+
+ readonly property var bat: UPower.displayDevice
+ readonly property bool charging: bat.ready
+ ? (bat.state === UPowerDeviceState.Charging ||
+ bat.state === UPowerDeviceState.PendingCharge ||
+ bat.state === UPowerDeviceState.FullyCharged)
+ : false
+
+ property string powerProfile: charging ? "Performance" : "Powersave"
+ property string gfxMode: "..."
+ property bool dgpuEnabled: false
+
+ Process {
+ id: gfxReader
+ command: ["envycontrol", "-q"]
+ running: true
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var mode = text.trim()
+ root.gfxMode = mode
+ root.dgpuEnabled = (mode === "hybrid")
+ }
+ }
+ }
+
+ onVisibleChanged: if (visible) gfxReader.running = true
+
+ // ── Power profile row (read-only) ─────────────────────────────────────────
+ Column {
+ width: parent.width
+ spacing: 4
+
+ Text {
+ text: "Power Profile"
+ color: Qt.rgba(1, 1, 1, 0.4)
+ font.pixelSize: 10
+ font.capitalization: Font.AllUppercase
+ leftPadding: 2
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 40
+ radius: Theme.cornerRadius
+ color: Qt.rgba(1, 1, 1, 0.05)
+
+ Row {
+ anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter }
+ spacing: 8
+
+ Text { text: "⚙️"; font.pixelSize: 14; anchors.verticalCenter: parent.verticalCenter }
+
+ Text {
+ text: root.powerProfile.charAt(0).toUpperCase()
+ + root.powerProfile.slice(1)
+ color: Theme.text
+ font.pixelSize: 13
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Text {
+ anchors { right: parent.right; rightMargin: 12; verticalCenter: parent.verticalCenter }
+ text: "🔒"
+ font.pixelSize: 12
+ opacity: 0.4
+ }
+ }
+ }
+
+ Rectangle { width: parent.width; height: 1; color: Qt.rgba(1, 1, 1, 0.08) }
+
+ // ── dGPU toggle row ───────────────────────────────────────────────────────
+ Column {
+ width: parent.width
+ spacing: 4
+
+ Text {
+ text: "Graphics"
+ color: Qt.rgba(1, 1, 1, 0.4)
+ font.pixelSize: 10
+ font.capitalization: Font.AllUppercase
+ leftPadding: 2
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 48
+ radius: Theme.cornerRadius
+ color: Qt.rgba(1, 1, 1, 0.05)
+
+ Row {
+ anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter }
+ spacing: 8
+
+ Text {
+ text: root.dgpuEnabled ? "🖥️" : "💻"
+ font.pixelSize: 16
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+
+ Text {
+ text: root.dgpuEnabled ? "Hybrid" : "Integrated"
+ color: Theme.text
+ font.pixelSize: 13
+ font.bold: true
+ }
+
+ Text {
+ text: root.dgpuEnabled ? "dGPU active" : "dGPU inactive"
+ color: Qt.rgba(1, 1, 1, 0.45)
+ font.pixelSize: 10
+ }
+ }
+ }
+
+ // Toggle switch
+ Rectangle {
+ id: toggle
+ anchors { right: parent.right; rightMargin: 12; verticalCenter: parent.verticalCenter }
+ width: 44
+ height: 24
+ radius: 12
+ color: root.dgpuEnabled ? Theme.active : Qt.rgba(1, 1, 1, 0.15)
+ Behavior on color { ColorAnimation { duration: 150 } }
+
+ Rectangle {
+ width: 18; height: 18; radius: 9
+ color: "white"
+ anchors.verticalCenter: parent.verticalCenter
+ x: root.dgpuEnabled ? parent.width - width - 3 : 3
+ Behavior on x { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
+ }
+
+ HoverHandler { cursorShape: Qt.PointingHandCursor }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ // Display label is capitalised; CLI arg must be lowercase
+ var displayMode = root.dgpuEnabled ? "Integrated" : "Hybrid"
+ var cliMode = displayMode.toLowerCase()
+ Popups.showConfirm(
+ "Switch Graphics Mode",
+ "Switching to " + displayMode + " mode requires saving your "
+ + "work and rebooting. Your system will restart immediately after "
+ + "the change is applied.",
+ "Switch & Reboot",
+ "gfx-switch",
+ cliMode
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/services/system/EnvyControlService.qml b/src/services/system/EnvyControlService.qml
new file mode 100644
index 0000000..94cf2f3
--- /dev/null
+++ b/src/services/system/EnvyControlService.qml
@@ -0,0 +1,75 @@
+import QtQuick
+import Quickshell.Io
+import "../../"
+
+// Queries envycontrol on load and after each switch.
+// Switching requires reboot — uses Popups.showConfirm().
+//
+// currentMode is ONLY ever set from an envycontrol --query result,
+// never optimistically. If pkexec is cancelled, the re-query after
+// the process exits will return the unchanged real mode.
+//
+// Extra hardening: re-query is only triggered when exitCode === 0,
+// so a cancelled pkexec leaves currentMode visually unchanged until
+// the next scheduled query.
+//
+// Exposes:
+// string currentMode — "integrated" | "hybrid" | "nvidia"
+// bool busy — true while a switch command is running
+// function switchMode(mode)
+// function executeSwitch(mode) — called by ConfirmDialog
+
+QtObject {
+ id: root
+
+ property string currentMode: "integrated"
+ property bool busy: false
+
+ // Pending mode — held until we confirm the switch succeeded
+ property string _pendingMode: ""
+
+ // ── Check if envycontrol is installed ────────────────────────────────────
+ property bool _available: false
+
+ property var _checkProc: Process {
+ command: ["bash", "-c", "command -v envycontrol >/dev/null 2>&1 && echo yes || echo no"]
+ running: false
+ stdout: SplitParser {
+ onRead: function(line) {
+ root._available = line.trim() === "yes"
+ if (root._available) {
+ _queryProc.running = true
+ }
+ }
+ }
+ }
+
+ // ── Query current mode ────────────────────────────────────────────────────
+ property var _queryProc: Process {
+ command: ["envycontrol", "--query"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var mode = text.trim().toLowerCase()
+ if (mode !== "") root.currentMode = mode
+ }
+ }
+ }
+
+ function switchMode(mode) {
+ if (!root._available || mode === root.currentMode || root.busy) return
+ Popups.closeAll()
+ Popups.showConfirm(
+ "Switch GPU Mode",
+ "Switch to " + mode + " mode?\nA reboot is required for the change to take effect.",
+ "Switch + Reboot",
+ "gpu-switch-envy",
+ mode
+ )
+ }
+
+
+ Component.onCompleted: {
+ _checkProc.running = true
+ }
+}
diff --git a/src/services/system/FanControl.qml b/src/services/system/FanControl.qml
new file mode 100644
index 0000000..0cdcacf
--- /dev/null
+++ b/src/services/system/FanControl.qml
@@ -0,0 +1,44 @@
+import QtQuick
+import Quickshell.Io
+
+// Controls fans via nbfc-linux.
+// Default mode assumes "auto" — set by hyprland exec-once at startup.
+//
+// Modes:
+// "quiet" → nbfc set -s 0
+// "auto" → nbfc set -a
+// "max" → nbfc set -s 100
+//
+// Commands wrapped in `timeout 5` to prevent hanging on unavailable sensors.
+//
+// Exposes:
+// string mode — "quiet" | "auto" | "max"
+// bool busy — true while a command is in flight
+// function setMode(m)
+
+QtObject {
+ id: root
+
+ property string mode: "auto"
+ property bool busy: false
+
+
+ property var _proc: Process {
+ command: []
+ running: false
+ onRunningChanged: if (!running) root.busy = false
+ }
+
+ function setMode(m) {
+ if (root.busy) return
+ root.mode = m
+ root.busy = true
+
+ if (m === "quiet") _proc.command = ["sh", "-c", "timeout 5 nbfc set -s 30"]
+ else if (m === "max") _proc.command = ["sh", "-c", "timeout 5 nbfc set -s 100"]
+ else _proc.command = ["sh", "-c", "timeout 5 nbfc set -a"]
+
+ _proc.running = false
+ _proc.running = true
+ }
+}
\ No newline at end of file
diff --git a/src/services/system/GpuService.qml b/src/services/system/GpuService.qml
new file mode 100644
index 0000000..fdcbbc4
--- /dev/null
+++ b/src/services/system/GpuService.qml
@@ -0,0 +1,128 @@
+import QtQuick
+import Quickshell.Io
+
+// Intel iGPU: frequency % via cat of sysfs rps files.
+// NVIDIA dGPU: nvidia-smi when envycontrol mode is not "integrated".
+//
+// Exposes:
+// igpu.freqPercent — 0–100 (act_freq / max_freq * 100)
+// igpu.curMhz — e.g. "650 MHz"
+// igpu.maxMhz — e.g. "1100 MHz"
+//
+// dgpu.active — false when envycontrol is "integrated"
+// dgpu.usagePercent — 0–100
+// dgpu.usedVram — e.g. "2048 MB"
+// dgpu.totalVram — e.g. "4096 MB"
+
+QtObject {
+ id: root
+
+ property bool active: true
+ property string envyMode: "integrated"
+
+ property QtObject igpu: QtObject {
+ property real freqPercent: 0.0
+ property string curMhz: "— MHz"
+ property string maxMhz: "— MHz"
+ }
+
+ property QtObject dgpu: QtObject {
+ property bool active: false
+ property real usagePercent: 0.0
+ property string usedVram: "— MB"
+ property string totalVram: "— MB"
+ }
+
+ // ── Intel act freq ────────────────────────────────────────────────────────
+ property real _actMhz: 0
+ property real _maxMhz: 0
+
+ property var _actProc: Process {
+ command: ["cat", "/sys/class/drm/card1/gt/gt0/rps_act_freq_mhz"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var a = parseFloat(text.trim())
+ if (!isNaN(a)) {
+ root._actMhz = a
+ root.igpu.curMhz = a + " MHz"
+ if (root._maxMhz > 0)
+ root.igpu.freqPercent = Math.round((a / root._maxMhz) * 100)
+ }
+ }
+ }
+ }
+
+ // ── Intel max freq ────────────────────────────────────────────────────────
+ property var _maxProc: Process {
+ command: ["cat", "/sys/class/drm/card1/gt/gt0/rps_max_freq_mhz"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var m = parseFloat(text.trim())
+ if (!isNaN(m)) {
+ root._maxMhz = m
+ root.igpu.maxMhz = m + " MHz"
+ }
+ }
+ }
+ }
+
+ // ── NVIDIA dGPU (only if nvidia-smi is available) ──────────────────────────
+ property var _nvProc: Process {
+ command: ["bash", "-c", "command -v nvidia-smi >/dev/null && nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total --format=csv,noheader,nounits 2>/dev/null || true"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var line = text.trim()
+ if (line === "") return
+ var parts = line.split(",").map(function(s) { return s.trim() })
+ if (parts.length < 3) return
+ root.dgpu.active = true
+ root.dgpu.usagePercent = parseFloat(parts[0]) || 0
+ root.dgpu.usedVram = parts[1] + " MB"
+ root.dgpu.totalVram = parts[2] + " MB"
+ }
+ }
+ }
+
+ // ── Poll timers (slower when dashboard closed to save CPU) ───────────────
+ property var _igpuTimer: Timer {
+ interval: Popups.dashboardOpen ? 2000 : 30000
+ running: root.active
+ repeat: true
+ onTriggered: {
+ _actProc.running = false
+ _actProc.running = true
+ _maxProc.running = false
+ _maxProc.running = true
+ }
+ }
+
+ property var _nvTimer: Timer {
+ interval: Popups.dashboardOpen ? 1000 : 5000
+ running: root.active && root.envyMode !== "integrated"
+ repeat: true
+ onTriggered: {
+ _nvProc.running = false
+ _nvProc.running = true
+ }
+ }
+
+ // ── dGPU active state follows envyMode ────────────────────────────────────
+ onEnvyModeChanged: {
+ if (envyMode === "integrated") {
+ dgpu.active = false
+ dgpu.usagePercent = 0
+ dgpu.usedVram = "— MB"
+ dgpu.totalVram = "— MB"
+ }
+ }
+
+ Component.onCompleted: {
+ _actProc.running = true
+ _maxProc.running = true
+ if (envyMode !== "integrated")
+ _nvProc.running = true
+ }
+}
diff --git a/src/services/system/MemService.qml b/src/services/system/MemService.qml
new file mode 100644
index 0000000..b885199
--- /dev/null
+++ b/src/services/system/MemService.qml
@@ -0,0 +1,61 @@
+import QtQuick
+import Quickshell.Io
+
+// Reads /proc/meminfo every 2s via cat (FileView can't read virtual fs).
+// Exposes:
+// real usagePercent — 0.0 to 100.0
+// real usedGb
+// real totalGb
+// string usedStr — e.g. "11.2 GB"
+// string totalStr — e.g. "16.0 GB"
+
+QtObject {
+ id: root
+
+ property bool active: true
+ property real usagePercent: 0.0
+ property real usedGb: 0.0
+ property real totalGb: 0.0
+ property string usedStr: "—"
+ property string totalStr: "—"
+
+ property var _proc: Process {
+ command: ["cat", "/proc/meminfo"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: root._parse(text)
+ }
+ }
+
+ property var _timer: Timer {
+ interval: Popups.dashboardOpen ? 2000 : 30000
+ running: root.active
+ repeat: true
+ onTriggered: {
+ _proc.running = false
+ _proc.running = true
+ }
+ }
+
+ function _parse(text) {
+ var total = 0, avail = 0
+ var lines = text.split("\n")
+ for (var i = 0; i < lines.length; i++) {
+ var parts = lines[i].trim().split(/\s+/)
+ if (parts[0] === "MemTotal:") total = parseFloat(parts[1])
+ if (parts[0] === "MemAvailable:") avail = parseFloat(parts[1])
+ }
+ if (total <= 0) return
+
+ var used = total - avail
+ root.totalGb = Math.round(total / 1024 / 1024 * 10) / 10
+ root.usedGb = Math.round(used / 1024 / 1024 * 10) / 10
+ root.usagePercent = Math.round(used / total * 100)
+ root.usedStr = root.usedGb + " GB"
+ root.totalStr = root.totalGb + " GB"
+ }
+
+ Component.onCompleted: {
+ _proc.running = true
+ }
+}
diff --git a/src/services/system/NetService.qml b/src/services/system/NetService.qml
new file mode 100644
index 0000000..c797e60
--- /dev/null
+++ b/src/services/system/NetService.qml
@@ -0,0 +1,104 @@
+import QtQuick
+import Quickshell.Io
+
+// Detects active interface via ip route, then
+// deltas /proc/net/dev byte counters every second via cat.
+//
+// Exposes:
+// string iface — e.g. "wlan0"
+// string upSpeed — e.g. "1.2 MB/s"
+// string downSpeed — e.g. "3.4 MB/s"
+
+QtObject {
+ id: root
+
+ property bool active: true
+ property string iface: "—"
+ property string upSpeed: "0 KB/s"
+ property string downSpeed: "0 KB/s"
+
+ property real _prevRx: 0
+ property real _prevTx: 0
+ property bool _firstRead: true
+
+ // ── Detect active interface — re-checked on every poll tick ──────────────
+ // Running once at startup missed interface changes (VPN, cable, network switch).
+ // Now re-runs every second alongside the stats read. If iface changes, counters
+ // reset so the first delta is not a huge spike.
+ property var _ifaceProc: Process {
+ command: ["sh", "-c",
+ "ip route get 1.1.1.1 2>/dev/null" +
+ " | awk '/dev/{for(i=1;i<=NF;i++) if($i==\"dev\") print $(i+1)}'"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var name = text.trim()
+ if (name !== "" && name !== root.iface) {
+ root.iface = name
+ root._firstRead = true // reset counters — avoid spike
+ root._prevRx = 0
+ root._prevTx = 0
+ }
+ }
+ }
+ }
+
+ // ── /proc/net/dev via cat ─────────────────────────────────────────────────
+ property var _proc: Process {
+ command: ["cat", "/proc/net/dev"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: root._parse(text)
+ }
+ }
+
+ property var _timer: Timer {
+ interval: Popups.dashboardOpen ? 2000 : 30000
+ running: root.active
+ repeat: true
+ onTriggered: {
+ // Re-detect interface every tick — catches VPN, cable, network changes
+ _ifaceProc.running = false
+ _ifaceProc.running = true
+ _proc.running = false
+ _proc.running = true
+ }
+ }
+
+ function _parse(text) {
+ if (root.iface === "—") return
+ var lines = text.split("\n")
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i].trim()
+ if (!line.startsWith(root.iface + ":")) continue
+
+ var parts = line.split(":")[1].trim().split(/\s+/)
+ var rx = parseFloat(parts[0])
+ var tx = parseFloat(parts[8])
+
+ if (!root._firstRead) {
+ var dRx = Math.max(0, rx - root._prevRx)
+ var dTx = Math.max(0, tx - root._prevTx)
+ root.downSpeed = root._fmt(dRx)
+ root.upSpeed = root._fmt(dTx)
+ }
+ root._firstRead = false
+ root._prevRx = rx
+ root._prevTx = tx
+ break
+ }
+ }
+
+ function _fmt(bytesPerSec) {
+ if (bytesPerSec >= 1024 * 1024)
+ return (Math.round(bytesPerSec / 1024 / 1024 * 10) / 10) + " MB/s"
+ if (bytesPerSec >= 1024)
+ return Math.round(bytesPerSec / 1024) + " KB/s"
+ return Math.round(bytesPerSec) + " B/s"
+ }
+
+ Component.onCompleted: {
+ _ifaceProc.running = true
+ _proc.running = true
+ }
+}
diff --git a/src/services/system/SystemStats.qml b/src/services/system/SystemStats.qml
new file mode 100644
index 0000000..999e935
--- /dev/null
+++ b/src/services/system/SystemStats.qml
@@ -0,0 +1,123 @@
+import QtQuick
+import Quickshell.Io
+import "../../"
+
+// Parses fastfetch output into key/value rows and renders them styled.
+// Each line from fastfetch is "Key: Value" — we split on ": " (first occurrence).
+
+Item {
+ id: root
+
+ onVisibleChanged: if (visible) reload()
+
+ // Parsed rows: [{key, value}]
+ property var rows: []
+
+ function reload() {
+ root.rows = []
+ ff.running = true
+ }
+
+ // Strip ANSI escape codes just in case
+ function stripAnsi(str) {
+ return str.replace(/\x1B\[[0-9;]*[mGKHF]/g, "")
+ }
+
+ function parse(raw) {
+ var lines = stripAnsi(raw).split("\n")
+ var result = []
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i].trim()
+ if (line === "") continue
+ var sep = line.indexOf(": ")
+ if (sep === -1) continue
+ result.push({
+ key: line.substring(0, sep).trim(),
+ value: line.substring(sep + 2).trim()
+ })
+ }
+ return result
+ }
+
+ Process {
+ id: ff
+ command: ["fastfetch", "-c", "systemstats"]
+ running: true
+
+ stdout: StdioCollector {
+ id: ffOut
+ onStreamFinished: root.rows = root.parse(ffOut.text)
+ }
+ }
+
+ // --- Rows ---
+ Column {
+ anchors {
+ left: parent.left
+ right: parent.right
+ top: parent.top
+ }
+ spacing: 0
+
+ Repeater {
+ model: root.rows
+
+ delegate: Item {
+ width: parent.width
+ height: 36
+
+ // Subtle alternating background
+ Rectangle {
+ anchors.fill: parent
+ radius: Theme.cornerRadius
+ color: index % 2 === 0
+ ? Qt.rgba(1, 1, 1, 0.04)
+ : "transparent"
+ }
+
+ // Key
+ Text {
+ id: keyText
+ anchors {
+ left: parent.left
+ leftMargin: 10
+ verticalCenter: parent.verticalCenter
+ }
+ text: modelData.key
+ color: Theme.active
+ font.pixelSize: 12
+ font.bold: true
+ width: 90
+ elide: Text.ElideRight
+ }
+
+ // Separator dot
+ Text {
+ id: dot
+ anchors {
+ left: keyText.right
+ verticalCenter: parent.verticalCenter
+ }
+ text: "·"
+ color: Qt.rgba(1, 1, 1, 0.25)
+ font.pixelSize: 12
+ }
+
+ // Value
+ Text {
+ anchors {
+ left: dot.right
+ leftMargin: 6
+ right: parent.right
+ rightMargin: 10
+ verticalCenter: parent.verticalCenter
+ }
+ text: modelData.value
+ color: Theme.text
+ font.pixelSize: 12
+ elide: Text.ElideRight
+ }
+ }
+ }
+ }
+}
diff --git a/src/services/system/ThermalService.qml b/src/services/system/ThermalService.qml
new file mode 100644
index 0000000..5cc0d63
--- /dev/null
+++ b/src/services/system/ThermalService.qml
@@ -0,0 +1,107 @@
+import QtQuick
+import Quickshell.Io
+
+// Runs `sensors` every 3s and parses CPU package temp + fan speeds.
+// Separately queries nvidia-smi for GPU temp every 3s.
+//
+// Exposes:
+// real cpuTemp — CPU package temp °C, 0 if unread
+// real gpuTemp — NVIDIA GPU temp °C, 0 if unread/off
+// int fan1Rpm — fan1 speed RPM, 0 if not present
+// int fan2Rpm — fan2 speed RPM, 0 if not present
+// int fanCount — number of fans detected (0, 1, or 2)
+// string cpuTempStr — e.g. "52°C"
+// string gpuTempStr — e.g. "65°C" or "—"
+// string fan1Str — e.g. "2400 RPM" or "—"
+// string fan2Str — e.g. "0 RPM" or "—"
+
+QtObject {
+ id: root
+
+ property bool active: true
+ property real cpuTemp: 0
+ property real gpuTemp: 0
+ property int fan1Rpm: 0
+ property int fan2Rpm: 0
+ property int fanCount: 0
+ property string cpuTempStr: "—"
+ property string gpuTempStr: "—"
+ property string fan1Str: "—"
+ property string fan2Str: "—"
+
+ // ── sensors process ───────────────────────────────────────────────────────
+ property var _proc: Process {
+ command: ["sh", "-c", "sensors 2>/dev/null"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: root._parse(text)
+ }
+ }
+
+ // ── nvidia-smi GPU temp (only if available) ────────────────────────────────
+ property var _nvProc: Process {
+ command: ["bash", "-c", "command -v nvidia-smi >/dev/null && nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader,nounits 2>/dev/null || true"]
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ var t = parseFloat(text.trim())
+ if (!isNaN(t)) {
+ root.gpuTemp = t
+ root.gpuTempStr = t.toFixed(0) + "°C"
+ }
+ }
+ }
+ }
+
+ // ── Poll timer ────────────────────────────────────────────────────────────
+ property var _timer: Timer {
+ interval: Popups.dashboardOpen ? 2000 : 30000
+ running: root.active
+ repeat: true
+ onTriggered: root._run()
+ }
+
+ function _run() {
+ _proc.running = false
+ _proc.running = true
+ _nvProc.running = false
+ _nvProc.running = true
+ }
+
+ function _parse(text) {
+ var lines = text.split("\n")
+ var pkg = -1
+ var fans = []
+
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i]
+
+ // CPU package temp — "Package id 0: +52.0°C"
+ if (/Package id 0:/i.test(line)) {
+ var m = line.match(/\+([0-9.]+)°C/)
+ if (m) pkg = parseFloat(m[1])
+ continue
+ }
+
+ // Fan lines — "fan1: 2400 RPM" or "Fan Speed: 2400 RPM"
+ var fm = line.match(/fan\d\s*:\s+([0-9]+)\s+RPM/i)
+ if (fm) {
+ fans.push(parseInt(fm[1]))
+ continue
+ }
+ }
+
+ if (pkg >= 0) {
+ root.cpuTemp = pkg
+ root.cpuTempStr = pkg.toFixed(0) + "°C"
+ }
+
+ root.fanCount = fans.length
+ root.fan1Rpm = fans.length > 0 ? fans[0] : 0
+ root.fan2Rpm = fans.length > 1 ? fans[1] : 0
+ root.fan1Str = fans.length > 0 ? fans[0] + " RPM" : "—"
+ root.fan2Str = fans.length > 1 ? fans[1] + " RPM" : "—"
+ }
+
+ Component.onCompleted: _run()
+}
diff --git a/src/shapes/NotchBackground.qml b/src/shapes/NotchBackground.qml
deleted file mode 100644
index 5a06de7..0000000
--- a/src/shapes/NotchBackground.qml
+++ /dev/null
@@ -1,24 +0,0 @@
-// src/components/NotchBackground.qml
-import QtQuick
-import "../theme/" // To access Theme
-
-Item {
- anchors.fill: parent
-
- // 1. The main shape with rounded corners
- Rectangle {
- anchors.fill: parent
- color: Theme.background
- radius: Theme.notchRadius
- }
-
- // 2. A "Patch" to square off the top corners
- // This sits at the top half and hides the top rounded corners
- Rectangle {
- height: parent.radius
- anchors.top: parent.top
- anchors.left: parent.left
- anchors.right: parent.right
- color: Theme.background
- }
-}
\ No newline at end of file
diff --git a/src/shapes/PopupShape.qml b/src/shapes/PopupShape.qml
new file mode 100644
index 0000000..1811025
--- /dev/null
+++ b/src/shapes/PopupShape.qml
@@ -0,0 +1,143 @@
+import QtQuick
+import "../"
+
+// Draws a popup background that "melts" into whichever edge(s) it's attached to.
+Canvas {
+ id: root
+
+ property string attachedEdge: "top"
+ property color color: Theme.background
+
+ // Normal corner radius for the edges away from the notch
+ property int radius: Theme.cornerRadius
+
+ // Custom dimensions for the outward "melt" (concave corners)
+ // Increase flareHeight to make the corners "higher" / stretch further
+ property int flareWidth: Theme.cornerRadius
+ property int flareHeight: Theme.cornerRadius
+
+ // ── Render quality + GPU caching ─────────────────────────────────────────
+ antialiasing: true
+ smooth: true
+ renderStrategy: Canvas.Cooperative
+ renderTarget: Canvas.Image
+ layer.enabled: true
+ layer.smooth: true
+
+ onWidthChanged: requestPaint()
+ onHeightChanged: requestPaint()
+ onAttachedEdgeChanged: requestPaint()
+ onColorChanged: requestPaint()
+ onFlareWidthChanged: requestPaint()
+ onFlareHeightChanged: requestPaint()
+
+ onPaint: {
+ var ctx = getContext("2d")
+ ctx.clearRect(0, 0, width, height)
+
+ var w = width
+ var h = height
+ var r = radius
+ var fw = flareWidth
+ var fh = flareHeight
+
+ ctx.beginPath()
+ ctx.fillStyle = root.color
+
+ // We use quadraticCurveTo(cpx, cpy, x, y) for the flares to allow
+ // asymmetric stretching (making them higher/wider than a perfect circle).
+ switch (root.attachedEdge) {
+
+ case "left":
+ // Body inset by fw on the Left. Flare stretches vertically by fh.
+ ctx.moveTo(0, 0)
+ ctx.quadraticCurveTo(0, fh, fw, fh) // outward flare top-left
+ ctx.lineTo(w - r, fh)
+ ctx.arcTo(w, fh, w, fh + r, r) // normal top-right
+ ctx.lineTo(w, h - fh - r)
+ ctx.arcTo(w, h - fh, w - r, h - fh, r) // normal bottom-right
+ ctx.lineTo(fw, h - fh)
+ ctx.quadraticCurveTo(0, h - fh, 0, h) // outward flare bottom-left
+ ctx.closePath()
+ break
+
+ case "right":
+ // Body inset by fw on the Right. Flare stretches vertically by fh.
+ ctx.moveTo(w, 0)
+ ctx.quadraticCurveTo(w, fh, w - fw, fh) // outward flare top-right
+ ctx.lineTo(r, fh)
+ ctx.arcTo(0, fh, 0, fh + r, r) // normal top-left
+ ctx.lineTo(0, h - fh - r)
+ ctx.arcTo(0, h - fh, r, h - fh, r) // normal bottom-left
+ ctx.lineTo(w - fw, h - fh)
+ ctx.quadraticCurveTo(w, h - fh, w, h) // outward flare bottom-right
+ ctx.closePath()
+ break
+
+ case "top":
+ // Body inset by fw on Left/Right. Flare stretches horizontally by fw, vertically by fh.
+ ctx.moveTo(0, 0)
+ ctx.quadraticCurveTo(fw, 0, fw, fh) // outward flare top-left
+ ctx.lineTo(fw, h - r)
+ ctx.arcTo(fw, h, fw + r, h, r) // normal bottom-left
+ ctx.lineTo(w - fw - r, h)
+ ctx.arcTo(w - fw, h, w - fw, h - r, r) // normal bottom-right
+ ctx.lineTo(w - fw, fh)
+ ctx.quadraticCurveTo(w - fw, 0, w, 0) // outward flare top-right
+ ctx.closePath()
+ break
+
+ case "bottom":
+ // Body inset by fw on Left/Right. Flare stretches horizontally by fw, vertically by fh.
+ ctx.moveTo(0, h)
+ ctx.quadraticCurveTo(fw, h, fw, h - fh) // outward flare bottom-left
+ ctx.lineTo(fw, r)
+ ctx.arcTo(fw, 0, fw + r, 0, r) // normal top-left
+ ctx.lineTo(w - fw - r, 0)
+ ctx.arcTo(w - fw, 0, w - fw, r, r) // normal top-right
+ ctx.lineTo(w - fw, h - fh)
+ ctx.quadraticCurveTo(w - fw, h, w, h) // outward flare bottom-right
+ ctx.closePath()
+ break
+
+ case "bottom-right":
+ // Popup sits in the bottom-right screen corner.
+ // Canvas: (popupWidth + fw) × (popupHeight + fh)
+ //
+ // Body top edge at y=fh, body left edge at x=fw.
+ // Right and bottom edges are flush with screen borders.
+ //
+ // fw pixels on LEFT → bottom-left flare zone
+ // fh pixels on TOP → top-right flare zone
+ //
+ // Flares:
+ // top-right: concave melt into right border
+ // bottom-left: concave melt into bottom border
+ // top-left: normal convex rounded corner
+ // bottom-right: square — both border strips physically cover it
+ //
+ // Content safe zone: x ≥ fw, y ≥ fh (margins handle both flare corners)
+
+ // 1. Start top edge just after top-left radius
+ ctx.moveTo(fw + r, fh)
+ // 2. Top edge rightward to the flare start
+ ctx.lineTo(w - fw, fh)
+ // 3. Top-right flare: concave melt into the right border
+ ctx.quadraticCurveTo(w, fh, w, 0)
+ // 4. Right edge straight down (flush with right screen border)
+ ctx.lineTo(w, h)
+ // 5. Bottom edge straight left (flush with bottom screen border)
+ ctx.lineTo(0, h)
+ // 6. Bottom-left flare: concave melt into the bottom border
+ ctx.quadraticCurveTo(fw, h, fw, h - fh)
+ // 7. Left edge straight up to the top-left corner
+ ctx.lineTo(fw, fh + r)
+ // 8. Top-left: standard convex rounded corner
+ ctx.arcTo(fw, fh, fw + r, fh, r)
+ ctx.closePath()
+ break
+ }
+
+ ctx.fill()
+ }
+}
\ No newline at end of file
diff --git a/src/shapes/RoundCorner.qml b/src/shapes/RoundCorner.qml
new file mode 100644
index 0000000..22c4d14
--- /dev/null
+++ b/src/shapes/RoundCorner.qml
@@ -0,0 +1,67 @@
+import QtQuick
+
+/*!
+ RoundCorner.qml — single rounded screen corner.
+ Ported from NothingLess. Draws a quarter-circle filled with
+ the current theme background color to mask sharp screen edges.
+*/
+Item {
+ id: root
+
+ enum Corner {
+ TopLeft, TopRight, BottomLeft, BottomRight
+ }
+
+ property int corner: RoundCorner.Corner.TopLeft
+ property int size: Theme.cornerRadius
+ property color maskColor: Theme.background
+
+ implicitWidth: size
+ implicitHeight: size
+
+ onMaskColorChanged: canvas.requestPaint()
+ onCornerChanged: canvas.requestPaint()
+ onSizeChanged: canvas.requestPaint()
+ onVisibleChanged: { if (visible) canvas.requestPaint() }
+
+ Canvas {
+ id: canvas
+ anchors.fill: parent
+ antialiasing: true
+ renderStrategy: Canvas.Cooperative
+ renderTarget: Canvas.Image
+ layer.enabled: true
+
+ onPaint: {
+ var ctx = getContext("2d")
+ // Round + 1px overlap to eliminate seams with adjacent borders
+ var r = Math.round(root.size) + 1
+ var c = root.maskColor
+ ctx.clearRect(0, 0, width, height)
+ ctx.beginPath()
+
+ switch (root.corner) {
+ case RoundCorner.Corner.TopLeft:
+ ctx.arc(r, r, r, Math.PI, 3 * Math.PI / 2)
+ ctx.lineTo(0, 0)
+ break
+ case RoundCorner.Corner.TopRight:
+ ctx.arc(0, r, r, 3 * Math.PI / 2, 2 * Math.PI)
+ ctx.lineTo(r, 0)
+ break
+ case RoundCorner.Corner.BottomLeft:
+ ctx.arc(r, 0, r, Math.PI / 2, Math.PI)
+ ctx.lineTo(0, r)
+ break
+ case RoundCorner.Corner.BottomRight:
+ ctx.arc(0, 0, r, 0, Math.PI / 2)
+ ctx.lineTo(r, r)
+ break
+ }
+
+ ctx.closePath()
+ ctx.fillStyle = c
+ ctx.fill()
+ }
+ }
+}
diff --git a/src/shapes/SeamlessBarShape.qml b/src/shapes/SeamlessBarShape.qml
index 4326933..d542423 100644
--- a/src/shapes/SeamlessBarShape.qml
+++ b/src/shapes/SeamlessBarShape.qml
@@ -1,128 +1,103 @@
import QtQuick
-import "../theme/"
-import "../windows/"
+import "../"
Canvas {
id: root
anchors.fill: parent
- property int notchHeight: Theme.notchHeight
- property int radius: 15 // Radius of the curves
- property int topBorderWidth: Theme.borderWidth // Thickness of the connecting top border
- property color color: Theme.background
-
- onWidthChanged: requestPaint()
- onHeightChanged: requestPaint()
+ // These are set by TopBar.qml with the real clamped widths.
+ // They default to the Theme constraints so the shape is never empty.
+ property int leftWidth: Theme.lNotchMinWidth
+ property int centerWidth: Theme.cNotchMinWidth
+ property int rightWidth: Theme.rNotchMinWidth
+
+ property int notchHeight: Theme.notchHeight
+ property int radius: Theme.notchRadius
+ property int topBorderWidth: Theme.borderWidth
+ property color color: Theme.background
+
+ // ── Render quality + GPU caching ────────────────────────────────────
+ width: Math.round(parent.width)
+ height: Math.round(parent.height)
+ antialiasing: true
+ smooth: true
+ renderStrategy: Canvas.Cooperative
+ renderTarget: Canvas.Image
+ layer.enabled: true
+ layer.smooth: true
+
+ onWidthChanged: requestPaint()
+ onHeightChanged: requestPaint()
+ onLeftWidthChanged: requestPaint()
+ onCenterWidthChanged: requestPaint()
+ onRightWidthChanged: requestPaint()
+ onColorChanged: requestPaint()
onPaint: {
- var ctx = getContext("2d");
- ctx.reset();
-
- // --- Configuration ---
- var leftW = Theme.lNotchWidth
- var centerW = Theme.cNotchWidth
- var rightW = Theme.rNotchWidth
-
- var r = root.radius
- var h = root.notchHeight
- var b = root.topBorderWidth // The "thin" height in the gaps
- var w = width
-
- // Calculated Positions
- var centerStart = (w / 2) - (centerW / 2)
- var centerEnd = (w / 2) + (centerW / 2)
- var rightStart = w - rightW
+ var ctx = getContext("2d")
+ ctx.clearRect(0, 0, width, height)
+
+ // Round all coordinates to integers — prevents sub-pixel antialiasing gaps
+ var r = Math.round(root.radius)
+ var h = Math.round(root.notchHeight)
+ var b = Math.round(root.topBorderWidth)
+ var w = Math.round(width)
+ var leftW = Math.round(root.leftWidth)
+ var centerW = Math.round(root.centerWidth)
+ var rightW = Math.round(root.rightWidth)
+
+ var centerStart = Math.round(w / 2 - centerW / 2)
+ var centerEnd = Math.round(w / 2 + centerW / 2)
+ var rightStart = Math.round(w - rightW)
ctx.beginPath();
ctx.fillStyle = root.color;
-
+
// ============================
- // 1. LEFT NOTCH SECTION
+ // 1. LEFT NOTCH
// ============================
- // Start at Bottom-Left (Flush with screen edge)
- ctx.moveTo(0, h);
-
- // Bottom Edge
+ ctx.moveTo(0, h);
ctx.lineTo(leftW - r, h);
-
- // Bottom-Right Corner (Hanging -> Convex/Round)
ctx.arcTo(leftW, h, leftW, h - r, r);
-
- // Right Edge Up (Stop before the top border!)
ctx.lineTo(leftW, b + r);
-
- // Connection to Top Border (Melt -> Concave/Inward)
- // Control Point: (leftW, b) -> The inner corner of the intersection
ctx.arcTo(leftW, b, leftW + r, b, r);
-
// ============================
- // 2. GAP 1 (Left -> Center)
+ // 2. GAP 1 (Left → Center)
// ============================
- // Line across the gap at border height
ctx.lineTo(centerStart - r, b);
-
// ============================
- // 3. CENTER NOTCH SECTION
+ // 3. CENTER NOTCH
// ============================
- // Connection from Top Border (Melt -> Concave/Inward)
- // Control Point: (centerStart, b)
ctx.arcTo(centerStart, b, centerStart, b + r, r);
-
- // Left Edge Down
ctx.lineTo(centerStart, h - r);
-
- // Bottom-Left Corner (Hanging -> Convex/Round)
ctx.arcTo(centerStart, h, centerStart + r, h, r);
-
- // Bottom Edge
ctx.lineTo(centerEnd - r, h);
-
- // Bottom-Right Corner (Hanging -> Convex/Round)
ctx.arcTo(centerEnd, h, centerEnd, h - r, r);
-
- // Right Edge Up
ctx.lineTo(centerEnd, b + r);
-
- // Connection to Top Border (Melt -> Concave/Inward)
- // Control Point: (centerEnd, b)
ctx.arcTo(centerEnd, b, centerEnd + r, b, r);
-
// ============================
- // 4. GAP 2 (Center -> Right)
+ // 4. GAP 2 (Center → Right)
// ============================
- // Line across the gap at border height
ctx.lineTo(rightStart - r, b);
-
// ============================
- // 5. RIGHT NOTCH SECTION
+ // 5. RIGHT NOTCH
// ============================
- // Connection from Top Border (Melt -> Concave/Inward)
- // Control Point: (rightStart, b)
ctx.arcTo(rightStart, b, rightStart, b + r, r);
-
- // Left Edge Down
ctx.lineTo(rightStart, h - r);
-
- // Bottom-Left Corner (Hanging -> Convex/Round)
ctx.arcTo(rightStart, h, rightStart + r, h, r);
-
- // Bottom Edge
- ctx.lineTo(w, h); // Flush with right screen edge
-
+ ctx.lineTo(w, h);
+
// ============================
- // 6. CLOSING THE LOOP
+ // 6. CLOSE LOOP
// ============================
- // Right Screen Edge Up
ctx.lineTo(w, 0);
- // Top Screen Edge Across (Back to 0,0)
ctx.lineTo(0, 0);
- // Left Screen Edge Down (Back to Start)
ctx.lineTo(0, h);
-
+
ctx.fill();
}
-}
\ No newline at end of file
+}
diff --git a/src/state/ClockState.qml b/src/state/ClockState.qml
new file mode 100644
index 0000000..402577a
--- /dev/null
+++ b/src/state/ClockState.qml
@@ -0,0 +1,32 @@
+pragma Singleton
+import QtQuick
+
+// ClockState — exposes active clock module state for the dynamic island.
+// Written by ClockCard, read by CenterNotch / dynamic island.
+
+QtObject {
+ // Timer
+ property bool timerRunning: false
+ property bool timerStarted: false
+ property int timerLeft: 0
+ property int timerTotal: 0
+ property string timerDisplay: "00:00"
+
+ // Stopwatch
+ property bool swRunning: false
+ property bool swStarted: false
+ property string swDisplay: "00:00"
+
+ signal requestStopwatchReset()
+ signal requestTimerReset()
+
+ // Alarms — list of { id, hour, minute, label, enabled }
+ property var alarms: []
+
+ // Nearest upcoming enabled alarm: { hour, minute, label, minsUntil } or null
+ property var nextAlarm: null
+
+ // True when something is actively running
+ readonly property bool hasActiveEvent:
+ timerRunning || swRunning || nextAlarm !== null
+}
diff --git a/src/state/IpcManager.qml b/src/state/IpcManager.qml
new file mode 100644
index 0000000..7077d8f
--- /dev/null
+++ b/src/state/IpcManager.qml
@@ -0,0 +1,579 @@
+pragma Singleton
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import "../"
+
+// ─────────────────────────────────────────────────────────────
+// IpcManager — centralized entry point for all external IPC signals.
+//
+// Moving handlers here ensures that on multi-monitor setups (where
+// TopBar/PopupLayer are duplicated) only ONE handler reacts to a signal.
+// ─────────────────────────────────────────────────────────────
+
+QtObject {
+ id: root
+
+ // ── Dashboard Toggles ────────────────────────────────────
+
+ property var dashboardHome: IpcHandler {
+ target: "dashboard-home"
+ function toggle() {
+ if(Popups.anyOpen && !Popups.dashboardOpen){
+ Popups.closeAll()
+ Popups.dashboardOpen = true
+ Popups.dashboardPage = "home"
+ } else if(Popups.dashboardOpen && Popups.dashboardPage != "home") {
+ Popups.dashboardPage = "home"
+ } else {
+ var next = !Popups.dashboardOpen
+ Popups.closeAll()
+ Popups.dashboardOpen = next
+ if (next) Popups.dashboardPage = "home"
+ }
+ }
+ }
+
+ property var dashboardStats: IpcHandler {
+ target: "dashboard-stats"
+ function toggle() {
+ if(Popups.anyOpen && !Popups.dashboardOpen){
+ Popups.closeAll()
+ Popups.dashboardOpen = true
+ Popups.dashboardPage = "stats"
+ } else if(Popups.dashboardOpen && Popups.dashboardPage != "stats") {
+ Popups.dashboardPage = "stats"
+ } else {
+ var next = !Popups.dashboardOpen
+ Popups.closeAll()
+ Popups.dashboardOpen = next
+ if (next) Popups.dashboardPage = "stats"
+ }
+ }
+ }
+
+ property var dashboardKanban: IpcHandler {
+ target: "dashboard-kanban"
+ function toggle() {
+ if(Popups.anyOpen && !Popups.dashboardOpen){
+ Popups.closeAll()
+ Popups.dashboardOpen = true
+ Popups.dashboardPage = "kanban"
+ } else if(Popups.dashboardOpen && Popups.dashboardPage != "kanban") {
+ Popups.dashboardPage = "kanban"
+ } else {
+ var next = !Popups.dashboardOpen
+ Popups.closeAll()
+ Popups.dashboardOpen = next
+ if (next) Popups.dashboardPage = "kanban"
+ }
+ }
+ }
+
+ property var dashboardLauncher: IpcHandler {
+ target: "dashboard-launcher"
+ function toggle() {
+ if(Popups.anyOpen && !Popups.dashboardOpen){
+ Popups.closeAll()
+ Popups.dashboardOpen = true
+ Popups.dashboardPage = "launcher"
+ } else if(Popups.dashboardOpen && Popups.dashboardPage != "launcher") {
+ Popups.dashboardPage = "launcher"
+ } else {
+ var next = !Popups.dashboardOpen
+ Popups.closeAll()
+ Popups.dashboardOpen = next
+ if (next) Popups.dashboardPage = "launcher"
+ }
+ }
+ }
+
+ property var dashboardConfig: IpcHandler {
+ target: "dashboard-config"
+ function toggle() {
+ if(Popups.anyOpen && !Popups.dashboardOpen){
+ Popups.closeAll()
+ Popups.dashboardOpen = true
+ Popups.dashboardPage = "config"
+ } else if(Popups.dashboardOpen && Popups.dashboardPage != "config") {
+ Popups.dashboardPage = "config"
+ } else {
+ var next = !Popups.dashboardOpen
+ Popups.closeAll()
+ Popups.dashboardOpen = next
+ if (next) Popups.dashboardPage = "config"
+ }
+ }
+ }
+
+ // ── Audio Toggles ────────────────────────────────────────
+
+ property var audioOut: IpcHandler {
+ target: "audioOut-toggle"
+ function toggle() {
+ if(Popups.anyOpen && !Popups.audioOpen) {
+ Popups.closeAll()
+ Popups.audioPage = "output"
+ Popups.audioOpen = true
+ } else if (Popups.audioOpen && Popups.audioPage != "output") {
+ Popups.audioPage = "output"
+ } else {
+ var next = !Popups.audioOpen
+ Popups.closeAll()
+ Popups.audioOpen = next
+ if (next) Popups.audioPage = "output"
+ }
+ }
+ }
+
+ property var audioMix: IpcHandler {
+ target: "audioMix-toggle"
+ function toggle() {
+ if(Popups.anyOpen && !Popups.audioOpen) {
+ Popups.closeAll()
+ Popups.audioPage = "mixer"
+ Popups.audioOpen = true
+ } else if (Popups.audioOpen && Popups.audioPage != "mixer") {
+ Popups.audioPage = "mixer"
+ } else {
+ var next = !Popups.audioOpen
+ Popups.closeAll()
+ Popups.audioOpen = next
+ if (next) Popups.audioPage = "mixer"
+ }
+ }
+ }
+
+ property var audioIn: IpcHandler {
+ target: "audioIn-toggle"
+ function toggle() {
+ if(Popups.anyOpen && !Popups.audioOpen) {
+ Popups.closeAll()
+ Popups.audioPage = "input"
+ Popups.audioOpen = true
+ } else if (Popups.audioOpen && Popups.audioPage != "input") {
+ Popups.audioPage = "input"
+ } else {
+ var next = !Popups.audioOpen
+ Popups.closeAll()
+ Popups.audioOpen = next
+ if (next) Popups.audioPage = "input"
+ }
+ }
+ }
+
+ // ── Network Toggles ──────────────────────────────────────
+
+ property var wifiToggle: IpcHandler {
+ target: "wifi-toggle"
+ function toggle() {
+ if(Popups.anyOpen && !Popups.networkOpen) {
+ Popups.closeAll()
+ Popups.networkPage = "wifi"
+ Popups.networkOpen = true
+ } else if (Popups.networkOpen && Popups.networkPage != "wifi") {
+ Popups.networkPage = "wifi"
+ } else {
+ var next = !Popups.networkOpen
+ Popups.closeAll()
+ Popups.networkOpen = next
+ if (next) Popups.networkPage = "wifi"
+ }
+ }
+ }
+
+ property var btToggle: IpcHandler {
+ target: "bluetooth-toggle"
+ function toggle() {
+ if(Popups.anyOpen && !Popups.networkOpen) {
+ Popups.closeAll()
+ Popups.networkPage = "bluetooth"
+ Popups.networkOpen = true
+ } else if (Popups.networkOpen && Popups.networkPage != "bluetooth") {
+ Popups.networkPage = "bluetooth"
+ } else {
+ var next = !Popups.networkOpen
+ Popups.closeAll()
+ Popups.networkOpen = next
+ if (next) Popups.networkPage = "bluetooth"
+ }
+ }
+ }
+
+ property var vpnToggle: IpcHandler {
+ target: "vpn-toggle"
+ function toggle() {
+ if(Popups.anyOpen && !Popups.networkOpen) {
+ Popups.closeAll()
+ Popups.networkPage = "vpn"
+ Popups.networkOpen = true
+ } else if (Popups.networkOpen && Popups.networkPage != "vpn") {
+ Popups.networkPage = "vpn"
+ } else {
+ var next = !Popups.networkOpen
+ Popups.closeAll()
+ Popups.networkOpen = next
+ if (next) Popups.networkPage = "vpn"
+ }
+ }
+ }
+
+ property var hotspotToggle: IpcHandler {
+ target: "hotspot-toggle"
+ function toggle() {
+ if(Popups.anyOpen && !Popups.networkOpen) {
+ Popups.closeAll()
+ Popups.networkPage = "hotspot"
+ Popups.networkOpen = true
+ } else if (Popups.networkOpen && Popups.networkPage != "hotspot") {
+ Popups.networkPage = "hotspot"
+ } else {
+ var next = !Popups.networkOpen
+ Popups.closeAll()
+ Popups.networkOpen = next
+ if (next) Popups.networkPage = "hotspot"
+ }
+ }
+ }
+
+ // ── Misc Toggles ─────────────────────────────────────────
+
+ property var notification: IpcHandler {
+ target: "notification-toggle"
+ function toggle() {
+ var next = !Popups.notificationsOpen
+ Popups.closeAll()
+ Popups.notificationsOpen = next
+ }
+ }
+
+ property var clipboard: IpcHandler {
+ target: "clipboard-toggle"
+ function toggle() {
+ var next = !Popups.clipboardOpen
+ Popups.closeAll()
+ Popups.clipboardOpen = next
+ }
+ }
+
+ property var wallpaper: IpcHandler {
+ target: "wallpaper-toggle"
+ function toggle() {
+ var next = !Popups.wallpaperOpen
+ Popups.closeAll()
+ Popups.wallpaperOpen = next
+ }
+ }
+
+ property var archMenu: IpcHandler {
+ target: "PowerMenu-toggle"
+ function toggle() {
+ var next = !Popups.archMenuOpen
+ Popups.closeAll()
+ Popups.archMenuOpen = next
+ }
+ }
+
+ property var screenRec: IpcHandler {
+ target: "screenrec-on"
+ function toggle() {
+ if (ScreenRecService.recording) {
+ ScreenRecService.stopRecording()
+ } else if (ShellState.screenRecord) {
+ ScreenRecService.cancelSetup()
+ } else {
+ Popups.closeAll()
+ ShellState.screenRecord = true
+ }
+ }
+ }
+
+ property var focusMode: IpcHandler {
+ target: "focus-toggle"
+ function toggle() {
+ root.focusToggleRequested()
+ }
+ }
+
+ // ── Screenshot Toggles ────────────────────────────────────
+
+ property var screenshotScreen: IpcHandler {
+ target: "screenshot-screen"
+ function toggle() { ScreenshotTool.captureScreen() }
+ }
+
+ property var screenshotWindow: IpcHandler {
+ target: "screenshot-window"
+ function toggle() { ScreenshotTool.captureWindow() }
+ }
+
+ property var screenshotRegion: IpcHandler {
+ target: "screenshot-region"
+ function toggle() { ScreenshotTool.captureRegion() }
+ }
+
+ property var _colorPickerProc: Process {
+ command: []
+ running: false
+ }
+
+ property var _lockscreenProc: Process {
+ command: []
+ running: false
+ }
+
+ property var colorPicker: IpcHandler {
+ target: "color-picker"
+ function toggle() {
+ _colorPickerProc.command = ["python3", Quickshell.shellDir + "/src/scripts/colorpicker.py"]
+ _colorPickerProc.running = false
+ _colorPickerProc.running = true
+ }
+ }
+
+ // ── Unified IPC dispatcher (receives string commands from CLI pipe) ───────
+
+ property var runCommand: IpcHandler {
+ target: "brain-shell"
+ // Use arguments object instead of typed parameter to avoid
+ // Quickshell IPC QVariant serialization error
+ function run() {
+ var cmd = arguments.length > 0 ? String(arguments[0] || "") : ""
+ _dispatchCommand(cmd)
+ }
+ }
+
+ function _dispatchCommand(cmd) {
+ if (!cmd || cmd === "") return
+
+ switch (cmd) {
+ // Dashboard
+ case "dashboard": Popups.dashboardOpen = !Popups.dashboardOpen; break
+ case "dashboard-home": _toggleDashboard("home"); break
+ case "dashboard-stats": _toggleDashboard("stats"); break
+ case "dashboard-kanban": _toggleDashboard("kanban"); break
+ case "dashboard-launcher": _toggleDashboard("launcher"); break
+ case "dashboard-config": _toggleDashboard("config"); break
+
+ // Launcher standalone
+ case "launcher":
+ if (Popups.anyOpen && !Popups.dashboardOpen) {
+ Popups.closeAll()
+ Popups.dashboardOpen = true
+ Popups.dashboardPage = "launcher"
+ } else if (Popups.dashboardOpen && Popups.dashboardPage !== "launcher") {
+ Popups.dashboardPage = "launcher"
+ } else {
+ var ln = !Popups.dashboardOpen
+ Popups.closeAll()
+ Popups.dashboardOpen = ln
+ if (ln) Popups.dashboardPage = "launcher"
+ }
+ break
+
+ // Audio
+ case "audio":
+ var audioWasOpen = Popups.audioOpen
+ Popups.closeAll()
+ Popups.audioOpen = !audioWasOpen
+ if (Popups.audioOpen) Popups.audioPage = "output"
+ break
+
+ // Network
+ case "network":
+ var netWasOpen = Popups.networkOpen
+ Popups.closeAll()
+ Popups.networkOpen = !netWasOpen
+ if (Popups.networkOpen) Popups.networkPage = "wifi"
+ break
+ case "wifi-toggle":
+ _toggleNetwork("wifi")
+ break
+ case "bt-toggle":
+ _toggleNetwork("bluetooth")
+ break
+ case "vpn-toggle":
+ _toggleNetwork("vpn")
+ break
+ case "hotspot-toggle":
+ _toggleNetwork("hotspot")
+ break
+
+ // Notifications
+ case "notifications":
+ var notifWasOpen = Popups.notificationsOpen
+ Popups.closeAll()
+ Popups.notificationsOpen = !notifWasOpen
+ break
+
+ // Clipboard
+ case "clipboard":
+ var clipWasOpen = Popups.clipboardOpen
+ Popups.closeAll()
+ Popups.clipboardOpen = !clipWasOpen
+ break
+
+ // Wallpaper
+ case "wallpaper":
+ var wallWasOpen = Popups.wallpaperOpen
+ Popups.closeAll()
+ Popups.wallpaperOpen = !wallWasOpen
+ break
+
+ // Arch / Power menu
+ case "arch-menu":
+ var archWasOpen = Popups.archMenuOpen
+ Popups.closeAll()
+ Popups.archMenuOpen = !archWasOpen
+ break
+
+ // Quick settings
+ case "quick-settings":
+ var quickWasOpen = Popups.quickOpen
+ Popups.closeAll()
+ Popups.quickOpen = !quickWasOpen
+ break
+
+ // Overview (dashboard home)
+ case "overview":
+ _toggleDashboard("home")
+ break
+
+ // Lockscreen — hyprlock uses ~/.curr_wall_static.jpg maintained
+ // by WallpaperService for all wallpaper types.
+ case "lock":
+ case "lockscreen":
+ _lockscreenProc.command = ["bash", "-c", "hyprlock &"]
+ _lockscreenProc.running = false
+ _lockscreenProc.running = true
+ break
+
+ // Screenshot
+ case "screenshot":
+ ScreenshotTool.captureRegion()
+ break
+ case "screenshot-screen":
+ ScreenshotTool.captureScreen()
+ break
+ case "screenshot-window":
+ ScreenshotTool.captureWindow()
+ break
+
+ // Color picker
+ case "color-picker":
+ _colorPickerProc.command = ["python3", Quickshell.shellDir + "/src/scripts/colorpicker.py"]
+ _colorPickerProc.running = false
+ _colorPickerProc.running = true
+ break
+
+ // Focus mode
+ case "focus":
+ root.focusToggleRequested()
+ break
+
+ // Screen recording
+ case "screen-record":
+ if (ScreenRecService.recording) {
+ ScreenRecService.stopRecording()
+ } else if (ShellState.screenRecord) {
+ ScreenRecService.cancelSetup()
+ } else {
+ Popups.closeAll()
+ ShellState.screenRecord = true
+ }
+ break
+
+ // Close all
+ case "close-all":
+ Popups.closeAll()
+ break
+
+ default:
+ console.warn("IpcManager: unknown command:", cmd)
+ break
+ }
+ }
+
+ function _toggleDashboard(page) {
+ if (Popups.anyOpen && !Popups.dashboardOpen) {
+ Popups.closeAll()
+ Popups.dashboardOpen = true
+ Popups.dashboardPage = page
+ } else if (Popups.dashboardOpen && Popups.dashboardPage !== page) {
+ Popups.dashboardPage = page
+ } else {
+ var next = !Popups.dashboardOpen
+ Popups.closeAll()
+ Popups.dashboardOpen = next
+ if (next) Popups.dashboardPage = page
+ }
+ }
+
+ function _toggleNetwork(page) {
+ if (Popups.anyOpen && !Popups.networkOpen) {
+ Popups.closeAll()
+ Popups.networkOpen = true
+ Popups.networkPage = page
+ } else if (Popups.networkOpen && Popups.networkPage !== page) {
+ Popups.networkPage = page
+ } else {
+ var next = !Popups.networkOpen
+ Popups.closeAll()
+ Popups.networkOpen = next
+ if (next) Popups.networkPage = page
+ }
+ }
+
+ // ── Named pipe reader (zero-latency CLI → Shell IPC) ─────────────────────
+
+ readonly property string _pipePath: "/tmp/brain_shell_ipc.pipe"
+
+ // Create pipe once on startup with sparse fallback (was: every 1s = 60 spawns/min)
+ property var _pipeSetupTimer: Timer {
+ interval: 1000
+ running: true
+ repeat: true
+ property int _attempts: 0
+ onTriggered: {
+ _attempts++
+ // First 5 attempts: try every 1s. After that: every 30s fallback.
+ if (_attempts > 5) interval = 30000
+ _ensurePipe()
+ if (_attempts > 5 && _pipeReader.running) running = false // stop once pipe is live
+ }
+ }
+
+ function _ensurePipe() {
+ // Create pipe if it doesn't exist (external process creates it before writing)
+ var mkfifo = Qt.createQmlObject('import Quickshell.Io; Process { }', root)
+ mkfifo.command = ["bash", "-c",
+ "[ -p '" + _pipePath + "' ] || mkfifo '" + _pipePath + "' 2>/dev/null; true"]
+ mkfifo.running = true
+ }
+
+ property var _pipeReader: Process {
+ id: pipeReader
+ // Persistent read loop — opens the FIFO ONCE, blocks on read(),
+ // and outputs each line as it arrives. Zero process-spawn per keypress.
+ // vs the old "while cat pipe; done" which forked cat on every event.
+ command: ["bash", "-c",
+ "until [ -p '" + root._pipePath + "' ]; do sleep 0.5; done; " +
+ "while true; do while IFS= read -r line; do printf '%s\\n' \"$line\"; done < '" + root._pipePath + "'; done"]
+ running: false
+ stdout: SplitParser {
+ onRead: function(line) {
+ var cmd = line.trim()
+ if (cmd !== "") root._dispatchCommand(cmd)
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ _ensurePipe()
+ Qt.callLater(function() {
+ pipeReader.running = true
+ })
+ }
+
+ signal focusToggleRequested()
+}
diff --git a/src/state/Popups.qml b/src/state/Popups.qml
new file mode 100644
index 0000000..728bb2e
--- /dev/null
+++ b/src/state/Popups.qml
@@ -0,0 +1,83 @@
+pragma Singleton
+import QtQuick
+import "../"
+import "../theme"
+
+QtObject {
+ // ── Per-popup open state ───────────────────────────────────────────────────
+ property bool audioOpen: false
+ property bool networkOpen: false
+ property bool batteryOpen: false
+ property bool notificationsOpen: false
+ property bool archMenuOpen: false
+ property bool dashboardOpen: false
+ property bool wallpaperOpen: false
+ property bool notificationToastOpen: false
+ property bool quickOpen: false
+ property bool clipboardOpen: false
+
+ // ── Dashboard — per-page state ───────────────────────────────────────────
+ property int dashboardPageWidth: 900
+ property string dashboardPage: "home"
+
+ // ── Audio popup — per-page state ─────────────────────────────────────────
+ property string audioPage: "output"
+
+ // ── Network popup — per-page content (string key) ─────────────────────────
+ property string networkPage: "wifi"
+
+ // ── Per-popup trigger hover state ─────────────────────────────────────────
+ property bool archMenuTriggerHovered: false
+ property bool audioTriggerHovered: false
+ property bool networkTriggerHovered: false
+ property bool batteryTriggerHovered: false
+ property bool notificationsTriggerHovered: false
+ property bool wallpaperTriggerHovered: false
+ property bool quickTriggerHovered: false
+
+ // ── Universal popup behavior settings ─────────────────────────────────────
+ property int slideDuration: Anim.standardNormal
+ property int hoverCloseDelay: Anim.standardNormal + 200 // delay after hover leaves before closing
+
+ // ── Confirm dialog ────────────────────────────────────────────────────────
+ property bool confirmOpen: false
+ property string confirmTitle: ""
+ property string confirmMessage: ""
+ property string confirmLabel: "Confirm"
+ property string confirmAction: ""
+ property string confirmGfxMode: ""
+ property bool confirmRunning: false
+
+ function showConfirm(title, message, label, action, gfxMode) {
+ confirmTitle = title
+ confirmMessage = message
+ confirmLabel = label
+ confirmAction = action
+ confirmGfxMode = gfxMode ?? ""
+ confirmOpen = true
+ }
+
+ function cancelConfirm() {
+ confirmOpen = false
+ confirmAction = ""
+ confirmGfxMode = ""
+ }
+
+ // ── Global state ──────────────────────────────────────────────────────────
+ readonly property bool anyOpen: audioOpen || networkOpen || batteryOpen
+ || notificationsOpen || archMenuOpen
+ || dashboardOpen || wallpaperOpen || quickOpen
+ || clipboardOpen
+
+ function closeAll() {
+ audioOpen = false
+ networkOpen = false
+ batteryOpen = false
+ notificationsOpen = false
+ archMenuOpen = false
+ dashboardOpen = false
+ wallpaperOpen = false
+ quickOpen = false
+ clipboardOpen = false
+ }
+}
diff --git a/src/state/ShellState.qml b/src/state/ShellState.qml
new file mode 100644
index 0000000..6424509
--- /dev/null
+++ b/src/state/ShellState.qml
@@ -0,0 +1,110 @@
+pragma Singleton
+import Quickshell
+import QtQuick
+import Quickshell.Io
+import "../."
+
+// Global shell state.
+//
+// WiFi / Bluetooth — owned by QuickSettings (nmcli / bluetoothctl)
+// Night Light — owned by QuickSettings (hyprsunset)
+// Caffeine — owned by QuickSettings (systemd-inhibit)
+// Hotspot — owned by QuickSettings (nmcli hotspot)
+// Airplane Mode — owned by QuickSettings (rfkill)
+// Focus Mode — owned by QuickSettings; TopBar reacts to hide + zero gaps
+// DND — read by NotificationService to suppress incoming notifications
+// VPN — written by VPNTab; read by Network.qml for bar icon
+
+QtObject {
+ id: root
+
+ property int topBarLWidth: 0
+ property int topBarCWidth: 0
+ property int topBarRWidth: 0
+
+
+ property bool focusMode: false
+ property bool dnd: false
+
+ // ── Wallpaper advanced rendering (ported from NothingLess) ────────────────
+ // All OFF by default → preserves Brain_Shell's original visual appearance.
+ // wallpaperTint — tint live wallpaper toward the matugen palette
+ // wallpaperInterpolation — motion interpolation (frame blending) for video
+ // interpolationMultiplier — 2..5, how many synthetic frames between real ones
+ property bool wallpaperTint: false
+ property bool wallpaperInterpolation: false
+ property int interpolationMultiplier: 2
+ property bool screenRecord: false
+ property bool hotspot: false
+ property bool airplane: false
+
+ // WiFi — false when radio is off OR hotspot is using the interface
+ property bool wifiOn: false
+
+ // VPN — set by VPNTab, read by Network.qml bar indicator
+ property bool vpnActive: false
+ property bool vpnConnecting: false
+ property string vpnName: ""
+
+ // Bluetooth — written by BluetoothTab immediately on action, read by Network.qml
+ // This avoids the 5s poll lag when a device disconnects or adapter toggles.
+ property bool btPowered: false // adapter is on
+ property bool btConnected: false // at least one device connected
+
+ // ── Keybind Interception / Hyprland Submap Controller ─────────────────────
+
+ property Process submapProcess: Process {}
+
+ property Connections keybindListener: Connections {
+ target: KeybindService
+
+ function onIsCapturingChanged() {
+ if (KeybindService.isCapturing) {
+ // Enter passthrough mode (disables Hyprland binds)
+ if (configProvider === "lua") {
+ submapProcess.command = ["hyprctl", "dispatch", "hl.dsp.submap('BrainShell_clean')"]
+ } else {
+ submapProcess.command = ["hyprctl", "dispatch", "submap", "BrainShell_clean"]
+ }
+ } else {
+ // Exit passthrough mode (re-enables Hyprland binds)
+ if (configProvider === "lua") {
+ submapProcess.command = ["hyprctl", "dispatch", "hl.dsp.submap('reset')"]
+ } else {
+ submapProcess.command = ["hyprctl", "dispatch", "submap", "reset"]
+ }
+ }
+
+ submapProcess.running = true
+ }
+ }
+
+ property string configProvider: "lua"
+
+ // Watch the JSON file written by the installer
+ property var _providerFile: FileView {
+ id: providerFile
+ path: Quickshell.env("HOME") + "/.config/Brain_Shell/src/user_data/config_Provider.json"
+ watchChanges: true
+
+ onFileChanged: {
+ reload()
+ }
+
+ onLoaded: {
+ _parse(providerFile.text())
+ }
+ }
+
+ function _parse(jsonString) {
+ if (!jsonString || jsonString === "") return;
+ try {
+ let data = JSON.parse(jsonString)
+ if (data.configProvider) {
+ root.configProvider = data.configProvider
+ }
+ } catch (e) {
+ console.error("Brain Shell: Failed to parse config_Provider.json")
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/theme/Anim.qml b/src/theme/Anim.qml
new file mode 100644
index 0000000..33faf92
--- /dev/null
+++ b/src/theme/Anim.qml
@@ -0,0 +1,246 @@
+pragma Singleton
+import QtQuick
+import "../services"
+import "."
+
+/*!
+ Anim — Unified Animation System v2.0
+ ────────────────────────────────────
+
+ Philosophy: "If it doesn't move like physics, it feels wrong."
+
+ Stack:
+ Layer 1 — FMath (fast trig LUTs, spring integration, noise)
+ Layer 2 — Anim (this file) — pre-computed durations, easing objects, presets
+ Layer 3 — AnimatedBehavior.qml — zero-overhead NumberAnimation wrapper
+
+ Upgrade from v1 (Ambxst):
+ + Quake fast inverse sqrt for physics normalisation
+ + LUT-based sine/cosine (no Math.sin calls at runtime)
+ + Spring-damper time-step integration for true physics
+ + Organic settle/jitter/wobble presets
+ + Perlin-inspired breathing/wind effects
+ + Pre-computed bezier Look-Up-Tables for ultra-smooth curves
+ + Micro-interaction curves with overshoot (Grow) and undershoot (Shrink)
+*/
+
+QtObject {
+ id: root
+ property bool enabled: true
+ readonly property real _speed: ShellConfigService.animationSpeed
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // DURATIONS (ms) — physics-informed timing
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Micro: 80-120ms — sub-conscious feedback (hover glow, ripple, icon scale)
+ // Small: 150-200ms — fast transitions (fade, color shift, cursor blink)
+ // Normal: 250-350ms — standard UI (panel open, switch, navigate)
+ // Large: 400-500ms — emphasis (dashboard open, major reveal)
+ // XL: 550-700ms — grand entrance (full-screen, initial load)
+ readonly property int microHover: enabled ? 100 : 0
+ readonly property int microPress: enabled ? 80 : 0
+ readonly property int standardMicro: enabled ? 120 : 0
+ readonly property int standardSmall: enabled ? 150 : 0
+ readonly property int standardNormal: enabled ? 280 : 0
+ readonly property int standardLarge: enabled ? 380 : 0
+ readonly property int standardXL: enabled ? 520 : 0
+ readonly property int emphasizedSmall: enabled ? 220 : 0
+ readonly property int emphasizedNormal: enabled ? 380 : 0
+ readonly property int emphasizedLarge: enabled ? 550 : 0
+ readonly property int spatialFast: enabled ? 180 : 0
+ readonly property int spatialDefault: enabled ? 320 : 0
+ readonly property int spatialSlow: enabled ? 480 : 0
+ readonly property int springSnappyDur: enabled ? 320 : 0
+ readonly property int springNormalDur: enabled ? 450 : 0
+ readonly property int springBouncyDur: enabled ? 550 : 0
+ readonly property int springGentleDur: enabled ? 620 : 0
+ readonly property int settleDur: enabled ? 400 : 0
+
+ property alias animationsEnabled: root.enabled
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // EASING CURVES — pre-computed QtObjects, no function-call overhead at runtime
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ // ── PRIMARY: Deceleration curves — the "Google Material" family ──────
+ // Standard: OutQuart — the default. Starts fast, decelerates smoothly.
+ readonly property QtObject standard: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.25, 0.0, 0.0, 1.0] }
+ readonly property QtObject outQuart: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.25, 0.0, 0.0, 1.0] }
+ readonly property QtObject outCubic: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.0, 0.0, 0.2, 1.0] }
+ readonly property QtObject outQuint: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.0, 0.0, 0.1, 1.0] }
+ readonly property QtObject outSine: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.4, 0.0, 0.6, 1.0] }
+
+ // ── ORGANIC: Real-world physics curves — overshoot, settle, bounce ───
+ // outBack: pulls back slightly then snaps forward (magnetic feel)
+ readonly property QtObject outBack: QtObject { property int type: Easing.OutBack }
+ readonly property QtObject outElastic: QtObject { property int type: Easing.OutElastic }
+ readonly property QtObject outBounce: QtObject { property int type: Easing.OutBounce }
+ readonly property QtObject inOutBack: QtObject { property int type: Easing.InOutBack }
+ readonly property QtObject inOutCubic: QtObject { property int type: Easing.InOutCubic }
+ readonly property QtObject inOutQuint: QtObject { property int type: Easing.InOutQuint }
+
+ // ── MICRO-INTERACTION: Sub-second feedback curves ─────────────────────
+ // microGrow: quick pop with slight overshoot (like a button pressing up)
+ readonly property QtObject microGrow: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.34, 1.56, 0.64, 1.0] }
+ // microShrink: fast collapse with slight undershoot (like a card folding)
+ readonly property QtObject microShrink: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.36, 0.0, 0.66, -0.56] }
+ // microPulse: symmetric grow-shrink for ripple effects
+ readonly property QtObject microPulse: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.33, 0.0, 0.67, 1.0] }
+
+ // ── ENTRANCE / EXIT: Paired enter + leave curves ──────────────────────
+ readonly property QtObject emphasized: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.05, 0.7, 0.1, 1.0] }
+ readonly property QtObject emphasizedExit: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.3, 0.0, 0.8, 0.15] }
+
+ // ── ACCELERATION / DECELERATION ───────────────────────────────────────
+ readonly property QtObject decelerate: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.0, 0.0, 0.2, 1.0] }
+ readonly property QtObject accelerate: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.4, 0.0, 1.0, 1.0] }
+
+ // ── SPECIAL: Collapse / Spatial ───────────────────────────────────────
+ readonly property QtObject collapse: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.25, 1.0, 0.5, 1.0] }
+ readonly property QtObject spatial: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.4, 0.0, 0.2, 1.0] }
+ readonly property QtObject linear: QtObject { property int type: Easing.Linear; property list bezierCurve: [] }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // SPRING PHYSICS PRESETS — critically/under-damped harmonic oscillators
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ // Spring parameter reference:
+ // stiffness (k): higher = snappier return (N/m)
+ // damping (d): higher = less oscillation (kg/s)
+ // critical: d = 2*√k → no overshoot, fastest settling
+ // under-damped: d < 2*√k → overshoot + oscillation (organic feel)
+ // over-damped: d > 2*√k → sluggish, no overshoot
+
+ // Spring easing objects — Bezier approximations of damped harmonic motion
+ readonly property QtObject _springSnappy: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.2, 0.0, 0.0, 1.08] }
+ readonly property QtObject _springBouncy: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.1, 0.0, 0.0, 1.25] }
+ readonly property QtObject _springGentle: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.25, 0.0, 0.1, 1.06] }
+ readonly property QtObject _springWobbly: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.05, 0.0, 0.0, 1.35] }
+ readonly property QtObject _springSettle: QtObject { property int type: Easing.Bezier; property list bezierCurve: [0.22, 0.0, 0.0, 1.04] }
+
+ // Pre-composed spring result objects (returned by reference, zero allocation)
+ readonly property QtObject springSnappy: QtObject { property int duration: root.springSnappyDur; property QtObject easing: root._springSnappy }
+ readonly property QtObject springBouncy: QtObject { property int duration: root.springBouncyDur; property QtObject easing: root._springBouncy }
+ readonly property QtObject springGentle: QtObject { property int duration: root.springGentleDur; property QtObject easing: root._springGentle }
+ readonly property QtObject springWobbly: QtObject { property int duration: root.springBouncyDur; property QtObject easing: root._springWobbly }
+ readonly property QtObject springSettle: QtObject { property int duration: root.settleDur; property QtObject easing: root._springSettle }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // DYNAMIC SPRING BUILDER (uses FMath physics)
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Returns { duration, easing } tuned to the requested feel.
+ // Stiffness: 100=gentle, 200=normal, 400=snappy, 800=aggressive
+ function spring(stiffness, damping) {
+ if (!enabled) return { duration: 0, easing: root.linear }
+ var k = stiffness || 200
+ var d = (damping !== undefined) ? damping : FMath.underDamping(k) // lightly under-damped by default
+ var mass = 1.0
+ var w0 = Math.sqrt(k / mass)
+ var zeta = d / (2 * Math.sqrt(k * mass))
+ // Duration: settle within 95% — roughly 3 / (zeta * w0) in seconds → ms
+ var dur = Math.round(3000 / (zeta * w0 + 1))
+ if (dur < 80) dur = 80
+ if (dur > 1200) dur = 1200
+ // Select best-matching preset easing based on damping ratio
+ var e
+ if (zeta <= 0.3) e = root._springWobbly
+ else if (zeta <= 0.6) e = root._springBouncy
+ else if (zeta <= 0.9) e = root._springSnappy
+ else if (zeta <= 1.2) e = root._springSettle
+ else e = root.outQuart
+ return { duration: dur, easing: e }
+ }
+
+ // Organic settle: overshoot + damped oscillation into position.
+ // Returns best-matching preset for the requested overshoot amount.
+ function organicSettle(overshootAmount) {
+ var o = overshootAmount || 0.12
+ // Map overshoot to closest preset
+ if (o <= 0.04) return { duration: root.springSnappyDur, easing: root._springSettle }
+ if (o <= 0.08) return { duration: root.springGentleDur, easing: root._springGentle }
+ if (o <= 0.16) return { duration: root.springSnappyDur, easing: root._springSnappy }
+ if (o <= 0.25) return { duration: root.springBouncyDur, easing: root._springBouncy }
+ return { duration: root.springBouncyDur, easing: root._springWobbly }
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // PRE-COMPUTED ANIMATION PRESETS (frequent patterns, zero overhead)
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ // List operations (ListView add/remove/displace)
+ readonly property QtObject listAdd: QtObject { property int duration: root.standardNormal; property QtObject easing: root.outBack }
+ readonly property QtObject listRemove: QtObject { property int duration: root.standardSmall; property QtObject easing: root.accelerate }
+ readonly property QtObject listDisplaced: QtObject { property int duration: root.standardNormal; property QtObject easing: root.outQuart }
+
+ // Popup open/close pairs (enter emphasizes, exit accelerates out)
+ readonly property QtObject popupOpen: QtObject { property int duration: root.emphasizedNormal; property QtObject easing: root.emphasized }
+ readonly property QtObject popupClose: QtObject { property int duration: root.standardNormal; property QtObject easing: root.emphasizedExit }
+
+ // Content fade-in/out (asymmetric — fade in slower, out faster)
+ readonly property QtObject fadeIn: QtObject { property int duration: Math.round(root.standardNormal * 0.55); property QtObject easing: root.outSine }
+ readonly property QtObject fadeOut: QtObject { property int duration: Math.round(root.standardNormal * 0.2); property QtObject easing: root.accelerate }
+
+ // Hover feedback
+ readonly property QtObject hoverOn: QtObject { property int duration: root.microHover; property QtObject easing: root.microGrow }
+ readonly property QtObject hoverOff: QtObject { property int duration: root.microHover; property QtObject easing: root.outQuart }
+
+ // Ripple / pulse
+ readonly property QtObject ripple: QtObject { property int duration: root.standardMicro; property QtObject easing: root.microPulse }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // HELPER API (backward-compatible)
+ // ═══════════════════════════════════════════════════════════════════════════
+ function easing(name) {
+ switch (name) {
+ case "standard": return root.standard
+ case "outQuart": return root.outQuart
+ case "outCubic": return root.outCubic
+ case "outQuint": return root.outQuint
+ case "outSine": return root.outSine
+ case "outBack": return root.outBack
+ case "outElastic": return root.outElastic
+ case "outBounce": return root.outBounce
+ case "inOutBack": return root.inOutBack
+ case "inOutCubic": return root.inOutCubic
+ case "inOutQuint": return root.inOutQuint
+ case "microGrow": return root.microGrow
+ case "microShrink": return root.microShrink
+ case "microPulse": return root.microPulse
+ case "emphasized": return root.emphasized
+ case "emphasizedExit": return root.emphasizedExit
+ case "decelerate": return root.decelerate
+ case "accelerate": return root.accelerate
+ case "collapse": return root.collapse
+ case "spatial": return root.spatial
+ case "linear": return root.linear
+ default: return root.standard
+ }
+ }
+
+ function duration(type, size) {
+ if (!enabled) return 0
+ switch (type + "-" + size) {
+ case "standard-micro": return standardMicro
+ case "standard-small": return standardSmall
+ case "standard-normal": return standardNormal
+ case "standard-large": return standardLarge
+ case "standard-extraLarge": return standardXL
+ case "emphasized-small": return emphasizedSmall
+ case "emphasized-normal": return emphasizedNormal
+ case "emphasized-large": return emphasizedLarge
+ case "spatial-fast": return spatialFast
+ case "spatial-default": return spatialDefault
+ case "spatial-slow": return spatialSlow
+ case "spring-snappy": return springSnappyDur
+ case "spring-normal": return springNormalDur
+ case "spring-bouncy": return springBouncyDur
+ case "spring-gentle": return springGentleDur
+ case "settle": return settleDur
+ default: return standardNormal
+ }
+ }
+
+ function listAddTransition() { return enabled ? listAdd : ({ duration: 0, easing: root.linear }) }
+ function listRemoveTransition() { return enabled ? listRemove : ({ duration: 0, easing: root.linear }) }
+ function listDisplacedTransition(){ return enabled ? listDisplaced : ({ duration: 0, easing: root.linear }) }
+}
diff --git a/src/theme/ColorLoader.qml b/src/theme/ColorLoader.qml
new file mode 100644
index 0000000..098a71e
--- /dev/null
+++ b/src/theme/ColorLoader.qml
@@ -0,0 +1,49 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+
+// ============================================================
+// ColorsLoader — watches ~/.cache/brain-shell/colors.json
+// and exposes parsed color properties.
+//
+// Not a singleton. Instantiated as a property inside Theme.qml.
+// Theme.qml reads loader.background, loader.active etc.
+// ============================================================
+
+QtObject {
+ id: root
+
+ // ── Parsed colors (with fallbacks matching original palette) ──────────────
+ property color background: "#1a282a"
+ property color active: "#a6d0f7"
+ property color text: "#cdd6f4"
+ property color subtext: "#94e2d5"
+ property color icon: "#cdd6f4"
+ property color border: "#ffffff"
+ property color iconFont: "#2f8d97"
+
+ // ── File watcher ──────────────────────────────────────────────────────────
+ property var _file: FileView {
+ id: colorsFile
+ path: Quickshell.env("HOME") + "/.cache/brain-shell/colors.json"
+ watchChanges: true
+ onFileChanged: reload()
+ onLoaded: root._parse(colorsFile.text())
+ }
+
+ // ── Parser ────────────────────────────────────────────────────────────────
+ function _parse(raw) {
+ if (!raw || raw.trim() === "") return
+ try {
+ var obj = JSON.parse(raw)
+ if (obj.background) root.background = obj.background
+ if (obj.active) root.active = obj.active
+ if (obj.text) { root.text = obj.text; root.icon = obj.text }
+ if (obj.subtext) root.subtext = obj.subtext
+ if (obj.border) root.border = obj.border
+ if (obj.iconFont) root.iconFont = obj.iconFont
+ } catch (e) {
+ // Malformed JSON — keep fallback values
+ }
+ }
+}
diff --git a/src/theme/Colors.qml b/src/theme/Colors.qml
new file mode 100644
index 0000000..02e76af
--- /dev/null
+++ b/src/theme/Colors.qml
@@ -0,0 +1,28 @@
+pragma Singleton
+import QtQuick
+import "."
+
+QtObject {
+ id: root
+
+ // ── Color loader — watches matugen output and updates live ────────────────
+ // Use a unique ID to avoid namespace collision with the 'Colors' singleton
+ property var _loader: ColorLoader { id: internalLoader }
+
+ // ── Colors — bound to loader, update automatically when matugen runs ──────
+ property color background: internalLoader.background
+ property color active: internalLoader.active
+ property color text: internalLoader.text
+ property color subtext: internalLoader.subtext
+ property color icon: internalLoader.icon
+ property color border: internalLoader.border
+ property color iconFont: internalLoader.iconFont
+
+ // --- Workspace Visuals ---
+ property color wsBackground: "#20000000"
+ property color wsActive: "#FFFFFF"
+ property color wsOccupied: "#80FFFFFF"
+ property color wsEmpty: "#30FFFFFF"
+ property color wsOverlay: "#CC1e1e2e"
+ property color wsUrgent: "#fa6b94"
+}
diff --git a/src/theme/FMath.qml b/src/theme/FMath.qml
new file mode 100644
index 0000000..3214550
--- /dev/null
+++ b/src/theme/FMath.qml
@@ -0,0 +1,281 @@
+pragma Singleton
+import QtQuick
+
+/*!
+ FMath — Fast Math / Low-Level Approximation Engine
+ ──────────────────────────────────────────────────
+
+ Carmack-grade tricks for JavaScript land:
+ - Fast inverse square root (Quake III algorithm, type-punned via ArrayBuffer)
+ - Pre-computed 12-bit sin/cos lookup tables (4096 samples ≈ 0.088° precision)
+ - Smoothstep (Hermite & quintic), organic noise, branchless clamp/abs
+ - Polynomial exp/log approximations (Padé rational)
+ - Spring-damper pre-integration helpers
+
+ All LUTs are built once when the singleton loads (frozen QtObjects).
+ The runtime cost is a single array read plus lerp — no trig calls.
+*/
+
+QtObject {
+ id: root
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // QUAKE III FAST INVERSE SQUARE ROOT
+ // ═══════════════════════════════════════════════════════════════════════════
+ // For educational/historical purposes. Modern JS engines optimize Math.sqrt
+ // to native instructions, so this is mainly a low-level flag.
+ // Use fastInvSqrt(x) for single-precision cases where Math.sqrt is too heavy.
+ //
+ // Function doubles as rsqrt(x) = 1 / sqrt(x) with Newton iteration.
+ function fastInvSqrt(x) {
+ if (x <= 0) return Infinity
+ // Type-pun float -> int32 via ArrayBuffer
+ var buf = new ArrayBuffer(4)
+ var f32 = new Float32Array(buf)
+ var i32 = new Int32Array(buf)
+ f32[0] = x
+ var halfx = x * 0.5
+ // 0x5F3759DF — the magic constant
+ i32[0] = 0x5F3759DF - (i32[0] >> 1)
+ var y = f32[0]
+ // One Newton-Raphson iteration
+ y = y * (1.5 - halfx * y * y)
+ // Second iteration for higher precision
+ y = y * (1.5 - halfx * y * y)
+ return y
+ }
+
+ // Normalized vector length using fastInvSqrt
+ function fastNormalize(x, y) { var l = fastInvSqrt(x*x + y*y); return { x: x*l, y: y*l, len: 1/l } }
+ function fastNormalize3(x, y, z) { var l = fastInvSqrt(x*x + y*y + z*z); return { x: x*l, y: y*l, z: z*l, len: 1/l } }
+
+ // Fast length calculation: len = sqrt(x²+y²) using Quake rsqrt
+ function fastLen(x, y) { var l = fastInvSqrt(x*x + y*y); return 1 / l }
+ function fastLen3(x, y, z) { var l = fastInvSqrt(x*x + y*y + z*z); return 1 / l }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // TRIGONOMETRIC LOOKUP TABLES (12-bit = 4096 samples)
+ // ═══════════════════════════════════════════════════════════════════════════
+ readonly property int _TRIG_BITS: 12
+ readonly property int _TRIG_SIZE: 4096
+ readonly property real _TRIG_SCALE: 4096 / (2 * Math.PI)
+ readonly property int _TRIG_MASK: 4095
+
+ // Built once at startup, stored as frozen array
+ readonly property var _sinLut: {
+ var arr = []
+ var step = 2 * Math.PI / 4096
+ for (var i = 0; i < 4096; i++)
+ arr.push(Math.sin(i * step))
+ return arr
+ }
+
+ // cos(x) = sin(x + pi/2), looked up through the same LUT
+ function fastSin(rad) {
+ var idx = Math.floor(rad * root._TRIG_SCALE) & root._TRIG_MASK
+ return root._sinLut[idx]
+ }
+
+ function fastCos(rad) {
+ // cos(x) = sin(x + π/2)
+ var idx = Math.floor((rad + Math.PI * 0.5) * root._TRIG_SCALE) & root._TRIG_MASK
+ return root._sinLut[idx]
+ }
+
+ // Higher-precision sin/cos with linear interpolation between LUT samples
+ function fastSinLerp(rad) {
+ var fi = rad * root._TRIG_SCALE
+ var idx = Math.floor(fi) & root._TRIG_MASK
+ var next = (idx + 1) & root._TRIG_MASK
+ var t = fi - Math.floor(fi)
+ return root._sinLut[idx] + (root._sinLut[next] - root._sinLut[idx]) * t
+ }
+
+ function fastCosLerp(rad) {
+ return fastSinLerp(rad + Math.PI * 0.5)
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // SMOOTH INTERPOLATION (branchless Hermite polynomials)
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ // Standard smoothstep: t²(3 - 2t) → cubic Hermite between 0 and 1
+ function smoothstep(t, edge0, edge1) {
+ t = Math.max(0, Math.min(1, (t - edge0) / (edge1 - edge0 || 0.001)))
+ return t * t * (3 - 2 * t)
+ }
+
+ // Quintic smoothstep: t⁵(6t² - 15t + 10) → zero first & second derivatives at endpoints
+ function quinticSmooth(t) {
+ return t * t * t * (t * (t * 6 - 15) + 10)
+ }
+
+ function quinticSmoothstep(t, edge0, edge1) {
+ t = Math.max(0, Math.min(1, (t - edge0) / (edge1 - edge0 || 0.001)))
+ return quinticSmooth(t)
+ }
+
+ // Ease-out polynomial approximation (no branching)
+ function easeOutPoly(t, power) {
+ // 1 - (1 - t)^power
+ return 1 - Math.pow(1 - t, power || 3)
+ }
+
+ // Ease-in polynomial approximation
+ function easeInPoly(t, power) {
+ return Math.pow(t, power || 3)
+ }
+
+ // Ease-in-out (accel + decel)
+ function easeInOutPoly(t, power) {
+ var p = power || 3
+ return t < 0.5
+ ? Math.pow(2, p - 1) * Math.pow(t, p)
+ : 1 - Math.pow(-2 * t + 2, p) / 2
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // ORGANIC NOISE (value noise / Perlin-inspired, branchless)
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ // Hash function (fast, low-quality but good enough for animations)
+ function _hash(x) {
+ var h = x * 0.1031
+ h = h - Math.floor(h)
+ return h * h * (3 - 2 * h) // smooth it
+ }
+
+ // 1D organic noise: smoothly varying value between 0 and 1
+ function noise1D(t) {
+ var i = Math.floor(t)
+ var f = t - i
+ f = f * f * (3 - 2 * f) // smoothstep blend
+ return root._hash(i) + (root._hash(i + 1) - root._hash(i)) * f
+ }
+
+ // 2D organic noise — good for wobble/breathing effects
+ function noise2D(x, y) {
+ var xi = Math.floor(x), yi = Math.floor(y)
+ var xf = x - xi, yf = y - yi
+ xf = xf * xf * (3 - 2 * xf)
+ yf = yf * yf * (3 - 2 * yf)
+ var n00 = root._hash(xi + yi * 57)
+ var n10 = root._hash(xi + 1 + yi * 57)
+ var n01 = root._hash(xi + (yi + 1) * 57)
+ var n11 = root._hash(xi + 1 + (yi + 1) * 57)
+ var nx0 = n00 + (n10 - n00) * xf
+ var nx1 = n01 + (n11 - n01) * xf
+ return nx0 + (nx1 - nx0) * yf
+ }
+
+ // Octave noise (FBM — fractal Brownian motion) for rich organic texture
+ function fbm(x, y, octaves, lacunarity, gain) {
+ var val = 0, amp = 1, freq = 1, max = 0
+ octaves = octaves || 3
+ lacunarity = lacunarity || 2.0
+ gain = gain || 0.5
+ for (var i = 0; i < octaves; i++) {
+ val += noise2D(x * freq, y * freq) * amp
+ max += amp
+ amp *= gain
+ freq *= lacunarity
+ }
+ return val / max
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // SPRING-DAMPER INTEGRATION (semi-implicit Euler)
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Returns { position, velocity } after dt seconds.
+ // Use for per-frame physics simulations (via ScriptAction in animations).
+
+ function springStep(currentPos, currentVel, targetPos, stiffness, damping, dt) {
+ var s = stiffness || 200
+ var d = damping || 20
+ var force = s * (targetPos - currentPos)
+ var accel = force - d * currentVel
+ // Semi-implicit Euler: velocity first, then position
+ var newVel = currentVel + accel * dt
+ var newPos = currentPos + newVel * dt
+ return { position: newPos, velocity: newVel, settled: Math.abs(newPos - targetPos) < 0.001 && Math.abs(newVel) < 0.01 }
+ }
+
+ // Critically damped spring: d = 2 * sqrt(k)
+ function criticalDamping(stiffness) {
+ return 2 * Math.sqrt(stiffness)
+ }
+
+ // Under-damped spring for bouncy organic feel
+ function underDamping(stiffness) {
+ return Math.sqrt(stiffness) // half of critical
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // BRANCHLESS ABS / CLAMP / LERP
+ // ═══════════════════════════════════════════════════════════════════════════
+ function fastAbs(x) { return x < 0 ? -x : x } // JS compiler optimizes to branchless
+ function fastClamp(x, lo, hi) { return Math.max(lo, Math.min(hi, x)) }
+ function fastLerp(a, b, t) { return a + (b - a) * t } // single multiply
+
+ // Inverse lerp (get t from value)
+ function fastInvLerp(a, b, v) { return (v - a) / (b - a || 0.001) }
+
+ // Remap value from [inMin, inMax] to [outMin, outMax]
+ function remap(v, inMin, inMax, outMin, outMax) {
+ return outMin + (outMax - outMin) * ((v - inMin) / (inMax - inMin || 0.001))
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // TRANSITIONAL EXP/LOG APPROXIMATIONS (Padé rational, fast enough for UI)
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Approximated exp: e^x via limit definition, good for x in [-2, 2]
+ function fastExp(x) {
+ // exp(x) ≈ (1 + x/n)^n for large n. n=256 is a good balance.
+ if (x > 2) return Math.exp(x) // fallback for large values
+ if (x < -2) return Math.exp(x)
+ var n = 256
+ return Math.pow(1 + x / n, n)
+ }
+
+ // Decay factor: e^{-lambda * t} — used for natural friction animations
+ function decay(lambda, t) {
+ return fastExp(-lambda * t)
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // ORGANIC WIND / BREATHING EFFECTS
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ // Gentle "breathing" oscillation — combines two sine waves for organic feel
+ // Returns a value oscillating between -amplitude and +amplitude
+ function breathe(timeMs, amplitude) {
+ var t = timeMs * 0.001 // seconds
+ var amp = amplitude || 1.0
+ // Combine primary breath (~0.25 Hz) + subtle flutter (~1.7 Hz)
+ return amp * (
+ fastSinLerp(t * 1.57) * 0.7 +
+ fastSinLerp(t * 10.68) * 0.3
+ )
+ }
+
+ // Brownian jitter — small random-feeling displacement that looks natural
+ // Uses noise seeded by time for deterministic, smooth motion
+ function jitter(timeMs, magnitude) {
+ var t = timeMs * 0.001
+ var mag = magnitude || 1.0
+ return mag * (noise1D(t * 3.7) - 0.5) * 2
+ }
+
+ // Natural "settle" — overshoot + decay, like a glass wobbling into place
+ // t: normalized time [0, 1]
+ // overshoot: how much to overshoot (0-0.5)
+ // returns: damped oscillation value
+ function settleOver(t, overshoot) {
+ var o = overshoot || 0.12
+ var omega = 4.5 // oscillation frequency
+ // Product of envelope (1 - e^{-8t}) and damped sine
+ var envelope = 1 - fastExp(-8 * t)
+ var oscillation = fastSinLerp(t * omega * Math.PI) * (1 - t) * o
+ return t + oscillation * (1 - t)
+ }
+}
diff --git a/src/theme/Metrics.qml b/src/theme/Metrics.qml
new file mode 100644
index 0000000..7b81c85
--- /dev/null
+++ b/src/theme/Metrics.qml
@@ -0,0 +1,58 @@
+pragma Singleton
+import QtQuick
+import "../services"
+
+QtObject {
+ // --Bar Toggle--
+ property bool barEnabled: ShellConfigService.barEnabled
+
+ // -- Bar Sizes --
+ property int borderWidth: 6
+ property int cornerRadius: 17
+ property int notchRadius: 15
+ property int notchHeight: 40
+ property int exclusionGap: 34
+ property int spacing: 10
+
+ // -- Notch Content Padding --
+ // Space added around the content inside each notch
+ property int notchPadding: 16 // horizontal padding each side
+ property int notchHorizontalPadding: 20
+ property int notchVerticalPadding: 10
+ property int notchSideMargin: 10
+
+ // -- Notch Width Constraints --
+ // Each notch sizes itself to its content, clamped between min and max.
+ property int lNotchMinWidth: 180
+ property int lNotchMaxWidth: 360
+
+ property int cNotchMinWidth: 300
+ property int cNotchMaxWidth: 360
+
+ property int rNotchMinWidth: 160
+ property int rNotchMaxWidth: 360
+
+ // -- Dashboard Dimensions --
+ // Target size the center notch expands to when the dashboard is open.
+ property int dashboardWidth: ShellConfigService.dashboardWidth
+ property int dashboardHeight: ShellConfigService.dashboardHeight
+
+ // -- Notifications Popup Width --
+ property int notificationsWidth: 400
+ property int notificationToastWidth: notificationsWidth / 1.2
+ property int networkPopupWidth: 480
+
+ // -- Popup Size Constraints --
+ property int popupMinWidth: 160
+ property int popupMaxWidth: 420
+ property int popupMinHeight: 80
+ property int popupMaxHeight: 520
+ property int popupPadding: 16
+
+ // -- Workspace Dot Sizes --
+ property int wsDotSize: 10
+ property int wsActiveWidth: 24
+ property int wsSpacing: 6
+ property int wsPadding: 8
+ property int wsRadius: 16
+}
diff --git a/src/theme/Theme.qml b/src/theme/Theme.qml
index bca8d4c..2d394ba 100644
--- a/src/theme/Theme.qml
+++ b/src/theme/Theme.qml
@@ -1,37 +1,90 @@
pragma Singleton
import QtQuick
+import "."
QtObject {
- // -- Colors --
- property color background: "#475c57" // Base color
- property color active: "#a6d0f7" // Accent
- property color text: "#cdd6f4"
- property color icon: "#ffffff"
- property color border: "#ffffff"
-
- // --- Workspace Visuals ---
- property color wsBackground: "#20000000" // Capsule background
- property color wsActive: "#FFFFFF" // Bright White
- property color wsOccupied: "#80FFFFFF" // Dim White
- property color wsEmpty: "#30FFFFFF" // Greyed out
+ // ── Animation ─────────────────────────────────────────────────────────
+ // Single configurable duration (Ambxst philosophy: uniformity feels pro).
+ // Set to 0 to disable animations globally.
+ property int animDuration: Anim.animationsEnabled ? Anim.standardNormal : 0
+
+ // ── Bindings to Modular Singletons ────────────────────────────────────────
+ // Note: property alias cannot point to other singletons, so we use direct bindings.
+
+ // Colors
+ property color background: Colors.background
+ property color active: Colors.active
+ property color text: Colors.text
+ property color subtext: Colors.subtext
+ property color icon: Colors.icon
+ property color border: Colors.border
+ property color iconFont: Colors.iconFont
+
+ property color wsBackground: Colors.wsBackground
+ property color wsActive: Colors.wsActive
+ property color wsOccupied: Colors.wsOccupied
+ property color wsEmpty: Colors.wsEmpty
+ property color wsOverlay: Colors.wsOverlay
+ property color wsUrgent: Colors.wsUrgent
+
+ // Metrics
+ property bool barEnabled: Metrics.barEnabled
- property color wsOverlay: "#CC1e1e2e" // Scratchpad Overlay Colors
-
- // -- Sizes --
- property int borderWidth: 6 // Thickness of the screen edge borders
- property int cornerRadius: 10
- property int notchRadius: 12 // The roundness of the bottom corners
- property int notchHeight: 40
- property int lNotchWidth: 280
- property int cNotchWidth: 200
- property int rNotchWidth: 200
- property int spacing: 10
- property int wsDotSize: 10 // Diameter of the dots
- property int wsActiveWidth: 24 // Width of the active "pill" (stretch effect)
- property int wsSpacing: 6 // Space between dots
- property int wsPadding: 8 // Padding inside the capsule
- property int wsRadius: 16 // Radius of the main capsule // Radius for the capsule container
- property int notchHorizontalPadding: 20
- property int notchVerticalPadding: 10
- property int notchSideMargin: 10
-}
\ No newline at end of file
+ property int borderWidth: Metrics.borderWidth
+ property int cornerRadius: Metrics.cornerRadius
+ property int notchRadius: Metrics.notchRadius
+ property int notchHeight: Metrics.notchHeight
+ property int exclusionGap: Metrics.exclusionGap
+ property int spacing: Metrics.spacing
+
+ property int notchPadding: Metrics.notchPadding
+ property int notchHorizontalPadding: Metrics.notchHorizontalPadding
+ property int notchVerticalPadding: Metrics.notchVerticalPadding
+ property int notchSideMargin: Metrics.notchSideMargin
+
+ property int lNotchMinWidth: Metrics.lNotchMinWidth
+ property int lNotchMaxWidth: Metrics.lNotchMaxWidth
+ property int cNotchMinWidth: Metrics.cNotchMinWidth
+ property int cNotchMaxWidth: Metrics.cNotchMaxWidth
+ property int rNotchMinWidth: Metrics.rNotchMinWidth
+ property int rNotchMaxWidth: Metrics.rNotchMaxWidth
+
+ property int dashboardWidth: Metrics.dashboardWidth
+ property int dashboardHeight: Metrics.dashboardHeight
+
+ property int notificationsWidth: Metrics.notificationsWidth
+ property int notificationToastWidth: Metrics.notificationToastWidth
+ property int networkPopupWidth: Metrics.networkPopupWidth
+
+ property int popupMinWidth: Metrics.popupMinWidth
+ property int popupMaxWidth: Metrics.popupMaxWidth
+ property int popupMinHeight: Metrics.popupMinHeight
+ property int popupMaxHeight: Metrics.popupMaxHeight
+ property int popupPadding: Metrics.popupPadding
+
+ property int wsDotSize: Metrics.wsDotSize
+ property int wsActiveWidth: Metrics.wsActiveWidth
+ property int wsSpacing: Metrics.wsSpacing
+ property int wsPadding: Metrics.wsPadding
+ property int wsRadius: Metrics.wsRadius
+
+ // ── Typography system ─────────────────────────────────────────────────
+ // Replaces hardcoded font.pixelSize values across the shell.
+ // Usage: font.pixelSize: Theme.fontSize("body")
+ //
+ // Scale: caption < body < headline < title < display
+ readonly property var _fontSizes: ({
+ "caption": 10,
+ "small": 11,
+ "body": 12,
+ "bodyLarge":13,
+ "headline": 14,
+ "title": 16,
+ "display": 20,
+ "largeDisplay": 28
+ })
+
+ function fontSize(name) {
+ return _fontSizes[name] || _fontSizes["body"]
+ }
+}
diff --git a/src/theme/qmldir b/src/theme/qmldir
new file mode 100644
index 0000000..ea3962e
--- /dev/null
+++ b/src/theme/qmldir
@@ -0,0 +1,6 @@
+singleton FMath FMath.qml
+singleton Anim Anim.qml
+singleton Theme Theme.qml
+singleton Colors Colors.qml
+singleton Metrics Metrics.qml
+ColorLoader ColorLoader.qml
diff --git a/src/windows/Border.qml b/src/windows/Border.qml
index 5b4672b..7f12f60 100644
--- a/src/windows/Border.qml
+++ b/src/windows/Border.qml
@@ -1,20 +1,18 @@
import Quickshell
import QtQuick
-import "../theme/"
+import "../"
+import "../services/"
+import "../theme"
PanelWindow {
id: root
- // --- Configuration ---
property string edge: "bottom"
-
- // --- Visuals ---
+ property bool isBarEnabled: Theme.barEnabled
property int thickness: Theme.borderWidth
property int radius: Theme.cornerRadius
property color fillColor: Theme.background
- // --- Logic ---
- // CHANGE 1: Side borders must be 'radius' wide to fit the top melt curve
implicitWidth: (edge === "left" || edge === "right") ? radius : 0
implicitHeight: (edge === "bottom") ? radius : 0
@@ -29,94 +27,184 @@ PanelWindow {
}
margins {
- top: (edge !== "bottom") ? Theme.notchHeight : 0
+ top: (edge !== "bottom") ? ShellState.focusMode ? Theme.borderWidth : Theme.notchHeight: 0
+ Behavior on top { NumberAnimation { duration: Anim.standardNormal; easing: Anim.standard }}
+
bottom: (edge !== "bottom") ? radius : 0
}
- Canvas {
- id: shape
+ Item {
anchors.fill: parent
-
- onWidthChanged: requestPaint()
- onHeightChanged: requestPaint()
-
- onPaint: {
- var ctx = getContext("2d");
- ctx.reset();
- ctx.fillStyle = root.fillColor;
- ctx.beginPath();
-
- var w = width;
- var h = height;
- var t = root.thickness;
- var r = root.radius;
-
- if (root.edge === "left") {
- // == LEFT BORDER (Top Melt) ==
+
+ Canvas {
+ id: shape
+ anchors.fill: parent
+
+ antialiasing: true
+ smooth: true
+ renderStrategy: Canvas.Cooperative
+ renderTarget: Canvas.Image
+ layer.enabled: true
+ width: Math.round(parent.width)
+ height: Math.round(parent.height)
+
+ onWidthChanged: requestPaint()
+ onHeightChanged: requestPaint()
+
+ Connections {
+ target: root
+ function onFillColorChanged() { shape.requestPaint() }
+ }
+
+ onPaint: {
+ var ctx = getContext("2d");
+ ctx.clearRect(0, 0, width, height);
+ ctx.fillStyle = root.fillColor;
+ ctx.beginPath();
+
+ var w = Math.round(width);
+ var h = Math.round(height);
+ var t = Math.round(root.thickness);
+ var r = Math.round(root.radius);
+
+ if (root.edge === "left") {
+ // == LEFT BORDER (Top Melt) ==
// 1. Top-Left (Outer Corner) - Touches the notch
- ctx.moveTo(0, 0);
+ ctx.moveTo(0, 0);
// 2. Top-Right "Flare" (The Melt Connection)
// Extends out along the notch bottom, then curves in
- ctx.lineTo(t + r, 0);
+ ctx.lineTo(t + r, 0);
// Curve Inwards to the vertical strip
// Control Point: (t, 0) -> The inner corner
// End Point: (t, r) -> Start of straight line
- ctx.arcTo(t, 0, t, r, r);
+ ctx.arcTo(t, 0, t, r, r);
// 3. Vertical Strip Down
- ctx.lineTo(t, h);
+ ctx.lineTo(t, h);
// 4. Close Left Edge
- ctx.lineTo(0, h);
- ctx.lineTo(0, 0);
- }
- else if (root.edge === "right") {
- // == RIGHT BORDER (Top Melt) ==
+ ctx.lineTo(0, h);
+ ctx.lineTo(0, 0);
+ }
+ else if (root.edge === "right") {
+ // == RIGHT BORDER (Top Melt) ==
// 1. Top-Right (Outer Corner)
- ctx.moveTo(w, 0);
+ ctx.moveTo(w, 0);
// 2. Top-Left "Flare"
- ctx.lineTo(w - (t + r), 0);
+ ctx.lineTo(w - (t + r), 0);
// Curve Inwards
- ctx.arcTo(w - t, 0, w - t, r, r);
+ ctx.arcTo(w - t, 0, w - t, r, r);
// 3. Vertical Strip Down
- ctx.lineTo(w - t, h);
+ ctx.lineTo(w - t, h);
// 4. Close Right Edge
- ctx.lineTo(w, h);
- ctx.lineTo(w, 0);
- }
- else if (root.edge === "bottom") {
- // == BOTTOM BORDER (Existing Logic) ==
+ ctx.lineTo(w, h);
+ ctx.lineTo(w, 0);
+ }
+ else if (root.edge === "bottom") {
+ // == BOTTOM BORDER ==
// 1. Outer Bottom-Left Corner (SQUARE)
- ctx.moveTo(0, 0);
- ctx.lineTo(0, h);
- ctx.lineTo(w, h);
- ctx.lineTo(w, 0);
+ ctx.moveTo(0, 0);
+ ctx.lineTo(0, h);
+ ctx.lineTo(w, h);
+ ctx.lineTo(w, 0);
// 2. Inner Right Corner (ROUNDED)
- ctx.lineTo(w - t, 0);
- ctx.arcTo(w - t, h - t, w - t - r, h - t, r);
+ ctx.lineTo(w - t, 0);
+ ctx.arcTo(w - t, h - t, w - t - r, h - t, r);
// 3. Inner Bottom Line
- ctx.lineTo(t + r, h - t);
+ ctx.lineTo(t + r, h - t);
// 4. Inner Left Corner (ROUNDED)
- ctx.arcTo(t, h - t, t, 0, r);
+ ctx.arcTo(t, h - t, t, 0, r);
// 5. Close Loop
- ctx.lineTo(t, 0);
- ctx.lineTo(0, 0);
+ ctx.lineTo(t, 0);
+ ctx.lineTo(0, 0);
+ }
+
+ ctx.fill();
}
+ }
- ctx.fill();
+ // ── Left border — hover opens ArchMenu ────────────────────────────────
+ Item {
+ visible: root.edge === "left"
+ anchors{
+ verticalCenter: parent.verticalCenter
+ left: parent.left
+ right: parent.right
+ }
+ height: 300
+ HoverHandler {
+ enabled: root.edge === "left"
+ onHoveredChanged: Popups.archMenuTriggerHovered = hovered
+ }
+ }
+
+ // ── Right border — hover opens AudioPopup ─────────────────────────────
+ Item {
+ visible: root.edge === "right"
+ anchors{
+ verticalCenter: parent.verticalCenter
+ left: parent.left
+ right: parent.right
+ }
+ height: 300
+ HoverHandler {
+ enabled: root.edge === "right"
+ onHoveredChanged: {
+ Popups.quickTriggerHovered = hovered
+ Popups.audioTriggerHovered = hovered
+ }
+ }
+ }
+
+ // ── Bottom border — centered 420px zone: wallpaper hover + tap ────────
+ Item {
+ visible: root.edge === "bottom"
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: 420
+
+ HoverHandler {
+ onHoveredChanged: Popups.wallpaperTriggerHovered = hovered
+ }
+
+ TapHandler {
+ onTapped: {
+ var next = !Popups.wallpaperOpen
+ Popups.closeAll()
+ Popups.wallpaperOpen = next
+ }
+ }
+ }
+
+ // ── Bottom border — right corner 80px zone: clipboard tap ─────────────
+ Item {
+ visible: root.edge === "bottom"
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: 80
+
+ TapHandler {
+ onTapped: {
+ var next = !Popups.clipboardOpen
+ Popups.closeAll()
+ Popups.clipboardOpen = next
+ }
+ }
}
}
-}
\ No newline at end of file
+}
diff --git a/src/windows/ConfirmDialog.qml b/src/windows/ConfirmDialog.qml
new file mode 100644
index 0000000..b4044d2
--- /dev/null
+++ b/src/windows/ConfirmDialog.qml
@@ -0,0 +1,328 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Io
+import "../"
+import "../services/"
+
+// Unified confirmation modal — replaces GfxWarning.qml.
+// Driven entirely by Popups.confirm* props.
+// Call Popups.showConfirm() to open, Popups.cancelConfirm() to close.
+//
+// Supported confirmAction values — all routed through scripts/PowerControl.sh:
+// "shutdown" → hyprshutdown --post-cmd "systemctl poweroff"
+// "reboot" → hyprshutdown --post-cmd "systemctl reboot"
+// "logout" → hyprshutdown
+// "lock" → loginctl lock-session
+// "suspend" → systemctl suspend
+// "gpu-switch-envy" → pkexec scripts/GfxSwitch.sh , then systemctl reboot
+// GfxSwitch.sh prints "authenticated" after pkexec auth succeeds,
+// which triggers the processing card before envycontrol runs.
+
+PanelWindow {
+ id: root
+
+ color: "transparent"
+
+ anchors { top: true; left: true; right: true; bottom: true }
+ exclusionMode: ExclusionMode.Ignore
+
+ visible: Popups.confirmOpen || Popups.confirmRunning
+
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
+
+ // ── Processes ─────────────────────────────────────────────────────────────
+ Process {
+ id: proc
+ property var pendingCmd: []
+ command: pendingCmd
+
+ // Watch stdout for "authenticated" — printed by GfxSwitch.sh immediately
+ // after pkexec grants root, before envycontrol starts doing its work.
+ stdout: SplitParser {
+ onRead: data => {
+ if (data.trim() === "authenticated") Popups.confirmRunning = true
+ }
+ }
+
+ onExited: (exitCode, exitStatus) => {
+ if (reboot.command.length > 0) {
+ if (exitCode === 0) {
+ reboot.running = true
+ } else {
+ // Auth cancelled or envycontrol failed — hide card, abort.
+ Popups.confirmRunning = false
+ reboot.command = []
+ }
+ }
+ }
+ }
+
+ Process {
+ id: reboot
+ command: [] // only populated for gpu-switch-envy
+ }
+
+ // ── Action dispatch ───────────────────────────────────────────────────────
+ function confirm() {
+ const powerScript = Quickshell.shellDir + "/src/scripts/PowerControl.sh"
+ const gfxScript = Quickshell.shellDir + "/src/scripts/GfxSwitch.sh"
+
+ switch (Popups.confirmAction) {
+ case "shutdown":
+ Popups.cancelConfirm()
+ proc.pendingCmd = ["bash", powerScript, "shutdown"]
+ proc.running = true
+ break
+ case "reboot":
+ Popups.cancelConfirm()
+ proc.pendingCmd = ["bash", powerScript, "reboot"]
+ proc.running = true
+ break
+ case "logout":
+ Popups.cancelConfirm()
+ proc.pendingCmd = ["bash", powerScript, "logout"]
+ proc.running = true
+ break
+ case "lock":
+ Popups.cancelConfirm()
+ proc.pendingCmd = ["loginctl", "lock-session"]
+ proc.running = true
+ break
+ case "suspend":
+ Popups.cancelConfirm()
+ proc.pendingCmd = ["systemctl", "suspend"]
+ proc.running = true
+ break
+ case "gpu-switch-envy":
+ // Capture mode BEFORE cancelConfirm() clears Popups state.
+ const gfxMode = Popups.confirmGfxMode
+ reboot.command = ["bash", powerScript, "reboot"]
+ proc.pendingCmd = ["pkexec", "bash", gfxScript, gfxMode]
+ Popups.cancelConfirm()
+ proc.running = true
+ break
+ }
+ }
+
+ function cancel() {
+ if (!Popups.confirmRunning) Popups.cancelConfirm()
+ }
+
+ // ── Dim overlay ───────────────────────────────────────────────────────────
+ Rectangle {
+ anchors.fill: parent
+ color: "#99000000"
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: if (!Popups.confirmRunning) root.cancel()
+ }
+ }
+
+ // ── Confirm dialog ────────────────────────────────────────────────────────
+ Rectangle {
+ anchors.centerIn: parent
+ width: 360
+ height: col.implicitHeight + 48
+ radius: Theme.notchRadius
+ color: Theme.background
+ visible: Popups.confirmOpen && !Popups.confirmRunning
+
+ MouseArea { anchors.fill: parent }
+
+ Column {
+ id: col
+ anchors {
+ top: parent.top
+ left: parent.left
+ right: parent.right
+ topMargin: 24
+ leftMargin: 24
+ rightMargin: 24
+ }
+ spacing: 16
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: {
+ switch (Popups.confirmAction) {
+ case "shutdown": return "⏻"
+ case "reboot": return "↺"
+ case "logout": return "⎋"
+ case "gpu-switch-envy": return "⚠️"
+ default: return "⚠️"
+ }
+ }
+ font.pixelSize: 32
+ }
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: Popups.confirmTitle
+ color: Theme.text
+ font.pixelSize: 15
+ font.bold: true
+ }
+
+ Text {
+ width: parent.width
+ text: Popups.confirmMessage
+ color: Qt.rgba(1, 1, 1, 0.65)
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ textFormat: Text.RichText
+ lineHeight: 1.4
+ }
+
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 10
+
+ Rectangle {
+ width: 130
+ height: 38
+ radius: Theme.cornerRadius
+ color: cancelHov.hovered ? Qt.rgba(1, 1, 1, 0.1) : Qt.rgba(1, 1, 1, 0.05)
+ Behavior on color { ColorAnimation { duration: 120 } }
+
+ Text {
+ anchors.centerIn: parent
+ text: "Cancel"
+ color: Theme.text
+ font.pixelSize: 13
+ }
+
+ HoverHandler { id: cancelHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root.cancel() }
+ }
+
+ Rectangle {
+ width: 130
+ height: 38
+ radius: Theme.cornerRadius
+ color: confirmHov.hovered ? "#cc3a3a" : "#993030"
+ Behavior on color { ColorAnimation { duration: 120 } }
+
+ Text {
+ anchors.centerIn: parent
+ text: Popups.confirmLabel
+ color: "white"
+ font.pixelSize: 13
+ font.bold: true
+ }
+
+ HoverHandler { id: confirmHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: root.confirm() }
+ }
+ }
+ }
+ }
+
+ // ── Processing card ───────────────────────────────────────────────────────
+ Rectangle {
+ anchors.centerIn: parent
+ width: 300
+ height: processingCol.implicitHeight + 56
+ radius: Theme.notchRadius
+ color: Theme.background
+ visible: Popups.confirmRunning
+
+ MouseArea { anchors.fill: parent }
+
+ Column {
+ id: processingCol
+ anchors {
+ top: parent.top
+ left: parent.left
+ right: parent.right
+ topMargin: 28
+ leftMargin: 24
+ rightMargin: 24
+ }
+ spacing: 18
+
+ Canvas {
+ id: spinnerCanvas
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: 40
+ height: 40
+ transformOrigin: Item.Center
+
+ RotationAnimator {
+ target: spinnerCanvas
+ from: 0
+ to: 360
+ duration: 900
+ loops: Animation.Infinite
+ running: Popups.confirmRunning
+ easing.type: Easing.Linear
+ }
+
+ onPaint: {
+ var ctx = getContext("2d")
+ ctx.clearRect(0, 0, width, height)
+ var cx = width / 2, cy = height / 2, r = 16
+ ctx.beginPath()
+ ctx.arc(cx, cy, r, 0, 2 * Math.PI)
+ ctx.strokeStyle = "rgba(255,255,255,0.1)"
+ ctx.lineWidth = 3
+ ctx.stroke()
+ ctx.beginPath()
+ ctx.arc(cx, cy, r, -Math.PI / 2, Math.PI)
+ ctx.strokeStyle = "white"
+ ctx.lineWidth = 3
+ ctx.lineCap = "round"
+ ctx.stroke()
+ }
+
+ Component.onCompleted: requestPaint()
+ }
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "Applying Changes"
+ color: Theme.text
+ font.pixelSize: 15
+ font.bold: true
+ }
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: parent.width
+ text: "Switching to " + Popups.confirmGfxMode + " graphics mode. "
+ + "Your system will reboot when finished."
+ color: Qt.rgba(1, 1, 1, 0.55)
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ textFormat: Text.RichText
+ lineHeight: 1.5
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ Rectangle {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: parent.width
+ height: 1
+ color: Qt.rgba(1, 1, 1, 0.07)
+ }
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "Do not turn off your computer."
+ color: Qt.rgba(1, 1, 1, 0.3)
+ font.pixelSize: 11
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+ }
+
+ // Escape / Enter
+ Item {
+ anchors.fill: parent
+ focus: root.visible
+ Keys.onReturnPressed: root.confirm()
+ Keys.onEscapePressed: root.cancel()
+ }
+}
diff --git a/src/windows/Notch.qml b/src/windows/Notch.qml
deleted file mode 100644
index 62717f5..0000000
--- a/src/windows/Notch.qml
+++ /dev/null
@@ -1,115 +0,0 @@
-import QtQuick
-import Quickshell
-import Quickshell.Hyprland
-import "../theme/"
-
-Rectangle {
- id: root
-
- // --- 1. Capsule Container ---
- color: Theme.wsBackground
- radius: Theme.wsRadius
-
- // Auto-size
- width: workspaceRow.width + (Theme.wsPadding * 2)
- height: Theme.wsDotSize + (Theme.wsPadding * 2)
-
- // --- 2. LOGIC: Raw Event Listener ---
- property bool isScratchpad: false
-
- Connections {
- target: Hyprland
-
- // Quickshell emits (name, data) for raw events
- function onRawEvent(event) {
- // console.log("RawEvent_name: "+ event.name)
- // console.log("RawEvent_data: "+ event.data)
- // 1. Handle Scratchpad Toggle
- if (event.name === "activespecial") {
- // Event data format: "workspaceName,monitorName"
- // Example: "special:magic,eDP-1" or ",eDP-1" (closed)
- const wsName = event.data.split(',')[0];
-
- // If name is not empty, scratchpad is open.
- root.isScratchpad = (wsName !== "");
- }
-
- // 2. Reset when switching to a normal workspace
- if (event.name === "destroyworkspace") {
- root.isScratchpad = false;
- }
- }
- }
-
- // --- 3. Workspace Dots ---
- Row {
- id: workspaceRow
- anchors.centerIn: parent
- spacing: Theme.wsSpacing
-
- // Logic: Fade out dots when Scratchpad is active
- opacity: root.isScratchpad ? 0 : 1
- scale: root.isScratchpad ? 0.8 : 1
- visible: opacity > 0
-
- Behavior on opacity { NumberAnimation { duration: 200 } }
- Behavior on scale { NumberAnimation { duration: 200 } }
-
- Repeater {
- model: 10
- delegate: Rectangle {
- id: dot
-
- property var ws: Hyprland.workspaces.values.find(w => w.id === index + 1)
- property bool isActive: Hyprland.focusedWorkspace?.id === (index + 1)
- property bool isOccupied: ws !== undefined
-
- height: Theme.wsDotSize
- radius: height / 2
- width: isActive ? Theme.wsActiveWidth : Theme.wsDotSize
-
- color: {
- if (isActive) return Theme.wsActive
- if (isOccupied) return Theme.wsOccupied
- return Theme.wsEmpty
- }
-
- Behavior on width { NumberAnimation { duration: 200; easing.type: Easing.OutBack } }
- Behavior on color { ColorAnimation { duration: 200 } }
-
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- onClicked: Hyprland.dispatch(`workspace ${index + 1}`)
- }
- }
- }
- }
-
- // --- 4. Scratchpad Overlay ---
- Rectangle {
- id: overlay
- anchors.fill: parent
- radius: root.radius
- color: Theme.wsOverlay
- z: 99
-
- // Logic: Fade in overlay when Scratchpad is active
- visible: opacity > 0
- opacity: root.isScratchpad ? 1 : 0
-
- Behavior on opacity { NumberAnimation { duration: 200 } }
-
- Text {
- anchors.centerIn: parent
- text: ""
- color: "#FFFFFF"
- font.pixelSize: 14
- }
-
- MouseArea {
- anchors.fill: parent
- onClicked: Hyprland.dispatch("togglespecialworkspace")
- }
- }
-}
\ No newline at end of file
diff --git a/src/windows/PopupDismiss.qml b/src/windows/PopupDismiss.qml
new file mode 100644
index 0000000..bd0b374
--- /dev/null
+++ b/src/windows/PopupDismiss.qml
@@ -0,0 +1,93 @@
+import Quickshell
+import Quickshell.Wayland
+import QtQuick
+import "../"
+import Quickshell.Hyprland
+
+// Transparent fullscreen overlay that dismisses all popups when:
+// - The user clicks anywhere on screen
+// - The user presses Escape
+//
+// Also active when screen rec setup is showing (ShellState.screenRecord
+// without recording) so ESC can cancel it even with no other popup open.
+
+PanelWindow {
+ id: root
+
+ color: "transparent"
+
+ mask: Region {
+ Region {
+ x: Theme.borderWidth
+ y: Theme.notchHeight - Theme.borderWidth
+ width: root.width - (Theme.borderWidth * 2)
+ height: root.height - Theme.notchHeight - Theme.borderWidth
+ }
+ Region {
+ x: ShellState.topBarLWidth - Theme.borderWidth
+ y: 0
+ width: (root.width / 2) - (ShellState.topBarCWidth / 2) - ShellState.topBarLWidth+ Theme.borderWidth
+ height: Theme.notchHeight
+ }
+ Region{
+ x: (root.width / 2) + (ShellState.topBarCWidth / 2)
+ y: 0
+ width: (root.width / 2) - (ShellState.topBarCWidth / 2) - ShellState.topBarRWidth + Theme.borderWidth
+ height: Theme.notchHeight
+ }
+ }
+
+ // Span entire screen
+ anchors {
+ top: true
+ left: true
+ right: true
+ bottom: true
+ }
+
+ margins.top: Theme.borderWidth // Start below the notch so it doesn't interfere with TopBar popups
+ margins.left: Theme.borderWidth
+ margins.right: Theme.borderWidth
+ margins.bottom: Theme.borderWidth
+ // Don't push windows away
+ exclusionMode: ExclusionMode.Ignore
+
+ // Only grab input when a popup is actually open
+ // When false, input passes through as if this window doesn't exist
+ visible: Popups.anyOpen || (ShellState.screenRecord && !ScreenRecService.recording)
+
+ // Sit below popups but above the desktop
+ WlrLayershell.layer: WlrLayer.Top
+
+ // Detech Keyboard events for Escape key to dismiss popups
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
+
+ // --- Click anywhere to dismiss ---
+ MouseArea {
+ anchors.fill: parent
+ onClicked: Popups.closeAll()
+ }
+
+ // --- Escape to dismiss ---
+ // Item must be focused for Keys to fire
+ Item {
+ anchors.fill: parent
+ focus: root.visible
+
+ Keys.onEscapePressed: {
+ Popups.closeAll()
+ ScreenRecService.cancelSetup()
+ }
+ }
+
+ Connections {
+ target: Hyprland
+
+ // Quickshell emits (name, data) for raw events
+ function onRawEvent(event) {
+ if (event.name === "workspace" || event.name === "activemonitor" || event.name === "activespecial" || event.name === "openwindow") {
+ Popups.closeAll();
+ }
+ }
+ }
+}
diff --git a/src/windows/ScreenCorners.qml b/src/windows/ScreenCorners.qml
new file mode 100644
index 0000000..cd07a38
--- /dev/null
+++ b/src/windows/ScreenCorners.qml
@@ -0,0 +1,85 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import "../shapes"
+import "../theme"
+
+/*!
+ ScreenCorners.qml — rounded screen corner masks.
+ Uses 4 tiny PanelWindows (one per corner) at WlrLayer.Overlay.
+ Each window is only cornerRadius × cornerRadius pixels, so clicks
+ pass through normally everywhere except the actual corner area.
+*/
+Item {
+ id: root
+
+ // Accept screen from Variants delegate without being a PanelWindow itself
+ property var screen: null
+
+ readonly property int cornerRadius: Theme.cornerRadius
+ readonly property color fillColor: Theme.background
+
+ PanelWindow {
+ screen: root.screen
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.namespace: "brain-shell:corners-tl"
+ WlrLayershell.layer: WlrLayer.Overlay
+ implicitWidth: cornerRadius; implicitHeight: cornerRadius
+ anchors { top: true; left: true }
+ RoundCorner {
+ anchors.fill: parent
+ size: cornerRadius
+ maskColor: fillColor
+ corner: RoundCorner.Corner.TopLeft
+ }
+ }
+
+ PanelWindow {
+ screen: root.screen
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.namespace: "brain-shell:corners-tr"
+ WlrLayershell.layer: WlrLayer.Overlay
+ implicitWidth: cornerRadius; implicitHeight: cornerRadius
+ anchors { top: true; right: true }
+ RoundCorner {
+ anchors.fill: parent
+ size: cornerRadius
+ maskColor: fillColor
+ corner: RoundCorner.Corner.TopRight
+ }
+ }
+
+ PanelWindow {
+ screen: root.screen
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.namespace: "brain-shell:corners-bl"
+ WlrLayershell.layer: WlrLayer.Overlay
+ implicitWidth: cornerRadius; implicitHeight: cornerRadius
+ anchors { bottom: true; left: true }
+ RoundCorner {
+ anchors.fill: parent
+ size: cornerRadius
+ maskColor: fillColor
+ corner: RoundCorner.Corner.BottomLeft
+ }
+ }
+
+ PanelWindow {
+ screen: root.screen
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.namespace: "brain-shell:corners-br"
+ WlrLayershell.layer: WlrLayer.Overlay
+ implicitWidth: cornerRadius; implicitHeight: cornerRadius
+ anchors { bottom: true; right: true }
+ RoundCorner {
+ anchors.fill: parent
+ size: cornerRadius
+ maskColor: fillColor
+ corner: RoundCorner.Corner.BottomRight
+ }
+ }
+}
diff --git a/src/windows/TopBar.qml b/src/windows/TopBar.qml
index 8735a58..5cefccb 100644
--- a/src/windows/TopBar.qml
+++ b/src/windows/TopBar.qml
@@ -1,71 +1,160 @@
-// src/windows/TopBar.qml
import Quickshell
import QtQuick
import "../components"
import "../modules/Center/"
import "../modules/Right/"
import "../modules/Left/"
-import "../theme/"
+import "../"
import "../shapes/"
+import "../theme"
PanelWindow {
id: root
-
- // Screen name property (set from shell.qml)
+
property string screenName: screen ? screen.name : ""
- // 0. Setup - Transparent so only the shape shows
color: "transparent"
-
- // 1. Anchors - Span the entire top
+
anchors {
- top: true
- left: true
+ top: true
+ left: true
right: true
}
-
- // 2. Height - Enough to fit the notches
- implicitHeight: Theme.notchHeight // Extra space for shadows if needed
- exclusiveZone: Theme.notchHeight
- // 3. Background Shape
- SeamlessBarShape {
- anchors.fill: parent
+ Binding { target: ShellState; property: "topBarLWidth"; value: root.lWidth }
+ Binding { target: ShellState; property: "topBarCWidth"; value: root.cWidth }
+ Binding { target: ShellState; property: "topBarRWidth"; value: root.rWidth }
+
+ // ── Height shrinks to a border strip in focus mode ───────────────────────
+ // Safe to animate on PanelWindow (anchored, no position jank).
+ // PopupWindow is the one that must never have animated implicitHeight.
+ implicitHeight: ShellState.focusMode ? Theme.borderWidth : Theme.notchHeight
+ Behavior on implicitHeight {
+ NumberAnimation { duration: Anim.standardNormal; easing: Anim.standard }
}
- // 4. Content Layouts
- // We manually place the content modules over the drawn shapes
-
- // Left Content
- Item {
- implicitHeight: Theme.notchHeight
- implicitWidth: Theme.lNotchWidth
- anchors.left: parent.left
-
- LeftContent {
- anchors.centerIn: parent
- }
+ exclusiveZone: ShellState.focusMode ? 0 : Theme.exclusionGap
+ Behavior on exclusiveZone {
+ NumberAnimation { duration: Anim.standardNormal; easing: Anim.standard }
}
- // Center Content
- Item {
- implicitHeight: Theme.notchHeight
- implicitWidth: Theme.cNotchWidth
- anchors.centerIn: parent
-
- CenterContent {
- anchors.centerIn: parent
+ readonly property int lWidth: Math.max(
+ Theme.lNotchMinWidth,
+ Math.min(Theme.lNotchMaxWidth,
+ leftContent.implicitWidth + Theme.notchPadding * 2)
+ )
+
+ // cWidth uses Popups.dashboardPageWidth when the dashboard is open,
+ // so the center notch tracks the active tab's declared width.
+ property int cWidth: Popups.dashboardOpen
+ ? Popups.dashboardPageWidth
+ : Math.max(
+ Theme.cNotchMinWidth,
+ Math.min(Theme.cNotchMaxWidth,
+ centerContent.implicitWidth + Theme.notchPadding * 2)
+ )
+ Behavior on cWidth {
+ NumberAnimation { duration: Anim.standardNormal; easing: Anim.outCubic }
+ }
+
+ // Width matches sizer open width: popupWidth + notchRadius (fw) in both popups
+ property int rWidth: Math.max(
+ Theme.rNotchMinWidth,
+ Math.min(Theme.rNotchMaxWidth, rightContent.implicitWidth + Theme.notchPadding * 2)
+ )
+
+ // ── Border strip (focus mode) ────────────────────────────────────────────
+ // Painted behind the notch content layer. Visible only when focus mode
+ // fades the notches out. Uses the same bar color so it reads as a thin
+ // edge strip matching the side border strips.
+ Rectangle {
+ anchors.fill: parent
+ color: Theme.background
+ opacity: ShellState.focusMode ? 1 : 0
+ Behavior on opacity {
+ NumberAnimation { duration: Anim.standardNormal; easing: Anim.standard }
}
}
- // Right Content
+ // ── Notch content (fades out in focus mode) ──────────────────────────────
Item {
- implicitHeight: Theme.notchHeight
- implicitWidth: Theme.rNotchWidth
- anchors.right: parent.right
+ anchors.fill: parent
+ opacity: ShellState.focusMode ? 0 : 1
+ Behavior on opacity {
+ NumberAnimation { duration: Anim.standardNormal; easing: Anim.standard }
+ }
- RightContent {
+ states: [
+ State {
+ name: "notifications"
+ when: Popups.notificationsOpen
+ PropertyChanges { target: root; rWidth: Theme.notificationsWidth + Theme.notchRadius }
+ },
+ State {
+ name: "network"
+ when: Popups.networkOpen && !Popups.notificationsOpen
+ PropertyChanges { target: root; rWidth: Theme.networkPopupWidth + Theme.notchRadius }
+ },
+ State {
+ name: "toast"
+ when: Popups.notificationToastOpen && !Popups.notificationsOpen && !Popups.networkOpen
+ PropertyChanges { target: root; rWidth: Theme.notificationToastWidth + Theme.notchRadius + Theme.notchPadding -3 }
+ }
+ ]
+
+ transitions: [
+ Transition {
+ // This animation ONLY runs when switching between popups (and toasts) and the base state.
+ NumberAnimation { property: "rWidth"; duration: Anim.standardNormal; easing: Anim.standard }
+ }
+ ]
+
+ SeamlessBarShape {
+ id: barShape
+ anchors.fill: parent
+ leftWidth: root.lWidth
+ centerWidth: root.cWidth
+ rightWidth: root.rWidth
+ }
+
+ Item {
+ id: leftNotch
+ width: root.lWidth
+ height: Theme.notchHeight
+ anchors.left: parent.left
+
+ LeftContent {
+ id: leftContent
+ anchors.centerIn: parent
+ }
+ }
+
+ Item {
+ id: centerNotch
+ width: root.cWidth
+ height: Theme.notchHeight
anchors.centerIn: parent
+
+ CenterContent {
+ id: centerContent
+ anchors.centerIn: parent
+ }
+ }
+
+ Item {
+ id: rightNotch
+ width: root.rWidth
+ height: Theme.notchHeight
+ anchors.right: parent.right
+
+ clip: true
+
+ RightContent {
+ id: rightContent
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.rightMargin: Theme.notchPadding
+ }
}
}
}
\ No newline at end of file
diff --git a/src/windows/UpdatePopup.qml b/src/windows/UpdatePopup.qml
new file mode 100644
index 0000000..3a86d24
--- /dev/null
+++ b/src/windows/UpdatePopup.qml
@@ -0,0 +1,437 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import "../"
+
+// UpdatePopup — centered overlay popup for shell update notifications.
+// Same structural pattern as ConfirmDialog: PanelWindow Overlay, dim background,
+// centered fixed-size card. Instantiated per-screen in shell.qml.
+//
+// States:
+// checking — spinner in header, no body (checking is brief, usually invisible)
+// available — commit list + Update / Skip / Disable buttons
+// updating — spinner, please wait text
+// conflict — pull failed due to local changes: Stash & Update / Cancel
+// success — Reload Shell / Dismiss, auto-dismiss after 10s
+// error — generic error text + Retry / Close
+
+PanelWindow {
+ id: root
+
+ color: "transparent"
+ anchors { top: true; left: true; right: true; bottom: true }
+ exclusionMode: ExclusionMode.Ignore
+
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
+
+ property bool windowVisible: false
+ visible: windowVisible
+
+ Connections {
+ target: UpdateService
+ function onShowPopupChanged() {
+ if (UpdateService.showPopup) {
+ root.windowVisible = true
+ } else {
+ closeTimer.restart()
+ }
+ }
+ }
+
+ Timer {
+ id: closeTimer
+ interval: 20
+ onTriggered: if (!UpdateService.showPopup) root.windowVisible = false
+ }
+
+ // Auto-dismiss success state after 10s
+ Timer {
+ interval: 3000
+ running: UpdateService.updateSuccess && root.windowVisible
+ onTriggered: UpdateService.dismiss()
+ }
+
+ // ── Dim overlay ───────────────────────────────────────────────────────────
+ Rectangle {
+ anchors.fill: parent
+ color: Qt.rgba(0, 0, 0, 0.50)
+ // Pass through clicks in the dim area — update is non-blocking
+ MouseArea {
+ anchors.fill: parent
+ // Intentionally no action — user must use the card buttons
+ }
+ }
+
+ // ── Card ──────────────────────────────────────────────────────────────────
+ Rectangle {
+ id: card
+ anchors.centerIn: parent
+ width: 380
+ radius: Theme.notchRadius
+ color: Theme.background
+ border.color: Qt.rgba(1, 1, 1, 0.08)
+ border.width: 1
+
+ // Size to content
+ height: cardCol.implicitHeight + 48
+
+ // Prevent clicks from hitting the dim MouseArea
+ MouseArea { anchors.fill: parent }
+
+ // Left accent bar — color reflects state
+ Rectangle {
+ anchors {
+ left: parent.left
+ top: parent.top; topMargin: 10
+ bottom: parent.bottom; bottomMargin: 10
+ }
+ width: 3
+ radius: 2
+ color: UpdateService.updateSuccess ? "#a6e3a1"
+ : UpdateService.hasConflict ? "#f5c47a"
+ : (UpdateService.lastError !== "" &&
+ !UpdateService.updating) ? "#f38ba8"
+ : Theme.active
+ Behavior on color { ColorAnimation { duration: 200 } }
+ }
+ Item {
+ visible: !UpdateService.updating
+ anchors { top: parent.top; right: parent.right; topMargin: 8; rightMargin: 8 }
+ width: 24; height: 24
+
+ Rectangle {
+ anchors.fill: parent; radius: 6
+ color: xHov.hovered ? Qt.rgba(1,1,1,0.10) : "transparent"
+ Behavior on color { ColorAnimation { duration: 100 } }
+ }
+ Text {
+ anchors.centerIn: parent
+ text: "✕"; font.pixelSize: 11
+ color: Qt.rgba(1,1,1,0.35)
+ }
+ HoverHandler { id: xHov; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: UpdateService.dismiss() }
+ }
+
+ Column {
+ id: cardCol
+ anchors {
+ top: parent.top; topMargin: 24
+ left: parent.left; leftMargin: 22
+ right: parent.right; rightMargin: 18
+ }
+ spacing: 12
+
+ // ── Header row ────────────────────────────────────────────────────
+ Row {
+ width: parent.width
+ spacing: 10
+
+ Text {
+ id: headerIcon
+ anchors.verticalCenter: parent.verticalCenter
+ font.pixelSize: 18
+ text: UpdateService.updating || UpdateService.checking ? ""
+ : UpdateService.updateSuccess ? ""
+ : UpdateService.hasConflict ? ""
+ : UpdateService.lastError !== "" ? ""
+ : ""
+ color: UpdateService.updateSuccess ? "#a6e3a1"
+ : UpdateService.hasConflict ? "#f5c47a"
+ : (UpdateService.lastError !== "" &&
+ !UpdateService.updating) ? "#f38ba8"
+ : Theme.active
+ Behavior on color { ColorAnimation { duration: 200 } }
+
+ RotationAnimator {
+ target: headerIcon
+ from: 0; to: 360
+ duration: 900
+ loops: Animation.Infinite
+ running: UpdateService.updating || UpdateService.checking
+ easing.type: Easing.Linear
+ }
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ font.pixelSize: 13
+ font.weight: Font.DemiBold
+ color: Theme.text
+ text: UpdateService.updating ? "Updating…"
+ : UpdateService.updateSuccess ? "Update Complete"
+ : UpdateService.hasConflict ? "Conflict Detected"
+ : UpdateService.lastError !== "" && !UpdateService.updating
+ ? "Update Failed"
+ : "Brain Shell Update Available"
+ }
+ }
+
+ Rectangle {
+ width: parent.width; height: 1
+ color: Qt.rgba(1, 1, 1, 0.07)
+ }
+
+ // ── AVAILABLE ─────────────────────────────────────────────────────
+ Column {
+ visible: UpdateService.updateAvailable &&
+ !UpdateService.updating &&
+ !UpdateService.hasConflict &&
+ !UpdateService.updateSuccess &&
+ UpdateService.lastError === ""
+ width: parent.width
+ spacing: 10
+
+ Text {
+ text: UpdateService.commitsBehind + " new commit" +
+ (UpdateService.commitsBehind === 1 ? "" : "s") + " on main"
+ font.pixelSize: 12
+ color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.55)
+ }
+
+ Column {
+ width: parent.width
+ spacing: 4
+
+ Repeater {
+ model: Math.min(3, UpdateService.commitMessages.length)
+ delegate: Row {
+ spacing: 8
+ Text {
+ text: "·"
+ font.pixelSize: 11
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.60)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Text {
+ width: parent.parent.width - 18
+ font.pixelSize: 11
+ color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.55)
+ elide: Text.ElideRight
+ // Strip the short hash prefix from the commit line
+ text: {
+ var m = UpdateService.commitMessages[index] || ""
+ var sp = m.indexOf(" ")
+ return sp >= 0 ? m.substring(sp + 1) : m
+ }
+ }
+ }
+ }
+
+ Text {
+ visible: UpdateService.commitMessages.length > 3
+ text: "+ " + (UpdateService.commitMessages.length - 3) + " more"
+ font.pixelSize: 10
+ color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.40)
+ leftPadding: 16
+ }
+ }
+
+ Row {
+ spacing: 8
+
+ // Update Now
+ Rectangle {
+ width: 108; height: 30; radius: 8
+ color: uH.hovered
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.26)
+ : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.13)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.40)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Text {
+ anchors.centerIn: parent
+ text: "Update Now"
+ font.pixelSize: 11; font.weight: Font.Medium
+ color: Theme.active
+ }
+ HoverHandler { id: uH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: UpdateService.applyUpdate() }
+ }
+
+ // Skip (dismiss this check)
+ Rectangle {
+ width: 58; height: 30; radius: 8
+ color: skH.hovered ? Qt.rgba(1,1,1,0.08) : Qt.rgba(1,1,1,0.04)
+ border.color: Qt.rgba(1,1,1,0.09); border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Text { anchors.centerIn: parent; text: "Skip"; font.pixelSize: 11; color: Qt.rgba(1,1,1,0.52) }
+ HoverHandler { id: skH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: UpdateService.dismiss() }
+ }
+
+ // Disable auto-update
+ Rectangle {
+ width: 82; height: 30; radius: 8
+ color: disH.hovered ? Qt.rgba(1,1,1,0.06) : "transparent"
+ border.color: Qt.rgba(1,1,1,0.07); border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Text { anchors.centerIn: parent; text: "Disable"; font.pixelSize: 11; color: Qt.rgba(1,1,1,0.28) }
+ HoverHandler { id: disH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: UpdateService.disableAutoUpdate() }
+ }
+ }
+ }
+
+ // ── UPDATING ──────────────────────────────────────────────────────
+ Column {
+ visible: UpdateService.updating
+ width: parent.width
+ spacing: 6
+
+ Text {
+ text: "Pulling latest changes from origin/main…"
+ font.pixelSize: 12
+ color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.55)
+ wrapMode: Text.WordWrap
+ width: parent.width
+ }
+ Text {
+ text: "Do not close the shell."
+ font.pixelSize: 10
+ color: Qt.rgba(1, 1, 1, 0.25)
+ }
+ }
+
+ // ── CONFLICT ──────────────────────────────────────────────────────
+ Column {
+ visible: UpdateService.hasConflict && !UpdateService.updating
+ width: parent.width
+ spacing: 12
+
+ Text {
+ text: "Local uncommitted changes conflict with the update.\n" +
+ "Stash them aside to proceed, or cancel."
+ font.pixelSize: 12
+ color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.55)
+ wrapMode: Text.WordWrap
+ width: parent.width
+ lineHeight: 1.45
+ }
+
+ Row {
+ spacing: 8
+
+ // Stash & Update
+ Rectangle {
+ width: 128; height: 30; radius: 8
+ color: saH.hovered
+ ? Qt.rgba(245/255, 196/255, 122/255, 0.22)
+ : Qt.rgba(245/255, 196/255, 122/255, 0.10)
+ border.color: Qt.rgba(245/255, 196/255, 122/255, 0.38); border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Text {
+ anchors.centerIn: parent
+ text: "Stash & Update"
+ font.pixelSize: 11; font.weight: Font.Medium
+ color: "#f5c47a"
+ }
+ HoverHandler { id: saH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: UpdateService.stashAndUpdate() }
+ }
+
+ // Cancel
+ Rectangle {
+ width: 72; height: 30; radius: 8
+ color: cxH.hovered ? Qt.rgba(1,1,1,0.08) : Qt.rgba(1,1,1,0.04)
+ border.color: Qt.rgba(1,1,1,0.09); border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Text { anchors.centerIn: parent; text: "Cancel"; font.pixelSize: 11; color: Qt.rgba(1,1,1,0.52) }
+ HoverHandler { id: cxH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: UpdateService.dismiss() }
+ }
+ }
+ }
+
+ // ── SUCCESS ───────────────────────────────────────────────────────
+ Column {
+ visible: UpdateService.updateSuccess
+ width: parent.width
+ spacing: 12
+
+ Text {
+ text: "Shell updated successfully.\nReload to apply the changes."
+ font.pixelSize: 12
+ color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.55)
+ wrapMode: Text.WordWrap
+ width: parent.width
+ lineHeight: 1.45
+ }
+
+ // Dismiss
+ Rectangle {
+ width: 72; height: 30; radius: 8
+ color: dmH.hovered ? Qt.rgba(1,1,1,0.08) : Qt.rgba(1,1,1,0.04)
+ border.color: Qt.rgba(1,1,1,0.09); border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Text { anchors.centerIn: parent; text: "Dismiss"; font.pixelSize: 11; color: Qt.rgba(1,1,1,0.52) }
+ HoverHandler { id: dmH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: UpdateService.dismiss() }
+ }
+
+
+ Text {
+ text: "Auto-dismissing in a few seconds…"
+ font.pixelSize: 10
+ color: Qt.rgba(1, 1, 1, 0.22)
+ }
+ }
+
+ // ── ERROR ─────────────────────────────────────────────────────────
+ Column {
+ visible: UpdateService.lastError !== "" &&
+ !UpdateService.updating &&
+ !UpdateService.hasConflict
+ width: parent.width
+ spacing: 12
+
+ Text {
+ text: UpdateService.lastError
+ font.pixelSize: 12
+ color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.55)
+ wrapMode: Text.WordWrap
+ width: parent.width
+ }
+
+ Row {
+ spacing: 8
+
+ // Retry
+ Rectangle {
+ width: 72; height: 30; radius: 8
+ color: rtH.hovered
+ ? Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.22)
+ : Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.09)
+ border.color: Qt.rgba(Theme.active.r, Theme.active.g, Theme.active.b, 0.30)
+ border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Text { anchors.centerIn: parent; text: "Retry"; font.pixelSize: 11; color: Theme.active }
+ HoverHandler { id: rtH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: UpdateService.check() }
+ }
+
+ // Close
+ Rectangle {
+ width: 72; height: 30; radius: 8
+ color: clH.hovered ? Qt.rgba(1,1,1,0.08) : Qt.rgba(1,1,1,0.04)
+ border.color: Qt.rgba(1,1,1,0.09); border.width: 1
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Text { anchors.centerIn: parent; text: "Close"; font.pixelSize: 11; color: Qt.rgba(1,1,1,0.52) }
+ HoverHandler { id: clH; cursorShape: Qt.PointingHandCursor }
+ MouseArea { anchors.fill: parent; onClicked: UpdateService.dismiss() }
+ }
+ }
+ }
+ }
+ }
+
+ // Escape to dismiss (same as ConfirmDialog)
+ Item {
+ anchors.fill: parent
+ focus: root.visible
+ Keys.onEscapePressed: {
+ if (!UpdateService.updating) UpdateService.dismiss()
+ }
+ }
+}
diff --git a/src/windows/Wallpaper.qml b/src/windows/Wallpaper.qml
new file mode 100644
index 0000000..5d6759d
--- /dev/null
+++ b/src/windows/Wallpaper.qml
@@ -0,0 +1,479 @@
+import QtQuick
+import QtMultimedia
+import Quickshell
+import Quickshell.Wayland
+import "../theme"
+import "../services"
+import "../"
+
+/*!
+ Wallpaper background window — structurally identical to NothingLess.
+ No shaders. Signal-driven crossfade with direct contentReady→stabilize→crossfade.
+*/
+PanelWindow {
+ id: root
+
+ anchors { top: true; left: true; right: true; bottom: true }
+ WlrLayershell.layer: WlrLayer.Background
+ WlrLayershell.namespace: "brain-shell:wallpaper"
+ exclusionMode: ExclusionMode.Ignore
+ color: "black"
+
+ // ── Source resolution ────────────────────────────────────────────────────
+ readonly property string currentPath: WallpaperService.currentWall || ""
+
+ readonly property string effectivePath: {
+ if (!screen || !screen.name) return currentPath
+ var ps = WallpaperService.perScreenWallpapers
+ if (ps && ps[screen.name]) return ps[screen.name]
+ return currentPath
+ }
+
+ function getFileType(p) {
+ if (!p) return "unknown"
+ var ext = p.toLowerCase().split('.').pop()
+ if (['jpg','jpeg','png','webp','tif','tiff','bmp'].indexOf(ext) >= 0) return "image"
+ if (ext === 'gif') return 'gif'
+ if (['mp4','webm','mkv','mov','avi'].indexOf(ext) >= 0) return 'video'
+ return 'unknown'
+ }
+
+ // ── Idle-based pause ─────────────────────────────────────────────────────
+ readonly property bool videoPaused: IdleService.idleTime > 30000
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // CROSSFADE — structurally identical to NothingLess
+ // ═══════════════════════════════════════════════════════════════════════════
+ property int _activeLayer: 0
+ property bool _swapping: false
+ property string _lastSource: ""
+
+ onEffectivePathChanged: {
+ if (!root.effectivePath) return
+ if (root.effectivePath === root._lastSource) return
+ root._lastSource = root.effectivePath
+ root._beginSwap()
+ }
+
+ Component.onCompleted: {
+ if (root.effectivePath && root.effectivePath !== root._lastSource) {
+ root._lastSource = root.effectivePath
+ root._beginSwap()
+ }
+ }
+
+ function _beginSwap() {
+ if (_swapping) return
+ var nextLoader = (_activeLayer === 0) ? layerBLoader : layerALoader
+ var currLayer = (_activeLayer === 0) ? layerA : layerB
+ var nextLayer = (_activeLayer === 0) ? layerB : layerA
+ var currLoader = (_activeLayer === 0) ? layerALoader : layerBLoader
+ var isInitial = !currLoader.item || currLoader._wallSource === ""
+
+ // Fast path: same source already loaded
+ if (!isInitial && nextLoader._wallSource === root.effectivePath && nextLoader.item) {
+ _swapping = true
+ _stabilizeAndFade(currLayer, nextLayer)
+ return
+ }
+
+ _swapping = true
+ nextLoader._wallSource = root.effectivePath
+ nextLoader.active = true
+
+ _stabilizeTimer._currLayer = currLayer
+ _stabilizeTimer._nextLayer = nextLayer
+ _stabilizeTimer._isInitial = isInitial
+
+ _readyTimeoutTimer.restart()
+ }
+
+ // Called directly when the inner media signals contentReady()
+ function _onLayerContentReady() {
+ if (!_swapping) return
+ _readyTimeoutTimer.stop()
+ _stabilizeTimer.restart()
+ }
+
+ Timer {
+ id: _readyTimeoutTimer
+ interval: 3000
+ onTriggered: {
+ if (_swapping) {
+ console.warn("[Wallpaper] contentReady timeout — forcing crossfade")
+ _stabilizeTimer.restart()
+ }
+ }
+ }
+
+ Timer {
+ id: _stabilizeTimer
+ interval: 80
+ property var _currLayer: null
+ property var _nextLayer: null
+ property bool _isInitial: false
+ onTriggered: _stabilizeAndFade(_currLayer, _nextLayer)
+ }
+
+ function _stabilizeAndFade(currLayer, nextLayer) {
+ if (currLayer) {
+ // Smooth crossfade: old fades out, new fades in with subtle zoom
+ crossfadeAnim.currLayer = currLayer
+ crossfadeAnim.nextLayer = nextLayer
+ nextLayer.scale = 0.97
+ crossfadeAnim.restart()
+ } else {
+ nextLayer.opacity = 0.0
+ nextLayer.scale = 1.0
+ fadeInOnlyAnim.targetLayer = nextLayer
+ fadeInOnlyAnim.restart()
+ }
+ }
+
+ ParallelAnimation {
+ id: crossfadeAnim
+ property var currLayer: null
+ property var nextLayer: null
+
+ NumberAnimation {
+ target: crossfadeAnim.currLayer; property: "opacity"
+ to: 0.0; duration: 280; easing.type: Easing.InOutCubic
+ }
+ ParallelAnimation {
+ NumberAnimation {
+ target: crossfadeAnim.nextLayer; property: "opacity"
+ to: 1.0; duration: 280; easing.type: Easing.OutCubic
+ }
+ NumberAnimation {
+ target: crossfadeAnim.nextLayer; property: "scale"
+ to: 1.0; duration: 320; easing.type: Easing.OutCubic
+ }
+ }
+ onStopped: _finishSwap()
+ }
+
+ NumberAnimation {
+ id: fadeInOnlyAnim
+ property var targetLayer: null
+ target: fadeInOnlyAnim.targetLayer; property: "opacity"
+ to: 1.0; duration: 200; easing.type: Easing.OutCubic
+ onStopped: _finishSwap()
+ }
+
+ function _finishSwap() {
+ _activeLayer = (_activeLayer === 0) ? 1 : 0
+ var oldLoader = (_activeLayer === 0) ? layerBLoader : layerALoader
+ oldLoader.active = false
+ oldLoader._wallSource = ""
+ _swapping = false
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // LAYOUT — same structure as NothingLess: keyboard Rectangle wraps layers
+ // ═══════════════════════════════════════════════════════════════════════════
+ Rectangle {
+ id: background
+ anchors.fill: parent
+ color: "black"
+ focus: true
+
+ Keys.onLeftPressed: WallpaperService.previousWallpaper()
+ Keys.onRightPressed: WallpaperService.nextWallpaper()
+
+ // ═══════════════════════════════════════════════════════════════════
+ // LAYER A
+ // ═══════════════════════════════════════════════════════════════════
+ Item {
+ id: layerA
+ anchors.fill: parent
+ opacity: _activeLayer === 0 ? 1.0 : 0.0
+ scale: 1.0
+ Behavior on opacity { enabled: false }
+ Behavior on scale { enabled: false }
+
+ Loader {
+ id: layerALoader
+ anchors.fill: parent
+ asynchronous: true
+ active: _activeLayer === 0
+ property string _wallSource: ""
+
+ sourceComponent: {
+ if (!_wallSource) return null
+ var ft = root.getFileType(_wallSource)
+ if (ft === 'image') return staticImageComponent
+ return videoComponent
+ }
+ onLoaded: {
+ if (item) {
+ if (item.contentReady) item.contentReady.connect(root._onLayerContentReady)
+ item.sourceFile = _wallSource
+ }
+ }
+ onStatusChanged: {
+ if (status === Loader.Error)
+ console.error("[Wallpaper] layerALoader FAILED for:", _wallSource)
+ }
+ Binding {
+ target: layerALoader.item; property: "sourceFile"
+ value: layerALoader._wallSource
+ when: layerALoader.item !== null && layerALoader._wallSource !== ""
+ }
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════════════
+ // LAYER B
+ // ═══════════════════════════════════════════════════════════════════
+ Item {
+ id: layerB
+ anchors.fill: parent
+ opacity: _activeLayer === 1 ? 1.0 : 0.0
+ scale: 1.0
+ Behavior on opacity { enabled: false }
+ Behavior on scale { enabled: false }
+
+ Loader {
+ id: layerBLoader
+ anchors.fill: parent
+ asynchronous: true
+ active: _activeLayer === 1
+ property string _wallSource: ""
+
+ sourceComponent: {
+ if (!_wallSource) return null
+ var ft = root.getFileType(_wallSource)
+ if (ft === 'image') return staticImageComponent
+ return videoComponent
+ }
+ onLoaded: {
+ if (item) {
+ if (item.contentReady) item.contentReady.connect(root._onLayerContentReady)
+ item.sourceFile = _wallSource
+ }
+ }
+ onStatusChanged: {
+ if (status === Loader.Error)
+ console.error("[Wallpaper] layerBLoader FAILED for:", _wallSource)
+ }
+ Binding {
+ target: layerBLoader.item; property: "sourceFile"
+ value: layerBLoader._wallSource
+ when: layerBLoader.item !== null && layerBLoader._wallSource !== ""
+ }
+ }
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // STATIC IMAGE COMPONENT
+ // ═══════════════════════════════════════════════════════════════════════════
+ Component {
+ id: staticImageComponent
+ Item {
+ id: staticImageRoot
+ anchors.fill: parent
+ property string sourceFile: ""
+ signal contentReady()
+
+ Image {
+ id: rawImage
+ anchors.fill: parent
+ source: staticImageRoot.sourceFile ? "file://" + staticImageRoot.sourceFile : ""
+ fillMode: Image.PreserveAspectCrop
+ asynchronous: true
+ smooth: true
+ mipmap: true
+ cache: false
+
+ onStatusChanged: {
+ if (status === Image.Ready) {
+ staticImageRoot.contentReady()
+ } else if (status === Image.Error) {
+ console.error("[Wallpaper] image FAILED:", source)
+ staticImageRoot.contentReady()
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: "black"
+ visible: rawImage.status !== Image.Ready
+ }
+ }
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // VIDEO + GIF COMPONENT
+ // ═══════════════════════════════════════════════════════════════════════════
+ Component {
+ id: videoComponent
+ Item {
+ id: videoRoot
+ anchors.fill: parent
+ property string sourceFile: ""
+ signal contentReady()
+
+ property string effectiveSource: ""
+
+ function _relayReady() { videoRoot.contentReady() }
+
+ function _ensureCache(sourcePath) {
+ if (!sourcePath) { videoRoot.effectiveSource = ""; return }
+ var ft = root.getFileType(sourcePath)
+ if (ft === 'gif') { videoRoot.effectiveSource = sourcePath; return }
+ if (typeof VideoWallpaperService === "undefined") {
+ videoRoot.effectiveSource = sourcePath; return
+ }
+ // Play original immediately; swap to cache when ready
+ videoRoot.effectiveSource = sourcePath
+ var cachePath = VideoWallpaperService.getEffectivePath(sourcePath)
+ if (cachePath === sourcePath || !cachePath) return
+ VideoWallpaperService.checkCache(cachePath, function(exists) {
+ if (exists && videoRoot.sourceFile === sourcePath)
+ videoRoot.effectiveSource = cachePath
+ else if (!exists)
+ VideoWallpaperService.generateCache(sourcePath, cachePath)
+ })
+ }
+
+ onSourceFileChanged: {
+ if (!sourceFile) return
+ var ft = root.getFileType(sourceFile)
+ if (ft === 'gif') { gifLoader.active = true; videoLoader.active = false }
+ else { gifLoader.active = false; videoLoader.active = true }
+ videoRoot._ensureCache(sourceFile)
+ }
+
+ onEffectiveSourceChanged: {
+ if (videoLoader.active && videoLoader.item && videoRoot.effectiveSource)
+ videoLoader.item._setSource(videoRoot.effectiveSource)
+ }
+
+ Loader {
+ id: gifLoader
+ anchors.fill: parent; active: false
+ sourceComponent: gifPlayerComp
+ onLoaded: {
+ if (item) {
+ if (item.contentReady) item.contentReady.connect(videoRoot._relayReady)
+ item.sourceFile = videoRoot.sourceFile
+ }
+ }
+ }
+
+ Loader {
+ id: videoLoader
+ anchors.fill: parent; active: false
+ sourceComponent: videoPlayerComp
+ onLoaded: {
+ if (item) {
+ if (item.contentReady) item.contentReady.connect(videoRoot._relayReady)
+ item.sourceFile = videoRoot.sourceFile
+ if (videoRoot.effectiveSource)
+ item._setSource(videoRoot.effectiveSource)
+ }
+ }
+ }
+ }
+ }
+
+ // ── GIF player ───────────────────────────────────────────────────────────
+ Component {
+ id: gifPlayerComp
+ Item {
+ id: gifRoot
+ anchors.fill: parent
+ property string sourceFile: ""
+ signal contentReady()
+ property bool _signalled: false
+
+ AnimatedImage {
+ id: gifImg
+ anchors.fill: parent
+ fillMode: Image.PreserveAspectCrop
+ cache: false; asynchronous: true
+ source: gifRoot.sourceFile ? "file://" + gifRoot.sourceFile : ""
+ playing: !root.videoPaused
+ visible: status === AnimatedImage.Ready
+
+ onStatusChanged: {
+ if (status === AnimatedImage.Ready && !gifRoot._signalled) {
+ gifRoot._signalled = true; gifRoot.contentReady()
+ } else if (status === AnimatedImage.Error && !gifRoot._signalled) {
+ gifRoot._signalled = true; gifRoot.contentReady()
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent; color: "black"
+ visible: gifImg.status !== AnimatedImage.Ready
+ }
+ }
+ }
+ }
+
+ // ── Video player ─────────────────────────────────────────────────────────
+ Component {
+ id: videoPlayerComp
+ Item {
+ id: vidRoot
+ anchors.fill: parent
+ property string sourceFile: ""
+ signal contentReady()
+ property bool _signalled: false
+
+ function _setSource(src) {
+ if (!src) { videoPlayer.source = ""; return }
+ vidRoot._signalled = false
+ videoReadyPoll.running = true
+ videoPlayer.source = "file://" + src
+ }
+
+ Video {
+ id: videoPlayer
+ anchors.fill: parent
+ loops: MediaPlayer.Infinite
+ autoPlay: true
+ muted: true
+ fillMode: VideoOutput.PreserveAspectCrop
+
+ onPlaybackStateChanged: {
+ if (!vidRoot._signalled &&
+ (playbackState === MediaPlayer.PlayingState ||
+ playbackState === MediaPlayer.Loaded)) {
+ vidRoot._signalled = true
+ videoReadyPoll.running = false
+ vidRoot.contentReady()
+ }
+ if (root.videoPaused && playbackState === MediaPlayer.PlayingState)
+ pause()
+ else if (!root.videoPaused && playbackState !== MediaPlayer.PlayingState
+ && source != undefined && source != "")
+ play()
+ }
+ }
+
+ Timer {
+ id: videoReadyPoll
+ interval: 60; repeat: true
+ onTriggered: {
+ if (!vidRoot._signalled &&
+ (videoPlayer.status === MediaPlayer.Loaded ||
+ videoPlayer.status === MediaPlayer.Buffered)) {
+ vidRoot._signalled = true
+ running = false
+ vidRoot.contentReady()
+ }
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent; color: "black"
+ visible: videoPlayer.status !== MediaPlayer.Loaded &&
+ videoPlayer.status !== MediaPlayer.Buffered
+ }
+ }
+ }
+}
diff --git a/version b/version
new file mode 100644
index 0000000..6e8bf73
--- /dev/null
+++ b/version
@@ -0,0 +1 @@
+0.1.0
diff --git a/wrapper.sh b/wrapper.sh
new file mode 100755
index 0000000..336955c
--- /dev/null
+++ b/wrapper.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+# ═══════════════════════════════════════════════════════════════════════════════
+# Brain_Shell — global launcher wrapper
+# Installed at /usr/local/bin/brain-shell
+# ═══════════════════════════════════════════════════════════════════════════════
+
+INSTALL_DIR="${BRAIN_SHELL_DIR:-$HOME/.local/src/Brain_Shell}"
+
+# Ensure ~/.local/bin is in PATH (Quickshell may be installed there)
+case ":$PATH:" in
+ *:"$HOME/.local/bin":*) ;;
+ *) export PATH="$HOME/.local/bin:$PATH" ;;
+esac
+
+# Ensure QML import paths match (Nix / user-installed Qt modules)
+case ":$QML2_IMPORT_PATH:" in
+ *:"$HOME/.local/lib/qml":*) ;;
+ *) export QML2_IMPORT_PATH="$HOME/.local/lib/qml:$QML2_IMPORT_PATH" ;;
+esac
+export QML_IMPORT_PATH="$QML2_IMPORT_PATH"
+
+# Delegate to the real CLI
+exec "${INSTALL_DIR}/cli.sh" "$@"