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. +

+

+ +

+ Last Commit + Stars + Version 0.1.0 +
+ Hyprland v0.55+ + Quickshell Framework +
+ + License + + + Issues + + + Discord Invite + +

+ +--- + +

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" "$@"