diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/README.md b/README.md index ef9d0fa..d67ab46 100644 --- a/README.md +++ b/README.md @@ -1,129 +1 @@ -

- Logo -

-

Sparkles A modular and extensible shell for Hyprland, written on Fabric. Sparkles

- -

- - -People - - -Stars - - -Forks - - -Watches - - -Last Updated -

- -
- -
- fabric -
-
-
- -> [!WARNING] -> This configuration is tailored to my preferences with little regard for others. It's not designed to be "good"—just comfortable for me. - -

Package Installation

- -> [!NOTE] -> You need a functioning Hyprland installation. - -### Arch Linux - -> [!NOTE] -> You need to clone it into the Home directory. - -> [!TIP] -> This command also works for updating an existing installation! - -```bash -curl -fsSL https://raw.githubusercontent.com/S4NKALP/Modus/main/install.sh | bash -``` - -### Manual Installation - -1. Dependencies: - -```sh -fabric fabric-cli gnome-bluetooth-3.0 wf-recorder cliphist wl-clipboard grimblast power-profile-daemon adw-gtk-theme brightnessctl swww hypridle hyprlock imagemagick libnotify swappy libadwaita wlinhibit -``` - -```sh -python-materialyoucolor-git python-pillow python-toml python-ijson python-setproctitle python-requests python-numpy python-pywayland python-pyxdg -``` - -```sh -ttf-font-awesome otf-font-awesome ttf-material-symbols-variable-git ttf-google-sans ttf-opensans ttf-robot -``` - -- Icon: - -```sh -git clone https://github.com/vinceliuice/Tela-icon-theme -cd Tela-icon-theme -./install.sh nord -``` - -

Camera with Flash Screenshots

- - - - - - - - - - - - - - - - - - - -
- -

Rocket Todo

- -- [x] App Launcher -- [x] Power Menu -- [x] Wallpaper Picker -- [x] Todo -- [x] Bluetooth Manager -- [x] Clipboard Manager -- [x] OSD -- [x] Sh Runner -- [x] Emoji Picker -- [x] Update Screenshot -- [x] Installation script -- [x] Network Manager -- [x] Calendar -- [x] Power Manager -- [x] Dock -- [x] Web search in Launcher -- [ ] Notification Panel -- [ ] Improve CSS Styling -- [ ] Update Screenshot - -## Special Thanks - -A big thank you to the following people for their incredible help with code and creative ideas. Your help made a real difference! - -- [darsh](https://github.com/its-darsh): for creating Fabric, which made everything possible. -- [gummy bear album](https://github.com/muhchaudhary): for sharing fantastic code snippets that saved me time and effort. -- [axenide](https://github.com/Axenide): for the amazing config that not only inspired parts of mine but also provided some gems I couldn’t resist borrowing. -- [koeqaife](https://github.com/koeqaife): thanks for material-colors scripts - -I truly appreciate your support +# Comming Soon diff --git a/assets/logo.svg b/assets/logo.svg deleted file mode 100644 index 5d19dc1..0000000 --- a/assets/logo.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/screenshots/bluetooth.png b/assets/screenshots/bluetooth.png deleted file mode 100644 index 7033d48..0000000 Binary files a/assets/screenshots/bluetooth.png and /dev/null differ diff --git a/assets/screenshots/cliphist.png b/assets/screenshots/cliphist.png deleted file mode 100644 index 6e21db0..0000000 Binary files a/assets/screenshots/cliphist.png and /dev/null differ diff --git a/assets/screenshots/emoji.png b/assets/screenshots/emoji.png deleted file mode 100644 index 3202bf9..0000000 Binary files a/assets/screenshots/emoji.png and /dev/null differ diff --git a/assets/screenshots/home.png b/assets/screenshots/home.png deleted file mode 100644 index c7ba7c3..0000000 Binary files a/assets/screenshots/home.png and /dev/null differ diff --git a/assets/screenshots/notification.png b/assets/screenshots/notification.png deleted file mode 100644 index c8c9f30..0000000 Binary files a/assets/screenshots/notification.png and /dev/null differ diff --git a/assets/screenshots/osd.png b/assets/screenshots/osd.png deleted file mode 100644 index a02dec6..0000000 Binary files a/assets/screenshots/osd.png and /dev/null differ diff --git a/assets/screenshots/powermenu.png b/assets/screenshots/powermenu.png deleted file mode 100644 index 165fc0e..0000000 Binary files a/assets/screenshots/powermenu.png and /dev/null differ diff --git a/assets/screenshots/shrunner.png b/assets/screenshots/shrunner.png deleted file mode 100644 index 40f184b..0000000 Binary files a/assets/screenshots/shrunner.png and /dev/null differ diff --git a/assets/screenshots/todo.png b/assets/screenshots/todo.png deleted file mode 100644 index 0718afb..0000000 Binary files a/assets/screenshots/todo.png and /dev/null differ diff --git a/assets/screenshots/wallpaper.png b/assets/screenshots/wallpaper.png deleted file mode 100644 index 4e6789b..0000000 Binary files a/assets/screenshots/wallpaper.png and /dev/null differ diff --git a/assets/wallpaper/example-1.png b/assets/wallpaper/example-1.png deleted file mode 100644 index 137ceee..0000000 Binary files a/assets/wallpaper/example-1.png and /dev/null differ diff --git a/assets/wallpaper/example-2.png b/assets/wallpaper/example-2.png deleted file mode 100644 index 6d22cf9..0000000 Binary files a/assets/wallpaper/example-2.png and /dev/null differ diff --git a/assets/wallpaper/example-3.jpg b/assets/wallpaper/example-3.jpg deleted file mode 100755 index 5c2fd0d..0000000 Binary files a/assets/wallpaper/example-3.jpg and /dev/null differ diff --git a/assets/wallpaper/example-4.jpeg b/assets/wallpaper/example-4.jpeg deleted file mode 100644 index 7ca7ae6..0000000 Binary files a/assets/wallpaper/example-4.jpeg and /dev/null differ diff --git a/assets/wallpaper/example-5.jpg b/assets/wallpaper/example-5.jpg deleted file mode 100644 index eced4ab..0000000 Binary files a/assets/wallpaper/example-5.jpg and /dev/null differ diff --git a/config/__init__.py b/config/__init__.py deleted file mode 100644 index f94e240..0000000 --- a/config/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .data import * - -# Import only specific names actually defined in data.py -# This prevents circular imports by not importing everything - -from .data import APP_NAME, APP_NAME_CAP, CACHE_DIR, CONFIG_FILE, STATE_DIR diff --git a/config/assets/config.json b/config/assets/config.json deleted file mode 100644 index 2cdda50..0000000 --- a/config/assets/config.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "prefix_restart": "SHIFT ALT", - "suffix_restart": "T", - "prefix_bluetooth": "SHIFT ALT", - "suffix_bluetooth": "B", - "prefix_emoji": "SUPER", - "suffix_emoji": "E", - "prefix_cliphist": "SUPER", - "suffix_cliphist": "V", - "prefix_kanban": "SUPER", - "suffix_kanban": "T", - "prefix_toolbox": "SUPER", - "suffix_toolbox": "S", - "prefix_walls": "SUPER", - "suffix_walls": "W", - "prefix_launcher": "SUPER", - "suffix_launcher": "D", - "prefix_power": "SUPER", - "suffix_power": "X", - "prefix_tmux": "SUPER", - "suffix_tmux": "Z", - "prefix_notifications": "SUPER", - "suffix_notifications": "N", - "prefix_window_switcher": "ALT", - "suffix_window_switcher": "W", - "prefix_restart_inspector": "SUPER SHIFT", - "suffix_restart_inspector": "T", - "vertical": false, - "vertical_right_align": false, - "bottom_bar": true, - "centered_bar": true, - "terminal_command": "kitty -e", - "wallpapers_dir": "/home/sankalp/Pictures/wallpapers", - "dock_enabled": true, - "dock_always_occluded": false, - "dock_icon_size": 28, - "osd_enabled": true, - "bar_button_app_visible": true, - "bar_tray_visible": true, - "bar_workspaces_visible": true, - "bar_metrics_visible": true, - "bar_language_visible": true, - "bar_date_time_visible": true, - "bar_updates_visible": true, - "bar_indicators_visible": true, - "corners_visible": true, - "bar_metrics_disks": [ - "/" - ], - "metrics_visible": { - "cpu": true, - "ram": true, - "disk": true, - "swap": true, - "gpu": false - }, - "enabled_osd": true -} \ No newline at end of file diff --git a/config/config.py b/config/config.py index a1a1bd1..a10894e 100644 --- a/config/config.py +++ b/config/config.py @@ -37,9 +37,9 @@ try: except ImportError as e: print(f"Error importing data constants: {e}") # Provide fallback defaults if import fails - APP_NAME = "modus" - APP_NAME_CAP = "Modus" - CONFIG_DIR = "~/Modus" + APP_NAME = "modusv2" + APP_NAME_CAP = "Modusv2" + CONFIG_DIR = "~/Modusv2" HOME_DIR = "~" WALLPAPERS_DIR_DEFAULT = "~/Pictures/wallpapers" diff --git a/config/data.py b/config/data.py index 956159f..681e9d1 100644 --- a/config/data.py +++ b/config/data.py @@ -1,14 +1,14 @@ +from fabric.utils import get_relative_path +from gi.repository import Gdk, GLib import os import json import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gdk, GLib -from fabric.utils import get_relative_path -APP_NAME = "modus" -APP_NAME_CAP = "Modus" +APP_NAME = "modusv2" +APP_NAME_CAP = "Modusv2" CACHE_DIR = str(GLib.get_user_cache_dir()) + f"/{APP_NAME}" STATE_DIR = str(GLib.get_user_state_dir() + f"/{APP_NAME}") @@ -19,6 +19,7 @@ HOSTNAME = os.uname().nodename HOME_DIR = os.path.expanduser("~") CONFIG_DIR = os.path.expanduser(f"~/{APP_NAME}") +MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen") screen = Gdk.Screen.get_default() CURRENT_WIDTH = screen.get_width() @@ -31,19 +32,27 @@ if os.path.exists(CONFIG_FILE): with open(CONFIG_FILE, "r") as f: config = json.load(f) WALLPAPERS_DIR = config.get("wallpapers_dir", WALLPAPERS_DIR_DEFAULT) - VERTICAL = config.get("vertical", False) # Use saved value or False as default - VERTICAL_RIGHT_ALIGN = config.get("vertical_right_align", False) # Right side by default - BOTTOM_BAR = config.get("bottom_bar", False) # Use saved value or False as default - CENTERED_BAR = config.get("centered_bar", False) # Load centered bar setting + # Use saved value or False as default + VERTICAL = config.get("vertical", False) + VERTICAL_RIGHT_ALIGN = config.get( + "vertical_right_align", False + ) # Right side by default + # Use saved value or False as default + BOTTOM_BAR = config.get("bottom_bar", False) + # Load centered bar setting + CENTERED_BAR = config.get("centered_bar", False) TERMINAL_COMMAND = config.get( "terminal_command", "kitty -e" ) # Load terminal command - DOCK_ENABLED = config.get("dock_enabled", True) # Load dock visibility setting + # Load dock visibility setting + DOCK_ENABLED = config.get("dock_enabled", True) DOCK_ALWAYS_OCCLUDED = config.get( "dock_always_occluded", False ) # Load dock hover-only setting - DOCK_ICON_SIZE = config.get("dock_icon_size", 28) # Load dock icon size setting - OSD_ENABLED = config.get("osd_enabled", True) # Load OSD visibility setting + # Load dock icon size setting + DOCK_ICON_SIZE = config.get("dock_icon_size", 28) + # Load OSD visibility setting + OSD_ENABLED = config.get("osd_enabled", True) # Load bar component visibility settings BAR_COMPONENTS_VISIBILITY = { diff --git a/config/hypr/colors.conf b/config/hypr/colors.conf index e839fe7..1377374 100644 --- a/config/hypr/colors.conf +++ b/config/hypr/colors.conf @@ -1,11 +1,11 @@ -$wallpaper = /home/sankalp/Pictures/wallpapers/background.png -$background = 0e1514 -$foreground = dde4e2 +$wallpaper = /home/sankalp/Pictures/wallpapers/1.png +$background = 111318 +$foreground = e1e2e9 -$primary = 81d5ca -$secondary = b1ccc7 -$tertiary = aec9e6 -$surface = 0e1514 -$surface_bright = 343a39 -$outline = 899390 +$primary = a5c8ff +$secondary = bcc7dc +$tertiary = dabde2 +$surface = 111318 +$surface_bright = 37393e +$outline = 8d9199 $error = ff5945 diff --git a/config/hypr/modus.conf b/config/hypr/modus.conf deleted file mode 100644 index edd9ec3..0000000 --- a/config/hypr/modus.conf +++ /dev/null @@ -1,88 +0,0 @@ -# Generated by Modus - DO NOT EDIT MANUALLY -exec-once = python /home/sankalp/Modus/main.py -exec = pgrep -x "hypridle" > /dev/null || hypridle -exec = swww-daemon -exec-once = wl-paste --type text --watch cliphist store -exec-once = wl-paste --type image --watch cliphist store - -$fabricSend = fabric-cli exec modus -$scriptsDir = /home/sankalp/Modus/config/scripts/ - -# Key Bindings -bind = SHIFT ALT, T, exec, killall modus; python /home/sankalp/Modus/main.py # Reload Modus -bind = SHIFT ALT, B, exec, $fabricSend 'launcher.open("bluetooth")' # Bluetooth -bind = SUPER, N, exec, $fabricSend 'launcher.open("notification-center")' # Notifications -bind = SUPER, E, exec, $fabricSend 'launcher.open("emoji")' # Emoji -bind = SUPER, V, exec, $fabricSend 'launcher.open("cliphist")' # Clipboard History -bind = SUPER, T, exec, $fabricSend 'launcher.open("kanban")' # Kanban -bind = SUPER, Z, exec, $fabricSend 'launcher.open("tmux")' # Tmux Manager -bind = SUPER, S, exec, $fabricSend 'launcher.open("tools")' # Toolbox -bind = SUPER, W, exec, $fabricSend 'launcher.open("wallpapers")' # Wallpaper Selector -bind = SUPER, D, exec, $fabricSend 'launcher.open("launcher")' # App Launcher -bind = ALT, W, exec, $fabricSend 'launcher.open("window-switcher")' # Window Switcher -bind = SUPER, X, exec, $fabricSend 'launcher.open("power")' # Power Menu -bind = SUPER SHIFT, T, exec, killall modus; GTK_DEBUG=interactive python /home/sankalp/Modus/main.py # Restart with inspector - -# Wallpapers directory: /home/sankalp/Pictures/wallpapers - -# Source color scheme -source = /home/sankalp/Modus/config/hypr/colors.conf - -general { - col.active_border = 0xff$primary - col.inactive_border = 0xff$surface - gaps_in = 2 - gaps_out = 4 - border_size = 2 - layout = dwindle -} - -decoration { - blur { - enabled = yes - size = 5 - passes = 3 - new_optimizations = yes - contrast = 1 - brightness = 1 - } - rounding = 14 - shadow { - enabled = true - range = 10 - render_power = 2 - color = rgba(0, 0, 0, 0.25) - } -} - -animations { - enabled = true - # Animation curves - bezier = linear, 0, 0, 1, 1 - bezier = md3_standard, 0.2, 0, 0, 1 - bezier = md3_decel, 0.05, 0.7, 0.1, 1 - bezier = md3_accel, 0.3, 0, 0.8, 0.15 - bezier = overshot, 0.05, 0.9, 0.1, 1.1 - bezier = crazyshot, 0.1, 1.5, 0.76, 0.92 - bezier = hyprnostretch, 0.05, 0.9, 0.1, 1.0 - bezier = menu_decel, 0.1, 1, 0, 1 - bezier = menu_accel, 0.38, 0.04, 1, 0.07 - bezier = easeInOutCirc, 0.85, 0, 0.15, 1 - bezier = easeOutCirc, 0, 0.55, 0.45, 1 - bezier = easeOutExpo, 0.16, 1, 0.3, 1 - bezier = softAcDecel, 0.26, 0.26, 0.15, 1 - bezier = md2, 0.4, 0, 0.2, 1 - - animation = windows, 1, 3, md3_decel, popin 60% - animation = windowsIn, 1, 3, md3_decel, popin 60% - animation = windowsOut, 1, 3, md3_accel, popin 60% - animation = border, 1, 10, default - animation = fade, 1, 3, md3_decel - animation = layers, 1, 2, md3_decel, slide - animation = layersIn, 1, 3, menu_decel, slide - animation = layersOut, 1, 1.6, menu_accel, slide - animation = fadeLayersIn, 1, 2, menu_decel - animation = fadeLayersOut, 1, 4.5, menu_accel - animation = workspaces, 1, 7, menu_decel, slide - animation = specialWorkspace, 1, 3, md3_decel, slidevert -} diff --git a/config/scripts/capture.sh b/config/scripts/capture.sh index 8204108..465e038 100755 --- a/config/scripts/capture.sh +++ b/config/scripts/capture.sh @@ -1,3 +1,4 @@ + #!/bin/bash # Print usage information @@ -19,16 +20,28 @@ Modes: EOF } -# OCR functionality +# OCR functionality using hyprshot do_ocr() { sleep 0.25 - ocr_text=$(grimblast --freeze save area - | tesseract -l eng - - 2>/dev/null) + # Temporary file to store the snipped image + temp_ocr_image="$(mktemp --suffix=.png)" + hyprshot -s -z -m region -o "$(dirname "$temp_ocr_image")" -f "$(basename "$temp_ocr_image")" + + if [ ! -f "$temp_ocr_image" ]; then + notify-send -a "Modus" "OCR Failed" "No image captured" + exit 1 + fi + + # Perform OCR with tesseract + ocr_text=$(tesseract "$temp_ocr_image" - -l eng 2>/dev/null) + + rm -f "$temp_ocr_image" # Clean up temp file # Check if OCR was successful if [[ -n "$ocr_text" ]]; then echo -n "$ocr_text" | wl-copy - notify-send -a "Modus" "OCR Success" "Text Copied to Clipboard" + notify-send -a "Modus" "OCR Success" "Text copied to clipboard" else notify-send -a "Modus" "OCR Failed" "No text recognized or operation failed" fi @@ -36,10 +49,8 @@ do_ocr() { # Screen recording functionality do_screenrecord() { - if [ -z "$XDG_VIDEOS_DIR" ]; then XDG_VIDEOS_DIR="$HOME/Videos" - fi local save_dir="${XDG_VIDEOS_DIR:-$HOME/Videos}/Recordings" mkdir -p "$save_dir" @@ -49,8 +60,7 @@ do_screenrecord() { sleep 1 local last_video=$(ls -t "$save_dir"/*.mp4 2>/dev/null | head -n 1) - local action=$(notify-send -a "Modus" "⬜ Recording stopped" \ - -A "view=View" -A "open=Open folder") + local action=$(notify-send -a "Modus" "⬜ Recording stopped" -A "view=View" -A "open=Open folder") if [ "$action" = "view" ] && [ -n "$last_video" ]; then xdg-open "$last_video" @@ -81,10 +91,10 @@ do_screenshot() { local mockup_mode="$2" case $1 in - p) grimblast copysave screen "$full_path" ;; - s) grimblast copysave area "$full_path" ;; - sf) grimblast --freeze copysave area "$full_path" ;; - m) grimblast copysave output "$full_path" ;; + p) hyprshot -s -m output -o "$save_dir" -f "$save_file" ;; + s) hyprshot -s -m region -o "$save_dir" -f "$save_file" ;; + sf) hyprshot -s -z -m region -o "$save_dir" -f "$save_file" ;; + m) hyprshot -s -m output -m active -o "$save_dir" -f "$save_file" ;; *) echo "Invalid screenshot mode" print_usage @@ -93,30 +103,7 @@ do_screenshot() { esac if [ -f "$full_path" ]; then - if [ "$mockup_mode" = "mockup" ]; then - local temp_file="${full_path%.png}_temp.png" - cropped_file="${full_path%.png}_cropped.png" - local mockup_file="${full_path%.png}_mockup.png" - - # First crop the top pixel using a different approach - - # Use +0+1 to start at y-coordinate 1 (skipping the first pixel row) - - magick "$full_path" -crop +0+1 +repage "$cropped_file" - - magick "$cropped_file" \ - \( +clone -alpha extract -draw 'fill black polygon 0,0 0,20 20,0 fill white circle 20,20 20,0' \ - \( +clone -flip \) -compose Multiply -composite \ - \( +clone -flop \) -compose Multiply -composite \ - \) -alpha off -compose CopyOpacity -composite "$temp_file" - - magick "$temp_file" \ - \( +clone -background black -shadow 60x20+0+10 -alpha set -channel A -evaluate multiply 1 +channel \) \ - +swap -background none -layers merge +repage "$mockup_file" - - rm "$temp_file" "$cropped_file" - mv "$mockup_file" "$full_path" - + if [ "$mockup_mode" != "mockup" ]; then if command -v wl-copy >/dev/null 2>&1; then wl-copy <"$full_path" elif command -v xclip >/dev/null 2>&1; then @@ -124,6 +111,54 @@ do_screenshot() { fi fi + # Process as mockup if requested + if [ "$mockup_mode" = "mockup" ]; then + temp_file="${full_path%.png}_temp.png" + cropped_file="${full_path%.png}_cropped.png" + mockup_file="${full_path%.png}_mockup.png" + mockup_success=true + + # Crop top pixel + convert "$full_path" -crop +0+1 +repage "$cropped_file" || mockup_success=false + + # Rounded corners and transparency + if [ "$mockup_success" = true ]; then + convert "$cropped_file" \ + \( +clone -alpha extract -draw 'fill black polygon 0,0 0,20 20,0 fill white circle 20,20 20,0' \ + \( +clone -flip \) -compose Multiply -composite \ + \( +clone -flop \) -compose Multiply -composite \ + \) -alpha off -compose CopyOpacity -composite "$temp_file" || mockup_success=false + fi + + # Add shadow + if [ "$mockup_success" = true ]; then + convert "$temp_file" \ + \( +clone -background black -shadow 60x20+0+10 -alpha set -channel A -evaluate multiply 1 +channel \) \ + +swap -background none -layers merge +repage "$mockup_file" || mockup_success=false + fi + + # Finalize + if [ "$mockup_success" = true ] && [ -f "$mockup_file" ]; then + rm "$temp_file" "$cropped_file" + mv "$mockup_file" "$full_path" + if command -v wl-copy >/dev/null 2>&1; then + wl-copy <"$full_path" + elif command -v xclip >/dev/null 2>&1; then + xclip -selection clipboard -t image/png <"$full_path" + fi + else + echo "Warning: Mockup processing failed for $full_path." >&2 + rm -f "$temp_file" "$cropped_file" "$mockup_file" + if [ "$mockup_mode" = "mockup" ]; then + if command -v wl-copy >/dev/null 2>&1; then + wl-copy <"$full_path" + elif command -v xclip >/dev/null 2>&1; then + xclip -selection clipboard -t image/png <"$full_path" + fi + fi + fi + fi + local action=$(notify-send -a "Modus" -i "$full_path" "Screenshot saved" "in $full_path" \ -A "view=View" -A "edit=Edit" -A "open=Open Folder") diff --git a/install.sh b/install.sh deleted file mode 100644 index dc640c4..0000000 --- a/install.sh +++ /dev/null @@ -1,136 +0,0 @@ -#!/bin/bash - -set -e # Exit immediately if a command exits with a non-zero status -set -u # Treat unset variables as an error -set -o pipefail # Prevent errors in a pipeline from being masked - -REPO_URL="https://github.com/S4NKALP/Modus" -INSTALL_DIR="$HOME/Modus" - -PACKAGES=( - fabric-cli-git - gnome-bluetooth-3.0 - grimblast - hypridle - hyprlock - hyprpicker - imagemagick - libnotify - python-fabric-git - python-materialyoucolor-git - python-pillow - python-setproctitle - python-toml - python-requests - python-numpy - python-pywayland - python-pyxdg - python-ijson - sddm-theme-corners-git - swww - swappy - wl-clipboard - wf-recorder - libadwaita - acpi - adw-gtk-theme - brightnessctl - wlinhibit - power-profile-daemon - ttf-chakra-petch - ttf-tabler-icons -) - -# Ensure running on Arch Linux -if ! grep -q "arch" /etc/os-release; then - echo "This script is designed to run on Arch Linux." - exit 1 -fi - -# Prevent running as root -if [ "$(id -u)" -eq 0 ]; then - echo "Please do not run this script as root." - exit 1 -fi - -# Check for AUR helper (yay or paru) -aur_helper="" -if command -v yay &>/dev/null; then - aur_helper="yay" -elif command -v paru &>/dev/null; then - aur_helper="paru" -else - echo "Installing paru..." - tmpdir=$(mktemp -d) - git clone https://aur.archlinux.org/paru.git "$tmpdir/paru" - cd "$tmpdir/paru" - makepkg -si --noconfirm - cd - >/dev/null - rm -rf "$tmpdir" - aur_helper="paru" -fi - -# Clone or update the Modus repository -if [ -d "$INSTALL_DIR" ]; then - echo "Updating Modus..." - git -C "$INSTALL_DIR" pull -else - echo "Cloning Modus..." - git clone "$REPO_URL" "$INSTALL_DIR" -fi - -# Install required packages using the AUR helper -echo "Installing required packages..." -$aur_helper -Syy --needed --noconfirm "${PACKAGES[@]}" || true - -# Update outdated packages -echo "Checking for outdated packages..." -outdated=$($aur_helper -Qu | awk '{print $1}' || true) -to_update=() -for pkg in "${PACKAGES[@]}"; do - if echo "$outdated" | grep -q "^$pkg\$"; then - to_update+=("$pkg") - fi -done - -if [ ${#to_update[@]} -gt 0 ]; then - echo "Updating outdated packages..." - $aur_helper -S --noconfirm "${to_update[@]}" || true -else - echo "All required packages are up-to-date." -fi - -# Backup and replace GTK configurations -echo "Updating GTK configurations..." -mkdir -p "$HOME/.config" -[[ -d "$HOME/.config/gtk-3.0" ]] && mv "$HOME/.config/gtk-3.0" "$HOME/.config/gtk-3.0-bk-$(date +%s)" -[[ -d "$HOME/.config/gtk-4.0" ]] && mv "$HOME/.config/gtk-4.0" "$HOME/.config/gtk-4.0-bk-$(date +%s)" - -cp -r "$INSTALL_DIR/assets/gtk-3.0" "$HOME/.config/" || true -cp -r "$INSTALL_DIR/assets/gtk-4.0" "$HOME/.config/" || true - -# Install Icon Theme -echo "Installing Tela icon theme..." -tmpdir=$(mktemp -d) -git clone https://github.com/vinceliuice/Tela-icon-theme "$tmpdir/Tela-icon-theme" -cd "$tmpdir/Tela-icon-theme" -./install.sh nord -rm -rf "$tmpdir" - -# Install Vencord -echo "Installing Vencord..." -if command -v curl &>/dev/null; then - sh -c "$(curl -sS https://raw.githubusercontent.com/Vendicated/VencordInstaller/main/install.sh)" || true - mkdir -p "$HOME/.config/Vencord/settings/" - ln -sf "$HOME/.cache/material/material-discord.css" "$HOME/.config/Vencord/settings/quickCss.css" -else - echo "Skipping Vencord installation (curl not found)." -fi - -# Launch Modus -echo "Starting Modus..." -killall modus 2>/dev/null || true -python "$INSTALL_DIR/main.py" >/dev/null 2>&1 & -disown - -echo "Installation complete." diff --git a/main.py b/main.py new file mode 100644 index 0000000..24db785 --- /dev/null +++ b/main.py @@ -0,0 +1,55 @@ +from loguru import logger +import setproctitle +from fabric import Application +from fabric.utils import get_relative_path, exec_shell_command_async, monitor_file + +from config.data import APP_NAME, CACHE_DIR, CONFIG_FILE, DOCK_ICON_SIZE, APP_NAME_CAP +from modules.bar.bar import Bar +from modules.dock import Dock +from modules.corners import Corners + + + +# Disable unnecessary logging immediately +for log in [ + "fabric.hyprland.widgets", + "fabric.audio.service", + "fabric.bluetooth.service", + "fabric.widgets.wayland", + "fabric.utils.helpers", +]: + logger.disable(log) + +if __name__ == "__main__": + setproctitle.setproctitle(APP_NAME) + + # Load configuration + from config.data import load_config + config = load_config() + + bar = Bar() + dock = Dock() + corners = Corners() + + app = Application(f"{APP_NAME}", bar, dock) + + css_file = monitor_file(get_relative_path("styles")) + _ = css_file.connect("changed", lambda *_: set_css()) + + color_css_file = monitor_file(get_relative_path("./styles/colors.css")) + _ = color_css_file.connect("changed", lambda *_: set_css()) + + + def set_css(): + app.set_stylesheet_from_file( + get_relative_path("styles/main.css"), + exposed_functions={ + "dock_nmargin": lambda: f"margin-bottom: -{32 + DOCK_ICON_SIZE}px;", + "dock_sep": lambda: f"margin: 8px 0;", + }, + ) + app.set_css = set_css + + app.set_css() + + app.run() \ No newline at end of file diff --git a/modules/bar/bar.py b/modules/bar/bar.py index 61a2653..45b47fb 100644 --- a/modules/bar/bar.py +++ b/modules/bar/bar.py @@ -1,312 +1,166 @@ -from fabric.hyprland.widgets import Language, get_hyprland_connection -from fabric.hyprland.service import HyprlandEvent -from fabric.system_tray.widgets import SystemTray -from fabric.widgets.shapes import Corner from fabric.widgets.box import Box +from fabric.widgets.eventbox import EventBox +from fabric.widgets.datetime import DateTime from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox -from fabric.widgets.datetime import DateTime +from fabric.widgets.revealer import Revealer from fabric.widgets.label import Label +from fabric.system_tray.widgets import SystemTray from fabric.widgets.wayland import WaylandWindow as Window -from modules.bar.components import Metrics, workspace, SystemIndicators, UpdatesWidget -import config.data as data -from gi.repository import Gdk, GLib +from fabric.hyprland.widgets import ActiveWindow +from fabric.utils import truncate, FormattedString + +from modules.bar.components.workspaces import workspace +from modules.bar.components.language import LanguageWidget +from modules.bar.components.auto_hide import AutoHideBarController +from modules.corners import MyCorner +from modules.bar.components.power_menu import Power +from modules.bar.components.system_indicators import SystemIndicators +from modules.bar.components.metrics import Metrics import utils.icons as icons -import os -import json class Bar(Window): - def __init__(self, **kwargs): - super().__init__( - name="bar", - layer="top", - anchor="left top right" - if not data.VERTICAL and not data.BOTTOM_BAR - else "top right bottom" - if data.VERTICAL and data.VERTICAL_RIGHT_ALIGN - else "top left bottom" - if data.VERTICAL - else "bottom left right", - exclusivity="auto", - visible=True, - all_visible=True, - ) + """Main bar widget for the desktop environment.""" - self.launcher = kwargs.get("launcher", None) - self.component_visibility = data.BAR_COMPONENTS_VISIBILITY - - # Determine if we should use vertical layout for components - is_vertical_layout = data.VERTICAL - - self.workspaces = Button( - child=workspace, - name="workspaces", - ) - - self.connection = get_hyprland_connection() - self.tray = SystemTray( - name="tray", - spacing=4, - icon_size=16, - orientation="h" if not is_vertical_layout else "v", - ) - - self.date_time = Button( - name="datetime", - child=DateTime( - formatters=["%-I:%M:%p 󰧞 %a %d %b"] - if not is_vertical_layout - else ["%I\n%M\n%p"], - h_align="center" if not is_vertical_layout else "fill", - v_align="center", - ), - on_clicked=lambda *_: self.calendar(), - ) - self.date_time.connect("enter_notify_event", self.on_button_enter) - self.date_time.connect("leave_notify_event", self.on_button_leave) - - self.button_app = Button( - name="button-bar", - child=Label(name="button-bar-label", markup=icons.apps), - on_clicked=lambda *_: self.search_apps(), - ) - self.button_app.connect("enter_notify_event", self.on_button_enter) - self.button_app.connect("leave_notify_event", self.on_button_leave) - - self.updates = UpdatesWidget() - self.updates.connect("enter_notify_event", self.on_button_enter) - self.updates.connect("leave_notify_event", self.on_button_leave) - - self.lang_label = Label(name="lang-label") - self.language = Button( - name="language", h_align="center", v_align="center", child=self.lang_label - ) - self.on_language_switch() - self.connection.connect("event::activelayout", self.on_language_switch) - - self.metrics = Metrics() - self.indicators = SystemIndicators(launcher=self.launcher) - - self.applets = Box( - name="system-indicator", - spacing=4, - orientation="h" if not is_vertical_layout else "v", - children=[self.language, self.indicators], - ) - - # Apply visibility settings to components - self.apply_component_visibility() - - self.h_start_children = [self.button_app, self.updates] - - self.h_center_children = [ - self.date_time, - self.workspaces, - self.metrics, - self.applets, - ] - - self.h_end_children = [self.tray] - - self.v_start_children = [ - self.button_app, - self.updates, - self.workspaces, - self.metrics, - ] - self.v_end_children = [self.tray, self.applets, self.date_time] - - self.v_all_children = [] - self.v_all_children.extend(self.v_start_children) - self.v_all_children.extend(self.v_end_children) - - # Use centered layout when both vertical and centered_bar are enabled - is_centered_bar = is_vertical_layout and getattr(data, "CENTERED_BAR", False) - - self.bar = CenterBox( - name="bar", - orientation="h" if not is_vertical_layout else "v", - h_align="fill", - v_align="fill", - start_children=None - if is_centered_bar - else Box( - name="start-container", - spacing=4, - orientation="h" if not is_vertical_layout else "v", - children=self.h_start_children - if not is_vertical_layout - else self.v_start_children, - ), - center_children=Box( - orientation="v", - spacing=4, - children=self.v_all_children - if is_centered_bar - else Box( - name="center-container", - spacing=4, - orientation="h" if not is_vertical_layout else "v", - children=self.h_center_children if not is_vertical_layout else None, - ), - ) - if is_vertical_layout - else Box( - name="center-container", - spacing=4, - orientation="h" if not is_vertical_layout else "v", - children=self.h_center_children if not is_vertical_layout else None, - ), - end_children=None - if is_centered_bar - else Box( - name="end-container", - spacing=4, - orientation="h" if not is_vertical_layout else "v", - children=self.h_end_children - if not is_vertical_layout - else self.v_end_children, - ), - ) - - self.children = self.bar - - def apply_component_visibility(self): - """Apply saved visibility settings to all components""" - components = { - "metrics": self.metrics, - "indicators": self.applets, - "updates": self.updates, - "workspaces": self.workspaces, - "button_app": self.button_app, - # "button_tools": self.button_tools, - "language": self.language, - "date_time": self.date_time, - "tray": self.tray, - } - - for component_name, widget in components.items(): - if component_name in self.component_visibility: - widget.set_visible(self.component_visibility[component_name]) - - def toggle_component_visibility(self, component_name): - """Toggle visibility for a specific component""" - components = { - "metrics": self.metrics, - "indicators": self.applets, - "updates": self.updates, - "workspaces": self.workspaces, - "button_app": self.button_app, - # "button_tools": self.button_tools, - "language": self.language, - "date_time": self.date_time, - "tray": self.tray, - } - - if component_name in components and component_name in self.component_visibility: - # Toggle the visibility state - self.component_visibility[component_name] = not self.component_visibility[ - component_name - ] - # Apply the new state - components[component_name].set_visible( - self.component_visibility[component_name] - ) - - # Update the configuration - config_file = os.path.expanduser( - f"~/{data.APP_NAME}/config/assets/config.json" - ) - try: - if os.path.exists(config_file): - with open(config_file, "r") as f: - config = json.load(f) - - # Update the config with the new visibility state - config[f"bar_{component_name}_visible"] = self.component_visibility[ - component_name - ] - - # Write the updated config back to file - with open(config_file, "w") as f: - json.dump(config, f, indent=4) - except (json.JSONDecodeError, IOError) as e: - print(f"Error updating config file: {e}") - # Revert the visibility change if config update fails - self.component_visibility[ - component_name - ] = not self.component_visibility[component_name] - components[component_name].set_visible( - self.component_visibility[component_name] - ) - - return self.component_visibility[component_name] - - return None - - def on_button_enter(self, widget, event): - window = widget.get_window() - if window: - window.set_cursor(Gdk.Cursor(Gdk.CursorType.HAND2)) - - def on_button_leave(self, widget, event): - window = widget.get_window() - if window: - window.set_cursor(None) - - def search_apps(self): - self.launcher.open("launcher") - - def calendar(self): - self.launcher.open("calendar") - - def on_language_switch(self, _=None, event: HyprlandEvent = None): - lang = event.data[1] if event else Language().get_label() - self.language.set_tooltip_text(lang) - if not data.VERTICAL: - self.lang_label.set_label(lang[:2].lower()) - else: - self.lang_label.add_style_class("icon") - self.lang_label.set_markup(icons.keyboard) - - -class MyCorner(Box): - def __init__(self, position): - super().__init__( - name="corner-container", - children=Corner( - name="corner", - orientation=position, - size=20, - ), - ) - - -class Corners(Window): def __init__(self): super().__init__( layer="top", - anchor="top left bottom right", - pass_through=True, - child=Box( - orientation="vertical", - children=[ - Box( - children=[ - MyCorner("top-left"), - Box(h_expand=True), - MyCorner("top-right"), - ] - ), - Box(v_expand=True), - Box( - children=[ - MyCorner("bottom-left"), - Box(h_expand=True), - MyCorner("bottom-right"), - ] - ), - ], + anchor="top", + visible=True, + ) + + self.active_window = ActiveWindow( + name="active-window", + h_expand=True, + h_align="fill", + formatter=FormattedString( + f"{{'Desktop' if not win_class or win_class == 'unknown' else win_class}}", + truncate=truncate, ), ) + self.workspaces = Button( + name="workspaces", + child=workspace, + ) + + self.language = LanguageWidget() + self.date_time = DateTime(name="date-time", formatters=["%-I:%M:%p 󰧞 %a %d %b"]) + + # Tray setup + self._setup_tray() + + self.power_menu = Power() + self.system_indicators = SystemIndicators() + self.metrics = Metrics() + + # Main bar content + + self.bar = CenterBox( + name="bar", + # start_children=Box(spacing=4, orientation="h", children=self.workspaces), + center_children=Box( + spacing=4, + orientation="h", + children=[ + self.workspaces, + self.active_window, + self.date_time, + self.metrics, + self.system_indicators, + self.tray_button, + self.tray_revealer, + self.power_menu, + ], + ), + # end_children=Box( + # spacing=4, + # orientation="h", + # children=[ + # self.date_time, + # self.tray_button, + # self.tray_revealer, + # self.power_menu, + # ], + # ), + ) + + # Event handling and corners + self.eventbox = EventBox() + self.eventbox.add(self.bar) + + self.corner_left = Box( + name="bar-corner-left", + orientation="v", + h_align="start", + children=[ + MyCorner("top-right"), + Box(), + ], + ) + self.corner_left.set_margin_start(56) + + self.corner_right = Box( + name="bar-corner-right", + orientation="v", + h_align="end", + children=[ + MyCorner("top-left"), + Box(), + ], + ) + self.corner_right.set_margin_end(56) + self.main_box = Box( + name="bar-container", + orientation="h", + h_expand=True, + children=[ + self.corner_left, + self.eventbox, + self.corner_right, + ], + ) + + # Add hover activator + self.hover_activator = EventBox(name="hover-activator", v_align="start") + self.hover_activator.set_size_request(-1, 1) + + # Root container + self.root_box = Box( + orientation="v", children=[self.main_box, self.hover_activator] + ) + self.add(self.root_box) + + # Setup auto-hide controller + self.auto_hide = AutoHideBarController(self) + + # Connect event handlers for hover + self.eventbox.connect("enter-notify-event", self.auto_hide.on_bar_enter) + self.eventbox.connect("leave-notify-event", self.auto_hide.on_bar_leave) + self.hover_activator.connect( + "enter-notify-event", self.auto_hide.on_hover_enter + ) + + self.show_all() + + def _setup_tray(self): + """Setup the system tray with reveal functionality.""" + self.tray = SystemTray(name="tray", spacing=4, icon_size=16) + self.tray_revealer = Revealer( + name="tray-revealer", + transition_type="slide-left", + transition_duration=200, + child=self.tray, + reveal_child=False, + ) + self.tray_label = Label(markup=icons.chevron_left) + self.tray_button = EventBox(name="tray-button", child=self.tray_label) + self.tray_button.connect("button-press-event", self._toggle_tray) + + def _toggle_tray(self, *args): + """Toggle the tray visibility and update the icon.""" + is_revealed = not self.tray_revealer.get_reveal_child() + self.tray_revealer.set_reveal_child(is_revealed) + self.tray_label.set_markup( + icons.chevron_right if is_revealed else icons.chevron_left + ) diff --git a/modules/bar/components/__init__.py b/modules/bar/components/__init__.py index 161c235..cfde6ca 100644 --- a/modules/bar/components/__init__.py +++ b/modules/bar/components/__init__.py @@ -1,4 +1 @@ -from .metrics import Metrics -from .workspace import workspace -from .system_indicators import SystemIndicators -from .updates import UpdatesWidget +# Bar components package \ No newline at end of file diff --git a/modules/bar/components/auto_hide.py b/modules/bar/components/auto_hide.py new file mode 100644 index 0000000..22a5ad5 --- /dev/null +++ b/modules/bar/components/auto_hide.py @@ -0,0 +1,202 @@ +import json +from gi.repository import GLib, Gdk +from fabric.hyprland.widgets import get_hyprland_connection +from utils.occlusion import check_occlusion + + +class AutoHideBarController: + """Controller for bar auto-hide behavior.""" + + # Constants for configuration + OCCLUSION_CHECK_INTERVAL = 250 # ms + HIDE_DELAY = 300 # ms + + def __init__(self, bar): + self.bar = bar + self.is_hidden = False + self.hide_id = None + self.is_hovered = False + self.conn = get_hyprland_connection() + self.last_occlusion_state = False + + self._setup_event_handlers() + self._start_occlusion_checking() + + def _setup_event_handlers(self): + """Initialize event handlers for window management""" + # Initial state check + if self.conn.ready: + self.check_hide() + else: + self.conn.connect("event::ready", lambda *_: self.check_hide()) + + # Monitor window events + window_events = ("activewindow", "openwindow", "closewindow", "changefloatingmode") + for event in window_events: + self.conn.connect(f"event::{event}", lambda *_: self.check_hide()) + self.conn.connect("event::workspace", lambda *_: GLib.timeout_add(50, self.check_hide)) + + def _start_occlusion_checking(self): + """Start periodic occlusion check""" + GLib.timeout_add(self.OCCLUSION_CHECK_INTERVAL, self.check_occlusion_state) + + def _get_bar_height(self): + """Get the height of the bar with fallback""" + try: + height = self.bar.get_allocated_height() + return height if height > 0 else 30 + except Exception: + return 30 # Default fallback + + def _get_occlusion_region(self): + """Get the region to check for occlusion""" + return ("top", self._get_bar_height()) + + def check_occlusion_state(self): + """Periodic occlusion check""" + # Skip occlusion check if hovered + if self.is_hovered: + self.bar.main_box.remove_style_class("occluded") + return True + + # Check occlusion using the top region + is_occluded = check_occlusion(self._get_occlusion_region()) + + # Only apply changes if state has changed to avoid unnecessary CSS operations + if is_occluded != self.last_occlusion_state: + self.last_occlusion_state = is_occluded + if is_occluded: + self.bar.main_box.add_style_class("occluded") + else: + self.bar.main_box.remove_style_class("occluded") + if not self.is_hovered and self.is_hidden: + # Debounce showing the bar to prevent flickering + GLib.timeout_add(100, lambda: self.toggle_bar(not self._should_stay_hidden())) + + return True + + def _should_stay_hidden(self): + """Determine if the bar should stay hidden based on window state""" + clients = self.get_clients() + current_ws = self.get_workspace() + ws_clients = [w for w in clients if w["workspace"]["id"] == current_ws] + + # If we have normal windows and are not being hovered, stay hidden + return self._has_normal_windows(ws_clients) and not self.is_hovered + + def on_bar_enter(self, widget, event): + """Handle hover over bar content.""" + self.is_hovered = True + self.bar.main_box.remove_style_class("occluded") + + # Cancel any pending hide operations + self._cancel_hide_timer() + self.toggle_bar(show=True) + return True # Stop event propagation + + def on_bar_leave(self, widget, event): + """Handle leave from bar content.""" + # Only trigger if mouse actually left the entire bar area + if event.detail == Gdk.NotifyType.INFERIOR: + return False # Ignore child-to-child mouse movements + + self.is_hovered = False + + # Schedule hide if needed + if self._should_stay_hidden() or check_occlusion(self._get_occlusion_region()): + self.delay_hide() + + # Update occlusion state + if check_occlusion(self._get_occlusion_region()): + self.last_occlusion_state = True + self.bar.main_box.add_style_class("occluded") + + return True # Stop event propagation + + def on_hover_enter(self, *args): + """Handle hover over top activation area.""" + self.bar.main_box.remove_style_class("occluded") + self.toggle_bar(show=True) + return False + + def _cancel_hide_timer(self): + """Cancel any pending hide timer""" + if self.hide_id: + GLib.source_remove(self.hide_id) + self.hide_id = None + + def toggle_bar(self, show): + """Show or hide the bar immediately.""" + if show: + if self.is_hidden: + self.is_hidden = False + self.bar.main_box.remove_style_class("hide-bar") + self.bar.main_box.add_style_class("show-bar") + self._cancel_hide_timer() + else: + if not self.is_hidden: + self.is_hidden = True + self.bar.main_box.remove_style_class("show-bar") + self.bar.main_box.add_style_class("hide-bar") + + def delay_hide(self): + """Schedule hiding after short delay.""" + self._cancel_hide_timer() + self.hide_id = GLib.timeout_add(self.HIDE_DELAY, self.hide_bar) + + def hide_bar(self): + """Finalize hiding procedure.""" + if not self.is_hovered: + self.toggle_bar(show=False) + self.hide_id = None + return False + + def get_clients(self): + """Get current client list.""" + try: + return json.loads(self.conn.send_command("j/clients").reply.decode()) + except (json.JSONDecodeError, AttributeError): + return [] + + def get_workspace(self): + """Get current workspace ID.""" + try: + return json.loads( + self.conn.send_command("j/activeworkspace").reply.decode() + ).get("id", 0) + except (json.JSONDecodeError, AttributeError): + return 0 + + def _has_normal_windows(self, clients): + """Check if any non-floating, non-fullscreen windows exist""" + return any(not w.get("floating") and not w.get("fullscreen") for w in clients) + + def check_hide(self, *args): + """Determine if bar should auto-hide based on window state.""" + # Get window data + clients = self.get_clients() + current_ws = self.get_workspace() + ws_clients = [w for w in clients if w["workspace"]["id"] == current_ws] + + # Check for occlusion + is_occluded = check_occlusion(self._get_occlusion_region()) + self.last_occlusion_state = is_occluded + + # Determine visibility state + if not ws_clients: + # No windows in current workspace, show the bar + self.bar.main_box.remove_style_class("occluded") + self.toggle_bar(show=True) + elif self._has_normal_windows(ws_clients): + # Normal windows exist, handle occlusion and hide if not hovered + if is_occluded: + self.bar.main_box.add_style_class("occluded") + + if not self.is_hovered: + self.toggle_bar(show=False) + else: + # Only floating/fullscreen windows, show the bar + self.bar.main_box.remove_style_class("occluded") + self.toggle_bar(show=True) + + return False \ No newline at end of file diff --git a/modules/bar/components/language.py b/modules/bar/components/language.py new file mode 100644 index 0000000..858c0e2 --- /dev/null +++ b/modules/bar/components/language.py @@ -0,0 +1,24 @@ +from fabric.widgets.button import Button +from fabric.widgets.label import Label +from fabric.hyprland.widgets import Language, HyprlandEvent, get_hyprland_connection + + +class LanguageWidget(Button): + """Widget that displays the current keyboard layout.""" + + def __init__(self, **kwargs): + self.lang_label = Label(name="lang-label") + super().__init__( + name="language", h_align="center", v_align="center", + child=self.lang_label, **kwargs + ) + + self.conn = get_hyprland_connection() + self.update_language() + self.conn.connect("event::activelayout", self.update_language) + + def update_language(self, _=None, event: HyprlandEvent = None): + """Update the language indicator based on Hyprland events.""" + lang = event.data[1] if event else Language().get_label() + self.set_tooltip_text(lang) + self.lang_label.set_label(lang[:2].lower()) \ No newline at end of file diff --git a/modules/bar/components/metrics.py b/modules/bar/components/metrics.py index 52f9c64..685efa3 100644 --- a/modules/bar/components/metrics.py +++ b/modules/bar/components/metrics.py @@ -1,7 +1,7 @@ import subprocess import json import psutil -from gi.repository import GLib, Gdk +from gi.repository import GLib, Gdk, Gtk from fabric.widgets.label import Label from fabric.widgets.box import Box @@ -19,7 +19,6 @@ class MetricsProvider: self.mem = 0.0 self.swap = 0.0 self.disk = [] - self.gpu = [] self.bat_percent = 0.0 self.bat_charging = None @@ -34,8 +33,6 @@ class MetricsProvider: swap = psutil.swap_memory() self.swap = swap.percent self.disk = [psutil.disk_usage(path).percent for path in data.BAR_METRICS_DISKS] - info = self.get_gpu_info() - self.gpu = [int(v["gpu_util"][:-1]) for v in info] battery = psutil.sensors_battery() if battery is None: @@ -52,19 +49,12 @@ class MetricsProvider: def get_battery(self): return (self.bat_percent, self.bat_charging) - def get_gpu_info(self): - try: - return json.loads(subprocess.check_output(["nvtop", "-s"])) - except: - return [] - shared_provider = MetricsProvider() class SingularMetric: def __init__(self, id, name, icon): - self.is_vertical_layout = data.VERTICAL self.name_markup = name self.icon_markup = icon @@ -87,30 +77,28 @@ class SingularMetric: ) self.circle_button.add_events( - Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK + Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK | Gdk.EventMask.BUTTON_PRESS_MASK ) self.circle_button.connect("enter-notify-event", self.on_button_enter) self.circle_button.connect("leave-notify-event", self.on_button_leave) - - self.circle_button.connect("clicked", self.on_circle_clicked) + self.circle_button.connect("button-press-event", self.on_button_press) self.level = Label( name="metrics-level", style_classes=id, label="0%", - style="margin: 4px 0px;" if self.is_vertical_layout else "margin: 0 4px;", ) self.revealer = Revealer( name=f"metrics-{id}-revealer", transition_duration=250, - transition_type="slide-up" if self.is_vertical_layout else "slide-left", + transition_type="slide-left", child=self.level, child_revealed=False, ) self.box = Box( name=f"metrics-{id}-box", - orientation="v" if self.is_vertical_layout else "h", + orientation="h", spacing=0, children=[self.circle_button, self.revealer], ) @@ -119,12 +107,11 @@ class SingularMetric: self.box.connect("enter-notify-event", self.on_button_enter) self.box.connect("leave-notify-event", self.on_button_leave) - # Set initial tooltip - moved after box is created + # Set initial tooltip self.update_tooltip("0%") def update_tooltip(self, value_text): tooltip = f"{self.icon_markup} {self.name_markup}: {value_text}" - # self.box.set_tooltip_markup(tooltip) self.circle_button.set_tooltip_markup(tooltip) self.circle.set_tooltip_markup(tooltip) self.icon.set_tooltip_markup(tooltip) @@ -143,24 +130,31 @@ class SingularMetric: if window: window.set_cursor(None) - def on_circle_clicked(self, button): - # Toggle the revealed state of the label - current_state = self.revealer.get_child_revealed() - self.revealer.set_reveal_child(not current_state) + def on_button_press(self, widget, event): + if event.button == 1: # Left click + # Get the parent Metrics instance + parent = self.box.get_parent() + while parent and not isinstance(parent, Metrics): + parent = parent.get_parent() + if parent: + parent.toggle_all_metrics() + return True # Stop event propagation + elif event.button == 3: # Right click + # Toggle only this metric's revealer + current_state = self.revealer.get_child_revealed() + self.revealer.set_reveal_child(not current_state) + return True # Stop event propagation + return False class Metrics(Box): def __init__(self, **kwargs): - # Determine if we should use vertical layout for components - self.is_vertical_layout = data.VERTICAL - super().__init__( name="metrics", spacing=0, - orientation="h" if not self.is_vertical_layout else "v", + orientation="h", visible=True, all_visible=True, - style="padding:4px 0px" if self.is_vertical_layout else "padding: 0px 4px;", **kwargs, ) @@ -174,145 +168,103 @@ class Metrics(Box): self.batt_fabricator.changed.connect(self.update_battery) GLib.idle_add(self.update_battery, None, shared_provider.get_battery()) - visible = getattr( - data, - "METRICS_VISIBLE", - {"cpu": True, "ram": True, "disk": True, "gpu": False, "swap": True}, - ) - disks = ( - [ - SingularMetric( - "disk", - f"DISK ({path})" if len(data.BAR_METRICS_DISKS) != 1 else "DISK", - icons.disk, - ) - for path in data.BAR_METRICS_DISKS - ] - if visible.get("disk", True) - else [] - ) - gpu_info = shared_provider.get_gpu_info() - gpus = ( - [ - SingularMetric( - f"gpu", - f"GPU ({v['device_name']})" if len(gpu_info) != 1 else "GPU", - icons.gpu, - ) - for v in gpu_info - ] - if visible.get("gpu", True) - else [] - ) + # Initialize all metrics + disks = [ + SingularMetric( + "disk", + f"DISK ({path})" if len(data.BAR_METRICS_DISKS) != 1 else "DISK", + icons.disk, + ) + for path in data.BAR_METRICS_DISKS + ] - self.cpu = ( - SingularMetric("cpu", "CPU", icons.cpu) - if visible.get("cpu", True) - else None - ) - self.ram = ( - SingularMetric("ram", "RAM", icons.memory) - if visible.get("ram", True) - else None - ) - self.swap = ( - SingularMetric("swap", "SWAP", icons.swap) - if visible.get("swap", True) - else None - ) - self.bat = ( - SingularMetric("bat", "BATTERY", icons.battery) - if visible.get("bat", True) - else None - ) + self.ram = SingularMetric("ram", "RAM", icons.memory) + self.cpu = SingularMetric("cpu", "CPU", icons.cpu) + self.swap = SingularMetric("swap", "SWAP", icons.swap) + self.bat = SingularMetric("bat", "BATTERY", icons.battery) self.disk = disks - self.gpu = gpus - # Add only enabled metrics - for disk in self.disk: - self.add(disk.box) - self.add(Box(name="metrics-sep")) + # Create container for all metrics + self.metrics_container = Box(name="metrics-container", orientation="h", spacing=0) + self.add(self.metrics_container) + + # Create revealers for metrics + self.cpu_revealer = Revealer( + name="cpu-revealer", + transition_duration=250, + transition_type="slide-left", + child=self.cpu.box, + child_revealed=False, + ) + self.swap_revealer = Revealer( + name="swap-revealer", + transition_duration=250, + transition_type="slide-left", + child=self.swap.box, + child_revealed=False, + ) + self.disk_revealers = [ + Revealer( + name=f"disk-revealer-{i}", + transition_duration=250, + transition_type="slide-left", + child=disk.box, + child_revealed=False, + ) + for i, disk in enumerate(self.disk) + ] + + # Create permanent separator between memory and battery + self.memory_battery_sep = Box(name="metrics-sep") + + # Create container for revealed metrics + self.revealed_metrics = Box(name="revealed-metrics", orientation="h", spacing=0) + self.revealed_metrics.add(self.cpu_revealer) + for disk_revealer in self.disk_revealers: + self.revealed_metrics.add(disk_revealer) + self.revealed_metrics.add(self.swap_revealer) + + # Create container for memory and battery + self.memory_battery_container = Box(name="memory-battery-container", orientation="h", spacing=0) if self.ram: - self.add(self.ram.box) - self.add(Box(name="metrics-sep")) - if self.cpu: - self.add(self.cpu.box) - for gpu in self.gpu: - self.add(Box(name="metrics-sep")) - self.add(gpu.box) - if self.swap: - self.add(Box(name="metrics-sep")) - self.add(self.swap.box) + self.memory_battery_container.add(self.ram.box) + self.memory_battery_container.add(self.memory_battery_sep) if self.bat: - self.add(Box(name="metrics-sep")) - self.add(self.bat.box) + self.memory_battery_container.add(self.bat.box) - # Connect events directly to the button - self.connect("enter-notify-event", self.on_mouse_enter) - self.connect("leave-notify-event", self.on_mouse_leave) + # Add metrics in the correct order + self.metrics_container.add(self.revealed_metrics) + self.metrics_container.add(self.memory_battery_container) + + # Initially hide all non-visible metrics + self.cpu_revealer.set_reveal_child(False) + self.swap_revealer.set_reveal_child(False) + for disk_revealer in self.disk_revealers: + disk_revealer.set_reveal_child(False) + + self.showing_all_metrics = False GLib.timeout_add_seconds(1, self.update_metrics) - self.hide_timer = None - self.hover_counter = 0 + def toggle_all_metrics(self): + self.showing_all_metrics = not self.showing_all_metrics + + # Toggle all revealers + self.cpu_revealer.set_reveal_child(self.showing_all_metrics) + self.swap_revealer.set_reveal_child(self.showing_all_metrics) + for disk_revealer in self.disk_revealers: + disk_revealer.set_reveal_child(self.showing_all_metrics) + + # Toggle spacing for revealed metrics and memory-battery container + spacing = 6 if self.showing_all_metrics else 0 + self.revealed_metrics.set_spacing(spacing) + self.metrics_container.set_spacing(spacing) def _format_percentage(self, value: int) -> str: return f"{value}%" - def on_mouse_enter(self, widget, event): - window = widget.get_window() - if window: - window.set_cursor(Gdk.Cursor(Gdk.CursorType.HAND2)) - - if not data.VERTICAL: - self.hover_counter += 1 - if self.hide_timer is not None: - GLib.source_remove(self.hide_timer) - self.hide_timer = None - if self.cpu: - self.cpu.revealer.set_reveal_child(True) - if self.ram: - self.ram.revealer.set_reveal_child(True) - for disk in self.disk: - disk.revealer.set_reveal_child(True) - for gpu in self.gpu: - gpu.revealer.set_reveal_child(True) - if self.swap: - self.swap.revealer.set_reveal_child(True) - if self.bat: - self.bat.revealer.set_reveal_child(True) - return False - - def on_mouse_leave(self, widget, event): - window = widget.get_window() - if window: - window.set_cursor(None) - - if not data.VERTICAL: - if self.hover_counter > 0: - self.hover_counter -= 1 - if self.hover_counter == 0: - if self.hide_timer is not None: - GLib.source_remove(self.hide_timer) - self.hide_timer = GLib.timeout_add(500, self.hide_revealer) - return False - - def hide_revealer(self): - if not data.VERTICAL: - if self.cpu: - self.cpu.revealer.set_reveal_child(False) - if self.ram: - self.ram.revealer.set_reveal_child(False) - for disk in self.disk: - disk.revealer.set_reveal_child(False) - for gpu in self.gpu: - gpu.revealer.set_reveal_child(False) - self.hide_timer = None - return False - def update_metrics(self): cpu, mem, swap, disk = shared_provider.get_metrics() - gpu = shared_provider.gpu if self.cpu: self.cpu.circle.set_value(cpu / 100.0) value_text = self._format_percentage(int(cpu)) @@ -329,12 +281,6 @@ class Metrics(Box): disk_metric.circle.set_value(disk[i] / 100.0) disk_metric.level.set_label(value_text) disk_metric.update_tooltip(value_text) - for i, gpu_metric in enumerate(self.gpu): - if i < len(gpu): - value_text = self._format_percentage(int(gpu[i])) - gpu_metric.circle.set_value(gpu[i] / 100.0) - gpu_metric.level.set_label(value_text) - gpu_metric.update_tooltip(value_text) if self.swap: self.swap.circle.set_value(swap / 100.0) value_text = self._format_percentage(int(swap)) @@ -354,8 +300,6 @@ class Metrics(Box): if self.bat: tooltip_metrics.append(self.bat) if self.ram: tooltip_metrics.append(self.ram) if self.cpu: tooltip_metrics.append(self.cpu) - if self.gpu: tooltip_metrics.extend(self.gpu) - self.set_tooltip_markup((" - " if not data.VERTICAL else "\n").join([v.markup() for v in tooltip_metrics])) return True @@ -364,13 +308,10 @@ class Metrics(Box): return value, charging = battery_data - if value == 0: - if self.bat: - self.bat.box.set_visible(False) - else: - if self.bat: - self.bat.box.set_visible(True) - self.bat.circle.set_value(value / 100) + # Always show battery, even if value is 0 + if self.bat: + self.bat.box.set_visible(True) + self.bat.circle.set_value(value / 100) percentage = int(value) self.bat.level.set_label(self._format_percentage(percentage)) diff --git a/modules/bar/components/power_menu.py b/modules/bar/components/power_menu.py new file mode 100644 index 0000000..fe0cd79 --- /dev/null +++ b/modules/bar/components/power_menu.py @@ -0,0 +1,180 @@ +from fabric.widgets.button import Button +from fabric.widgets.box import Box +from fabric.widgets.label import Label +from fabric.widgets.stack import Stack +from fabric.widgets.revealer import Revealer +from fabric.utils import exec_shell_command_async +from gi.repository import GLib, Gdk, Gtk +import utils.icons as icons + + +class Power(Button): + TRANSITION_TYPE = "slide-up-down" + TRANSITION_DURATION = 300 # milliseconds + LABEL_TRANSITION_TYPE = "slide-left" + LONG_PRESS_DURATION = 500 # milliseconds + SCROLL_COOLDOWN = 100 # milliseconds + + def __init__(self): + self.current_action = "shutdown" + self.is_locked = True + self.long_press_timeout = None + self.scroll_timeout = None + self.just_unlocked = False + + # Initialize stacks for icons and labels + for name in ["icon_stack", "label_stack"]: + setattr( + self, + name, + Stack( + transition_type=self.TRANSITION_TYPE, + transition_duration=self.TRANSITION_DURATION, + ), + ) + + # Define power actions + self.power_actions = { + "shutdown": { + "icon": icons.shutdown, + "label": "Shutdown", + "command": "systemctl poweroff" + }, + "reboot": { + "icon": icons.reboot, + "label": "Reboot", + "command": "systemctl reboot" + }, + "suspend": { + "icon": icons.suspend, + "label": "Suspend", + "command": "systemctl suspend" + }, + "logout": { + "icon": icons.logout, + "label": "Logout", + "command": "hyprctl dispatch exit" + }, + "lock": { + "icon": icons.lock, + "label": "Lock", + "command": "hyprlock --immediate" + } + } + + # Add all actions to stacks + for action, data in self.power_actions.items(): + self.icon_stack.add_named( + Label( + markup=data["icon"], + style_classes="powericon", + ), + name=action, + ) + self.label_stack.add_named( + Label( + label=data["label"], + style_classes="powerlabel" + ), + name=action, + ) + + self.revealer = Revealer( + transition_type=self.LABEL_TRANSITION_TYPE, + transition_duration=self.TRANSITION_DURATION, + child=self.label_stack, + ) + + super().__init__( + name="power-menu", + on_enter_notify_event=lambda *args: self.revealer.set_reveal_child(True), + on_leave_notify_event=self.on_leave, + on_pressed=self.on_pressed, + on_released=self.on_released, + on_scroll_event=self.on_scroll, + child=Box( + children=[ + self.icon_stack, + self.revealer, + ] + ), + ) + + self.add_events( + Gdk.EventMask.SCROLL_MASK | + Gdk.EventMask.BUTTON_PRESS_MASK | + Gdk.EventMask.SMOOTH_SCROLL_MASK + ) + self.add_style_class("locked") + + # Add cursor change on hover + self.connect( + "state-flags-changed", + lambda btn, *_: ( + btn.set_cursor("pointer") + if btn.get_state_flags() & Gtk.StateFlags.PRELIGHT # type: ignore + else btn.set_cursor("default"), + ), + ) + + def on_pressed(self, widget, event=None): + if self.is_locked: + self.long_press_timeout = GLib.timeout_add( + self.LONG_PRESS_DURATION, + self.on_long_press + ) + + def on_released(self, widget, event=None): + timeout = self.long_press_timeout + self.long_press_timeout = None + if timeout: + GLib.source_remove(timeout) + + if not self.is_locked and not self.just_unlocked: + exec_shell_command_async(self.power_actions[self.current_action]["command"]) + self.is_locked = True + self.add_style_class("locked") + + self.just_unlocked = False + + def on_long_press(self): + if self.is_locked: + self.is_locked = False + self.just_unlocked = True + self.remove_style_class("locked") + self.long_press_timeout = None + return False + + def _change_action(self, direction): + if self.is_locked: + return + + actions = list(self.power_actions.keys()) + current_index = actions.index(self.current_action) + new_index = (current_index + direction) % len(actions) + + self.current_action = actions[new_index] + self.icon_stack.set_visible_child_name(self.current_action) + self.label_stack.set_visible_child_name(self.current_action) + + def on_scroll(self, widget, event): + if self.is_locked: + return True + + if event.direction == Gdk.ScrollDirection.SMOOTH: + if event.delta_y < -0.1: + self._change_action(-1) + elif event.delta_y > 0.1: + self._change_action(1) + elif event.direction == Gdk.ScrollDirection.UP: + self._change_action(-1) + elif event.direction == Gdk.ScrollDirection.DOWN: + self._change_action(1) + + self.scroll_timeout = GLib.timeout_add(self.SCROLL_COOLDOWN, lambda: None) + return True + def on_leave(self, widget, event=None): + self.revealer.set_reveal_child(False) + if not self.is_locked: + self.is_locked = True + self.add_style_class("locked") diff --git a/modules/bar/components/system_indicators.py b/modules/bar/components/system_indicators.py index 7d9b453..51fc528 100644 --- a/modules/bar/components/system_indicators.py +++ b/modules/bar/components/system_indicators.py @@ -1,62 +1,90 @@ import subprocess +from gi.repository import Gtk, Gdk from fabric import Fabricator from fabric.widgets.label import Label from fabric.widgets.box import Box -from fabric.widgets.button import Button +from fabric.widgets.revealer import Revealer from fabric.bluetooth import BluetoothClient -from services import network_client, audio, notification_service +from services import network_client, audio import utils.icons as icons -import config.data as data +from fabric.hyprland.widgets import Language, HyprlandEvent, get_hyprland_connection class SystemIndicators(Box): def __init__(self, **kwargs): - # Determine if we should use vertical layout for components - is_vertical_layout = data.VERTICAL - super().__init__( - orientation="h" if not is_vertical_layout else "v", spacing=0, **kwargs + orientation="h", spacing=2, name="system-indicator", **kwargs ) - self.launcher = kwargs.get("launcher", None) self.bluetooth_icon = Label(name="system-indicator-icon") self.wifi_icon = Label(name="system-indicator-icon") self.volume_icon_button = Label(name="system-indicator-icon") - self.microphone_icon = Label(name="system-indicator-icon") + self.language_icon = Label(name="lang-label") self.idle_label = Label(name="system-indicator-icon") self.night_label = Label(name="system-indicator-icon") self.power_label = Label(name="system-indicator-icon") - self.notification_icon = Button(name="system-indicator-icon") - self.notification_label = Label(name="system-notif-label") - self.notification_icon.add(self.notification_label) - self.notification_icon.connect("clicked", self.open_notif_center) - for widget in [ - self.bluetooth_icon, - self.wifi_icon, - self.volume_icon_button, - self.microphone_icon, - self.idle_label, - self.night_label, - self.power_label, - self.notification_icon, - ]: - self.add(widget) + # Create event box to wrap indicators + self.event_box = Gtk.EventBox() + self.indicators_box = Box(name="indicators-container", orientation="h", spacing=2) + self.event_box.add(self.indicators_box) + + # Create revealers for additional indicators + self.volume_revealer = Revealer( + name="volume-revealer", + transition_duration=250, + transition_type="slide-left", + child=self.volume_icon_button, + child_revealed=False, + ) + self.idle_revealer = Revealer( + name="idle-revealer", + transition_duration=250, + transition_type="slide-left", + child=self.idle_label, + child_revealed=False, + ) + self.night_revealer = Revealer( + name="night-revealer", + transition_duration=250, + transition_type="slide-left", + child=self.night_label, + child_revealed=False, + ) + self.power_revealer = Revealer( + name="power-revealer", + transition_duration=250, + transition_type="slide-left", + child=self.power_label, + child_revealed=False, + ) + + # Add all indicators with revealers + self.indicators_box.add(self.language_icon) + self.indicators_box.add(self.wifi_icon) + self.indicators_box.add(self.bluetooth_icon) + self.indicators_box.add(self.volume_revealer) + self.indicators_box.add(self.idle_revealer) + self.indicators_box.add(self.night_revealer) + self.indicators_box.add(self.power_revealer) + + # Add event box to main container + self.add(self.event_box) + + # Connect click events + self.event_box.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + self.event_box.connect("button-press-event", self.on_button_press) self.audio_service = audio self.bluetooth_client = BluetoothClient() self.network_client = network_client - self.notification_service = notification_service + self.hyprland = get_hyprland_connection() # Connect signals self.bluetooth_client.connect("changed", self.update_bluetooth_status) - self.audio_service.connect("microphone_changed", self.update_mic_status) self.audio_service.connect("changed", self.update_volume_status) self.network_client.connect("device_ready", self.update_network_status) - self.notification_service.connect("dnd", self.update_notification_status) - self.notification_service.connect( - "notification_count", self.update_notification_status - ) + self.hyprland.connect("event::activelayout", self.update_language) if self.network_client.wifi_device: self.network_client.wifi_device.connect( @@ -70,14 +98,59 @@ class SystemIndicators(Box): Fabricator(interval=1000, poll_from=self.update_all_statuses) + # Initial updates self.update_bluetooth_status() - self.update_notification_status() + self.update_language() + self.update_idle_night_status() + self.showing_all_indicators = False + + def on_button_press(self, widget, event): + if event.button == 3: # Right click + self.toggle_labels() + return True # Stop event propagation + elif event.button == 1: # Left click + self.toggle_all_indicators() + return True # Stop event propagation + return False + + def toggle_all_indicators(self): + self.showing_all_indicators = not self.showing_all_indicators + + # Toggle revealers + self.volume_revealer.set_reveal_child(self.showing_all_indicators) + self.idle_revealer.set_reveal_child(self.showing_all_indicators) + self.night_revealer.set_reveal_child(self.showing_all_indicators) + self.power_revealer.set_reveal_child(self.showing_all_indicators) + + def toggle_labels(self): + # Toggle tooltips for all visible indicators + for child in self.indicators_box.get_children(): + if isinstance(child, Label): + current_tooltip = child.get_tooltip_text() + if current_tooltip: + child.set_tooltip_text("") + else: + # Restore original tooltip based on the indicator type + if child == self.wifi_icon: + self.update_network_status() + elif child == self.bluetooth_icon: + self.update_bluetooth_status() + elif child == self.volume_icon_button: + self.update_volume_status() + elif child == self.language_icon: + self.update_language() def update_all_statuses(self, *_args): self.update_idle_night_status() self.update_power_profile() self.update_network_status() + def update_language(self, _=None, event: HyprlandEvent = None): + """Update the language indicator based on Hyprland events.""" + lang = event.data[1] if event else Language().get_label() + self.language_icon.set_tooltip_text(lang) + self.language_icon.set_label(lang[:2].lower()) + def update_idle_night_status(self, *_): # Update idle status has_idle = ( @@ -127,26 +200,6 @@ class SystemIndicators(Box): tooltip_text = "Muted" if is_muted else f"Volume: {volume_level}%" self.volume_icon_button.set_tooltip_text(tooltip_text) - def update_mic_status(self, *_): - mic = self.audio_service.microphone - if not mic: - self.microphone_icon.set_visible(False) - return - - self.microphone_icon.set_visible(True) - # Normalize volume to integer percentage (0-100) - volume_level = mic.volume - if volume_level > 1: - volume_level = min(int(volume_level), 100) - else: - volume_level = int(volume_level * 100) - - is_muted = mic.muted - icon = icons.mic_muted if is_muted else icons.mic - - self.microphone_icon.set_markup(icon) - tooltip_text = "Muted" if is_muted else f"Microphone: {volume_level}%" - self.microphone_icon.set_tooltip_text(tooltip_text) def update_bluetooth_status(self, *_): if self.bluetooth_client.enabled: @@ -236,23 +289,3 @@ class SystemIndicators(Box): self.power_label.set_visible(False) self.power_label.set_tooltip_text(f"Error fetching power profile: {e}") - def update_notification_status(self, *_): - """Update the notification icon based on DND state and notification count.""" - count = self.notification_service.count - - if count == 0 and not self.notification_service.dont_disturb: - self.notification_icon.set_visible(False) - return - - if self.notification_service.dont_disturb: - self.notification_label.set_markup(icons.notifications_off) - self.notification_icon.set_tooltip_text("Do Not Disturb: On") - self.notification_icon.set_visible(True) - else: - # At this point count must be > 0 - self.notification_label.set_markup(icons.notifications_clear) - self.notification_icon.set_tooltip_text(f"Notifications: {count}") - self.notification_icon.set_visible(True) - - def open_notif_center(self, *_): - self.launcher.open("notification-center") diff --git a/modules/bar/components/updates.py b/modules/bar/components/updates.py deleted file mode 100644 index f43add2..0000000 --- a/modules/bar/components/updates.py +++ /dev/null @@ -1,115 +0,0 @@ -import json -from fabric.utils import exec_shell_command_async, get_relative_path, invoke_repeater -from fabric.widgets.box import Box -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.widgets.stack import Stack -from gi.repository import Gtk -import utils.icons as icons -import config.data as data - - -class UpdatesWidget(Button): - """A widget to display the number of available updates.""" - - def __init__( - self, - **kwargs, - ): - # Determine if we should use vertical layout for components - is_vertical_layout = data.VERTICAL - - # Initialize the button with specific name and style - super().__init__( - name="update", - orientation="h" if not is_vertical_layout else "v", - **kwargs, - ) - - self.script_file = get_relative_path("../../../config/scripts/systemupdates.sh") - - self.update_level_label = Label( - name="update-label", - label="0", - visible=False, # Initially hidden - ) - - self.update_icon = Label(name="update-icon", markup=icons.update) - self.updated_icon = Label(name="updated-icon", markup=icons.updated) - - # Create boxes with proper orientation - self.update_box = CenterBox( - orientation="v" if is_vertical_layout else "h", - center_children=[self.update_icon, self.update_level_label], - ) - - self.updated_box = CenterBox( - orientation="v" if is_vertical_layout else "h", - center_children=[self.updated_icon], - ) - - # Stack to switch between states - self.stack = Stack() - self.stack.add_named(self.update_box, "update_box") - self.stack.add_named(self.updated_box, "updated_box") - self.stack.set_visible_child_name("update_box") - - self.children = Box( - orientation="v" if is_vertical_layout else "h", - children=self.stack, - ) - - # Configure stack transitions - self.stack.set_homogeneous(False) - self.stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) - - # Connect click handler - self.connect("button-press-event", self.on_button_press) - - # Check for updates every minute - invoke_repeater(60000, self.update, initial_call=True) - - def update_values(self, value: str) -> bool: - """Update the widget state based on update check results""" - try: - value = json.loads(value) - except json.JSONDecodeError: - return False - - update_count = str(value["total"]) - self.update_level_label.set_label(update_count) - - # Show appropriate state based on update count - if update_count == "0": - self.stack.set_visible_child_name("updated_box") - self.update_level_label.set_visible(False) - else: - self.stack.set_visible_child_name("update_box") - self.update_level_label.set_visible(True) - - self.set_tooltip_text(value.get("tooltip", "")) - return True - - def on_button_press(self, _, event) -> bool: - """Handle button press events""" - if event.button == 1: # Left click - # Run update command - exec_shell_command_async( - f"{self.script_file} -arch -up", - lambda _: None, - ) - self.update() - return True - else: - # Other clicks just refresh - self.update() - - def update(self) -> bool: - """Check for system updates""" - exec_shell_command_async( - f"{self.script_file} -arch", - lambda output: self.update_values(output), - ) - - return True diff --git a/modules/bar/components/workspace.py b/modules/bar/components/workspace.py deleted file mode 100644 index 072364b..0000000 --- a/modules/bar/components/workspace.py +++ /dev/null @@ -1,30 +0,0 @@ -import json - -from fabric.hyprland.service import Hyprland -from fabric.widgets.label import Label -import config.data as data - -connection = Hyprland() - -workspaceData = connection.send_command("j/activeworkspace").reply -activeWorkspace = json.loads(workspaceData.decode("utf-8"))["name"] - -# Create the workspace label with proper initialization -if not data.VERTICAL: - workspace = Label(label=f"Workspace {activeWorkspace}", name="workspace-name") -else: - workspace = Label( - v_align="center", - label="W\no\nr\nk\ns\np\na\nc\ne\n\n" + activeWorkspace, - name="workspace-name", - ) - -def on_workspace(obj, signal): - global activeWorkspace - activeWorkspace = json.loads(signal.data[0]) - if not data.VERTICAL: - workspace.set_label(f"Workspace {activeWorkspace}") - else: - workspace.set_label(f"W\no\nr\nk\ns\np\na\nc\ne\n\n{activeWorkspace}") - -connection.connect("event::workspace", on_workspace) diff --git a/modules/bar/components/workspaces.py b/modules/bar/components/workspaces.py new file mode 100644 index 0000000..a0d8df2 --- /dev/null +++ b/modules/bar/components/workspaces.py @@ -0,0 +1,19 @@ +import json + +from fabric.hyprland.service import Hyprland +from fabric.widgets.label import Label + +connection = Hyprland() + +workspaceData = connection.send_command("j/activeworkspace").reply +activeWorkspace = json.loads(workspaceData.decode("utf-8"))["name"] +workspace = Label(label=f"{activeWorkspace}") + + +def on_workspace(obj, signal): + global activeWorkspace + activeWorkspace = json.loads(signal.data[0]) + workspace.set_label(f"{activeWorkspace}") + + +connection.connect("event::workspace", on_workspace) diff --git a/modules/corners.py b/modules/corners.py new file mode 100644 index 0000000..0df401d --- /dev/null +++ b/modules/corners.py @@ -0,0 +1,62 @@ +from fabric.widgets.box import Box +from fabric.widgets.wayland import WaylandWindow as Window +from fabric.widgets.shapes import Corner + +class MyCorner(Box): + def __init__(self, corner): + super().__init__( + name="corner-container", + children=Corner( + name="corner", + orientation=corner, + size=20, + ), + ) + +class Corners(Window): + def __init__(self): + super().__init__( + name="corners", + layer="top", + anchor="top bottom left right", + exclusivity="normal", + pass_through=True, + visible=False, + all_visible=False, + ) + + self.all_corners = Box( + name="all-corners", + orientation="v", + h_expand=True, + v_expand=True, + h_align="fill", + v_align="fill", + children=[ + Box( + name="top-corners", + orientation="h", + h_align="fill", + children=[ + MyCorner("top-left"), + Box(h_expand=True), + MyCorner("top-right"), + ], + ), + Box(v_expand=True), + Box( + name="bottom-corners", + orientation="h", + h_align="fill", + children=[ + MyCorner("bottom-left"), + Box(h_expand=True), + MyCorner("bottom-right"), + ], + ), + ], + ) + + self.add(self.all_corners) + + self.show_all() \ No newline at end of file diff --git a/modules/dock.py b/modules/dock.py index fb89eb7..c638174 100644 --- a/modules/dock.py +++ b/modules/dock.py @@ -9,21 +9,52 @@ from fabric.widgets.image import Image from fabric.widgets.eventbox import EventBox from fabric.widgets.wayland import WaylandWindow as Window from fabric.hyprland.widgets import get_hyprland_connection -from fabric.utils import ( - exec_shell_command, - exec_shell_command_async, - idle_add, - remove_handler, - get_relative_path, -) +from fabric.utils import exec_shell_command, exec_shell_command_async, idle_add, remove_handler, get_relative_path from fabric.utils.helpers import get_desktop_applications -from modules.bar.bar import MyCorner # Add this import for corners +from modules.corners import MyCorner # Add this import for corners import config.data as data import logging OCCLUSION = 36 + data.DOCK_ICON_SIZE +def read_config(): + """Read and return the full configuration from the JSON file, handling missing file.""" + config_path = get_relative_path("../config/dock.json") + try: + with open(config_path, "r") as file: + data = json.load(file) + + # Migration: Convert old format (simple string array) to new format (array of objects with full app data) + if "pinned_apps" in data and data["pinned_apps"] and isinstance(data["pinned_apps"][0], str): + # Get all desktop apps for lookup during migration + all_apps = get_desktop_applications() + app_map = {app.name: app for app in all_apps if app.name} + + # This is the old format - convert it with full app data + old_pinned = data["pinned_apps"] + data["pinned_apps"] = [] + + for app_id in old_pinned: + # Try to find the app by name + app = app_map.get(app_id) + if app: + # Create comprehensive app data + app_data = { + "name": app.name, + "display_name": app.display_name, + "window_class": app.window_class, + "executable": app.executable, + "command_line": app.command_line + } + data["pinned_apps"].append(app_data) + else: + # Fallback to just the name if app not found + data["pinned_apps"].append({"name": app_id}) + + except (FileNotFoundError, json.JSONDecodeError): + data = {"pinned_apps": []} # Default to empty pinned apps + return data # Credit to Aylur for the createSurfaceFromWidget code def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface: @@ -40,39 +71,30 @@ def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface: widget.draw(cr) return surface - class Dock(Window): # Static registry to track all active dock instances _instances = [] - + def __init__(self, **kwargs): super().__init__( name="dock-window", layer="top", - anchor="bottom center" - if not data.VERTICAL and not data.BOTTOM_BAR - else "left center" - if data.VERTICAL_RIGHT_ALIGN - else "right center", - margin="-8px 0 -8px 0" - if not data.VERTICAL and not data.BOTTOM_BAR - else "-8px 0 -80px 0" - if data.VERTICAL_RIGHT_ALIGN - else "0 -80px 0 -8px", + anchor="bottom center", + margin="-8px 0 -8px 0", exclusivity="none", **kwargs, ) # Add this instance to the registry Dock._instances.append(self) - + # Initialize configuration - self.config = data.read_config() + self.config = read_config() self.conn = get_hyprland_connection() self.icon = IconResolver() self.pinned = self.config.get("pinned_apps", []) - self.config_path = get_relative_path("../config/assets/modus.json") + self.config_path = get_relative_path("../config/dock.json") self.app_map = {} # Initialize the app map - self._all_apps = get_desktop_applications() # Get all apps for lookup + self._all_apps = get_desktop_applications() # Get all apps for lookup # Create app identifiers mapping self.app_identifiers = self._build_app_identifiers_map() self.is_hidden = False @@ -80,20 +102,10 @@ class Dock(Window): self._arranger_handler = None self._drag_in_progress = False # Drag lock flag self.is_hovered = False - self.always_occluded = ( - data.DOCK_ALWAYS_OCCLUDED - ) # Track the always occluded setting + self.always_occluded = data.DOCK_ALWAYS_OCCLUDED # Track the always occluded setting # Set up UI containers - self.view = Box( - name="viewport", - orientation="h" - if not data.VERTICAL - and not data.VERTICAL_RIGHT_ALIGN - and not data.BOTTOM_BAR - else "v", - spacing=4, - ) + self.view = Box(name="viewport", orientation="h", spacing=4) self.wrapper = Box(name="dock", orientation="v", children=[self.view]) # Main dock container with hover handling @@ -102,146 +114,61 @@ class Dock(Window): self.dock_eventbox.connect("enter-notify-event", self._on_dock_enter) self.dock_eventbox.connect("leave-notify-event", self._on_dock_leave) - # Helper to create corner boxes - def create_corner_box(name, orientation, align, corner_position): - return Box( - name=name, - orientation=orientation, - **align, - children=[ - Box( - **( - {"v_expand": True, "v_align": "fill"} - if orientation == "v" - else {"h_expand": True, "h_align": "fill"} - ) - ), - MyCorner(corner_position), - ], - ) + # Create corners in the orientation-appropriate direction + + self.corner_left = Box( + name="dock-corner-left", + orientation="v", + h_align="start", + children=[ + Box(v_expand=True, v_align="fill"), + MyCorner("bottom-right"), + ] + ) + self.corner_right = Box( + name="dock-corner-right", + orientation="v", + h_align="end", + children=[ + Box(v_expand=True, v_align="fill"), + MyCorner("bottom-left"), + ] + ) + # Create a horizontal box that contains the corners and the dock + self.dock_full = Box( + name="dock-full", + orientation="h", + h_expand=True, + h_align="fill", + children=[ + self.corner_left, + self.dock_eventbox, + self.corner_right, + ] + ) + - # Determine dock layout based on orientation - if not data.VERTICAL and not data.BOTTOM_BAR: - # Horizontal layout: corners at the bottom - self.corner_left = create_corner_box( - name="dock-corner-left", - orientation="v", - align={"h_align": "start"}, - corner_position="bottom-right", - ) - - self.corner_right = create_corner_box( - name="dock-corner-right", - orientation="v", - align={"h_align": "end"}, - corner_position="bottom-left", - ) - - self.dock_full = Box( - name="dock-full", - orientation="h", - h_expand=True, - h_align="fill", - children=[ - self.corner_left, - self.dock_eventbox, - self.corner_right, - ], - ) - - elif data.VERTICAL_RIGHT_ALIGN: - # Vertical right-aligned layout: corners at top and bottom (left-angled corners) - self.corner_top = create_corner_box( - name="dock-corner-top", - orientation="h", - align={"v_align": "start"}, - corner_position="bottom-left", - ) - - self.corner_bottom = create_corner_box( - name="dock-corner-bottom", - orientation="h", - align={"v_align": "end"}, - corner_position="top-left", - ) - - self.dock_full = Box( - name="dock-full", - orientation="v", - v_expand=True, - v_align="fill", - children=[ - self.corner_top, - self.dock_eventbox, - self.corner_bottom, - ], - ) - - else: - # Vertical left/default alignment: corners at top and bottom (right-angled corners) - self.corner_top = create_corner_box( - name="dock-corner-top", - orientation="h", - align={"v_align": "start"}, - corner_position="bottom-right", - ) - - self.corner_bottom = create_corner_box( - name="dock-corner-bottom", - orientation="h", - align={"v_align": "end"}, - corner_position="top-right", - ) - - self.dock_full = Box( - name="dock-full", - orientation="v", - v_expand=True, - v_align="fill", - children=[ - self.corner_top, - self.dock_eventbox, - self.corner_bottom, - ], - ) # Bottom/side hover activation area self.hover_activator = EventBox() - self.hover_activator.set_size_request( - -1 - if not data.VERTICAL - and not data.VERTICAL_RIGHT_ALIGN - and not data.BOTTOM_BAR - else 1, - 1 - if not data.VERTICAL - and not data.VERTICAL_RIGHT_ALIGN - and not data.BOTTOM_BAR - else -1, - ) + self.hover_activator.set_size_request(-1, 1) self.hover_activator.connect("enter-notify-event", self._on_hover_enter) self.hover_activator.connect("leave-notify-event", self._on_hover_leave) # Update main_box to include the dock_full and activator - self.main_box = Box( - orientation="v" - if not data.VERTICAL - and not data.VERTICAL_RIGHT_ALIGN - and not data.BOTTOM_BAR - else "h", - children=[self.dock_full, self.hover_activator], - ) + self.main_box = Box(orientation="v", + children=[self.dock_full, self.hover_activator]) self.add(self.main_box) # Drag-and-drop setup for the viewport self.view.drag_source_set( Gdk.ModifierType.BUTTON1_MASK, [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, + Gdk.DragAction.MOVE ) self.view.drag_dest_set( Gtk.DestDefaults.ALL, [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, + Gdk.DragAction.MOVE ) self.view.connect("drag-data-get", self.on_drag_data_get) self.view.connect("drag-data-received", self.on_drag_data_received) @@ -259,34 +186,20 @@ class Dock(Window): for ev in ("activewindow", "openwindow", "closewindow", "changefloatingmode"): self.conn.connect(f"event::{ev}", self.update_dock) self.conn.connect("event::workspace", self.check_hide) - - if data.VERTICAL: - self.wrapper.add_style_class("vertical") - else: - self.wrapper.remove_style_class("vertical") - - if data.BOTTOM_BAR: - self.wrapper.add_style_class("vertical-right") - else: - self.wrapper.remove_style_class("vertical-right") - - if data.VERTICAL_RIGHT_ALIGN: - self.wrapper.add_style_class("vertical-left") - else: - self.wrapper.remove_style_class("vertical-left") + GLib.timeout_add(250, self.check_occlusion_state) - # Monitor modus.json for changes + # Monitor dock.json for changes GLib.timeout_add_seconds(1, self.check_config_change) - + # Check if dock should be visible based on configuration if not data.DOCK_ENABLED: self.set_visible(False) - + # Apply always occluded state if enabled in settings if self.always_occluded: self.dock_full.add_style_class("occluded") - + def _build_app_identifiers_map(self): """Build a mapping of app identifiers (class names, executables, names) to DesktopApp objects""" identifiers = {} @@ -294,55 +207,55 @@ class Dock(Window): # Map by name (lowercase) if app.name: identifiers[app.name.lower()] = app - + # Map by display name if app.display_name: identifiers[app.display_name.lower()] = app - + # Map by window class if available if app.window_class: identifiers[app.window_class.lower()] = app - + # Map by executable name if available if app.executable: - exe_basename = app.executable.split("/")[-1].lower() + exe_basename = app.executable.split('/')[-1].lower() identifiers[exe_basename] = app - + # Map by command line if available (without parameters) if app.command_line: - cmd_base = app.command_line.split()[0].split("/")[-1].lower() + cmd_base = app.command_line.split()[0].split('/')[-1].lower() identifiers[cmd_base] = app - + return identifiers def _normalize_window_class(self, class_name): """Normalize window class by removing common suffixes and lowercase.""" if not class_name: return "" - + normalized = class_name.lower() - + # Remove common suffixes suffixes = [".bin", ".exe", ".so", "-bin", "-gtk"] for suffix in suffixes: if normalized.endswith(suffix): - normalized = normalized[: -len(suffix)] - + normalized = normalized[:-len(suffix)] + return normalized - + def _classes_match(self, class1, class2): """Check if two window class names match with stricter comparison.""" if not class1 or not class2: return False - + # Normalize both classes norm1 = self._normalize_window_class(class1) norm2 = self._normalize_window_class(class2) - + # Direct match after normalization if norm1 == norm2: return True - + # Don't do substring matching as it's too error-prone # This avoids incorrectly matching flatpak apps and others return False @@ -381,61 +294,44 @@ class Dock(Window): self.is_hovered = False self.delay_hide() # Immediate occlusion check on true leave using simplified format - occlusion_region = ( - ("bottom", OCCLUSION) - if not data.VERTICAL - and not data.VERTICAL_RIGHT_ALIGN - and not data.BOTTOM_BAR - else ("left", OCCLUSION) - if data.VERTICAL_RIGHT_ALIGN - else ("right", OCCLUSION) - ) + occlusion_region = ("bottom", OCCLUSION) # Add occlusion style if not dragging an icon or if always_occluded is enabled - if self.always_occluded or ( - not self._drag_in_progress - and (check_occlusion(occlusion_region) or not self.view.get_children()) - ): + if (self.always_occluded or (not self._drag_in_progress and (check_occlusion(occlusion_region) or not self.view.get_children()))): self.dock_full.add_style_class("occluded") return True # Enhanced app lookup methods def find_app(self, app_identifier): """Return the DesktopApp object by matching any app identifier. - + If app_identifier is a dict, it will use all available keys for matching. """ if not app_identifier: return None - + # If we got a dict object with app data (new format) if isinstance(app_identifier, dict): # Try to find by all available identifiers in priority order - for key in [ - "window_class", - "executable", - "command_line", - "name", - "display_name", - ]: + for key in ["window_class", "executable", "command_line", "name", "display_name"]: if key in app_identifier and app_identifier[key]: app = self.find_app_by_key(app_identifier[key]) if app: return app return None - + # Simple string identifier (backward compatibility) return self.find_app_by_key(app_identifier) - + def find_app_by_key(self, key_value): """Find app by a single identifier value""" if not key_value: return None - + # Try direct lookup in our identifiers map normalized_id = str(key_value).lower() if normalized_id in self.app_identifiers: return self.app_identifiers[normalized_id] - + # Try fuzzy matching - find apps where the identifier is part of their names for app in self._all_apps: if app.name and normalized_id in app.name.lower(): @@ -448,54 +344,40 @@ class Dock(Window): return app if app.command_line and normalized_id in app.command_line.lower(): return app - + return None # Update the dock's app map using DesktopApp objects from the system. def update_app_map(self): """Updates the mapping of commands to DesktopApp objects.""" - self._all_apps = get_desktop_applications() # Refresh all apps - self.app_map = { - app.name: app for app in self._all_apps if app.name - } # Map app names to DesktopApp objects - self.app_identifiers = ( - self._build_app_identifiers_map() - ) # Rebuild identifiers map + self._all_apps = get_desktop_applications() # Refresh all apps + self.app_map = {app.name: app for app in self._all_apps if app.name} # Map app names to DesktopApp objects + self.app_identifiers = self._build_app_identifiers_map() # Rebuild identifiers map def create_button(self, app_identifier, instances): """Create dock application button""" - desktop_app = self.find_app(app_identifier) # Find app by identifier + desktop_app = self.find_app(app_identifier) # Find app by identifier icon_img = None display_name = None - + if desktop_app: icon_img = desktop_app.get_icon_pixbuf(size=data.DOCK_ICON_SIZE) display_name = desktop_app.display_name or desktop_app.name - + # Extract identifier for fallback - id_value = ( - app_identifier["name"] - if isinstance(app_identifier, dict) - else app_identifier - ) - + id_value = app_identifier["name"] if isinstance(app_identifier, dict) else app_identifier + if not icon_img: # Fallback to IconResolver with the app command - icon_img = self.icon.get_icon_pixbuf( - id_value, data.DOCK_ICON_SIZE - ) # Use identifier for fallback - - if not icon_img: # Double check after exec path try + icon_img = self.icon.get_icon_pixbuf(id_value, data.DOCK_ICON_SIZE) # Use identifier for fallback + + if not icon_img: # Double check after exec path try # Fallback icon if no DesktopApp is found - icon_img = self.icon.get_icon_pixbuf( - "application-x-executable-symbolic", data.DOCK_ICON_SIZE - ) + icon_img = self.icon.get_icon_pixbuf("application-x-executable-symbolic", data.DOCK_ICON_SIZE) # Final fallback if not icon_img: - icon_img = self.icon.get_icon_pixbuf( - "image-missing", data.DOCK_ICON_SIZE - ) - + icon_img = self.icon.get_icon_pixbuf("image-missing", data.DOCK_ICON_SIZE) + items = [Image(pixbuf=icon_img)] tooltip = display_name or (id_value if isinstance(id_value, str) else "Unknown") @@ -503,15 +385,13 @@ class Dock(Window): tooltip = instances[0]["title"] button = Button( - child=Box( + child= Box( name="dock-icon", orientation="v", h_align="center", children=items, ), - on_clicked=lambda *a: self.handle_app( - app_identifier, instances, desktop_app - ), # Pass desktop_app as well + on_clicked=lambda *a: self.handle_app(app_identifier, instances, desktop_app), # Pass desktop_app as well tooltip_text=tooltip, name="dock-app-button", ) @@ -522,22 +402,22 @@ class Dock(Window): button.instances = instances if instances: - button.add_style_class("instance") # Style running apps + button.add_style_class("instance") # Style running apps # Enable DnD for ALL apps button.drag_source_set( Gdk.ModifierType.BUTTON1_MASK, [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, + Gdk.DragAction.MOVE ) button.connect("drag-begin", self.on_drag_begin) button.connect("drag-end", self.on_drag_end) # Connect drag-end to ALL buttons - + # Make all buttons drop targets for better reordering button.drag_dest_set( Gtk.DestDefaults.ALL, [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, + Gdk.DragAction.MOVE ) button.connect("drag-data-get", self.on_drag_data_get) button.connect("drag-data-received", self.on_drag_data_received) @@ -552,11 +432,11 @@ class Dock(Window): # Try to launch the app with multiple fallback methods if not desktop_app: desktop_app = self.find_app(app_identifier) - + if desktop_app: # Try the standard launch method first launch_success = desktop_app.launch() - + # If that fails, try command line or executable if not launch_success: if desktop_app.command_line: @@ -567,19 +447,10 @@ class Dock(Window): # No desktop entry found, try direct execution if isinstance(app_identifier, dict): # Try command_line first, then executable, then name as last resort - if ( - "command_line" in app_identifier - and app_identifier["command_line"] - ): - exec_shell_command_async( - f"nohup {app_identifier['command_line']}" - ) - elif ( - "executable" in app_identifier and app_identifier["executable"] - ): - exec_shell_command_async( - f"nohup {app_identifier['executable']}" - ) + if "command_line" in app_identifier and app_identifier["command_line"]: + exec_shell_command_async(f"nohup {app_identifier['command_line']}") + elif "executable" in app_identifier and app_identifier["executable"]: + exec_shell_command_async(f"nohup {app_identifier['executable']}") elif "name" in app_identifier and app_identifier["name"]: exec_shell_command_async(f"nohup {app_identifier['name']}") elif isinstance(app_identifier, str): @@ -653,96 +524,84 @@ class Dock(Window): if arranger_handler: remove_handler(arranger_handler) clients = self.get_clients() - + # Create a mapping of window class to instances running_windows = {} for c in clients: # Try multiple identification methods in order of reliability window_id = None - + # Try initialClass first (most reliable) if class_name := c.get("initialClass", "").lower(): window_id = class_name - + # Try class second (fallback) elif class_name := c.get("class", "").lower(): window_id = class_name - + # Use title as last resort if both class identifiers are missing elif title := c.get("title", "").lower(): # Extract app name from title (common format: "App Name - Document") possible_name = title.split(" - ")[0].strip() - if ( - possible_name and len(possible_name) > 1 - ): # Avoid single letter app names + if possible_name and len(potential_name) > 1: # Avoid single letter app names window_id = possible_name else: window_id = title # Use full title if we can't extract a good name - + # Use generic identifier as absolute fallback if not window_id: window_id = "unknown-app" - + # Log window for debugging purposes - logging.debug( - f"Window detected: {window_id} (from {c.get('initialClass', '')}/{c.get('class', '')}/{c.get('title', '')})" - ) - + logging.debug(f"Window detected: {window_id} (from {c.get('initialClass', '')}/{c.get('class', '')}/{c.get('title', '')})") + # Add to running windows - store with both original and normalized keys running_windows.setdefault(window_id, []).append(c) - + # Also store with normalized key for more flexible matching normalized_id = self._normalize_window_class(window_id) if normalized_id != window_id: - running_windows.setdefault(normalized_id, []).extend( - running_windows[window_id] - ) - + running_windows.setdefault(normalized_id, []).extend(running_windows[window_id]) + # Map pinned apps to their running instances pinned_buttons = [] used_window_classes = set() # Track which window classes we've already assigned - + for app_data in self.pinned: app = self.find_app(app_data) - + # Try to find running instances for this pinned app instances = [] matched_class = None - + # Extract all possible identifiers from app_data for matching possible_identifiers = [] - + if isinstance(app_data, dict): - for key in [ - "window_class", - "executable", - "command_line", - "name", - "display_name", - ]: + for key in ["window_class", "executable", "command_line", "name", "display_name"]: if key in app_data and app_data[key]: possible_identifiers.append(app_data[key].lower()) elif isinstance(app_data, str): possible_identifiers.append(app_data.lower()) - + # Add identifiers from DesktopApp if available if app: - if app.window_class: + if app.window_class: possible_identifiers.append(app.window_class.lower()) if app.executable: - possible_identifiers.append(app.executable.split("/")[-1].lower()) + possible_identifiers.append(app.executable.split('/')[-1].lower()) if app.command_line: cmd_parts = app.command_line.split() if cmd_parts: - possible_identifiers.append(cmd_parts[0].split("/")[-1].lower()) + possible_identifiers.append(cmd_parts[0].split('/')[-1].lower()) if app.name: possible_identifiers.append(app.name.lower()) if app.display_name: possible_identifiers.append(app.display_name.lower()) - + # Remove duplicates possible_identifiers = list(set(possible_identifiers)) - + # Try each identifier for matching for identifier in possible_identifiers: # Try direct match @@ -750,14 +609,14 @@ class Dock(Window): instances = running_windows[identifier] matched_class = identifier break - + # Try normalized version normalized = self._normalize_window_class(identifier) if normalized in running_windows: instances = running_windows[normalized] matched_class = normalized break - + # Try substring matching for window classes (less reliable but helpful) for window_class in running_windows: # For substring matching, ensure the identifier is at least 3 chars @@ -765,54 +624,50 @@ class Dock(Window): if len(identifier) >= 3 and identifier in window_class: instances = running_windows[window_class] matched_class = window_class - logging.debug( - f"Substring match: {identifier} in {window_class}" - ) + logging.debug(f"Substring match: {identifier} in {window_class}") break - + if matched_class: break - + # Mark the matched class as used if matched_class: used_window_classes.add(matched_class) # Also mark the normalized version as used used_window_classes.add(self._normalize_window_class(matched_class)) - logging.debug( - f"Matched pinned app {app_data} to running instances via {matched_class}" - ) - + logging.debug(f"Matched pinned app {app_data} to running instances via {matched_class}") + # Create button for this pinned app with any found instances pinned_buttons.append(self.create_button(app_data, instances)) - + # For any remaining window classes that aren't assigned to pinned apps open_buttons = [] for class_name, instances in running_windows.items(): if class_name not in used_window_classes: # Enhanced app identification for running windows app = None - + # Try multiple methods to find the correct app # 1. Direct lookup by class name app = self.app_identifiers.get(class_name) - + # 2. Try with normalized class name if not app: norm_class = self._normalize_window_class(class_name) app = self.app_identifiers.get(norm_class) - + # 3. Try with our more robust find_app method if not app: app = self.find_app_by_key(class_name) - + # 4. Try using window title which often contains app name if not app and instances and instances[0].get("title"): title = instances[0].get("title", "") # Extract potential app name from title (common format: "App Name - Document") - possible_name = title.split(" - ")[0].strip() - if len(possible_name) > 2: # Avoid very short names - app = self.find_app_by_key(possible_name) - + potential_name = title.split(" - ")[0].strip() + if len(potential_name) > 2: # Avoid very short names + app = self.find_app_by_key(potential_name) + # Create comprehensive app data if app was found if app: app_data = { @@ -820,37 +675,22 @@ class Dock(Window): "display_name": app.display_name, "window_class": app.window_class, "executable": app.executable, - "command_line": app.command_line, + "command_line": app.command_line } identifier = app_data else: # Fallback to just class name identifier = class_name - + open_buttons.append(self.create_button(identifier, instances)) # Assemble dock layout children = pinned_buttons # Only add separator if both pinned and open buttons exist if pinned_buttons and open_buttons: - children += [ - Box( - orientation="v" - if not data.VERTICAL - and not data.VERTICAL_RIGHT_ALIGN - and not data.BOTTOM_BAR - else "h", - v_expand=not data.VERTICAL - and not data.VERTICAL_RIGHT_ALIGN - and not data.BOTTOM_BAR, - h_expand=data.VERTICAL - and data.VERTICAL_RIGHT_ALIGN - and data.BOTTOM_BAR, - name="dock-separator", - ) - ] + children += [Box(orientation="v", v_expand=True, name="dock-separator")] children += open_buttons - + self.view.children = children idle_add(self._update_size) self._drag_in_progress = False # Clear the drag lock @@ -892,21 +732,13 @@ class Dock(Window): if self.is_hovered or self._drag_in_progress: self.dock_full.remove_style_class("occluded") return True - + # If always_occluded is enabled, keep the dock occluded if self.always_occluded: self.dock_full.add_style_class("occluded") return True - - occlusion_region = ( - ("bottom", OCCLUSION) - if not data.VERTICAL - and not data.VERTICAL_RIGHT_ALIGN - and not data.BOTTOM_BAR - else ("left", OCCLUSION) - if data.VERTICAL_RIGHT_ALIGN - else ("right", OCCLUSION) - ) + + occlusion_region = ("bottom", OCCLUSION) if check_occlusion(occlusion_region) or not self.view.get_children(): self.dock_full.add_style_class("occluded") else: @@ -922,18 +754,14 @@ class Dock(Window): def on_drag_data_get(self, widget, drag_context, data, info, time): """Handle drag start""" - target = self._find_drag_target( - widget.get_parent() if isinstance(widget, Box) else widget - ) + target = self._find_drag_target(widget.get_parent() if isinstance(widget, Box) else widget) if target is not None: index = self.view.get_children().index(target) data.set_text(str(index), -1) def on_drag_data_received(self, widget, drag_context, x, y, data, info, time): """Handle drop event""" - target = self._find_drag_target( - widget.get_parent() if isinstance(widget, Box) else widget - ) + target = self._find_drag_target(widget.get_parent() if isinstance(widget, Box) else widget) if target is None: return try: @@ -954,21 +782,20 @@ class Dock(Window): if child.get_name() == "dock-separator": separator_index = i break - + # Detect if we're dragging across the separator (between pinned/unpinned) - cross_section_drag = separator_index != -1 and ( - (source_index < separator_index and target_index > separator_index) - or (source_index > separator_index and target_index < separator_index) - ) - + cross_section_drag = (separator_index != -1 and + ((source_index < separator_index and target_index > separator_index) or + (source_index > separator_index and target_index < separator_index))) + # Move the item in the children list child = children.pop(source_index) children.insert(target_index, child) self.view.children = children # Update view immediately - + # Update pinned apps configuration self.update_pinned_apps(skip_update=not cross_section_drag) - + # Force immediate update if we dragged across sections to update separator visibility if cross_section_drag: GLib.idle_add(self.update_dock) @@ -988,13 +815,11 @@ class Dock(Window): window = self.get_window() if window: # Check if we got a window win_x, win_y, width, height = window.get_geometry() - if not ( - win_x <= x <= win_x + width and win_y <= y <= win_y + height - ): + if not (win_x <= x <= win_x + width and win_y <= y <= win_y + height): # Drag ended outside the dock - get app data from the button app_id = widget.app_identifier instances = widget.instances - + # Find this app in pinned list app_index = -1 for i, pinned_app in enumerate(self.pinned): @@ -1003,26 +828,22 @@ class Dock(Window): app_index = i break # For dict format, match by name - elif isinstance(pinned_app, dict) and isinstance( - app_id, dict - ): + elif isinstance(pinned_app, dict) and isinstance(app_id, dict): if pinned_app.get("name") == app_id.get("name"): app_index = i break - + # If it's a pinned app, remove from pinned if app_index >= 0: self.pinned.pop(app_index) self.config["pinned_apps"] = self.pinned self.update_pinned_apps_file() self.update_dock() # Update dock after file save - elif instances: # Close if running and unpinned + elif instances: # Close if running and unpinned # Close running app (if not pinned) address = instances[0].get("address") if address: - exec_shell_command( - f"hyprctl dispatch closewindow address:{address}" - ) + exec_shell_command(f"hyprctl dispatch closewindow address:{address}") self.update_dock() # Update dock after closing window self._drag_in_progress = False # Clear the drag lock @@ -1030,7 +851,7 @@ class Dock(Window): def check_config_change(self): """Check if the config file has been modified.""" - new_config = data.read_config() + new_config = read_config() if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []): self.config = new_config self.pinned = self.config.get("pinned_apps", []) @@ -1039,8 +860,8 @@ class Dock(Window): return True # Continue the timeout def update_pinned_apps_file(self): - """Writes the updated pinned apps list to modus.json""" - config_path = get_relative_path("../config/assets/modus.json") + """Writes the updated pinned apps list to dock.json""" + config_path = get_relative_path("../config/dock.json") try: with open(config_path, "w") as file: json.dump(self.config, file, indent=4) @@ -1055,7 +876,7 @@ class Dock(Window): for child in self.view.get_children(): if child.get_name() == "dock-separator": break # Stop at separator - + # Use stored app identifier from the button if hasattr(child, "app_identifier"): # If the button has a desktop_app, use it to create comprehensive data @@ -1066,7 +887,7 @@ class Dock(Window): "display_name": app.display_name, "window_class": app.window_class, "executable": app.executable, - "command_line": app.command_line, + "command_line": app.command_line } pinned_children.append(app_data) else: @@ -1079,7 +900,7 @@ class Dock(Window): # Write to file first to ensure persistence file_updated = self.update_pinned_apps_file() - + # Only update the dock if needed and file was successfully written if file_updated and not skip_update: self.update_dock() # Refresh dock only after file is saved @@ -1091,33 +912,27 @@ class Dock(Window): for dock in Dock._instances: # Use idle_add to avoid potential issues with direct calls GLib.idle_add(dock.check_config_change_immediate) - + def check_config_change_immediate(self): """Immediately check for configuration changes and update if needed.""" - new_config = data.read_config() - + new_config = read_config() + # Check if always_occluded setting has changed previous_always_occluded = self.always_occluded self.always_occluded = data.DOCK_ALWAYS_OCCLUDED - + # If always_occluded changed, update the dock appearance if previous_always_occluded != self.always_occluded: if self.always_occluded and not self.is_hovered: self.dock_full.add_style_class("occluded") elif not self.always_occluded and not self.is_hovered: # Perform a normal occlusion check - occlusion_region = ( - ("bottom", OCCLUSION) - if not data.VERTICAL and not data.BOTTOM_BAR - else ("left", OCCLUSION) - if data.VERTICAL_RIGHT_ALIGN - else ("right", OCCLUSION) - ) + occlusion_region = ("bottom", OCCLUSION) if check_occlusion(occlusion_region) or not self.view.get_children(): self.dock_full.add_style_class("occluded") else: self.dock_full.remove_style_class("occluded") - + if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []): self.config = new_config self.pinned = self.config.get("pinned_apps", []) @@ -1130,4 +945,4 @@ class Dock(Window): def update_visibility(visible): """Update visibility for all dock instances.""" for dock in Dock._instances: - dock.set_visible(visible) + dock.set_visible(visible) \ No newline at end of file diff --git a/modules/launcher/components/__init__.py b/modules/launcher/components/__init__.py deleted file mode 100644 index da2ef07..0000000 --- a/modules/launcher/components/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -from .applauncher import * -from .bluetooth import * -from .cliphist import * -from .dashboard import * -from .emoji import * -from .power import * -from .sh import * -from .wallpapers import * -from .wifi import * -from .calendar import * -from .dashboard import * -from .tools import * -from .kanban import * -from .tmux import * -from .window_switcher import * -from .notification_center import * diff --git a/modules/launcher/components/applauncher.py b/modules/launcher/components/applauncher.py deleted file mode 100644 index 83380de..0000000 --- a/modules/launcher/components/applauncher.py +++ /dev/null @@ -1,630 +0,0 @@ -import operator -from collections.abc import Iterator -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.widgets.entry import Entry -from fabric.widgets.image import Image -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.utils import ( - DesktopApp, - get_desktop_applications, - idle_add, - remove_handler, - exec_shell_command_async, -) -from fabric.utils.helpers import get_relative_path -from gi.repository import GLib, Gdk -import utils.icons as icons -import config.data as data -import json -import os -import re -import math -import numpy as np -import subprocess -from modules.dock import Dock # Import the Dock class - - -class AppLauncher(Box): - def __init__(self, **kwargs): - super().__init__( - name="app-launcher", - visible=False, - all_visible=False, - **kwargs, - ) - - self.launcher = kwargs["launcher"] - self.selected_index = -1 # Track the selected item index - - self._arranger_handler: int = 0 - self._all_apps = get_desktop_applications() - self.config = data.read_config() - - # Calculator history initialization - self.calc_history_path = f"{data.CACHE_DIR}/calc.json" - if os.path.exists(self.calc_history_path): - with open(self.calc_history_path, "r") as f: - self.calc_history = json.load(f) - else: - self.calc_history = [] - - self.viewport = Box(name="viewport", spacing=4, orientation="v") - self.search_entry = Entry( - name="search-entry", - placeholder="Search Applications...", - h_expand=True, - notify_text=self.notify_text, # Use the method instead of lambda - on_activate=lambda entry, *_: self.on_search_entry_activate( - entry.get_text() - ), - on_key_press_event=self.on_search_entry_key_press, # Handle key presses - ) - self.search_entry.props.xalign = 0.5 - self.scrolled_window = ScrolledWindow( - name="scrolled-window", - spacing=10, - min_content_size=(450, 105), - max_content_size=(450, 105), - child=self.viewport, - ) - - self.header_box = Box( - name="header_box", - spacing=10, - orientation="h", - children=[ - Button( - name="config-button", - child=Label(name="config-label", markup=icons.config), - on_clicked=lambda *_: ( - exec_shell_command_async( - f"python {get_relative_path('../../../config/config.py')}" - ), - self.close_launcher(), - ), - ), - self.search_entry, - ], - ) - - self.launcher_box = Box( - name="launcher-box", - spacing=10, - h_expand=True, - orientation="v", - children=[ - self.header_box, - self.scrolled_window, - ], - ) - - self.resize_viewport() - - self.add(self.launcher_box) - self.show_all() - - def close_launcher(self): - self.viewport.children = [] - self.selected_index = -1 # Reset selection - self.launcher.close() - - def open_launcher(self): - self._all_apps = get_desktop_applications() - self.arrange_viewport() - - def ensure_initialized(self): - """Make sure the launcher is initialized with apps list before opening""" - if not hasattr(self, "_initialized"): - # Force pre-loading apps - self._all_apps = get_desktop_applications() - self._initialized = True - return True # Was initialized for first time - return False # Was already initialized - - def arrange_viewport(self, query: str = ""): - if query.startswith("="): - # In calculator mode, update history view once (not per keystroke) - self.show_scrolled_window() - self.launcher.dashboard.hide() - self.update_calculator_viewport() - return - remove_handler(self._arranger_handler) if self._arranger_handler else None - self.viewport.children = [] - self.selected_index = -1 # Clear selection when viewport changes - - if query.startswith("/"): - self.launcher.dashboard.hide() - return - - if query.strip(): - self.launcher.dashboard.hide() - self.show_scrolled_window() - else: - self.launcher.dashboard.show() - self.hide_scrolled_window() - return False - - filtered_apps_iter = iter( - sorted( - [ - app - for app in self._all_apps - if query.casefold() - in ( - (app.display_name or "") - + (" " + app.name + " ") - + (app.generic_name or "") - ).casefold() - ], - key=lambda app: (app.display_name or "").casefold(), - ) - ) - should_resize = operator.length_hint(filtered_apps_iter) == len(self._all_apps) - - self._arranger_handler = idle_add( - lambda apps_iter: self.add_next_application(apps_iter) - or self.handle_arrange_complete(should_resize, query), - filtered_apps_iter, - pin=True, - ) - - def handle_arrange_complete(self, should_resize, query): - if should_resize: - self.resize_viewport() - # Only auto-select first item if query exists - if query.strip() != "" and self.viewport.get_children(): - self.update_selection(0) - return False - - def add_next_application(self, apps_iter: Iterator[DesktopApp]): - if not (app := next(apps_iter, None)): - return False - self.viewport.add(self.bake_application_slot(app)) - return True - - def show_scrolled_window(self): - if hasattr(self, "scrolled_window") and self.scrolled_window: - self.scrolled_window.show_all() - - def hide_scrolled_window(self): - if hasattr(self, "scrolled_window") and self.scrolled_window: - self.scrolled_window.hide() - - def resize_viewport(self): - self.scrolled_window.set_min_content_width( - self.viewport.get_allocation().width # type: ignore - ) - return False - - def bake_application_slot(self, app: DesktopApp, **kwargs) -> Button: - button = Button( - name="slot-button", - child=Box( - name="slot-box", - orientation="h", - spacing=10, - children=[ - Image( - name="app-icon", - pixbuf=app.get_icon_pixbuf(size=24), - h_align="start", - ), - Label( - name="app-label", - label=app.display_name or "Unknown", - ellipsization="end", - v_align="center", - h_align="center", - ), - Label( - name="app-desc", - label=app.description or "", - ellipsization="end", - v_align="center", - h_align="start", - h_expand=True, - ), - ], - ), - tooltip_text=app.description, - on_clicked=lambda *_: (app.launch(), self.close_launcher()), - **kwargs, - ) - return button - - def update_selection(self, new_index: int): - # Unselect current - if self.selected_index != -1 and self.selected_index < len( - self.viewport.get_children() - ): - current_button = self.viewport.get_children()[self.selected_index] - current_button.get_style_context().remove_class("selected") - # Select new - if new_index != -1 and new_index < len(self.viewport.get_children()): - new_button = self.viewport.get_children()[new_index] - new_button.get_style_context().add_class("selected") - self.selected_index = new_index - self.scroll_to_selected(new_button) - else: - self.selected_index = -1 - - def scroll_to_selected(self, button): - def scroll(): - adj = self.scrolled_window.get_vadjustment() - alloc = button.get_allocation() - if alloc.height == 0: - return False # Retry if allocation isn't ready - - y = alloc.y - height = alloc.height - page_size = adj.get_page_size() - current_value = adj.get_value() - - # Calculate visible boundaries - visible_top = current_value - visible_bottom = current_value + page_size - - if y < visible_top: - # Item above viewport - align to top - adj.set_value(y) - elif y + height > visible_bottom: - # Item below viewport - align to bottom - new_value = y + height - page_size - adj.set_value(new_value) - # No action if already fully visible - return False - - GLib.idle_add(scroll) - - def on_search_entry_activate(self, text): - # This method is now only used for non-Enter key activations - commands = self.config.get("commands", {}) - - # Check if text is a mathematical expression - if text and (text[0].isdigit()): - self.evaluate_calculator_expression(text) - return - - if text in commands: - command = commands[text] - eval(command, {"launcher": self.launcher}) - else: - children = self.viewport.get_children() - if children: - # Only activate if we have selection or non-empty query - if text.strip() == "" and self.selected_index == -1: - return # Prevent accidental activation when empty - selected_index = self.selected_index if self.selected_index != -1 else 0 - if 0 <= selected_index < len(children): - children[selected_index].clicked() - - def on_search_entry_key_press(self, widget, event): - text = widget.get_text() - - # Check if we're in calculator mode - if text.startswith("="): - if event.keyval == Gdk.KEY_Down: - self.move_selection(1) - return True - elif event.keyval == Gdk.KEY_Up: - self.move_selection(-1) - return True - elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): - # In calculator mode, if a history item is highlighted: - if self.selected_index != -1 and self.selected_index < len( - self.calc_history - ): - if event.state & Gdk.ModifierType.SHIFT_MASK: - # Shift+Enter deletes the selected calculator history item - self.delete_selected_calc_history() - else: - # Normal Enter copies the result - selected_text = self.calc_history[self.selected_index] - self.copy_text_to_clipboard(selected_text) - # Clear selection so new expressions are evaluated on further Return presses - self.selected_index = -1 - else: - # Force reset selection index - self.selected_index = -1 - # No item selected, evaluate the expression - self.evaluate_calculator_expression(text) - return True - elif event.keyval == Gdk.KEY_Escape: - self.close_launcher() - return True - return False - else: - # Normal app mode behavior - if event.keyval == Gdk.KEY_Down: - self.move_selection(1) - return True - elif event.keyval == Gdk.KEY_Up: - self.move_selection(-1) - return True - elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter) and ( - event.state & Gdk.ModifierType.SHIFT_MASK - ): - # Only add to dock when in app mode (not calculator mode) - self.add_selected_app_to_dock() - return True - elif event.keyval == Gdk.KEY_Escape: - self.close_launcher() - return True - return False - - def notify_text(self, entry, *_): - """Handle text changes in the search entry""" - text = entry.get_text() - if text.startswith("="): - # In calculator mode, show scrolled window and update viewport - self.show_scrolled_window() - self.launcher.dashboard.hide() - self.update_calculator_viewport() - # Always reset selection when typing a new expression - self.selected_index = -1 - else: - self.arrange_viewport(text) - - def add_selected_app_to_dock(self): - """Adds the currently selected application to the modus.json file with comprehensive metadata.""" - children = self.viewport.get_children() - if ( - not children - or self.selected_index == -1 - or self.selected_index >= len(children) - ): - return # No app selected - - selected_button = children[self.selected_index] - # Extract the app's display name from the label to find the DesktopApp object - selected_app = next( - ( - app - for app in self._all_apps - if app.display_name - == selected_button.get_child().get_children()[1].props.label - ), - None, - ) - if not selected_app: - return - - # Create comprehensive app data dictionary - Include all available properties - # Filter out None values to keep the JSON clean - app_data = { - k: v - for k, v in { - "name": selected_app.name, - "display_name": selected_app.display_name, - "window_class": selected_app.window_class, - "executable": selected_app.executable, - "command_line": selected_app.command_line, - "icon_name": selected_app.icon_name, - }.items() - if v is not None - } - - config_path = get_relative_path("../../../config/assets/modus.json") - try: - with open(config_path, "r") as file: - data = json.load(file) - except (FileNotFoundError, json.JSONDecodeError): - data = {"pinned_apps": []} - - # Check if app is already pinned (by name) - already_pinned = False - for pinned_app in data.get("pinned_apps", []): - if ( - isinstance(pinned_app, dict) - and pinned_app.get("name") == app_data["name"] - ): - already_pinned = True - # Update existing entry with latest app data - pinned_app.update(app_data) - break - elif isinstance(pinned_app, str) and pinned_app == app_data["name"]: - already_pinned = True - # Replace string format with new comprehensive format - data["pinned_apps"].remove(pinned_app) - data["pinned_apps"].append(app_data) - break - - # Add to pinned apps if not already there - if not already_pinned: - data.setdefault("pinned_apps", []).append(app_data) - - # Write the updated config - with open(config_path, "w") as file: - json.dump(data, file, indent=4) - - # Notify dock instances of the configuration change immediately - Dock.notify_config_change() - - def move_selection(self, delta: int): - children = self.viewport.get_children() - if not children: - return - # Allow starting selection from nothing when empty - if self.selected_index == -1 and delta == 1: - new_index = 0 - else: - new_index = self.selected_index + delta - new_index = max(0, min(new_index, len(children) - 1)) - self.update_selection(new_index) - - def save_calc_history(self): - with open(self.calc_history_path, "w") as f: - json.dump(self.calc_history, f) - - def evaluate_calculator_expression(self, text: str): - # Add debug print - print(f"Evaluating calculator expression: {text}") - - # Remove the '=' prefix and extra spaces - expr = text.lstrip("=").strip() - if not expr: - return - - # Common replacements to make expressions more natural - replacements = { - "^": "**", # Power operator - "×": "*", # Multiplication - "÷": "/", # Division - "π": "np.pi", # Pi constant - "pi": "np.pi", # Pi constant (text) - "e": "np.e", # Euler's number - "sin(": "np.sin(", # Sine - "cos(": "np.cos(", # Cosine - "tan(": "np.tan(", # Tangent - "log(": "np.log10(", # Log base 10 - "ln(": "np.log(", # Natural log - "sqrt(": "np.sqrt(", # Square root - "abs(": "np.abs(", # Absolute value - "exp(": "np.exp(", # Exponential - } - - # Apply all replacements - for old, new in replacements.items(): - expr = expr.replace(old, new) - - # Replace factorial: e.g. 5! -> np.factorial(5) - expr = re.sub(r"(\d+)!", r"np.factorial(\1)", expr) - - # Replace brackets: allow [] and {} as () - for old, new in [("[", "("), ("]", ")"), ("{", "("), ("}", ")")]: - expr = expr.replace(old, new) - - # Define the safe execution environment - safe_dict = { - "np": np, - "math": math, - "arange": np.arange, - "linspace": np.linspace, - "array": np.array, - } - - try: - # Evaluate the expression in the safe environment - result = eval(expr, {"__builtins__": None}, safe_dict) - - # Format the result based on its type - if isinstance(result, np.ndarray): - if result.size > 10: # Truncate large arrays - result_str = f"Array of shape {result.shape}" - else: - result_str = str(result) - elif isinstance(result, (int, float, np.number)): - # Format numbers nicely - integers as integers, floats with limited precision - if isinstance(result, (int, np.integer)) or result.is_integer(): - result_str = str(int(result)) - else: - result_str = f"{float(result):.10g}" # Remove trailing zeros - else: - result_str = str(result) - - except Exception as e: - result_str = f"Error: {str(e)}" - - # Prepend to history (newest first) - self.calc_history.insert(0, f"{text} => {result_str}") - self.save_calc_history() - self.update_calculator_viewport() - - def update_calculator_viewport(self): - self.viewport.children = [] - for item in self.calc_history: - btn = self.create_calc_history_button(item) - self.viewport.add(btn) - # Don't reset selection index here automatically - # Ensure selection state stays valid - if self.selected_index >= len(self.calc_history): - self.selected_index = -1 - - def create_calc_history_button(self, text: str) -> Button: - # Parse the result to create a more readable display - if "=>" in text: - parts = text.split("=>") - expression = parts[0].strip() - result = parts[1].strip() - - # For very long results, truncate for display but keep full in tooltip - display_text = text - if len(result) > 50: # Truncate long results - display_text = f"{expression} => {result[:47]}..." - - btn = Button( - name="slot-button", # reuse existing CSS styling - child=Box( - name="calc-slot-box", - orientation="h", - spacing=10, - children=[ - Label( - name="calc-label", - label=display_text, - ellipsization="end", - v_align="center", - h_align="center", - ), - ], - ), - tooltip_text=text, - on_clicked=lambda *_: self.copy_text_to_clipboard(text), - ) - else: - # Fallback for non-calculation entries - btn = Button( - name="slot-button", - child=Box( - name="calc-slot-box", - orientation="h", - spacing=10, - children=[ - Label( - name="calc-label", - label=text, - ellipsization="end", - v_align="center", - h_align="center", - ), - ], - ), - tooltip_text=text, - on_clicked=lambda *_: self.copy_text_to_clipboard(text), - ) - return btn - - def copy_text_to_clipboard(self, text: str): - # Split the text on "=>" and copy only the result part if available - parts = text.split("=>", 1) - copy_text = parts[1].strip() if len(parts) > 1 else text - try: - subprocess.run(["wl-copy"], input=copy_text.encode(), check=True) - except subprocess.CalledProcessError as e: - print(f"Clipboard copy failed: {e}") - - def delete_selected_calc_history(self): - if self.selected_index != -1 and self.selected_index < len(self.calc_history): - # Store the current index before deletion - current_index = self.selected_index - - # Delete the item - del self.calc_history[current_index] - self.save_calc_history() - - # Determine the new selection index - # If we deleted the first item, stay at index 0 - # Otherwise, move to the previous item - new_index = 0 if current_index == 0 else current_index - 1 - - # Reset selection before updating viewport - self.selected_index = -1 - - # Update the viewport - self.update_calculator_viewport() - - # If we still have items, select the determined index - if len(self.calc_history) > 0: - self.update_selection(min(new_index, len(self.calc_history) - 1)) diff --git a/modules/launcher/components/bluetooth.py b/modules/launcher/components/bluetooth.py deleted file mode 100644 index c3f6930..0000000 --- a/modules/launcher/components/bluetooth.py +++ /dev/null @@ -1,170 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.image import Image -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.bluetooth import BluetoothClient, BluetoothDevice -import utils.icons as icons - - -class BluetoothDeviceSlot(CenterBox): - def __init__(self, device: BluetoothDevice, **kwargs): - super().__init__(name="bluetooth-device", **kwargs) - self.device = device - self.device.connect("changed", self.on_changed) - self.device.connect( - "notify::closed", lambda *_: self.device.closed and self.destroy() - ) - - self.connection_label = Label( - name="bluetooth-connection", markup=icons.bluetooth_disconnected - ) - self.connect_button = Button( - name="bluetooth-connect", - label="Connect", - on_clicked=lambda *_: self.device.set_connecting(not self.device.connected), - ) - - self.start_children = [ - Box( - spacing=8, - children=[ - Image(icon_name=device.icon_name + "-symbolic", size=32), - Label(label=device.name), - self.connection_label, - ], - ) - ] - self.end_children = self.connect_button - - self.device.emit("changed") # to update display status - - def on_changed(self, *_): - self.connection_label.set_markup( - icons.bluetooth_connected - if self.device.connected - else icons.bluetooth_disconnected - ) - if self.device.connecting: - self.connect_button.set_label( - "Connecting..." if not self.device.connecting else "Disconnecting..." - ) - else: - self.connect_button.set_label( - "Connect" if not self.device.connected else "Disconnect" - ) - return - - -class BluetoothConnections(Box): - def __init__(self, **kwargs): - super().__init__( - name="bluetooth", - spacing=4, - orientation="vertical", - **kwargs, - ) - - self.launcher = kwargs["launcher"] - - self.buttons = self.launcher.dashboard.bluetooth_button - self.bt_status_text = self.buttons.bluetooth_status_text - self.bt_status_button = self.buttons.bluetooth_status_button - self.bt_icon = self.buttons.bluetooth_icon - self.bt_label = self.buttons.bluetooth_label - self.bt_menu_button = self.buttons.bluetooth_menu_button - self.bt_menu_label = self.buttons.bluetooth_menu_label - - self.client = BluetoothClient(on_device_added=self.on_device_added) - self.scan_label = Label(name="bluetooth-scan-label", markup=icons.radar) - self.scan_button = Button( - name="bluetooth-scan", - child=self.scan_label, - tooltip_text="Scan for Bluetooth devices", - on_clicked=lambda *_: self.client.toggle_scan(), - ) - self.back_button = Button( - name="bluetooth-back", - child=Label(name="bluetooth-back-label", markup=icons.chevron_left), - on_clicked=lambda *_: self.launcher.open("launcher"), - ) - - self.client.connect("notify::enabled", lambda *_: self.status_label()) - self.client.connect("notify::scanning", lambda *_: self.update_scan_label()) - - self.paired_box = Box(spacing=2, orientation="vertical") - self.available_box = Box(spacing=2, orientation="vertical") - - # Create a single content container with the required structure: - # [Paired label] [Paired devices list] [Available label] [Available devices list] - content_box = Box(spacing=4, orientation="vertical") - content_box.add(self.paired_box) - content_box.add(Label(name="bluetooth-section", label="Available")) - content_box.add(self.available_box) - - self.children = [ - CenterBox( - name="bluetooth-header", - start_children=self.back_button, - center_children=Label(name="bluetooth-text", label="Bluetooth Devices"), - end_children=self.scan_button, - ), - ScrolledWindow( - name="bluetooth-devices", - min_content_size=(-1, -1), - child=content_box, - v_expand=True, - ), - ] - - # Trigger initial notifications to update status without delay. - self.client.notify("scanning") - self.client.notify("enabled") - - def status_label(self): - print(self.client.enabled) - if self.client.enabled: - self.bt_status_text.set_label("Enabled") - for i in [ - self.bt_status_button, - self.bt_status_text, - self.bt_icon, - self.bt_label, - self.bt_menu_button, - self.bt_menu_label, - ]: - i.remove_style_class("disabled") - self.bt_icon.set_markup(icons.bluetooth) - else: - self.bt_status_text.set_label("Disabled") - for i in [ - self.bt_status_button, - self.bt_status_text, - self.bt_icon, - self.bt_label, - self.bt_menu_button, - self.bt_menu_label, - ]: - i.add_style_class("disabled") - self.bt_icon.set_markup(icons.bluetooth_off) - - def on_device_added(self, client: BluetoothClient, address: str): - if not (device := client.get_device(address)): - return - slot = BluetoothDeviceSlot(device) - - if device.paired: - return self.paired_box.add(slot) - return self.available_box.add(slot) - - def update_scan_label(self): - if self.client.scanning: - self.scan_label.add_style_class("scanning") - self.scan_button.add_style_class("scanning") - self.scan_button.set_tooltip_text("Stop scanning for Bluetooth devices") - else: - self.scan_label.remove_style_class("scanning") - self.scan_button.remove_style_class("scanning") - self.scan_button.set_tooltip_text("Scan for Bluetooth devices") - diff --git a/modules/launcher/components/calendar.py b/modules/launcher/components/calendar.py deleted file mode 100644 index 263e572..0000000 --- a/modules/launcher/components/calendar.py +++ /dev/null @@ -1,217 +0,0 @@ -import calendar -from datetime import datetime -import gi -import utils.icons as icons -from fabric.widgets.label import Label -from fabric.widgets.box import Box -from fabric.widgets.centerbox import CenterBox - -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib - - -class Calendar(Gtk.Box): - def __init__(self): - super().__init__( - orientation=Gtk.Orientation.VERTICAL, spacing=8, name="calendar" - ) - - self.current_year = datetime.now().year - self.current_month = datetime.now().month - self.current_day = datetime.now().day - # Store previous month/year to determine transition direction. - self.previous_key = (self.current_year, self.current_month) - - # Cache threshold in number of months away from the current view. - self.cache_threshold = 1 # Allow current month +/- 1 month - - # Dictionary to store built views for each month. - self.month_views = {} - - self.prev_month_button = Gtk.Button( - name="prev-month-button", - child=Label(name="month-button-label", markup=icons.chevron_left), - ) - self.prev_month_button.connect("clicked", self.on_prev_month_clicked) - - self.month_label = Gtk.Label(name="month-label") - - self.next_month_button = Gtk.Button( - name="next-month-button", - child=Label(name="month-button-label", markup=icons.chevron_right), - ) - self.next_month_button.connect("clicked", self.on_next_month_clicked) - - self.header = CenterBox( - spacing=4, - name="header", - start_children=[self.prev_month_button], - center_children=[ - self.month_label, - ], - end_children=[self.next_month_button], - ) - - self.add(self.header) - - self.weekday_row = Gtk.Box(spacing=4, name="weekday-row") - self.pack_start(self.weekday_row, False, False, 0) - - # Create a stack to hold month days views. - self.stack = Gtk.Stack(name="calendar-stack") - self.stack.set_transition_duration(250) - self.pack_start(self.stack, True, True, 0) - - self.update_header() - self.update_calendar() - GLib.timeout_add_seconds(60, self.check_date_change) - - def update_header(self): - # Update header month label and weekday row. - self.month_label.set_text( - datetime(self.current_year, self.current_month, 1) - .strftime("%B %Y") - .capitalize() - ) - # Clear existing children from weekday_row. - for child in self.weekday_row.get_children(): - self.weekday_row.remove(child) - days = self.get_weekday_initials() - for day in days: - label = Gtk.Label(label=day.upper(), name="weekday-label") - self.weekday_row.pack_start(label, True, True, 0) - self.weekday_row.show_all() - - def update_calendar(self): - new_key = (self.current_year, self.current_month) - # Set the transition type based on whether we're moving to a later or earlier month. - if new_key > self.previous_key: - self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT) - elif new_key < self.previous_key: - self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_RIGHT) - # Update previous key for next comparison. - self.previous_key = new_key - - # Create the month view if it doesn't exist. - if new_key not in self.month_views: - month_view = self.create_month_view(self.current_year, self.current_month) - self.month_views[new_key] = month_view - self.stack.add_titled( - month_view, - f"{self.current_year}_{self.current_month}", - f"{self.current_year}_{self.current_month}", - ) - # Switch the visible child in the stack. - self.stack.set_visible_child_name(f"{self.current_year}_{self.current_month}") - self.update_header() - self.stack.show_all() - - # Purge any cached month views that are too far away. - self.prune_cache() - - def prune_cache(self): - # Compute a numerical value for a (year, month) key. - def month_index(key): - year, month = key - return year * 12 + (month - 1) - - current_index = month_index((self.current_year, self.current_month)) - keys_to_remove = [] - for key in self.month_views: - if abs(month_index(key) - current_index) > self.cache_threshold: - keys_to_remove.append(key) - for key in keys_to_remove: - widget = self.month_views.pop(key) - self.stack.remove(widget) - - def create_month_view(self, year, month): - grid = Gtk.Grid( - column_homogeneous=True, row_homogeneous=False, name="calendar-grid" - ) - - cal = calendar.Calendar(firstweekday=6) - month_days = cal.monthdayscalendar(year, month) - - prev_month = month - 1 if month > 1 else 12 - next_month = month + 1 if month < 12 else 1 - prev_year = year if month > 1 else year - 1 - next_year = year if month < 12 else year + 1 - - prev_month_days_count = calendar.monthrange(prev_year, prev_month)[1] - next_month_day = 1 - - for row, week in enumerate(month_days): - for col, day in enumerate(week): - day_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="day-box") - top_spacer = Gtk.Box(hexpand=True, vexpand=True) - middle_box = Gtk.Box(hexpand=True, vexpand=True) - bottom_spacer = Gtk.Box(hexpand=True, vexpand=True) - - if day == 0: - if row == 0: - day_num = prev_month_days_count - week[:col].count(0) - label = Gtk.Label(label=str(day_num), name="prev-month-day") - else: - label = Gtk.Label( - label=str(next_month_day), name="next-month-day" - ) - next_month_day += 1 - else: - label = Gtk.Label(label=str(day), name="day-label") - if ( - day == self.current_day - and month == datetime.now().month - and year == datetime.now().year - ): - label.get_style_context().add_class("current-day") - - middle_box.pack_start( - Gtk.Box(hexpand=True, vexpand=True), True, True, 0 - ) - middle_box.pack_start(label, False, False, 0) - middle_box.pack_start( - Gtk.Box(hexpand=True, vexpand=True), True, True, 0 - ) - - day_box.pack_start(top_spacer, True, True, 0) - day_box.pack_start(middle_box, True, True, 0) - day_box.pack_start(bottom_spacer, True, True, 0) - - grid.attach(day_box, col, row, 1, 1) - - grid.show_all() - return grid - - def get_weekday_initials(self): - # Returns localized weekday initials. - return [datetime(2023, 1, i + 1).strftime("%a")[:1] for i in range(7)] - - def on_prev_month_clicked(self, widget): - if self.current_month == 1: - self.current_month = 12 - self.current_year -= 1 - else: - self.current_month -= 1 - self.update_calendar() - - def on_next_month_clicked(self, widget): - if self.current_month == 12: - self.current_month = 1 - self.current_year += 1 - else: - self.current_month += 1 - self.update_calendar() - - def check_date_change(self): - now = datetime.now() - if ( - now.day != self.current_day - or now.month != self.current_month - or now.year != self.current_year - ): - self.current_day = now.day - self.current_month = now.month - self.current_year = now.year - self.update_calendar() - return True - diff --git a/modules/launcher/components/cliphist.py b/modules/launcher/components/cliphist.py deleted file mode 100644 index 5747747..0000000 --- a/modules/launcher/components/cliphist.py +++ /dev/null @@ -1,504 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.widgets.entry import Entry -from fabric.widgets.image import Image -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.utils import idle_add, remove_handler -from fabric.utils.helpers import get_relative_path -from gi.repository import GLib, Gdk, GdkPixbuf -import subprocess -import os -import re -import sys -import tempfile - -import utils.icons as icons - - -class ClipHistory(Box): - def __init__(self, **kwargs): - super().__init__( - name="clip-history", - visible=False, - all_visible=False, - **kwargs, - ) - - # Create a temporary directory for image icons - self.tmp_dir = tempfile.mkdtemp(prefix="cliphist-") - self.image_cache = {} # Cache for image previews - - self.launcher = kwargs["launcher"] - self.selected_index = -1 # Track the selected item index - self._arranger_handler = 0 - self.clipboard_items = [] - self._loading = False - self._pending_updates = False - - self.viewport = Box(name="viewport", spacing=4, orientation="v") - self.search_entry = Entry( - name="search-entry", - placeholder="Search Clipboard History...", - h_expand=True, - notify_text=self.filter_items, - on_activate=lambda entry, *_: self.use_selected_item(), - on_key_press_event=self.on_search_entry_key_press, - ) - self.search_entry.props.xalign = 0.5 - - self.scrolled_window = ScrolledWindow( - name="scrolled-window", - spacing=10, - min_content_size=(450, 105), - max_content_size=(450, 105), - child=self.viewport, - ) - - self.header_box = Box( - name="header_box", - spacing=10, - orientation="h", - children=[ - Button( - name="clear-button", - child=Label(name="clear-label", markup=icons.trash), - on_clicked=lambda *_: self.clear_history(), - ), - self.search_entry, - ], - ) - - self.history_box = Box( - name="launcher-box", - spacing=10, - h_expand=True, - orientation="v", - children=[ - self.header_box, - self.scrolled_window, - ], - ) - - self.add(self.history_box) - self.show_all() - - def close(self): - """Close the clipboard history panel""" - self.viewport.children = [] - self.selected_index = -1 # Reset selection - self.launcher.close() - - def open(self): - """Open the clipboard history panel and load items""" - self.viewport.set_visible(True) - if self._loading: - return - self._loading = True - self.search_entry.set_text("") # Clear search - self.search_entry.grab_focus() - # Start loading in background using GLib.idle_add - GLib.idle_add(self._load_clipboard_items_thread) - - def _load_clipboard_items_thread(self): - """Worker for loading clipboard items, now runs in main loop via idle_add""" - try: - result = subprocess.run( - ["cliphist", "list"], capture_output=True, text=True, check=True - ) - lines = result.stdout.strip().split("\n") - new_items = [] - for line in lines: - if not line or " 1 else "0" - content = parts[1] if len(parts) > 1 else item - - # Truncate content for display - display_text = content.strip() - if len(display_text) > 100: - display_text = display_text[:97] + "..." - - # Check if this is an image by examining the content - is_image = self.is_image_data(content) - - if is_image: - # For images, create item with image preview - button = Button( - name="slot-button", - child=Box( - name="slot-box", - orientation="h", - spacing=10, - children=[ - Image(name="clip-icon", h_align="start"), # Placeholder - Label( - name="clip-label", - label="[Image]", - ellipsization="end", - v_align="center", - h_align="start", - h_expand=True, - ), - ], - ), - tooltip_text="Image in clipboard", - on_clicked=lambda *_, id=item_id: self.paste_item(id), - ) - # Load image preview in background - self._load_image_preview_async(item_id, button) - else: - # For text, create regular item - button = self.create_text_item_button(item_id, display_text) - - # Add key press event handler for Enter key - button.connect( - "key-press-event", - lambda widget, event, id=item_id: self.on_item_key_press(widget, event, id), - ) - - # Make sure button can receive focus and key events - button.set_can_focus(True) - button.add_events(Gdk.EventMask.KEY_PRESS_MASK) - - return button - - def _load_image_preview_async(self, item_id, button): - """Load image preview using GLib.idle_add instead of threads""" - - def load_image(): - try: - if item_id in self.image_cache: - pixbuf = self.image_cache[item_id] - else: - result = subprocess.run( - ["cliphist", "decode", item_id], capture_output=True, check=True - ) - loader = GdkPixbuf.PixbufLoader() - loader.write(result.stdout) - loader.close() - pixbuf = loader.get_pixbuf() - width, height = pixbuf.get_width(), pixbuf.get_height() - max_size = 72 - if width > height: - new_width = max_size - new_height = int(height * (max_size / width)) - else: - new_height = max_size - new_width = int(width * (max_size / height)) - pixbuf = pixbuf.scale_simple( - new_width, new_height, GdkPixbuf.InterpType.BILINEAR - ) - self.image_cache[item_id] = pixbuf - self._update_image_button(button, pixbuf) - except Exception as e: - print(f"Error loading image preview: {e}", file=sys.stderr) - return False - - GLib.idle_add(load_image) - - def _update_image_button(self, button, pixbuf): - """Update the button with the loaded image preview""" - box = button.get_child() - if box and len(box.get_children()) > 0: - image_widget = box.get_children()[0] - if isinstance(image_widget, Image): - image_widget.set_from_pixbuf(pixbuf) - - def create_text_item_button(self, item_id, display_text): - """Create a button for a text clipboard item""" - return Button( - name="slot-button", - child=Box( - name="slot-box", - orientation="h", - spacing=10, - children=[ - Label( - name="clip-icon", - markup=icons.clip_text, - h_align="start", - ), - Label( - name="clip-label", - label=display_text, - ellipsization="end", - v_align="center", - h_align="start", - h_expand=True, - ), - ], - ), - tooltip_text=display_text, - on_clicked=lambda *_: self.paste_item(item_id), - ) - - def is_image_data(self, content): - """Determine if clipboard content is likely an image""" - # Check for common image data patterns - return ( - content.startswith("data:image/") - or content.startswith("\x89PNG") - or content.startswith("GIF8") - or content.startswith("\xff\xd8\xff") # JPEG - or re.match(r"^\s* visible_bottom: - # Item below viewport - align to bottom - new_value = y + height - page_size - adj.set_value(new_value) - return False - - GLib.idle_add(scroll) - - def use_selected_item(self): - """Use (paste) the selected clipboard item""" - children = self.viewport.get_children() - if ( - not children - or self.selected_index == -1 - or self.selected_index >= len(self.clipboard_items) - ): - return - - # Get the item ID from the first part before the tab - item_line = self.clipboard_items[self.selected_index] - item_id = item_line.split("\t", 1)[0] - self.paste_item(item_id) - - def delete_selected_item(self): - """Delete the selected clipboard item""" - children = self.viewport.get_children() - if not children or self.selected_index == -1: - return - - # Get the item ID from the first part before the tab - item_line = self.clipboard_items[self.selected_index] - item_id = item_line.split("\t", 1)[0] - self.delete_item(item_id) - - def on_item_key_press(self, widget, event, item_id): - """Handle key press events on clipboard items""" - if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): - # Copy item to clipboard and close - self.paste_item(item_id) - return True - return False - - def __del__(self): - """Clean up temporary files on destruction""" - try: - if hasattr(self, "tmp_dir") and os.path.exists(self.tmp_dir): - import shutil - - shutil.rmtree(self.tmp_dir) - self.image_cache.clear() - except Exception as e: - print(f"Error cleaning up temporary files: {e}", file=sys.stderr) diff --git a/modules/launcher/components/dashboard.py b/modules/launcher/components/dashboard.py deleted file mode 100644 index 49b8ad8..0000000 --- a/modules/launcher/components/dashboard.py +++ /dev/null @@ -1,605 +0,0 @@ -import subprocess -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.utils.helpers import exec_shell_command_async -import gi -from gi.repository import Gtk, Gdk, GLib -from services import network_client -from services.powerprofile import PowerProfiles -from fabric import Fabricator - -gi.require_version("Gtk", "3.0") -import utils.icons as icons - - -def add_hover_cursor(widget): - # Add enter/leave events to change the cursor - widget.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK) - widget.connect( - "enter-notify-event", - lambda w, e: w.get_window().set_cursor( - Gdk.Cursor.new_from_name(w.get_display(), "pointer") - ) - if w.get_window() - else None, - ) - widget.connect( - "leave-notify-event", - lambda w, e: w.get_window().set_cursor(None) if w.get_window() else None, - ) - - -class NetworkButton(Box): - def __init__(self): - self.network_client = network_client - self._animation_timeout_id = None - self._animation_step = 0 - self._animation_direction = 1 - - self.network_icon = Label( - name="network-icon", - markup=None, - ) - self.network_label = Label( - name="network-label", - label="Wi-Fi", - justification="left", - ) - self.network_label_box = Box(children=[self.network_label, Box(h_expand=True)]) - self.network_ssid = Label( - name="network-ssid", - justification="left", - ) - self.network_ssid_box = Box(children=[self.network_ssid, Box(h_expand=True)]) - self.network_text = Box( - name="network-text", - orientation="v", - h_align="start", - v_align="center", - children=[self.network_label_box, self.network_ssid_box], - ) - self.network_status_box = Box( - h_align="start", - v_align="center", - spacing=10, - children=[self.network_icon, self.network_text], - ) - self.network_status_button = Button( - name="network-status-button", - h_expand=True, - child=self.network_status_box, - on_clicked=lambda *_: self.network_client.wifi_device.toggle_wifi() - if self.network_client.wifi_device - else None, - ) - add_hover_cursor(self.network_status_button) # <-- Added hover - - self.network_menu_label = Label( - name="network-menu-label", - markup=icons.chevron_right, - ) - self.network_menu_button = Button( - name="network-menu-button", - child=self.network_menu_label, - ) - add_hover_cursor(self.network_menu_button) # <-- Added hover - - super().__init__( - name="network-button", - orientation="h", - h_align="fill", - v_align="fill", - h_expand=True, - v_expand=True, - spacing=0, - children=[self.network_status_button, self.network_menu_button], - ) - - self.widgets = [ - self, - self.network_icon, - self.network_label, - self.network_ssid, - self.network_status_button, - self.network_menu_button, - self.network_menu_label, - ] - - # Connect to wifi device signals when ready - self.network_client.connect("device-ready", self._on_wifi_ready) - - # Check initial state using idle_add to defer until GTK loop is running - GLib.idle_add(self._initial_update) - - def _initial_update(self): - self.update_state() - return False # Run only once - - def _on_wifi_ready(self, *args): - if self.network_client.wifi_device: - self.network_client.wifi_device.connect( - "notify::enabled", self.update_state - ) - self.network_client.wifi_device.connect("notify::ssid", self.update_state) - self.update_state() - - def _animate_searching(self): - """Animate wifi icon when searching for networks""" - wifi_icons = [ - icons.wifi_0, - icons.wifi_1, - icons.wifi_2, - icons.wifi_3, - icons.wifi_2, - icons.wifi_1, - ] - - # Si el widget no existe o el WiFi está desactivado, detener la animación - wifi = self.network_client.wifi_device - if not self.network_icon or not wifi or not wifi.enabled: - self._stop_animation() - return False - - # Si estamos conectados, detener la animación - if wifi.state == "activated" and wifi.ssid != "Disconnected": - self._stop_animation() - return False - - GLib.idle_add(self.network_icon.set_markup, wifi_icons[self._animation_step]) - - # Reiniciar al principio cuando llegamos al final - self._animation_step = (self._animation_step + 1) % len(wifi_icons) - - return True # Mantener la animación activa - - def _start_animation(self): - if self._animation_timeout_id is None: - self._animation_step = 0 - self._animation_direction = 1 - # Ejecuta la animación cada 500ms sin usar idle_add - self._animation_timeout_id = GLib.timeout_add(500, self._animate_searching) - - def _stop_animation(self): - if self._animation_timeout_id is not None: - GLib.source_remove(self._animation_timeout_id) - self._animation_timeout_id = None - - def update_state(self, *args): - """Update the button state based on network status""" - wifi = self.network_client.wifi_device - ethernet = self.network_client.ethernet_device - - # Update enabled/disabled state - if wifi and not wifi.enabled: - self._stop_animation() - self.network_icon.set_markup(icons.wifi_off) - for widget in self.widgets: - widget.add_style_class("disabled") - self.network_ssid.set_label("Disabled") - return - - # Remove disabled class if we got here - for widget in self.widgets: - widget.remove_style_class("disabled") - - # Update text and animation based on state - if wifi and wifi.enabled: - if wifi.state == "activated" and wifi.ssid != "Disconnected": - self._stop_animation() - self.network_ssid.set_label(wifi.ssid) - # Update icon based on signal strength - if wifi.strength > 0: - strength = wifi.strength - if strength < 25: - self.network_icon.set_markup(icons.wifi_0) - elif strength < 50: - self.network_icon.set_markup(icons.wifi_1) - elif strength < 75: - self.network_icon.set_markup(icons.wifi_2) - else: - self.network_icon.set_markup(icons.wifi_3) - else: - self.network_ssid.set_label("Enabled") - self._start_animation() - - # Handle primary device check safely - try: - primary_device = self.network_client.primary_device - except AttributeError: - primary_device = "wireless" # Default to wireless if error occurs - - # Handle wired connection case - if primary_device == "wired": - self._stop_animation() - if ethernet and ethernet.internet == "activated": - self.network_icon.set_markup(icons.world) - else: - self.network_icon.set_markup(icons.world_off) - else: - if not wifi: - self._stop_animation() - self.network_icon.set_markup(icons.wifi_off) - elif ( - wifi.state == "activated" - and wifi.ssid != "Disconnected" - and wifi.strength > 0 - ): - self._stop_animation() - strength = wifi.strength - if strength < 25: - self.network_icon.set_markup(icons.wifi_0) - elif strength < 50: - self.network_icon.set_markup(icons.wifi_1) - elif strength < 75: - self.network_icon.set_markup(icons.wifi_2) - else: - self.network_icon.set_markup(icons.wifi_3) - else: - self._start_animation() - - -class BluetoothButton(Box): - def __init__(self, launcher): - super().__init__( - name="bluetooth-button", - orientation="h", - h_align="fill", - v_align="fill", - h_expand=True, - v_expand=True, - ) - self.launcher = launcher - - self.bluetooth_icon = Label( - name="bluetooth-icon", - markup=icons.bluetooth, - ) - self.bluetooth_label = Label( - name="bluetooth-label", - label="Bluetooth", - justification="left", - ) - self.bluetooth_label_box = Box( - children=[self.bluetooth_label, Box(h_expand=True)] - ) - self.bluetooth_status_text = Label( - name="bluetooth-status", - label="Disabled", - justification="left", - ) - self.bluetooth_status_box = Box( - children=[self.bluetooth_status_text, Box(h_expand=True)] - ) - self.bluetooth_text = Box( - orientation="v", - h_align="start", - v_align="center", - children=[self.bluetooth_label_box, self.bluetooth_status_box], - ) - self.bluetooth_status_container = Box( - h_align="start", - v_align="center", - spacing=10, - children=[self.bluetooth_icon, self.bluetooth_text], - ) - self.bluetooth_status_button = Button( - name="bluetooth-status-button", - h_expand=True, - child=self.bluetooth_status_container, - on_clicked=lambda *_: self.launcher.bluetooth.client.toggle_power(), - ) - add_hover_cursor(self.bluetooth_status_button) # <-- Added hover - self.bluetooth_menu_label = Label( - name="bluetooth-menu-label", - markup=icons.chevron_right, - ) - self.bluetooth_menu_button = Button( - name="bluetooth-menu-button", - on_clicked=lambda *_: self.launcher.open("bluetooth"), - child=self.bluetooth_menu_label, - ) - add_hover_cursor(self.bluetooth_menu_button) # <-- Added hover - - self.add(self.bluetooth_status_button) - self.add(self.bluetooth_menu_button) - - -class NightModeButton(Button): - def __init__(self): - self.night_mode_icon = Label( - name="night-mode-icon", - markup=icons.night, - ) - self.night_mode_label = Label( - name="night-mode-label", - label="Night Mode", - justification="left", - ) - self.night_mode_label_box = Box( - children=[self.night_mode_label, Box(h_expand=True)] - ) - self.night_mode_status = Label( - name="night-mode-status", - label="Enabled", - justification="left", - ) - self.night_mode_status_box = Box( - children=[self.night_mode_status, Box(h_expand=True)] - ) - self.night_mode_text = Box( - name="night-mode-text", - orientation="v", - h_align="start", - v_align="center", - children=[self.night_mode_label_box, self.night_mode_status_box], - ) - self.night_mode_box = Box( - h_align="start", - v_align="center", - spacing=10, - children=[self.night_mode_icon, self.night_mode_text], - ) - - super().__init__( - name="night-mode-button", - h_expand=True, - child=self.night_mode_box, - on_clicked=self.toggle_hyprsunset, - ) - add_hover_cursor(self) # <-- Added hover - - self.widgets = [ - self, - self.night_mode_label, - self.night_mode_status, - self.night_mode_icon, - ] - # self.check_hyprsunset() - # **Monitor external changes every 3 seconds** - self.hyprsunset_watcher = Fabricator( - self.is_hyprsunset_running, - interval=3000, - stream=False, - default_value=False, - ) - self.hyprsunset_watcher.changed.connect(self.update_night_mode_status) - self.update_night_mode_status(None, self.is_hyprsunset_running()) - - def toggle_hyprsunset(self, *args): - """ - Toggle the 'hyprsunset' process: - - If running, kill it and mark as 'Disabled'. - - If not running, start it and mark as 'Enabled'. - """ - try: - subprocess.check_output(["pgrep", "hyprsunset"]) - exec_shell_command_async("pkill hyprsunset") - self.night_mode_status.set_label("Disabled") - for widget in self.widgets: - widget.add_style_class("disabled") - except subprocess.CalledProcessError: - exec_shell_command_async("hyprsunset -t 3500") - self.night_mode_status.set_label("Enabled") - for widget in self.widgets: - widget.remove_style_class("disabled") - - def is_hyprsunset_running(self, *args): - """Returns True if hyprsunset is running.""" - try: - subprocess.check_output(["pgrep", "hyprsunset"]) - return True - except subprocess.CalledProcessError: - return False - - def update_night_mode_status(self, _, is_running): - """Update UI based on hyprsunset status.""" - if is_running: - self.night_mode_status.set_label("Enabled") - for widget in self.widgets: - widget.remove_style_class("disabled") - else: - self.night_mode_status.set_label("Disabled") - for widget in self.widgets: - widget.add_style_class("disabled") - - -class CaffeineButton(Button): - def __init__(self): - self.caffeine_icon = Label( - name="caffeine-icon", - markup=icons.coffee, - ) - self.caffeine_label = Label( - name="caffeine-label", - label="Caffeine", - justification="left", - ) - self.caffeine_label_box = Box( - children=[self.caffeine_label, Box(h_expand=True)] - ) - self.caffeine_status = Label( - name="caffeine-status", - label="Enabled", - justification="left", - ) - self.caffeine_status_box = Box( - children=[self.caffeine_status, Box(h_expand=True)] - ) - self.caffeine_text = Box( - name="caffeine-text", - orientation="v", - h_align="start", - v_align="center", - children=[self.caffeine_label_box, self.caffeine_status_box], - ) - self.caffeine_box = Box( - h_align="start", - v_align="center", - spacing=10, - children=[self.caffeine_icon, self.caffeine_text], - ) - super().__init__( - name="caffeine-button", - h_expand=True, - child=self.caffeine_box, - on_clicked=self.toggle_wlinhibit, - ) - add_hover_cursor(self) # <-- Added hover - - self.widgets = [ - self, - self.caffeine_label, - self.caffeine_status, - self.caffeine_icon, - ] - # **Monitor external changes every 3 seconds** - self.wlinhibit_watcher = Fabricator( - self.is_wlinhibit_running, - interval=3000, - stream=False, - default_value=False, - ) - self.wlinhibit_watcher.changed.connect(self.update_wlinhibit_status) - self.update_wlinhibit_status(None, self.is_wlinhibit_running()) - - def toggle_wlinhibit(self, *args): - """Toggle the 'wlinhibit' process.""" - if self.is_wlinhibit_running(): - exec_shell_command_async("pkill wlinhibit") - else: - exec_shell_command_async("wlinhibit") - - def is_wlinhibit_running(self, *args): - """Returns True if wlinhibit is running. Accepts extra arguments to avoid Fabricator errors.""" - try: - subprocess.check_output(["pgrep", "wlinhibit"]) - return True - except subprocess.CalledProcessError: - return False - - def update_wlinhibit_status(self, _, is_running): - """Update UI based on wlinhibit status.""" - if is_running: - self.caffeine_status.set_label("Enabled") - for widget in self.widgets: - widget.remove_style_class("disabled") - else: - self.caffeine_status.set_label("Disabled") - for widget in self.widgets: - widget.add_style_class("disabled") - - -class PowerProfileButton(Button): - def __init__(self): - self.power_profile_service = PowerProfiles.get_default() - - self.power_profile_icon = Label( - name="power-profile-icon", - markup=icons.power_balanced, # Default icon - ) - self.power_profile_label = Label( - name="power-profile-label", - label="Power Profile", - justification="left", - ) - self.power_profile_label_box = Box( - children=[self.power_profile_label, Box(h_expand=True)] - ) - self.power_profile_status = Label( - name="power-profile-status", - label="Balanced", - justification="left", - ) - self.power_profile_status_box = Box( - children=[self.power_profile_status, Box(h_expand=True)] - ) - self.power_profile_text = Box( - name="power-profile-text", - orientation="v", - h_align="start", - v_align="center", - children=[self.power_profile_label_box, self.power_profile_status_box], - ) - self.power_profile_box = Box( - h_align="start", - v_align="center", - spacing=10, - children=[self.power_profile_icon, self.power_profile_text], - ) - - super().__init__( - name="power-profile-button", - h_expand=True, - child=self.power_profile_box, - on_clicked=self.toggle_power_profile, - ) - add_hover_cursor(self) - - self.widgets = [ - self, - self.power_profile_label, - self.power_profile_status, - self.power_profile_icon, - ] - - # Connect to power profile service signals - self.power_profile_service.connect("profile", self.update_power_profile_status) - - # Initial update - self.update_power_profile_status(None, self.power_profile_service.get_current_profile()) - - def toggle_power_profile(self, *args): - current_profile = self.power_profile_service.get_current_profile() - profiles = ["power-saver", "balanced", "performance"] - current_index = profiles.index(current_profile) - next_profile = profiles[(current_index + 1) % len(profiles)] - self.power_profile_service.set_power_profile(next_profile) - - def update_power_profile_status(self, _, profile): - # Map profiles to their display names and icons - profile_map = { - "power-saver": {"name": "Power Saver", "icon": icons.power_saving}, - "balanced": {"name": "Balanced", "icon": icons.power_balanced}, - "performance": {"name": "Performance", "icon": icons.power_performance}, - } - - profile_info = profile_map.get(profile, profile_map["balanced"]) - self.power_profile_status.set_label(profile_info["name"]) - self.power_profile_icon.set_markup(profile_info["icon"]) - - if profile == "balanced": - for widget in self.widgets: - widget.add_style_class("disabled") - else: - for widget in self.widgets: - widget.remove_style_class("disabled") - - - -class Dashboard(Gtk.Grid): - def __init__(self, **kwargs): - super().__init__(name="buttons-grid") - self.set_row_homogeneous(True) - self.set_column_homogeneous(True) - self.set_row_spacing(8) - self.set_column_spacing(8) - self.set_vexpand(True) # Prevent vertical expansion - - self.launcher = kwargs["launcher"] - - # Instantiate each button - self.network_button = NetworkButton() - self.bluetooth_button = BluetoothButton(self.launcher) - self.night_mode_button = NightModeButton() - self.caffeine_button = CaffeineButton() - self.power_profile_button = PowerProfileButton() - - # Attach buttons into the grid (two rows, three columns) - self.attach(self.network_button, 0, 0, 1, 1) - self.attach(self.bluetooth_button, 1, 0, 1, 1) - self.attach(self.power_profile_button, 2, 0, 1, 1) - self.attach(self.night_mode_button, 0, 1, 1, 1) - self.attach(self.caffeine_button, 1, 1, 1, 1) - - self.show_all() diff --git a/modules/launcher/components/emoji.py b/modules/launcher/components/emoji.py deleted file mode 100644 index b69355e..0000000 --- a/modules/launcher/components/emoji.py +++ /dev/null @@ -1,330 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.widgets.entry import Entry -from fabric.widgets.stack import Stack -from fabric.utils import remove_handler, get_relative_path -from gi.repository import Gdk -import utils.icons as icons -import os -import subprocess -import ijson - - -class EmojiPicker(Box): - def __init__(self, **kwargs): - super().__init__( - name="emoji", - visible=False, - all_visible=False, - **kwargs, - ) - - self.launcher = kwargs["launcher"] - self.selected_index = -1 - self.emojis_per_page = 9 * 3 - self.current_page_index = 0 - self.filtered_emojis = [] - self.total_pages = 0 - - self._arranger_handler: int = 0 - self._all_emojis = self._load_emoji_data() - - self.stack = Stack( - name="viewport", - spacing=4, - orientation="v", - transition_type="slide-up-down", - transition_duration=200, - ) - self.search_entry = Entry( - name="search-entry", - placeholder="Search Emojis...", - h_expand=True, - notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()), - on_activate=lambda entry, *_: self.on_search_entry_activate( - entry.get_text() - ), - on_key_press_event=self.on_search_entry_key_press, - ) - self.search_entry.props.xalign = 0.5 - self.header_box = Box( - name="header_box", - spacing=10, - orientation="h", - children=[ - self.search_entry, - # Button( - # name="close-button", - # child=Label(name="close-label", markup=icons.cancel), - # tooltip_text="Exit", - # on_clicked=lambda *_: self.close_picker(), - # ), - ], - ) - - self.picker_box = Box( - name="picker-box", - spacing=10, - h_expand=True, - orientation="v", - children=[ - self.header_box, - self.stack, - ], - ) - - self.resize_viewport() - - self.add(self.picker_box) - self.show_all() - - def _load_emoji_data(self): - emoji_data = {} - emoji_file_path = get_relative_path("../../../config/assets/emoji.json") - if not os.path.exists(emoji_file_path): - print(f"Emoji JSON file not found at: {emoji_file_path}") - return {} - - with open(emoji_file_path, "r") as f: - for emoji_char, emoji_info in ijson.kvitems(f, ""): - emoji_data[emoji_char] = emoji_info - return emoji_data - - def close_picker(self): - self.stack.children = [] - self.selected_index = -1 - self.launcher.close() - - def open_picker(self): - self.search_entry.set_text("") - self.current_page_index = 0 - self.arrange_viewport() - self.search_entry.grab_focus() - - def arrange_viewport(self, query: str = ""): - remove_handler(self._arranger_handler) if self._arranger_handler else None - self.stack.children = [] - self.selected_index = -1 - self.current_page_index = 0 - - self.filtered_emojis = [ - (emoji_char, emoji_info) - for emoji_char, emoji_info in self._all_emojis.items() - if query.casefold() - in ( - emoji_info.get("name", "") + " " + emoji_info.get("group", "") - ).casefold() - ] - self.total_pages = ( - (len(self.filtered_emojis) + self.emojis_per_page - 1) - // self.emojis_per_page - if self.filtered_emojis - else 0 - ) - - self.load_page(self.current_page_index) - - should_resize = not query - - if should_resize: - self.resize_viewport() - if query.strip() != "" and self.get_all_emoji_buttons(): - self.update_selection(0) - - def load_page(self, page_index): - self.update_selection(-1) - page_box = Box(name=f"page-box-{page_index}", orientation="v", spacing=4) - start_index = page_index * self.emojis_per_page - end_index = min( - (page_index + 1) * self.emojis_per_page, len(self.filtered_emojis) - ) - page_emojis = self.filtered_emojis[start_index:end_index] - - grid_box = Box(name="emoji-grid-box", orientation="v", spacing=2) - - row_box = None - for i, (emoji_char, emoji_info) in enumerate(page_emojis): - if i % 9 == 0: - row_box = Box(name="emoji-row-box", orientation="h", spacing=2) - grid_box.add(row_box) - if row_box is not None: - row_box.add(self.bake_emoji_slot(emoji_char, emoji_info)) - page_box.add(grid_box) - self.stack.add_named(page_box, f"page-{page_index}") - self.stack.set_visible_child_name(f"page-{page_index}") - page_box.show_all() - - buttons = self.get_all_emoji_buttons() - if buttons and self.selected_index != -1: - page_relative_index = self.selected_index % self.emojis_per_page - if page_relative_index < len(buttons): - self.update_selection(page_relative_index) - else: - self.update_selection(len(buttons) - 1) - - def resize_viewport(self): - return False - - def bake_emoji_slot(self, emoji_char: str, emoji_info: dict, **kwargs) -> Button: - button = Button( - name="emoji-slot-button", - child=Box( - name="emoji-slot-box", - orientation="horizontal", - halign="center", - valign="center", - children=[ - Label( - name="emoji-char-label", - label=emoji_char, - use_markup=True, - v_align="center", - h_align="center", - css_name="emoji-char-label", - ), - ], - ), - tooltip_text=emoji_info.get("name", "Unknown"), - on_clicked=lambda *_: ( - self.copy_emoji_to_clipboard(emoji_char), - self.close_picker(), - ), - **kwargs, - ) - return button - - def update_selection(self, new_index: int): - buttons = self.get_all_emoji_buttons() - if not buttons: - self.selected_index = -1 - return - - if self.selected_index != -1 and self.selected_index < len(buttons): - current_button = buttons[self.selected_index] - current_button.get_style_context().remove_class("selected") - if not buttons or current_button not in buttons: - self.selected_index = -1 - - if 0 <= new_index < len(buttons): - new_button = buttons[new_index] - new_button.get_style_context().add_class("selected") - self.selected_index = new_index - else: - self.selected_index = -1 - - def get_all_emoji_buttons(self): - buttons = [] - current_page = self.stack.get_visible_child() - if current_page and current_page.get_children(): - if current_page.get_children()[0].get_children(): - for row_box in current_page.get_children()[0].get_children(): - buttons.extend(row_box.get_children()) - return buttons - - def on_search_entry_activate(self, text): - buttons = self.get_all_emoji_buttons() - if buttons: - if self.selected_index != -1: - buttons[self.selected_index].clicked() - elif buttons and text.strip() != "": - buttons[0].clicked() - - def on_search_entry_key_press(self, widget, event): - if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right): - self.move_selection_2d(event.keyval) - return True - elif event.keyval == Gdk.KEY_Escape: - self.close_picker() - return True - return False - - def move_selection_2d(self, keyval): - buttons = self.get_all_emoji_buttons() - total_items_current_page = len(buttons) - if total_items_current_page == 0: - return - - rows = 3 - columns = 9 - - if self.selected_index == -1: - if keyval in (Gdk.KEY_Down, Gdk.KEY_Right): - new_index = 0 - elif keyval in (Gdk.KEY_Up, Gdk.KEY_Left): - new_index = total_items_current_page - 1 - else: - return - else: - current_index_page = self.selected_index - row = current_index_page // columns - col = current_index_page % columns - - if keyval == Gdk.KEY_Right: - new_col = (col + 1) % columns - new_row = row - if new_col == 0: - new_row = row + 1 - elif keyval == Gdk.KEY_Left: - new_col = (col - 1) % columns - new_row = row - if new_col == (columns - 1): - new_row = row - 1 - elif keyval == Gdk.KEY_Down: - new_row = row + 1 - new_col = col - elif keyval == Gdk.KEY_Up: - new_row = row - 1 - new_col = col - else: - return - - if new_row >= rows: - if self.current_page_index < self.total_pages - 1: - current_col = col # Keep track of current column - self.current_page_index += 1 - self.load_page(self.current_page_index) - new_index = current_col # Try to keep the same column - if ( - new_index >= total_items_current_page - ): # if column is out of bound, select last - new_index = total_items_current_page - 1 - self.selected_index = -1 - self.update_selection(new_index) - return - else: - new_index = total_items_current_page - 1 - elif new_row < 0: - if self.current_page_index > 0: - current_col = col # Keep track of current column - self.current_page_index -= 1 - self.load_page(self.current_page_index) - new_index = ( - rows - 1 - ) * columns + current_col # Select last row, same column - if ( - new_index >= total_items_current_page - ): # if column is out of bound, select last - new_index = total_items_current_page - 1 - self.selected_index = -1 - self.update_selection(new_index) - return - else: - new_index = 0 - else: - new_index = new_row * columns + new_col - if new_index >= total_items_current_page: - new_index = total_items_current_page - 1 - - if new_index < 0: - new_index = 0 - elif new_index >= total_items_current_page: - new_index = total_items_current_page - 1 - - self.update_selection(new_index) - - def copy_emoji_to_clipboard(self, emoji_char: str): - try: - subprocess.run(["wl-copy"], input=emoji_char.encode("utf-8"), check=True) - except subprocess.CalledProcessError as e: - print(f"Clipboard copy failed: {e}") diff --git a/modules/launcher/components/kanban.py b/modules/launcher/components/kanban.py deleted file mode 100644 index 8c2bf8a..0000000 --- a/modules/launcher/components/kanban.py +++ /dev/null @@ -1,365 +0,0 @@ -import gi -import json -import os -from pathlib import Path -import cairo # For rendering the drag preview - -from fabric.widgets.label import Label -from fabric.widgets.box import Box -from fabric.widgets.centerbox import CenterBox -import utils.icons as icons - -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, Gdk, GObject, GLib - - -def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface: - alloc = widget.get_allocation() - surface = cairo.ImageSurface(cairo.Format.ARGB32, alloc.width, alloc.height) - cr = cairo.Context(surface) - # Use a transparent background. - cr.set_source_rgba(0, 0, 0, 0) - cr.rectangle(0, 0, alloc.width, alloc.height) - cr.fill() - widget.draw(cr) - return surface - - -class InlineEditor(Gtk.Box): - __gsignals__ = { - "confirmed": (GObject.SignalFlags.RUN_LAST, None, (str,)), - "canceled": (GObject.SignalFlags.RUN_LAST, None, ()), - } - - def __init__(self, initial_text=""): - super().__init__(name="inline-editor", spacing=4) - # Replace Gtk.Entry with a Gtk.TextView for multiline editing. - self.text_view = Gtk.TextView() - self.text_view.set_wrap_mode(Gtk.WrapMode.WORD) - buffer = self.text_view.get_buffer() - buffer.set_text(initial_text) - # Connect key press events to handle Return and SHIFT+Return. - self.text_view.connect("key-press-event", self.on_key_press) - - confirm_btn = Gtk.Button( - name="kanban-btn", child=Label(name="kanban-btn-label", markup=icons.accept) - ) - confirm_btn.connect("clicked", self.on_confirm) - confirm_btn.get_style_context().add_class("flat") - - cancel_btn = Gtk.Button( - name="kanban-btn", child=Label(name="kanban-btn-neg", markup=icons.cancel) - ) - cancel_btn.connect("clicked", self.on_cancel) - cancel_btn.get_style_context().add_class("flat") - - # Pack the TextView inside a ScrolledWindow for better appearance. - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - sw.set_min_content_height(50) - sw.add(self.text_view) - - self.button_box = Box(children=[confirm_btn, cancel_btn], spacing=4) - self.center_box = CenterBox(center_children=[self.button_box], orientation="v") - - self.pack_start(sw, True, True, 0) - self.pack_start(self.center_box, False, False, 0) - self.show_all() - - def on_confirm(self, widget): - buffer = self.text_view.get_buffer() - start, end = buffer.get_bounds() - text = buffer.get_text(start, end, True).strip() - if text: - self.emit("confirmed", text) - else: - self.emit("canceled") - - def on_cancel(self, widget): - self.emit("canceled") - - def on_key_press(self, widget, event): - # Check for Escape to cancel. - if event.keyval == Gdk.KEY_Escape: - self.emit("canceled") - return True - - # If Return is pressed... - if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): - state = event.get_state() - if state & Gdk.ModifierType.SHIFT_MASK: - # SHIFT+Return: insert a newline. - buffer = self.text_view.get_buffer() - cursor_iter = buffer.get_iter_at_mark(buffer.get_insert()) - buffer.insert(cursor_iter, "\n") - return True # Prevent further handling. - else: - # Plain Return: confirm the edit. - self.on_confirm(widget) - return True - return False - - -class KanbanNote(Gtk.EventBox): - __gsignals__ = { - "changed": (GObject.SignalFlags.RUN_LAST, None, ()), - } - - def __init__(self, text): - super().__init__() - self.text = text - # Variables to store the click offset for drag preview. - self.setup_ui() - self.setup_dnd() - self.connect("button-press-event", self.on_button_press) - - def setup_ui(self): - self.box = Gtk.Box(name="kanban-note", spacing=4) - self.label = Gtk.Label(label=self.text) - self.label.set_line_wrap(True) - # Wrap long lines. - self.label.set_line_wrap_mode(Gtk.WrapMode.WORD) - - self.delete_btn = Gtk.Button( - name="kanban-btn", child=Label(name="kanban-btn-neg", markup=icons.trash) - ) - self.delete_btn.connect("clicked", self.on_delete_clicked) - - self.center_btn = CenterBox(orientation="v", start_children=[self.delete_btn]) - - self.box.pack_start(self.label, True, True, 0) - self.box.pack_start(self.center_btn, False, False, 0) - self.add(self.box) - self.show_all() - - def setup_dnd(self): - self.drag_source_set( - Gdk.ModifierType.BUTTON1_MASK, - [Gtk.TargetEntry.new("UTF8_STRING", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, - ) - self.connect("drag-data-get", self.on_drag_data_get) - self.connect("drag-data-delete", self.on_drag_data_delete) - # Set up drag-begin to display a preview of the card. - self.connect("drag-begin", self.on_drag_begin) - - def on_button_press(self, widget, event): - if event.type != Gdk.EventType._2BUTTON_PRESS: - return True - self.start_edit() - return False - - def on_drag_begin(self, widget, context): - surface = createSurfaceFromWidget(self) - Gtk.drag_set_icon_surface(context, surface) - - def on_drag_data_get(self, widget, drag_context, data, info, time): - data.set_text(self.label.get_text(), -1) - - def on_drag_data_delete(self, widget, drag_context): - self.get_parent().destroy() - - def on_delete_clicked(self, button): - self.get_parent().destroy() - - def start_edit(self): - row = self.get_parent() - editor = InlineEditor(self.label.get_text()) - - def on_confirmed(editor, text): - self.label.set_text(text) - row.remove(editor) - row.add(self) - row.show_all() - self.emit("changed") - - def on_canceled(editor): - row.remove(editor) - row.add(self) - row.show_all() - - editor.connect("confirmed", on_confirmed) - editor.connect("canceled", on_canceled) - - row.remove(self) - row.add(editor) - row.show_all() - # Usamos un retardo de 50 milisegundos: - GLib.timeout_add(50, lambda: (editor.text_view.grab_focus(), False)) - - -class KanbanColumn(Gtk.Frame): - __gsignals__ = { - "changed": (GObject.SignalFlags.RUN_LAST, None, ()), - } - - def __init__(self, title): - super().__init__(name="kanban-column") - self.title = title - self.setup_ui() - self.setup_dnd() - self.set_hexpand(True) - self.set_vexpand(True) - - def setup_ui(self): - self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) - self.listbox = Gtk.ListBox() - self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) - - self.add_btn = Gtk.Button( - name="kanban-btn-add", - child=Label(name="kanban-btn-label", markup=icons.add), - ) - header = CenterBox( - name="kanban-header", - center_children=[Label(name="column-header", label=self.title)], - end_children=[self.add_btn], - ) - self.box.pack_start(header, False, False, 0) - - self.add_btn.connect("clicked", self.on_add_clicked) - - scrolled = Gtk.ScrolledWindow(name="kanban-scroll") - scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - scrolled.add(self.listbox) - scrolled.set_vexpand(True) - - self.box.pack_start(scrolled, True, True, 0) - self.box.pack_start(self.add_btn, False, False, 0) - self.add(self.box) - self.show_all() - - def setup_dnd(self): - self.listbox.drag_dest_set( - Gtk.DestDefaults.ALL, - [Gtk.TargetEntry.new("UTF8_STRING", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, - ) - - self.listbox.connect("drag-data-received", self.on_drag_data_received) - self.listbox.connect("drag-motion", self.on_drag_motion) - self.listbox.connect("drag-leave", self.on_drag_leave) - - def on_add_clicked(self, button): - editor = InlineEditor() - row = Gtk.ListBoxRow(name="kanban-row") - row.add(editor) - self.listbox.add(row) - self.listbox.show_all() - editor.text_view.grab_focus() - - def on_confirmed(editor, text): - note = KanbanNote(text) - note.connect("changed", lambda x: self.emit("changed")) - row.remove(editor) - row.add(note) - self.listbox.show_all() - self.emit("changed") # Emit on add - - def on_canceled(editor): - row.destroy() - - editor.connect("confirmed", on_confirmed) - editor.connect("canceled", on_canceled) - - def add_note(self, text, suppress_signal=False): - note = KanbanNote(text) - note.connect("changed", lambda x: self.emit("changed")) - row = Gtk.ListBoxRow(name="kanban-row") - row.add(note) - row.connect("destroy", lambda x: self.emit("changed")) - self.listbox.add(row) - self.listbox.show_all() - if not suppress_signal: - self.emit("changed") - - def get_notes(self): - return [ - row.get_children()[0].label.get_text() - for row in self.listbox.get_children() - if isinstance(row.get_children()[0], KanbanNote) - ] - - def clear_notes(self, suppress_signal=False): - for row in self.listbox.get_children(): - row.destroy() - if not suppress_signal: - self.emit("changed") - - def on_drag_data_received(self, widget, drag_context, x, y, data, info, time): - text = data.get_text() - if text: - row = self.listbox.get_row_at_y(y) - new_note = KanbanNote(text) - new_note.connect("changed", lambda x: self.emit("changed")) - new_row = Gtk.ListBoxRow(name="kanban-row") - new_row.add(new_note) - new_row.connect("destroy", lambda x: self.emit("changed")) - - if row: - self.listbox.insert(new_row, row.get_index()) - else: - self.listbox.add(new_row) - - self.listbox.show_all() - drag_context.finish(True, False, time) - self.emit("changed") # Emit on move - - def on_drag_motion(self, widget, drag_context, x, y, time): - Gdk.drag_status(drag_context, Gdk.DragAction.MOVE, time) - return True - - def on_drag_leave(self, widget, drag_context, time): - widget.get_parent().get_parent().drag_unhighlight() - - -class Kanban(Gtk.Box): - STATE_FILE = Path(os.path.expanduser("~/.local/state/modus/kanban.json")) - - def __init__(self): - super().__init__(name="kanban") - - self.grid = Gtk.Grid(column_spacing=4, column_homogeneous=True) - self.grid.set_vexpand(True) - self.add(self.grid) - - self.columns = [ - KanbanColumn("To Do"), - KanbanColumn("In Progress"), - KanbanColumn("Done"), - ] - - for i, column in enumerate(self.columns): - self.grid.attach(column, i, 0, 1, 1) - column.connect("changed", lambda x: self.save_state()) - - self.load_state() - self.show_all() - - def save_state(self): - state = { - "columns": [ - {"title": col.title, "notes": col.get_notes()} for col in self.columns - ] - } - try: - with open(self.STATE_FILE, "w") as f: - json.dump(state, f, indent=2) - except Exception as e: - print(f"Error saving state: {e}") - - def load_state(self): - try: - with open(self.STATE_FILE, "r") as f: - state = json.load(f) - for col_data in state["columns"]: - for column in self.columns: - if column.title == col_data["title"]: - column.clear_notes(suppress_signal=True) - for note_text in col_data["notes"]: - column.add_note(note_text, suppress_signal=True) - break - except FileNotFoundError: - pass - except Exception as e: - print(f"Error loading state: {e}") diff --git a/modules/launcher/components/notification_center.py b/modules/launcher/components/notification_center.py deleted file mode 100644 index 86ade48..0000000 --- a/modules/launcher/components/notification_center.py +++ /dev/null @@ -1,409 +0,0 @@ -from fabric.notifications import Notification -from services import notification_service -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.label import Label -from fabric.widgets.image import Image -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.revealer import Revealer -from loguru import logger -import utils.icons as icons -from gi.repository import GdkPixbuf, GLib -from utils import CustomImage -import os -from gi.repository import Gtk -from fabric.widgets.centerbox import CenterBox - - -class NotificationCenter(Box): - def __init__(self, **kwargs): - super().__init__( - name="notification-center", - visible=False, - all_visible=False, - **kwargs, - ) - - self.launcher = kwargs["launcher"] - self.notification_service = notification_service - - # Main container for notifications - self.notifications = Box( - v_expand=True, - h_expand=True, - orientation="v", - spacing=8, - ) - self.header_switch = Gtk.Switch(name="matugen-switcher") - self.header_switch.set_vexpand(False) - self.header_switch.set_valign(Gtk.Align.CENTER) - self.header_switch.set_active(self.notification_service.dont_disturb) - self.do_not_disturb_enabled = self.notification_service.dont_disturb - self.header_switch.connect("notify::active", self.on_do_not_disturb_changed) - self.dnd_label = Label(name="dnd-label", markup=icons.notifications_off) - # Header with clear all button - header = CenterBox( - h_expand=True, - start_children=[self.header_switch, self.dnd_label], - center_children=[ - Label( - label="Notifications", - name="nhh", - ) - ], - end_children=[ - Button( - name="nhh-button", - child=Label(name="nhh-button-label", markup=icons.trash), - on_clicked=lambda *args: self.clear_all_notifications(), - ) - ], - ) - - self.scrolledwindow = ScrolledWindow( - name="scrolled-window", - child=self.notifications, - min_content_size=(400, 300), - max_content_size=(400, 300), - v_expand=True, - h_expand=True, - ) - - # Main container - main_container = Box( - v_expand=True, - h_expand=True, - orientation="v", - children=[header, self.scrolledwindow], - ) - - self.add(main_container) - - # Connect to notification service signals - self.notification_service.connect( - "notification-added", self.on_new_notification - ) - self.notification_service.connect("clear_all", self.on_clear_all) - - # Load existing notifications - self.load_notifications() - - def load_notifications(self): - """Load existing notifications from the service.""" - notifications = self.notification_service.get_deserialized() - if not notifications: - # Create a container box to better center the message - container = Box( - name="no-notifications-container", - orientation="v", - h_align="center", - v_align="center", - h_expand=True, - v_expand=True, - ) - - # Show a message if no notifications - label = Label( - name="no-notifications-label", - markup=icons.notifications_clear, - v_align="fill", - h_align="fill", - v_expand=True, - h_expand=True, - justification="center", - ) - - container.add(label) - self.notifications.add(container) - return - - for notification in notifications: - self.add_notification(notification) - - def toggle_notification_body(self, revealer, chevron_label): - """Toggle the visibility of notification body.""" - is_revealed = revealer.get_reveal_child() - revealer.set_reveal_child(not is_revealed) - # Update chevron icon - chevron_label.set_markup( - icons.chevron_up if not is_revealed else icons.chevron_down - ) - - def add_notification(self, notification: Notification): - """Add a notification to the center.""" - # Remove empty state container if it exists - for child in self.notifications.get_children(): - if child.get_name() == "no-notifications-container": - self.notifications.remove(child) - break - - # Get timestamp from notification object - timestamp = getattr(notification, 'timestamp', GLib.get_real_time() / 1000000) - time_str = GLib.DateTime.new_from_unix_local(timestamp).format("%I:%M %p") - - # Create the notification header (summary) box - header_box = Box( - name="notification-header", - # h_expand=True, - spacing=8, - children=[ - Box( - orientation="h", - h_expand=True, - spacing=8, - style="min-width: 0;", - children=[ - # Summary - Label( - name="notification-app-name", - markup=notification.app_name + " | ", - h_align="start", - ellipsization="end", - ), - Label( - markup=notification.summary.replace("\n", " "), - h_align="start", - ellipsization="end", - ), - Label( - name="notification-time", - markup=f" | {time_str}", - h_align="end", - style="color: var(--outline);", - ), - ], - ), - # Add chevron button if there's content to show - *( - [ - Button( - name="chevron-button", - child=Label( - name="chevron-label", markup=icons.chevron_down - ), - ) - ] - if notification.body or notification.image_pixbuf - else [] - ), - Button( - name="notif-close-button", - child=Label(name="notif-close-label", markup=icons.cancel), - on_clicked=lambda *args: self.remove_notification(notification), - ), - ], - ) - - # Create content box that will be revealed - # Create content box in vertical layout for body + actions - content_box = Box( - name="notification-content", - spacing=8, - orientation="v", - ) - - # Create horizontal box for image and body - body_box = Box( - name="notification-body-box", - spacing=8, - orientation="h", - ) - - # Add image if available - if notification.image_pixbuf: - image_box = Box( - name="notification-image", - children=[ - CustomImage( - pixbuf=notification.image_pixbuf.scale_simple( - 48, 48, GdkPixbuf.InterpType.BILINEAR - ) - if notification.image_pixbuf - else self.get_pixbuf(notification.app_icon, 48, 48) - ), - ], - ) - body_box.add(image_box) - - # Add body text if available - if notification.body: - body_box.add( - Label( - markup=notification.body.replace("\n", " "), - name="notification-body", - h_align="start", - ellipsization="end", - ) - ) - - # Add body box to content - content_box.add(body_box) - - # Add action buttons if available - if notification.actions: - action_box = Box( - name="notification-action-buttons", - spacing=4, - h_expand=True, - ) - for i, action in enumerate(notification.actions): - style_class = ( - "start-action" - if i == 0 - else "end-action" - if i == len(notification.actions) - 1 - else "middle-action" - ) - button = Button( - name="action-button", - h_expand=True, - child=Label(name="button-label", label=action.label), - on_clicked=lambda btn, act=action, notif=notification: [ - act.invoke(), - self.notification_service.remove_notification(notif.id), - self.remove_notification(notif) - ], - ) - button.add_style_class(style_class) - action_box.add(button) - content_box.add(action_box) - - # Create revealer for content - body_revealer = None - if notification.body or notification.image_pixbuf: - body_revealer = Revealer( - name="body-revealer", - transition_type="slide-down", - reveal_child=False, - child=content_box, - ) - - # Create main notification box - notification_box = Box( - name="notification-box", - orientation="v", - h_expand=True, - style="margin: 4px; padding: 16px; border-radius: 20px; background-color: var(--surface-container-low); min-width: 200px;", - children=[header_box], - ) - - # Add body revealer if exists - if body_revealer: - notification_box.add(body_revealer) - # Get the chevron button from header_box children - chevron_button = [ - c for c in header_box.get_children() if c.get_name() == "chevron-button" - ][0] - chevron_label = chevron_button.get_child() - chevron_button.connect( - "clicked", - lambda *args: self.toggle_notification_body( - body_revealer, chevron_label - ), - ) - - self.notifications.add(notification_box) - - def on_do_not_disturb_changed(self, switch, pspec): - """Handle Do Not Disturb toggle.""" - self.do_not_disturb_enabled = switch.get_active() - self.notification_service.dont_disturb = self.do_not_disturb_enabled - - def on_new_notification(self, fabric_notif, id): - """Handle new notification event.""" - notification = fabric_notif.get_notification_from_id(id) - self.add_notification(notification) - - def remove_notification(self, notification: Notification): - """Remove a notification from the center.""" - self.notification_service.remove_notification(notification.id) - # Find and remove the notification box - for child in self.notifications.get_children(): - if child.get_name() == "notification-box": - # Get the header box - header_box = child.get_children()[0] - # Get the text container box - text_box = header_box.get_children()[0] - # Get the summary label (second child of text box) - summary_label = text_box.get_children()[1] - if summary_label.get_text() == notification.summary: - self.notifications.remove(child) - break - - def clear_all_notifications(self, *args): - """Clear all notifications.""" - self.notification_service.clear_all_notifications() - # Remove all child widgets - for child in self.notifications.get_children(): - self.notifications.remove(child) - - # Add empty state - container = Box( - name="no-notifications-container", - orientation="v", - h_align="center", - v_align="center", - h_expand=True, - v_expand=True, - ) - - label = Label( - name="no-notifications-label", - markup=icons.notifications_clear, - v_align="fill", - h_align="fill", - v_expand=True, - h_expand=True, - justification="center", - ) - - container.add(label) - self.notifications.add(container) - - def on_clear_all(self, *args): - """Handle clear all event from service.""" - # Remove all child widgets - for child in self.notifications.get_children(): - self.notifications.remove(child) - - # Add empty state - container = Box( - name="no-notifications-container", - orientation="v", - h_align="center", - v_align="center", - h_expand=True, - v_expand=True, - ) - - label = Label( - name="no-notifications-label", - markup=icons.notifications_clear, - v_align="fill", - h_align="fill", - v_expand=True, - h_expand=True, - justification="center", - ) - - container.add(label) - self.notifications.add(container) - - def get_pixbuf(self, icon_path, width, height): - if icon_path.startswith("file://"): - icon_path = icon_path[7:] - - if not os.path.exists(icon_path): - logger.warning(f"Icon path does not exist: {icon_path}") - return None - - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path) - return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR) - except Exception as e: - logger.error(f"Failed to load or scale icon: {e}") - return None - - def open_center(self): - """Open the notification center.""" - self.show_all() diff --git a/modules/launcher/components/power.py b/modules/launcher/components/power.py deleted file mode 100644 index b441e25..0000000 --- a/modules/launcher/components/power.py +++ /dev/null @@ -1,88 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.utils.helpers import exec_shell_command_async -import utils.icons as icons - - -class PowerMenu(Box): - def __init__(self, **kwargs): - super().__init__( - name="power-menu", - orientation="h", - spacing=10, - v_align="center", - h_align="center", - v_expand=True, - h_expand=True, - visible=True, - **kwargs, - ) - - self.launcher = kwargs["launcher"] - - self.btn_lock = Button( - name="power-menu-button", - child=Label(name="button-label", markup=icons.lock), - on_clicked=self.lock, - ) - - self.btn_suspend = Button( - name="power-menu-button", - child=Label(name="button-label", markup=icons.suspend), - on_clicked=self.suspend, - ) - - self.btn_logout = Button( - name="power-menu-button", - child=Label(name="button-label", markup=icons.logout), - on_clicked=self.logout, - ) - - self.btn_reboot = Button( - name="power-menu-button", - child=Label(name="button-label", markup=icons.reboot), - on_clicked=self.reboot, - ) - - self.btn_shutdown = Button( - name="power-menu-button", - child=Label(name="button-label", markup=icons.shutdown), - on_clicked=self.poweroff, - ) - - self.buttons = [ - self.btn_lock, - self.btn_suspend, - self.btn_logout, - self.btn_reboot, - self.btn_shutdown, - ] - - for button in self.buttons: - self.add(button) - - self.show_all() - - def close_menu(self): - self.launcher.close() - - def lock(self, *args): - exec_shell_command_async("hyprlock --immediate") - self.close_menu() - - def suspend(self, *args): - exec_shell_command_async("systemctl suspend") - self.close_menu() - - def logout(self, *args): - exec_shell_command_async("hyprctl dispatch exit") - self.close_menu() - - def reboot(self, *args): - exec_shell_command_async("systemctl reboot") - self.close_menu() - - def poweroff(self, *args): - exec_shell_command_async("systemctl poweroff") - self.close_menu() diff --git a/modules/launcher/components/sh.py b/modules/launcher/components/sh.py deleted file mode 100644 index 6fef79a..0000000 --- a/modules/launcher/components/sh.py +++ /dev/null @@ -1,139 +0,0 @@ -import os -import subprocess -from typing import List -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.entry import Entry -from fabric.widgets.label import Label -from fabric.widgets.scrolledwindow import ScrolledWindow -import config.data as data - -BINS = f"{data.CACHE_DIR}/binaries" - - -class Sh(Box): - def __init__(self, **kwargs): - super().__init__( - all_visible=False, - visible=False, - **kwargs, - ) - - self.launcher = kwargs["launcher"] - self._arranger_handler: int = 0 - self.shell_command_manager = ShellCommandManager(self) - self.viewport = None - - self.search_entry = Entry( - name="search-entry", - placeholder="Search Shell Commands...", - h_expand=True, - notify_text=lambda entry, *_: self.handle_search_input(entry.get_text()), - on_activate=lambda entry, *_: self.handle_search_input(entry.get_text()), - ) - self.search_entry.props.xalign = 0.5 - - self.header_box = Box( - spacing=10, - orientation="h", - children=[self.search_entry], - ) - - self.launcher_box = Box( - spacing=10, - orientation="v", - h_expand=True, - children=[self.header_box], - ) - - self.add(self.launcher_box) - - def open_sh(self): - if not self.viewport: - self.viewport = Box(name="viewport", spacing=10, orientation="v") - self.scrolled_window = ScrolledWindow( - name="scrolled-window", - spacing=10, - h_scrollbar_policy="never", - v_scrollbar_policy="never", - # min_content_size=(-1, -1), - child=self.viewport, - ) - self.launcher_box.add(self.scrolled_window) - - self.viewport.children = [] - self.shell_command_manager.show_shell_commands(self.viewport, "") - - self.viewport.show() - self.search_entry.grab_focus() - - def handle_search_input(self, text: str): - self.shell_command_manager.show_shell_commands(self.viewport, text) - - -class ShellCommandManager: - def __init__(self, launcher): - self.launcher = launcher - self.binaries = set() - self.reload_binaries() - - def reload_binaries(self): - paths = os.getenv("PATH", "").split(":") - self.binaries = set() - - for path in paths: - self.binaries.update(self.list_binaries_in_path(path)) - - with open(BINS, "w") as f: - f.write("\n".join(self.binaries)) - - def list_binaries_in_path(self, path): - if not os.path.exists(path): - return [] - try: - return [ - entry - for entry in os.listdir(path) - if os.access(os.path.join(path, entry), os.X_OK) - ] - except OSError: - return [] - - def show_shell_commands(self, viewport, search_query: str): - if not search_query: - return - - viewport.children = [] - - results = self.query_binaries(search_query) - self.display_results(viewport, results) - - def query_binaries(self, filter_str: str) -> List[str]: - filter_str = filter_str.lower() - filtered_bins = (cmd for cmd in self.binaries if filter_str in cmd) - return list(dict.fromkeys(filtered_bins))[:8] - - def display_results(self, viewport, results: List[str]): - for command in results: - button = Button( - child=Label( - label=command, - v_align="center", - h_align="start", - ), - on_clicked=lambda _, cmd=command: self.execute_command(cmd), - name="app-slot-button", - ) - viewport.add(button) - - def close_menu(self): - self.launcher.close() - - def execute_command(self, cmd: str): - try: - output = subprocess.check_output(cmd, shell=True, text=True) - print(output) - except subprocess.CalledProcessError as e: - print(e.output) - finally: - self.close_menu() diff --git a/modules/launcher/components/tmux.py b/modules/launcher/components/tmux.py deleted file mode 100644 index 583e477..0000000 --- a/modules/launcher/components/tmux.py +++ /dev/null @@ -1,540 +0,0 @@ -import subprocess -import os -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.widgets.entry import Entry -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.utils import idle_add, remove_handler, exec_shell_command_async -from gi.repository import GLib, Gdk, Gtk -import utils.icons as icons -import config.data as data - -class TmuxManager(Box): - def __init__(self, **kwargs): - super().__init__( - name="tmux-manager", - visible=False, - all_visible=False, - **kwargs, - ) - - self.launcher = kwargs["launcher"] - self.selected_index = -1 # Track the selected item index - - self._arranger_handler: int = 0 - - self.viewport = Box(name="viewport", spacing=4, orientation="v") - self.session_name_entry = Entry( - name="session-name-entry", - placeholder="Create Tmux Session...", - h_expand=True, - on_activate=lambda entry, *_: self.create_session(entry.get_text()), - on_key_press_event=self.on_entry_key_press, - ) - self.session_name_entry.props.xalign = 0.5 - self.scrolled_window = ScrolledWindow( - name="scrolled-window", - spacing=10, - min_content_size=(450, 105), - max_content_size=(450, 105), - child=self.viewport, - ) - - self.header_box = Box( - name="header_box", - spacing=10, - orientation="h", - children=[ - Button( - name="new-session-button", - child=Label(name="new-session-label", markup=icons.add), - tooltip_text="Create New Session", - on_clicked=lambda *_: self.create_session(self.session_name_entry.get_text()), - ), - self.session_name_entry, - ], - ) - - self.tmux_box = Box( - name="tmux-box", - spacing=10, - h_expand=True, - orientation="v", - children=[ - self.header_box, - self.scrolled_window, - ], - ) - - self.add(self.tmux_box) - self.show_all() - - def close_manager(self): - """Close the tmux manager""" - self.viewport.children = [] - self.selected_index = -1 # Reset selection - self.launcher.close() - - def open_manager(self): - """Open the tmux manager and refresh sessions""" - self.viewport.set_visible(True) - self.refresh_sessions() - self.session_name_entry.set_text("") - self.session_name_entry.grab_focus() - - def refresh_sessions(self): - """Get tmux sessions and populate the viewport""" - remove_handler(self._arranger_handler) if self._arranger_handler else None - self.viewport.children = [] - self.selected_index = -1 # Clear selection when viewport changes - - # Get tmux sessions - sessions = self.get_tmux_sessions() - if not sessions: - # Create a container box to better center the message - container = Box( - name="no-tmux-container", - orientation="v", - h_align="center", - v_align="center", - h_expand=True, - v_expand=True - ) - - # Show a message if no sessions - label = Label( - name="no-tmux", - markup=icons.terminal, - h_align="center", - v_align="center", - ) - - container.add(label) - - self.viewport.add(container) - return - - # Add session slots to viewport - for session in sessions: - self.viewport.add(self.create_session_slot(session)) - - def get_tmux_sessions(self): - """Get list of tmux sessions""" - try: - result = subprocess.run( - ["tmux", "list-sessions", "-F", "#{session_name}"], - capture_output=True, - text=True - ) - if result.returncode == 0: - return [s.strip() for s in result.stdout.strip().split('\n') if s.strip()] - return [] - except Exception as e: - print(f"Error getting tmux sessions: {e}") - return [] - - def create_session_slot(self, session_name): - """Create a button for a tmux session""" - # Create an entry for inline editing (initially hidden) - name_entry = Entry( - name="session-name-entry", - text=session_name, - visible=False, - on_activate=lambda entry, *_: self.finish_rename(button, session_name, entry), - on_key_press_event=self.on_rename_key_press, - ) - - # Create the label showing the session name - name_label = Label( - name="app-label", - label=session_name, - ellipsization="end", - v_align="center", - h_align="center", - ) - - # Session slot content box - slot_box = Box( - name="slot-box", - orientation="h", - spacing=10, - children=[ - Label( - name="tmux-icon", - markup=icons.terminal, # Use existing terminal icon - ), - name_label, - name_entry, - ], - ) - - button = Button( - name="slot-button", # reuse existing CSS styling - child=slot_box, - tooltip_text=f"Attach to session: {session_name}", - on_clicked=lambda *_: self.attach_to_session(session_name), - can_focus=True, # Ensure the button can receive focus - ) - - # Add double-click handler to start renaming - button.connect("button-press-event", self.on_session_click, session_name, name_label, name_entry) - - # Add key press handler for 'r' to rename - button.connect("key-press-event", self.on_slot_key_press, session_name, name_label, name_entry) - - # Store reference to entry and label in button for later access - button.name_entry = name_entry - button.name_label = name_label - button.session_name = session_name - - return button - - def on_session_click(self, button, event, session_name, label, entry): - """Handle clicks on session buttons""" - # Handle double-click to rename - if event.type == Gdk.EventType.DOUBLE_BUTTON_PRESS and event.button == 1: - self.start_rename(button, session_name, label, entry) - return True - # Handle right click for context menu - elif event.button == 3: - menu = Gtk.Menu() - - # Rename option - rename_item = Gtk.MenuItem(label="Rename") - rename_item.connect("activate", lambda _: self.start_rename(button, session_name, label, entry)) - menu.append(rename_item) - - # Kill option - kill_item = Gtk.MenuItem(label="Kill Session") - kill_item.connect("activate", lambda _: self.kill_session(session_name)) - menu.append(kill_item) - - menu.show_all() - menu.popup_at_pointer(event) - return True - - return False - - def start_rename(self, button, session_name, label, entry): - """Start inline renaming of a session""" - # Hide label, show entry - label.set_visible(False) - entry.set_visible(True) - - # Focus entry and select all text - entry.grab_focus() - entry.select_region(0, -1) - - # Mark button as being edited - button.get_style_context().add_class("editing") - - def finish_rename(self, button, old_name, entry): - """Finish renaming a session""" - new_name = entry.get_text().strip() - - # Only rename if the name changed and isn't empty - if new_name and new_name != old_name: - self.rename_session(old_name, new_name) - - # Reset UI state - self.cancel_rename(button) - - def cancel_rename(self, button): - """Cancel renaming operation""" - # Restore original view - button.name_entry.set_visible(False) - button.name_label.set_visible(True) - - # Remove editing style - button.get_style_context().remove_class("editing") - - # Return focus to session name entry - self.session_name_entry.grab_focus() - - def on_rename_key_press(self, entry, event): - """Handle key presses in the rename entry""" - if event.keyval == Gdk.KEY_Escape: - # Find the parent button - parent = entry.get_parent() - while parent and not isinstance(parent, Button): - parent = parent.get_parent() - - if parent: - self.cancel_rename(parent) - return True - - return False - - def on_session_right_click(self, button, event, session_name): - """Handle right-click on a session button to show context menu""" - if event.button == 3: # Right click - menu = Gtk.Menu() - - # Rename option - rename_item = Gtk.MenuItem(label="Rename") - rename_item.connect("activate", lambda _: self.start_rename( - button, - session_name, - button.name_label, - button.name_entry - )) - menu.append(rename_item) - - # Kill option - kill_item = Gtk.MenuItem(label="Kill Session") - kill_item.connect("activate", lambda _: self.kill_session(session_name)) - menu.append(kill_item) - - menu.show_all() - menu.popup_at_pointer(event) - return True - - return False - - def on_entry_key_press(self, widget, event): - """Handle key press events in the entry""" - if event.keyval == Gdk.KEY_Escape: - self.close_manager() - return True - - # Custom navigation with UP/DOWN keys removed - return False - - def scroll_to_selected(self, button): - """Scroll to ensure the selected button is visible""" - def scroll(): - adj = self.scrolled_window.get_vadjustment() - alloc = button.get_allocation() - if alloc.height == 0: - return False # Retry if allocation isn't ready - - y = alloc.y - height = alloc.height - page_size = adj.get_page_size() - current_value = adj.get_value() - - # Calculate visible boundaries - visible_top = current_value - visible_bottom = current_value + page_size - - if y < visible_top: - # Item above viewport - align to top - adj.set_value(y) - elif y + height > visible_bottom: - # Item below viewport - align to bottom - new_value = y + height - page_size - adj.set_value(new_value) - # No action if already fully visible - return False - GLib.idle_add(scroll) - - def create_session(self, session_name): - """Create a new tmux session""" - if not session_name: - # Get existing session names - existing_sessions = self.get_tmux_sessions() - - # Find the next available number - counter = 0 - while str(counter) in existing_sessions: - counter += 1 - - session_name = str(counter) - - try: - # Clean the session name (replace spaces with underscores) - clean_name = session_name.strip().replace(" ", "_") - - # Create session - subprocess.run( - ["tmux", "new-session", "-d", "-s", clean_name], - check=True - ) - - # Refresh the session list - self.refresh_sessions() - - # Clear entry - self.session_name_entry.set_text("") - - # Launch a terminal and attach to this session - terminal_cmd = self.get_terminal_command(f"tmux attach-session -t {clean_name}") - exec_shell_command_async(terminal_cmd) - - # Close manager - self.close_manager() - - except Exception as e: - print(f"Error creating tmux session: {e}") - - def attach_to_session(self, session_name): - """Attach to an existing tmux session""" - try: - # Launch a terminal and attach to this session - terminal_cmd = self.get_terminal_command(f"tmux attach-session -t {session_name}") - exec_shell_command_async(terminal_cmd) - self.close_manager() - except Exception as e: - print(f"Error attaching to tmux session: {e}") - - def get_terminal_command(self, cmd): - """Get terminal command based on configured terminal or available terminals""" - # First try to use the configured terminal command - if hasattr(data, 'TERMINAL_COMMAND') and data.TERMINAL_COMMAND: - parts = data.TERMINAL_COMMAND.split() - terminal = parts[0] - - try: - # Check if the configured terminal is available - subprocess.run(["which", terminal], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - return f"{data.TERMINAL_COMMAND} {cmd}" - except subprocess.CalledProcessError: - # If configured terminal is not available, fall back to defaults - pass - - # Fallback to checking available terminals - terminals = [ - ("kitty", f"kitty -e {cmd}"), - ("alacritty", f"alacritty -e {cmd}"), - ("foot", f"foot {cmd}"), - ("gnome-terminal", f"gnome-terminal -- {cmd}"), - ("konsole", f"konsole -e {cmd}"), - ("xfce4-terminal", f"xfce4-terminal -e '{cmd}'"), - ] - - for term, term_cmd in terminals: - try: - # Check if terminal is available - subprocess.run(["which", term], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - return term_cmd - except subprocess.CalledProcessError: - continue - - # Default fallback - return f"kitty -e {cmd}" - - def rename_session_dialog(self, old_name): - """Show dialog to rename a session""" - dialog = Gtk.Dialog( - title="Rename Session", - transient_for=None, - flags=0 - ) - - dialog.add_buttons( - Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, - Gtk.STOCK_OK, Gtk.ResponseType.OK - ) - - content_area = dialog.get_content_area() - entry = Gtk.Entry() - entry.set_text(old_name) - entry.set_activates_default(True) - content_area.add(entry) - - dialog.set_default_response(Gtk.ResponseType.OK) - dialog.show_all() - - response = dialog.run() - if response == Gtk.ResponseType.OK: - new_name = entry.get_text() - if new_name and new_name != old_name: - self.rename_session(old_name, new_name) - - dialog.destroy() - - def rename_session(self, old_name, new_name): - """Rename a tmux session""" - try: - # Clean the session name (replace spaces with underscores) - clean_name = new_name.strip().replace(" ", "_") - - # Rename session - subprocess.run( - ["tmux", "rename-session", "-t", old_name, clean_name], - check=True - ) - - # Refresh the session list - self.refresh_sessions() - - except Exception as e: - print(f"Error renaming tmux session: {e}") - - def kill_session(self, session_name): - """Kill a tmux session""" - try: - # Kill session - subprocess.run( - ["tmux", "kill-session", "-t", session_name], - check=True - ) - - # Refresh the session list - self.refresh_sessions() - - # Close the launcher after killing session - self.close_manager() - - except Exception as e: - print(f"Error killing tmux session: {e}") - - # Add new method to handle key presses on session slots - def on_slot_key_press(self, button, event, session_name, label, entry): - """Handle key presses on session buttons""" - # Print debugging info - print(f"Key pressed: {event.keyval}, State: {event.state}") - - # Check if 'r' key was pressed for renaming - if event.keyval == Gdk.KEY_r: - self.start_rename(button, session_name, label, entry) - return True - # Check for 'K' (capital K) which indicates Shift is pressed - elif event.keyval == Gdk.KEY_K: - print("Shift+K detected - killing session without confirmation") - self.kill_session(session_name) - return True - # Check for lowercase 'k' - elif event.keyval == Gdk.KEY_k: - print("Regular k detected - showing confirmation") - self.show_kill_confirmation_menu(button, session_name) - return True - # Check if Delete key was pressed for killing session - elif event.keyval == Gdk.KEY_Delete: - self.show_kill_confirmation_menu(button, session_name) - return True - return False - - def show_kill_confirmation_menu(self, button, session_name): - """Show a confirmation menu for killing a session""" - menu = Gtk.Menu() - - # Confirmation message as a disabled menu item - msg_item = Gtk.MenuItem(label=f"Kill session '{session_name}'?") - msg_item.set_sensitive(False) - menu.append(msg_item) - - # Separator - menu.append(Gtk.SeparatorMenuItem()) - - # Confirm option - confirm_item = Gtk.MenuItem(label="Confirm") - confirm_item.connect("activate", lambda _: self.kill_session(session_name)) - menu.append(confirm_item) - - # Cancel option - cancel_item = Gtk.MenuItem(label="Cancel") - # Close launcher on cancel - cancel_item.connect("activate", lambda _: self.close_manager()) - menu.append(cancel_item) - - menu.show_all() - - # Show the menu positioned at the button - menu.popup_at_widget( - button, - Gdk.Gravity.SOUTH_WEST, - Gdk.Gravity.NORTH_WEST, - None - ) \ No newline at end of file diff --git a/modules/launcher/components/tools.py b/modules/launcher/components/tools.py deleted file mode 100644 index d04c39d..0000000 --- a/modules/launcher/components/tools.py +++ /dev/null @@ -1,370 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.utils.helpers import exec_shell_command_async, get_relative_path -import utils.icons as icons -from gi.repository import Gdk, GLib -import os -import subprocess -import threading - - -CAPTURE_SCRIPT = get_relative_path("../../../config/scripts/capture.sh") -GAMEMODE_SCRIPT = get_relative_path("../../../config/scripts/gamemode.sh") - - -class Toolbox(Box): - def __init__(self, **kwargs): - super().__init__( - name="toolbox", - orientation="h", - spacing=4, - v_align="center", - h_align="center", - visible=True, - **kwargs, - ) - - self.launcher = kwargs["launcher"] - - self.btn_ssregion = Button( - name="toolbox-button", - child=Label(name="button-label", markup=icons.ssregion), - on_clicked=self.ssregion, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - # Enable keyboard focus and connect events - self.btn_ssregion.set_can_focus(True) - self.btn_ssregion.connect("button-press-event", self.on_ssregion_click) - self.btn_ssregion.connect("key-press-event", self.on_ssregion_key) - - self.btn_ssfull = Button( - name="toolbox-button", - child=Label(name="button-label", markup=icons.ssfull), - on_clicked=self.ssfull, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - # Enable keyboard focus and connect events - self.btn_ssfull.set_can_focus(True) - self.btn_ssfull.connect("button-press-event", self.on_ssfull_click) - self.btn_ssfull.connect("key-press-event", self.on_ssfull_key) - - self.btn_screenrecord = Button( - name="toolbox-button", - child=Label(name="button-label", markup=icons.screenrecord), - on_clicked=self.screenrecord, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - - self.btn_ocr = Button( - name="toolbox-button", - child=Label(name="button-label", markup=icons.ocr), - on_clicked=self.ocr, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - - self.btn_color = Button( - name="toolbox-button", - tooltip_text="Color Picker\nLeft Click: HEX\nMiddle Click: HSV\nRight Click: RGB\n\nKeyboard:\nEnter: HEX\nShift+Enter: RGB\nCtrl+Enter: HSV", - child=Label(name="button-bar-label", markup=icons.colorpicker), - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - - self.btn_gamemode = Button( - name="toolbox-button", - child=Label(name="button-label", markup=icons.gamemode), - on_clicked=self.gamemode, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - - # Enable keyboard focus for the colorpicker button. - self.btn_color.set_can_focus(True) - # Connect both mouse and keyboard events. - self.btn_color.connect("button-press-event", self.colorpicker) - self.btn_color.connect("key_press_event", self.colorpicker_key) - - self.btn_emoji = Button( - name="toolbox-button", - child=Label(name="button-label", markup=icons.emoji), - on_clicked=self.emoji, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - - self.btn_screenshots_folder = Button( - name="toolbox-button", - child=Label(name="button-label", markup=icons.screenshots), - on_clicked=self.open_screenshots_folder, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - - self.btn_recordings_folder = Button( - name="toolbox-button", - child=Label(name="button-label", markup=icons.recordings), - on_clicked=self.open_recordings_folder, - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ) - - self.buttons = [ - self.btn_ssregion, - self.btn_ssfull, - self.btn_screenshots_folder, - Box( - name="tool-sep", - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ), - self.btn_screenrecord, - self.btn_recordings_folder, - Box( - name="tool-sep", - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ), - self.btn_ocr, - self.btn_color, - Box( - name="tool-sep", - h_expand=False, - v_expand=False, - h_align="center", - v_align="center", - ), - self.btn_gamemode, - self.btn_emoji, - ] - - for button in self.buttons: - self.add(button) - - self.show_all() - - # Start polling for process state every second. - self.recorder_timer_id = GLib.timeout_add_seconds( - 1, self.update_screenrecord_state - ) - - def close_menu(self): - self.launcher.close() - - # Action methods - def ssfull(self, *args): - exec_shell_command_async(f"bash {CAPTURE_SCRIPT} screenshot p") - self.close_menu() - - def on_ssfull_click(self, button, event): - if event.type == Gdk.EventType.BUTTON_PRESS: - if event.button == 1: # Left click - self.ssfull() - elif event.button == 3: # Right click - exec_shell_command_async( - f"bash {CAPTURE_SCRIPT} screenshot p mockup") - self.close_menu() - return True - return False - - def on_ssfull_key(self, widget, event): - if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}: - modifiers = event.get_state() - if modifiers & Gdk.ModifierType.SHIFT_MASK: - exec_shell_command_async( - f"bash {CAPTURE_SCRIPT} screenshot p mockup") - self.close_menu() - else: - self.ssfull() - return True - return False - - def ssregion(self, *args): - exec_shell_command_async(f"bash {CAPTURE_SCRIPT} screenshot sf") - self.close_menu() - - def on_ssregion_click(self, button, event): - if event.type == Gdk.EventType.BUTTON_PRESS: - if event.button == 1: # Left click - self.ssregion() - elif event.button == 3: # Right click - exec_shell_command_async( - f"bash {CAPTURE_SCRIPT} screenshot sf mockup") - self.close_menu() - return True - return False - - def on_ssregion_key(self, widget, event): - if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}: - modifiers = event.get_state() - if modifiers & Gdk.ModifierType.SHIFT_MASK: - exec_shell_command_async( - f"bash {CAPTURE_SCRIPT} screenshot sf mockup") - self.close_menu() - else: - self.ssregion() - return True - return False - - def screenrecord(self, *args): - # Launch screenrecord script in detached mode so that it remains running independently of this program. - exec_shell_command_async( - f"bash -c 'nohup bash {CAPTURE_SCRIPT} record > /dev/null 2>&1 & disown'" - ) - self.close_menu() - - def ocr(self, *args): - exec_shell_command_async(f"bash {CAPTURE_SCRIPT} ocr sf") - self.close_menu() - - def gamemode(self, *args): - exec_shell_command_async(f"bash {GAMEMODE_SCRIPT}") - self.gamemode_check() - self.close_menu() - - def gamemode_check(self): - def check(): - try: - result = subprocess.run( - f"bash {GAMEMODE_SCRIPT} check", - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - enabled = result.stdout == b"t\n" - except Exception: - enabled = False - - def update_ui(): - if enabled: - self.btn_gamemode.get_child().set_markup(icons.gamemode_off) - else: - self.btn_gamemode.get_child().set_markup(icons.gamemode) - return False - - GLib.idle_add(update_ui) - return False - - GLib.idle_add(lambda: threading.Thread(target=check).start()) - return True - - def ssregion(self, *args): - exec_shell_command_async(f"bash {CAPTURE_SCRIPT} screenshot sf") - self.close_menu() - - def colorpicker(self, button, event): - if event.type == Gdk.EventType.BUTTON_PRESS: - cmd = { - 1: "-hex", # Left click - 2: "-hsv", # Middle click - 3: "-rgb", # Right click - }.get(event.button) - - if cmd: - exec_shell_command_async( - f"bash { - get_relative_path( - '../../../config/scripts/hyprpicker.sh') - } {cmd}" - ) - self.close_menu() - - def colorpicker_key(self, widget, event): - if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}: - modifiers = event.get_state() - cmd = "-hex" # Default - - match modifiers & ( - Gdk.ModifierType.SHIFT_MASK | Gdk.ModifierType.CONTROL_MASK - ): - case Gdk.ModifierType.SHIFT_MASK: - cmd = "-rgb" - case Gdk.ModifierType.CONTROL_MASK: - cmd = "-hsv" - - exec_shell_command_async( - f"bash {get_relative_path('../../../config/scripts/hyprpicker.sh')} { - cmd - }" - ) - self.close_menu() - return True - return False - - def update_screenrecord_state(self): - def check(): - try: - result = subprocess.run( - "pgrep -f gpu-screen-recorder", - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - running = result.returncode == 0 - except Exception: - running = False - - def update_ui(): - if running: - self.btn_screenrecord.get_child().set_markup(icons.stop) - self.btn_screenrecord.add_style_class("recording") - else: - self.btn_screenrecord.get_child().set_markup(icons.screenrecord) - self.btn_screenrecord.remove_style_class("recording") - return False - - GLib.idle_add(update_ui) - return False - - GLib.idle_add(lambda: threading.Thread(target=check).start()) - return True - - def open_screenshots_folder(self, *args): - screenshots_dir = os.path.join( - os.environ.get("XDG_PICTURES_DIR", - os.path.expanduser("~/Pictures")), - "Screenshots", - ) - # Create directory if it doesn't exist - os.makedirs(screenshots_dir, exist_ok=True) - exec_shell_command_async(f"xdg-open {screenshots_dir}") - self.close_menu() - - def open_recordings_folder(self, *args): - recordings_dir = os.path.join( - os.environ.get("XDG_VIDEOS_DIR", os.path.expanduser("~/Videos")), - "Recordings", - ) - # Create directory if it doesn't exist - os.makedirs(recordings_dir, exist_ok=True) - exec_shell_command_async(f"xdg-open {recordings_dir}") - self.close_menu() - - def emoji(self, *args): - self.launcher.open("emoji") diff --git a/modules/launcher/components/wallpapers.py b/modules/launcher/components/wallpapers.py deleted file mode 100644 index d8d2197..0000000 --- a/modules/launcher/components/wallpapers.py +++ /dev/null @@ -1,340 +0,0 @@ -import os -import hashlib -from gi.repository import GdkPixbuf, Gtk, GLib, Gio, Gdk -from fabric.widgets.box import Box -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.entry import Entry -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.label import Label -from fabric.utils.helpers import exec_shell_command_async -from PIL import Image -import concurrent.futures -from concurrent.futures import ThreadPoolExecutor -import utils.icons as icons -import config.data as data - - -class WallpaperSelector(Box): - CACHE_DIR = f"{data.CACHE_DIR}/thumbnails" - - def __init__(self, **kwargs): - super().__init__( - name="wallpapers", - spacing=4, - orientation="v", - h_expand=False, - v_expand=False, - **kwargs, - ) - os.makedirs(self.CACHE_DIR, exist_ok=True) - - with os.scandir(data.WALLPAPERS_DIR) as entries: - for entry in entries: - if entry.is_file() and self._is_image(entry.name): - # Check if the file needs renaming: file should be lowercase and have hyphens instead of spaces - if entry.name != entry.name.lower() or " " in entry.name: - new_name = entry.name.lower().replace(" ", "-") - full_path = os.path.join(data.WALLPAPERS_DIR, entry.name) - new_full_path = os.path.join(data.WALLPAPERS_DIR, new_name) - try: - os.rename(full_path, new_full_path) - print( - f"Renamed old wallpaper '{full_path}' to '{new_full_path}'" - ) - except Exception as e: - print(f"Error renaming file {full_path}: {e}") - - # Refresh the file list after possible renaming - self.files = sorted( - [f for f in os.listdir(data.WALLPAPERS_DIR) if self._is_image(f)] - ) - self.thumbnails = [] - self.thumbnail_queue = [] - self.executor = ThreadPoolExecutor(max_workers=4) # Shared executor - - # Variable to control the selection (similar to AppLauncher) - self.selected_index = -1 - - # Initialize UI components - self.viewport = Gtk.IconView(name="wallpaper-icons") - self.viewport.set_model(Gtk.ListStore(GdkPixbuf.Pixbuf, str)) - self.viewport.set_pixbuf_column(0) - # Hide text column so only the image is shown - self.viewport.set_text_column(-1) - self.viewport.set_item_width(0) - self.viewport.connect("item-activated", self.on_wallpaper_selected) - - self.scrolled_window = ScrolledWindow( - name="scrolled-window", - spacing=10, - h_expand=True, - v_expand=True, - child=self.viewport, - ) - - self.search_entry = Entry( - name="search-entry-walls", - placeholder="Search Wallpapers...", - h_expand=True, - notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()), - on_key_press_event=self.on_search_entry_key_press, - ) - self.search_entry.props.xalign = 0.5 - self.search_entry.connect("focus-out-event", self.on_search_entry_focus_out) - - self.schemes = { - "scheme-tonal-spot": "Tonal Spot", - "scheme-content": "Content", - "scheme-expressive": "Expressive", - "scheme-fidelity": "Fidelity", - "scheme-fruit-salad": "Fruit Salad", - "scheme-monochrome": "Monochrome", - "scheme-neutral": "Neutral", - "scheme-rainbow": "Rainbow", - } - - self.scheme_dropdown = Gtk.ComboBoxText() - self.scheme_dropdown.set_name("scheme-dropdown") - self.scheme_dropdown.set_tooltip_text("Select color scheme") - for key, display_name in self.schemes.items(): - self.scheme_dropdown.append(key, display_name) - self.scheme_dropdown.set_active_id("scheme-tonal-spot") - self.scheme_dropdown.connect("changed", self.on_scheme_changed) - - # Create a switcher to enable/disable Matugen (enabled by default) - self.matugen_switcher = Gtk.Switch(name="matugen-switcher") - self.matugen_switcher.set_vexpand(False) - self.matugen_switcher.set_hexpand(False) - self.matugen_switcher.set_valign(Gtk.Align.CENTER) - self.matugen_switcher.set_halign(Gtk.Align.CENTER) - self.matugen_switcher.set_active(True) - - self.mat_icon = Label(name="mat-label", markup=icons.palette) - - # Add the switcher to the header_box's start_children - self.header_box = CenterBox( - name="header-box", - spacing=8, - orientation="h", - start_children=[self.matugen_switcher, self.mat_icon], - center_children=[self.search_entry], - end_children=[self.scheme_dropdown], - ) - - self.add(self.header_box) - self.add(self.scrolled_window) - self._start_thumbnail_thread() - self.setup_file_monitor() # Initialize file monitoring - self.show_all() - # Ensure the search entry gets focus when starting - self.search_entry.grab_focus() - - def setup_file_monitor(self): - gfile = Gio.File.new_for_path(data.WALLPAPERS_DIR) - self.file_monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None) - self.file_monitor.connect("changed", self.on_directory_changed) - - def on_directory_changed(self, monitor, file, other_file, event_type): - file_name = file.get_basename() - if event_type == Gio.FileMonitorEvent.DELETED: - if file_name in self.files: - self.files.remove(file_name) - cache_path = self._get_cache_path(file_name) - if os.path.exists(cache_path): - try: - os.remove(cache_path) - except Exception as e: - print(f"Error deleting cache {cache_path}: {e}") - self.thumbnails = [(p, n) for p, n in self.thumbnails if n != file_name] - GLib.idle_add(self.arrange_viewport, self.search_entry.get_text()) - elif event_type == Gio.FileMonitorEvent.CREATED: - if self._is_image(file_name): - # Convert filename to lowercase and replace spaces with "-" - new_name = file_name.lower().replace(" ", "-") - full_path = os.path.join(data.WALLPAPERS_DIR, file_name) - new_full_path = os.path.join(data.WALLPAPERS_DIR, new_name) - if new_name != file_name: - try: - os.rename(full_path, new_full_path) - file_name = new_name - print(f"Renamed file '{full_path}' to '{new_full_path}'") - except Exception as e: - print(f"Error renaming file {full_path}: {e}") - if file_name not in self.files: - self.files.append(file_name) - self.files.sort() - self.executor.submit(self._process_file, file_name) - elif event_type == Gio.FileMonitorEvent.CHANGED: - if self._is_image(file_name) and file_name in self.files: - cache_path = self._get_cache_path(file_name) - if os.path.exists(cache_path): - try: - os.remove(cache_path) - except Exception as e: - print(f"Error deleting cache for changed file {file_name}: {e}") - self.executor.submit(self._process_file, file_name) - - def arrange_viewport(self, query: str = ""): - model = self.viewport.get_model() - model.clear() - filtered_thumbnails = [ - (thumb, name) - for thumb, name in self.thumbnails - if query.casefold() in name.casefold() - ] - filtered_thumbnails.sort(key=lambda x: x[1].lower()) - for pixbuf, file_name in filtered_thumbnails: - model.append([pixbuf, file_name]) - # If the search entry is empty, no icon is selected; otherwise, select the first one. - if query.strip() == "": - self.viewport.unselect_all() - self.selected_index = -1 - elif len(model) > 0: - self.update_selection(0) - - def on_wallpaper_selected(self, iconview, path): - model = iconview.get_model() - file_name = model[path][1] - full_path = os.path.join(data.WALLPAPERS_DIR, file_name) - selected_scheme = self.scheme_dropdown.get_active_id() - if self.matugen_switcher.get_active(): - # Matugen is enabled: run the normal command. - exec_shell_command_async(f"matugen image {full_path} -t {selected_scheme}") - else: - # Matugen is disabled: run the alternative swww command. - exec_shell_command_async( - f"swww img {full_path} -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest" - ) - - def on_scheme_changed(self, combo): - selected_scheme = combo.get_active_id() - print(f"Color scheme selected: {selected_scheme}") - - def on_search_entry_key_press(self, widget, event): - if event.state & Gdk.ModifierType.SHIFT_MASK: - if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down): - schemes_list = list(self.schemes.keys()) - current_id = self.scheme_dropdown.get_active_id() - current_index = ( - schemes_list.index(current_id) if current_id in schemes_list else 0 - ) - new_index = ( - (current_index - 1) % len(schemes_list) - if event.keyval == Gdk.KEY_Up - else (current_index + 1) % len(schemes_list) - ) - self.scheme_dropdown.set_active(new_index) - return True - elif event.keyval == Gdk.KEY_Right: - self.scheme_dropdown.popup() - return True - - if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right): - self.move_selection_2d(event.keyval) - return True - elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): - if self.selected_index != -1: - path = Gtk.TreePath.new_from_indices([self.selected_index]) - self.on_wallpaper_selected(self.viewport, path) - return True - return False - - def move_selection_2d(self, keyval): - model = self.viewport.get_model() - total_items = len(model) - if total_items == 0: - return - - if self.selected_index == -1: - new_index = ( - 0 if keyval in (Gdk.KEY_Down, Gdk.KEY_Right) else total_items - 1 - ) - else: - current_index = self.selected_index - allocation = self.viewport.get_allocation() - item_width = 108 # Approximate item width including margins - columns = max(1, allocation.width // item_width) - if keyval == Gdk.KEY_Right: - new_index = current_index + 1 - elif keyval == Gdk.KEY_Left: - new_index = current_index - 1 - elif keyval == Gdk.KEY_Down: - new_index = current_index + columns - elif keyval == Gdk.KEY_Up: - new_index = current_index - columns - if new_index < 0: - new_index = 0 - if new_index >= total_items: - new_index = total_items - 1 - - self.update_selection(new_index) - - def update_selection(self, new_index: int): - self.viewport.unselect_all() - path = Gtk.TreePath.new_from_indices([new_index]) - self.viewport.select_path(path) - self.viewport.scroll_to_path( - path, False, 0.5, 0.5 - ) # Ensure the selected icon is visible - self.selected_index = new_index - - def _start_thumbnail_thread(self): - thread = GLib.Thread.new("thumbnail-loader", self._preload_thumbnails, None) - - def _preload_thumbnails(self, _data): - futures = [ - self.executor.submit(self._process_file, file_name) - for file_name in self.files - ] - concurrent.futures.wait(futures) - GLib.idle_add(self._process_batch) - - def _process_file(self, file_name): - full_path = os.path.join(data.WALLPAPERS_DIR, file_name) - cache_path = self._get_cache_path(file_name) - if not os.path.exists(cache_path): - try: - with Image.open(full_path) as img: - width, height = img.size - side = min(width, height) - left = (width - side) // 2 - top = (height - side) // 2 - right = left + side - bottom = top + side - img_cropped = img.crop((left, top, right, bottom)) - img_cropped.thumbnail((96, 96), Image.Resampling.LANCZOS) - img_cropped.save(cache_path, "PNG") - except Exception as e: - print(f"Error processing {file_name}: {e}") - return - self.thumbnail_queue.append((cache_path, file_name)) - GLib.idle_add(self._process_batch) - - def _process_batch(self): - batch = self.thumbnail_queue[:10] - del self.thumbnail_queue[:10] - for cache_path, file_name in batch: - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) - self.thumbnails.append((pixbuf, file_name)) - self.viewport.get_model().append([pixbuf, file_name]) - except Exception as e: - print(f"Error loading thumbnail {cache_path}: {e}") - if self.thumbnail_queue: - GLib.idle_add(self._process_batch) - return False - - def _get_cache_path(self, file_name: str) -> str: - file_hash = hashlib.md5(file_name.encode("utf-8")).hexdigest() - return os.path.join(self.CACHE_DIR, f"{file_hash}.png") - - @staticmethod - def _is_image(file_name: str) -> bool: - return file_name.lower().endswith( - (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") - ) - - def on_search_entry_focus_out(self, widget, event): - if self.get_mapped(): - widget.grab_focus() - return False diff --git a/modules/launcher/components/wifi.py b/modules/launcher/components/wifi.py deleted file mode 100644 index 94e52d6..0000000 --- a/modules/launcher/components/wifi.py +++ /dev/null @@ -1,153 +0,0 @@ -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.image import Image -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.scrolledwindow import ScrolledWindow -from services import network_client -import utils.icons as icons - - -class WifiNetworkSlot(CenterBox): - def __init__(self, network, client: network_client, **kwargs): - super().__init__(name="wifi-device", **kwargs) - self.network = network - self.client = client - self.connecting = False # Tracks if we are connecting/disconnecting - - self.connection_icon = Label(name="wifi-connection", markup=icons.wifi_off) - self.connect_button = Button( - name="wifi-connect", - label="Connect", - on_clicked=self.on_connect_clicked, - ) - - self.start_children = [ - Box( - spacing=8, - children=[ - Image(icon_name=network.get("icon-name"), size=32), - Label(label=network.get("ssid")), - self.connection_icon, - ], - ) - ] - self.end_children = self.connect_button - - self.visible = True - self.show_all() - - # Listen to WiFi state changes - self.client.wifi_device.connect("changed", lambda *_: self.update_status()) - - self.update_status() - - def on_connect_clicked(self, *_): - connected = self.network.get("ssid") == self.client.wifi_device.ssid - if connected: - self.connect_button.set_label("Disconnecting...") - self.client.wifi_device.enabled = False # Disables WiFi to disconnect - else: - self.connect_button.set_label("Connecting...") - self.client.connect_wifi_bssid(self.network.get("bssid")) - - self.connecting = True - self.connect_button.sensitive = False - - def update_status(self): - state = self.client.wifi_device.state - connected = self.network.get("ssid") == self.client.wifi_device.ssid - - if self.connecting: - if state in ["activating"]: - self.connect_button.set_label("Connecting...") - elif state in ["deactivating"]: - self.connect_button.set_label("Disconnecting...") - else: - self.connecting = False - self.connect_button.sensitive = True - - if not self.connecting: - self.connection_icon.set_markup(icons.wifi if connected else icons.wifi_off) - self.connect_button.set_label("Disconnect" if connected else "Connect") - - -class WifiManager(Box): - def __init__(self, **kwargs): - super().__init__( - name="wifi", - spacing=8, - orientation="vertical", - **kwargs, - ) - - self.client = network_client - self.scan_icon = Label(name="wifi-scan-icon", markup=icons.scan) - self.toggle_icon = Label(name="wifi-toggle-icon", markup=icons.wifi_off) - - self.scan_button = Button( - name="wifi-scan", - child=self.scan_icon, - tooltip_text="Scan", - on_clicked=lambda *_: self.client.wifi_device - and self.client.wifi_device.scan(), - ) - - self.toggle_button = Button( - name="wifi-toggle", - tooltip_text="Toggle WiFi", - child=self.toggle_icon, - on_clicked=lambda *_: self.client.wifi_device - and self.client.wifi_device.toggle_wifi(), - ) - - self.available_box = Box(spacing=2, orientation="vertical") - - self.children = [ - CenterBox( - name="wifi-header", - start_children=self.scan_button, - center_children=Label(name="wifi-text", label="WiFi Networks"), - end_children=self.toggle_button, - ), - ScrolledWindow( - name="wifi-available", - v_expand=True, - h_scrollbar_policy="never", - min_content_size=(-1, -1), - child=self.available_box, - ), - ] - - self.client.connect("device-ready", lambda *_: self.on_device_ready()) - - def on_device_ready(self): - if not self.client.wifi_device: - return - - self.update_toggle_icon() - - self.client.wifi_device.connect( - "notify::enabled", - lambda *_: self.update_toggle_icon(), - ) - - self.build_wifi_options() - - def update_toggle_icon(self): - self.toggle_icon.set_markup( - icons.wifi if self.client.wifi_device.enabled else icons.wifi_off - ) - - def build_wifi_options(self): - self.available_box.children = [] - if not self.client.wifi_device: - return - - aps = self.client.wifi_device.access_points - seen_ssids = set() - for ap in aps: - ssid = ap.get("ssid") - if ap.get("ssid") != "Unknown" and ssid not in seen_ssids: - seen_ssids.add(ssid) - self.available_box.add(WifiNetworkSlot(ap, self.client)) diff --git a/modules/launcher/components/window_switcher.py b/modules/launcher/components/window_switcher.py deleted file mode 100644 index c2ed906..0000000 --- a/modules/launcher/components/window_switcher.py +++ /dev/null @@ -1,312 +0,0 @@ -import subprocess -import json -from fabric.widgets.box import Box -from fabric.widgets.label import Label -from fabric.widgets.button import Button -from fabric.widgets.entry import Entry -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.image import Image -from fabric.utils import idle_add, remove_handler, exec_shell_command_async -from utils import IconResolver -from gi.repository import GLib, Gdk, Gtk -import utils.icons as icons -import config.data as data - -class WindowSwitcher(Box): - def __init__(self, **kwargs): - super().__init__( - name="window-switcher", - visible=False, - all_visible=False, - **kwargs, - ) - - self.launcher = kwargs["launcher"] - self.selected_index = -1 # Track the selected item index - self.icon_resolver = IconResolver() - - self.viewport = Box(name="viewport", spacing=4, orientation="v") - self.search_entry = Entry( - name="search-entry", - placeholder="Search Windows...", - h_expand=True, - notify_text=lambda entry, *_: self.handle_search(entry.get_text()), - on_activate=lambda entry, *_: self.handle_search(entry.get_text()), - on_key_press_event=self.on_entry_key_press, - ) - self.search_entry.props.xalign = 0.5 - self.scrolled_window = ScrolledWindow( - name="scrolled-window", - spacing=10, - min_content_size=(450, 305), - max_content_size=(450, 305), - child=self.viewport, - ) - - self.header_box = Box( - name="header_box", - spacing=10, - orientation="h", - children=[ - self.search_entry, - ], - ) - - self.window_switcher_box = Box( - name="window-switcher-box", - spacing=10, - h_expand=True, - orientation="v", - children=[ - self.header_box, - self.scrolled_window, - ], - ) - - self.add(self.window_switcher_box) - self.show_all() - - def close_switcher(self): - """Close the window switcher""" - self.viewport.hide() - self.selected_index = -1 # Reset selection - self.launcher.close() - - def open_switcher(self): - """Open the window switcher and refresh windows""" - self.refresh_windows() - self.search_entry.set_text("") - self.search_entry.grab_focus() - self.viewport.show() - - def refresh_windows(self): - """Get Hyprland windows and populate the viewport""" - remove_handler(self._arranger_handler) if hasattr(self, '_arranger_handler') else None - self.viewport.children = [] - self.selected_index = -1 # Clear selection when viewport changes - - # Get Hyprland windows - windows = self.get_hyprland_windows() - if not windows: - # Create a container box to better center the message - container = Box( - name="no-windows-container", - orientation="v", - h_align="center", - v_align="center", - h_expand=True, - v_expand=True - ) - - # Show a message if no windows - label = Label( - name="no-windows", - markup=icons.window, - h_align="center", - v_align="center", - ) - - container.add(label) - - self.viewport.add(container) - return - - # Add window slots to viewport - for window in windows: - self.viewport.add(self.create_window_slot(window)) - - def get_hyprland_windows(self): - """Get list of windows from Hyprland""" - try: - # Get clients info from Hyprland - result = subprocess.run( - ["hyprctl", "clients", "-j"], - capture_output=True, - text=True, - check=True - ) - clients = json.loads(result.stdout) - - # Filter out special windows and sort by workspace - windows = [] - for client in clients: - if client.get("class") and not client.get("class").startswith("special"): - windows.append(client) - - # Sort windows by workspace number - windows.sort(key=lambda x: int(x.get("workspace", {}).get("id", 0))) - return windows - - except Exception as e: - print(f"Error getting Hyprland windows: {e}") - return [] - - def create_window_slot(self, window): - """Create a button slot for a window""" - # Get window title or class as fallback - title = window.get("title", window.get("class", "Unknown")) - workspace = window.get("workspace", {}).get("id", 0) - window_class = window.get("class", "") - - # Get window icon using IconResolver - icon = self.get_window_icon(window_class) - - # Create the button with window info - button = Button( - name="slot-button", - child=Box( - name="window-slot-box", - orientation="h", - spacing=10, - children=[ - Image( - name="window-icon", - pixbuf=icon, - h_align="start", - ), - Label( - name="workspace-label", - label=f"Workspace {workspace}", - h_align="start", - ), - Label( - name="window-title", - label=title, - ellipsization="end", - v_align="center", - h_align="center", - ), - ], - ), - on_clicked=lambda *_: self.focus_window(window.get("address")), - on_key_press_event=lambda btn, event, addr=window.get("address"): self.on_slot_key_press(btn, event, addr), - ) - return button - - def get_window_icon(self, window_class): - """Get the appropriate icon for a window class using IconResolver""" - try: - # Try to get icon from IconResolver - icon = self.icon_resolver.get_icon_pixbuf(window_class, size=24) - if icon: - return icon - except Exception as e: - print(f"Error resolving icon for {window_class}: {e}") - - # Fallback to default window icon if resolution fails - return icons.window - - def focus_window(self, address): - """Focus a window by its address""" - try: - subprocess.run( - ["hyprctl", "dispatch", "focuswindow", f"address:{address}"], - check=True - ) - self.close_switcher() - except Exception as e: - print(f"Error focusing window: {e}") - - def handle_search(self, query): - """Handle search input""" - if not query: - self.refresh_windows() - return - - # Filter windows based on search query - windows = self.get_hyprland_windows() - filtered_windows = [ - window for window in windows - if query.lower() in window.get("title", "").lower() or - query.lower() in window.get("class", "").lower() - ] - - # Update viewport with filtered results - self.viewport.children = [] - self.selected_index = -1 - - for window in filtered_windows: - self.viewport.add(self.create_window_slot(window)) - - def on_entry_key_press(self, widget, event): - """Handle key presses on the search entry""" - if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): - if self.selected_index != -1: - children = self.viewport.get_children() - if 0 <= self.selected_index < len(children): - children[self.selected_index].clicked() - return True - elif event.keyval == Gdk.KEY_Down: - self.move_selection(1) - return True - elif event.keyval == Gdk.KEY_Up: - self.move_selection(-1) - return True - elif event.keyval == Gdk.KEY_Escape: - self.close_switcher() - return True - return False - - def on_slot_key_press(self, button, event, address): - """Handle key presses on window slots""" - if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): - self.focus_window(address) - return True - elif event.keyval == Gdk.KEY_Escape: - self.close_switcher() - return True - return False - - def move_selection(self, direction): - """Move the selection up or down""" - children = self.viewport.get_children() - if not children: - return - - new_index = self.selected_index + direction - if 0 <= new_index < len(children): - self.update_selection(new_index) - - def update_selection(self, new_index): - """Update the selected item""" - # Unselect current - if self.selected_index != -1 and self.selected_index < len(self.viewport.get_children()): - current_button = self.viewport.get_children()[self.selected_index] - current_button.get_style_context().remove_class("selected") - - # Select new - if new_index != -1 and new_index < len(self.viewport.get_children()): - new_button = self.viewport.get_children()[new_index] - new_button.get_style_context().add_class("selected") - self.selected_index = new_index - self.scroll_to_selected(new_button) - else: - self.selected_index = -1 - - def scroll_to_selected(self, button): - """Scroll to ensure the selected button is visible""" - def scroll(): - adj = self.scrolled_window.get_vadjustment() - alloc = button.get_allocation() - if alloc.height == 0: - return False # Retry if allocation isn't ready - - y = alloc.y - height = alloc.height - page_size = adj.get_page_size() - current_value = adj.get_value() - - # Calculate visible boundaries - visible_top = current_value - visible_bottom = current_value + page_size - - if y < visible_top: - # Item above viewport - align to top - adj.set_value(y) - elif y + height > visible_bottom: - # Item below viewport - align to bottom - new_value = y + height - page_size - adj.set_value(new_value) - # No action if already fully visible - return False - GLib.idle_add(scroll) \ No newline at end of file diff --git a/modules/launcher/launcher.py b/modules/launcher/launcher.py deleted file mode 100644 index 4704e75..0000000 --- a/modules/launcher/launcher.py +++ /dev/null @@ -1,201 +0,0 @@ -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.stack import Stack -from fabric.widgets.wayland import WaylandWindow as Window -from fabric.widgets.box import Box -from modules.launcher.components import ( - AppLauncher, - BluetoothConnections, - EmojiPicker, - PowerMenu, - Sh, - WallpaperSelector, - WifiManager, - Calendar, - Dashboard, - Toolbox, - Kanban, - TmuxManager, - WindowSwitcher, - NotificationCenter, - ClipHistory, -) - - -class Launcher(Window): - def __init__(self, **kwargs): - super().__init__( - layer="top", - anchor="center", - keyboard_mode="none", - exclusivity="normal", - visible=False, - all_visible=False, - **kwargs, - ) - - self.dashboard = Dashboard(launcher=self) - self.wallpapers = WallpaperSelector(launcher=self) - self.power = PowerMenu(launcher=self) - self.emoji = EmojiPicker(launcher=self) - self.bluetooth = BluetoothConnections(launcher=self) - self.sh = Sh(launcher=self) - self.wifi = WifiManager() - self.calendar = Calendar() - self.tools = Toolbox(launcher=self) - self.kanban = Kanban() - self.tmux = TmuxManager(launcher=self) - self.window_switcher = WindowSwitcher(launcher=self) - self.notification_center = NotificationCenter(launcher=self) - self.cliphist = ClipHistory(launcher=self) - - # Wrap the dashboard in a Box container - self.dashboard = Box( - name="dashboard", - orientation="h", - spacing=10, - children=[self.dashboard], - ) - self.launcher = AppLauncher(launcher=self) - - self.stack = Stack( - name="launcher-content", - v_expand=True, - h_expand=True, - transition_type="crossfade", - transition_duration=100, - children=[ - self.launcher, - self.wallpapers, - self.power, - self.emoji, - self.cliphist, - self.bluetooth, - self.sh, - self.wifi, - self.calendar, - self.tools, - self.kanban, - self.tmux, - self.window_switcher, - self.notification_center, - ], - ) - - self.launcher_box = CenterBox( - name="launcher", - orientation="v", - start_children=self.stack, - end_children=self.dashboard, - ) - - self.add(self.launcher_box) - self.show_all() - self.hide() - self.add_keybinding("Escape", lambda *_: self.close()) - - def close(self): - self.set_keyboard_mode("none") - self.hide() - - for widget in [ - self.launcher, - self.wallpapers, - self.power, - self.emoji, - self.cliphist, - self.bluetooth, - self.sh, - self.wifi, - self.calendar, - self.tools, - self.kanban, - self.tmux, - self.window_switcher, - self.notification_center, - ]: - if hasattr(widget, "viewport") and widget.viewport: - widget.viewport.hide() - - for style in [ - "launcher", - "wallpapers", - "power", - "emoji", - "cliphist", - "bluetooth", - "sh", - "wifi", - "calendar", - "tools", - "kanban", - "tmux", - "window-switcher", - "notification-center", - ]: - self.stack.remove_style_class(style) - - return True - - def open(self, widget): - widgets = { - "launcher": self.launcher, - "wallpapers": self.wallpapers, - "power": self.power, - "emoji": self.emoji, - "cliphist": self.cliphist, - "bluetooth": self.bluetooth, - "sh": self.sh, - "wifi": self.wifi, - "calendar": self.calendar, - "tools": self.tools, - "kanban": self.kanban, - "tmux": self.tmux, - "window-switcher": self.window_switcher, - "notification-center": self.notification_center, - } - self.set_keyboard_mode("exclusive") - self.show() - - for w in widgets.values(): - w.hide() - self.dashboard.hide() - for style in widgets.keys(): - self.stack.remove_style_class(style) - - if widget in widgets: - self.stack.get_style_context().add_class(widget) - self.stack.set_visible_child(widgets[widget]) - widgets[widget].show() - - if widget == "launcher": - self.launcher.open_launcher() - self.launcher.search_entry.set_text("") - self.launcher.search_entry.grab_focus() - self.dashboard.show() - - elif widget == "wallpapers": - self.wallpapers.search_entry.set_text("") - self.wallpapers.search_entry.grab_focus() - self.wallpapers.viewport.show() - - elif widget == "tmux": - self.tmux.open_manager() - self.tmux.search_entry.set_text("") - self.tmux.search_entry.grab_focus() - - elif widget == "emoji": - self.emoji.open_picker() - self.emoji.search_entry.set_text("") - self.emoji.search_entry.grab_focus() - - elif widget == "cliphist": - self.cliphist.open() - - elif widget == "sh": - self.sh.open_sh() - - elif widget == "window-switcher": - self.window_switcher.open_switcher() - - elif widget == "notification-center": - self.notification_center.open_center() diff --git a/modules/notification_popup.py b/modules/notification_popup.py deleted file mode 100644 index 82dcb5d..0000000 --- a/modules/notification_popup.py +++ /dev/null @@ -1,302 +0,0 @@ -import os -from gi.repository import GdkPixbuf, GLib, Gdk -from fabric.notifications import ( - Notification, - NotificationAction, - NotificationCloseReason, -) -from loguru import logger -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.image import Image -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.label import Label -from fabric.widgets.revealer import Revealer -from fabric.widgets.wayland import WaylandWindow as Window -from utils import CustomImage -import utils.icons as icons -from services import notification_service - - -class ActionButton(Button): - def __init__( - self, action: NotificationAction, index: int, total: int, notification_box - ): - super().__init__( - name="action-button", - h_expand=True, - on_clicked=self.on_clicked, - child=Label(name="button-label", label=action.label), - ) - self.action = action - self.notification_box = notification_box - style_class = ( - "start-action" - if index == 0 - else "end-action" - if index == total - 1 - else "middle-action" - ) - self.add_style_class(style_class) - self.connect( - "enter-notify-event", lambda *_: notification_box.hover_button(self) - ) - self.connect( - "leave-notify-event", lambda *_: notification_box.unhover_button(self) - ) - - def on_clicked(self, *_): - self.action.invoke() - self.action.parent.close("dismissed-by-user") - - -class NotificationWidget(Box): - def __init__(self, notification: Notification, timeout_ms=5000, **kwargs): - super().__init__( - name="notification-box", - orientation="v", - h_align="fill", - h_expand=True, - children=[ - self.create_content(notification), - self.create_action_buttons(notification), - ], - ) - self.notification = notification - self.timeout_ms = timeout_ms - self._timeout_id = None - self.start_timeout() - - def create_header(self, notification): - app_icon = ( - Image( - name="notification-icon", - image_file=notification.app_icon[7:], - size=24, - ) - if "file://" in notification.app_icon - else Image( - name="notification-icon", - icon_name="dialog-information-symbolic" or notification.app_icon, - icon_size=24, - ) - ) - - return CenterBox( - name="notification-title", - start_children=[ - Box( - spacing=4, - children=[ - app_icon, - Label( - notification.app_name, - name="notification-app-name", - h_align="start", - ), - ], - ) - ], - end_children=[self.create_close_button()], - ) - - def create_content(self, notification): - return Box( - name="notification-content", - spacing=8, - children=[ - Box( - name="notification-image", - children=CustomImage( - pixbuf=notification.image_pixbuf.scale_simple( - 48, 48, GdkPixbuf.InterpType.BILINEAR - ) - if notification.image_pixbuf - else self.get_pixbuf(notification.app_icon, 48, 48) - ), - ), - Box( - name="notification-text", - orientation="v", - v_align="center", - children=[ - Box( - name="notification-summary-box", - orientation="h", - children=[ - Label( - name="notification-summary", - markup=notification.summary.replace("\n", " "), - h_align="start", - ellipsization="end", - ), - Label( - name="notification-app-name", - markup=" | " + notification.app_name, - h_align="start", - ellipsization="end", - ), - ], - ), - Label( - markup=notification.body.replace("\n", " "), - h_align="start", - ellipsization="end", - ) - if notification.body - else Box(), - ], - ), - Box(h_expand=True), - Box( - orientation="v", - children=[ - self.create_close_button(), - Box(v_expand=True), - ], - ), - ], - ) - - def get_pixbuf(self, icon_path, width, height): - if icon_path.startswith("file://"): - icon_path = icon_path[7:] - - if not os.path.exists(icon_path): - logger.warning(f"Icon path does not exist: {icon_path}") - return None - - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path) - return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR) - except Exception as e: - logger.error(f"Failed to load or scale icon: {e}") - return None - - def create_action_buttons(self, notification): - return Box( - name="notification-action-buttons", - spacing=4, - h_expand=True, - children=[ - ActionButton(action, i, len(notification.actions), self) - for i, action in enumerate(notification.actions) - ], - ) - - def create_close_button(self): - close_button = Button( - name="notif-close-button", - child=Label(name="notif-close-label", markup=icons.cancel), - on_clicked=lambda *_: self.notification.close("dismissed-by-user"), - ) - close_button.connect( - "enter-notify-event", lambda *_: self.hover_button(close_button) - ) - close_button.connect( - "leave-notify-event", lambda *_: self.unhover_button(close_button) - ) - return close_button - - def start_timeout(self): - self.stop_timeout() - self._timeout_id = GLib.timeout_add(self.timeout_ms, self.close_notification) - - def stop_timeout(self): - if self._timeout_id is not None: - GLib.source_remove(self._timeout_id) - self._timeout_id = None - - def close_notification(self): - self.notification.close("expired") - self.stop_timeout() - return False - - def pause_timeout(self): - self.stop_timeout() - - def resume_timeout(self): - self.start_timeout() - - def destroy(self): - self.stop_timeout() - super().destroy() - - # @staticmethod - def set_pointer_cursor(self, widget, cursor_name): - window = widget.get_window() - if window: - cursor = Gdk.Cursor.new_from_name(widget.get_display(), cursor_name) - window.set_cursor(cursor) - - def hover_button(self, button): - self.pause_timeout() - self.set_pointer_cursor(button, "hand2") - - def unhover_button(self, button): - self.resume_timeout() - self.set_pointer_cursor(button, "arrow") - - -class NotificationRevealer(Revealer): - def __init__(self, notification: Notification, **kwargs): - self.notif_box = NotificationWidget(notification) - self._notification = notification - super().__init__( - child=Box( - children=[self.notif_box], - ), - transition_duration=250, - transition_type="slide-down", - ) - - self.connect( - "notify::child-revealed", - lambda *_: self.destroy() if not self.get_child_revealed() else None, - ) - - self._notification.connect("closed", self.on_resolved) - - def on_resolved( - self, - notification: Notification, - reason: NotificationCloseReason, - ): - self.set_reveal_child(False) - - -class NotificationPopup(Window): - def __init__(self): - self._server = notification_service - self.cache_notification_service = notification_service - self.notifications = Box( - v_expand=True, - h_expand=True, - style="margin: 1px 0px 1px 1px;", - orientation="v", - spacing=5, - ) - self._server.connect("notification-added", self.on_new_notification) - - super().__init__( - anchor="top right", - child=self.notifications, - layer="overlay", - all_visible=True, - visible=True, - exclusive=False, - ) - - def on_new_notification(self, fabric_notif, id): - notification: Notification = fabric_notif.get_notification_from_id(id) - # Always cache the notification regardless of DND status - self.cache_notification_service.cache_notification(notification) - - # Only show popup if DND is not enabled - if not self.cache_notification_service.dont_disturb: - new_box = NotificationRevealer(fabric_notif.get_notification_from_id(id)) - self.notifications.add(new_box) - new_box.set_reveal_child(True) - logger.info(f"[Notification] New notification from {notification.app_name}") - else: - logger.info(f"[Notification] DND enabled, notification from {notification.app_name} not shown") diff --git a/modules/osd.py b/modules/osd.py deleted file mode 100644 index 6d79a60..0000000 --- a/modules/osd.py +++ /dev/null @@ -1,183 +0,0 @@ -import time -from typing import ClassVar - -from fabric.audio import Audio -from fabric.widgets.box import Box -from fabric.widgets.scale import Scale, ScaleMark -from fabric.widgets.wayland import WaylandWindow as Window -from fabric.utils import invoke_repeater -from gi.repository import GObject -from services.brightness import Brightness -from fabric.widgets.label import Label -from utils import Animator - - -class AnimatedScale(Scale): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.animator = None # Lazily initialized - - def animate_value(self, value: float): - if not self.animator: - self.animator = Animator( - bezier_curve=(0.34, 1.56, 0.64, 1.0), - duration=0.8, - min_value=self.min_value, - max_value=self.max_value, - tick_widget=self, - notify_value=lambda p, *_: self.set_value(p.value), - ) - self.animator.pause() - self.animator.min_value = self.value - self.animator.max_value = min(max(value, self.min_value), self.max_value) - self.animator.play() - - -class BaseOSDContainer(Box): - def __init__(self, label_text, **kwargs): - super().__init__(**kwargs, orientation="v", spacing=8, name="osd") - - self.header = Box(orientation="h") - self.label = Label( - label=label_text, name="osd-label", h_align="start", h_expand=True - ) - self.value_label = Label(name="osd-label", h_align="end") - self.header.add(self.label) - self.header.add(self.value_label) - - self.scale = AnimatedScale( - marks=(ScaleMark(value=i) for i in range(0, 100, 10)), - value=70, - min_value=0, - max_value=100, - increments=(1, 1), - orientation="h", - ) - - self.add(self.header) - self.add(self.scale) - - def update_value_label(self, value): - self.value_label.set_label(str(round(value))) - - -class BrightnessOSDContainer(BaseOSDContainer): - def __init__(self, **kwargs): - super().__init__(label_text="Brightness", **kwargs) - self.brightness_service = Brightness() - self.update_brightness() - - self.scale.connect("value-changed", lambda *_: self.update_brightness()) - self.brightness_service.connect("screen", self.on_brightness_changed) - - def update_brightness(self) -> None: - current_brightness = self.brightness_service.screen_brightness - if current_brightness != 0: - normalized_brightness = self._normalize_brightness(current_brightness) - self.scale.set_value(normalized_brightness) - self.update_value_label(normalized_brightness) - - def on_brightness_changed(self, _sender: any, value: float, *_args) -> None: - normalized_brightness = self._normalize_brightness(value) - self.scale.animate_value(normalized_brightness) - self.update_value_label(normalized_brightness) - - def _normalize_brightness(self, brightness: float) -> float: - normalized = (brightness / self.brightness_service.max_screen) * 100 - return max(0, min(100, normalized)) # Ensure value stays within bounds - - -class AudioOSDContainer(BaseOSDContainer): - __gsignals__: ClassVar[dict] = { - "volume-changed": (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, ()), - } - - def __init__(self, **kwargs): - super().__init__(label_text="Volume", **kwargs) - self.audio = Audio() - self.sync_with_audio() - - self.scale.connect("value-changed", self.on_volume_changed) - self.audio.connect("notify::speaker", self.on_audio_speaker_changed) - self.audio.connect("speaker-changed", self.on_speaker_changed) - - def sync_with_audio(self): - if self.audio.speaker: - volume = round(self.audio.speaker.volume) - self.scale.set_value( - max(0, min(100, volume)) - ) # Ensure value stays within bounds - self.update_value_label(volume) - - def on_volume_changed(self, *_): - if self.audio.speaker: - volume = self.scale.value - if 0 <= volume <= 100: - self.audio.speaker.set_volume(volume) - self.update_value_label(volume) - self.emit("volume-changed") - - def on_audio_speaker_changed(self, *_): - if self.audio.speaker: - self.audio.speaker.connect("changed", self.update_volume) - self.update_volume() - - def on_speaker_changed(self, *_): - self.update_volume() - - def update_volume(self, *_): - if self.audio.speaker and not self.is_hovered(): - volume = round(self.audio.speaker.volume) - self.scale.set_value( - max(0, min(100, volume)) - ) # Ensure value stays within bounds - self.update_value_label(volume) - - -class OSD(Window): - def __init__(self, **kwargs): - self.audio_container = AudioOSDContainer() - self.brightness_container = BrightnessOSDContainer() - - self.timeout = 1000 - - self.controls_box = Box( - orientation="h", - spacing=20, - children=[self.brightness_container, self.audio_container], - ) - - super().__init__( - layer="overlay", - anchor="top", - child=self.controls_box, - visible=False, - pass_through=True, - keyboard_mode="on-demand", - **kwargs, - ) - - self.last_activity_time = time.time() - - self.audio_container.audio.connect("notify::speaker", self.show_controls) - self.brightness_container.brightness_service.connect( - "screen", self.show_controls - ) - self.audio_container.connect("volume-changed", self.show_controls) - - invoke_repeater(100, self.check_inactivity, initial_call=True) - - def show_controls(self, *_): - self.set_visible(True) - self.reset_inactivity_timer() - - def start_hide_timer(self): - self.set_visible(False) - - def reset_inactivity_timer(self): - self.last_activity_time = time.time() - - def check_inactivity(self): - if time.time() - self.last_activity_time >= (self.timeout / 1000): - self.start_hide_timer() - return True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ca1847f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "modusv2" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "fabric", + "loguru>=0.7.3", + "psutil>=7.0.0", + "pydbus>=0.6.0", + "setproctitle>=1.3.6", +] + +[tool.uv.sources] +fabric = { git = "https://github.com/Fabric-Development/fabric.git" } diff --git a/services/__init__.py b/services/__init__.py index cdba666..fdf19b4 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -1,10 +1,9 @@ from fabric.audio import Audio from .brightness import Brightness from .network import NetworkClient -from .notification import * -from .powerprofile import * +from .battery import * network_client = NetworkClient() brightness = Brightness() audio = Audio() -notification_service = CustomNotifications() + diff --git a/services/battery.py b/services/battery.py new file mode 100644 index 0000000..1648f64 --- /dev/null +++ b/services/battery.py @@ -0,0 +1,112 @@ +from fabric.core import Service, Property, Signal +from pydbus import SystemBus +from loguru import logger + +DeviceState = { + 0: "UNKNOWN", + 1: "CHARGING", + 2: "DISCHARGING", + 3: "EMPTY", + 4: "FULLY_CHARGED", + 5: "PENDING_CHARGE", + 6: "PENDING_DISCHARGE", +} + + +class Battery(Service): + @staticmethod + def seconds_to_hours_minutes(seconds): + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return f"{hours}h {minutes}m" if hours else f"{minutes}m" + + @Signal + def changed(self) -> None: ... + + @Signal + def profile_changed(self, value: str) -> None: ... + + @Property(int, "readable") + def percentage(self): + return int(self._battery.Percentage) + + @Property(str, "readable") + def temperature(self): + return f"{self._battery.Temperature}°C" if hasattr(self._battery, "Temperature") else "N/A" + + @Property(str, "readable") + def time_to_empty(self): + return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToEmpty", 0)) + + @Property(str, "readable") + def time_to_full(self): + return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToFull", 0)) + + @Property(str, "readable") + def icon_name(self): + return self._battery.IconName + + @Property(str, "readable") + def state(self): + return DeviceState.get(self._battery.State, "UNKNOWN") + + @Property(str, "readable") + def capacity(self): + return f"{self._battery.Capacity}%" + + @Property(bool, "readable", default_value=False) + def is_present(self): + return self._battery.IsPresent + + @Property(str, "readable") + def power_profile(self): + return self._profile_proxy.ActiveProfile + + def set_power_profile(self, profile: str) -> bool: + if profile not in [p.Profile for p in self._profile_proxy.Profiles]: + logger.warning(f"[Battery] Invalid profile: {profile}") + return False + try: + self._profile_proxy.ActiveProfile = profile + logger.info(f"[Battery] Power profile set to {profile}") + self.profile_changed.emit(profile) + self.changed.emit() + return True + except Exception as e: + logger.error(f"[Battery] Failed to set profile: {e}") + return False + + def __init__(self): + super().__init__() + self._bus = SystemBus() + + # Battery device + try: + self._battery = self._bus.get("org.freedesktop.UPower", "/org/freedesktop/UPower/devices/battery_BAT0") + self._battery.onPropertiesChanged = self.handle_battery_change + logger.info("[Battery] UPower device connected.") + except Exception as e: + logger.error(f"[Battery] Failed to connect to UPower battery: {e}") + return + + # PowerProfiles + try: + self._profile_proxy = self._bus.get("net.hadess.PowerProfiles", "/net/hadess/PowerProfiles") + self._profile_proxy.onPropertiesChanged = self.handle_profile_change + logger.info("[Battery] PowerProfiles daemon connected.") + except Exception as e: + logger.error(f"[Battery] Failed to connect to PowerProfiles daemon: {e}") + self._profile_proxy = None + + self.changed.emit() + + def handle_battery_change(self, iface, changed, invalidated): + logger.debug(f"[Battery] UPower properties changed: {changed}") + self.changed.emit() + + def handle_profile_change(self, iface, changed, invalidated): + if "ActiveProfile" in changed: + new_profile = changed["ActiveProfile"] + logger.info(f"[Battery] Power profile changed: {new_profile}") + self.profile_changed.emit(new_profile) + self.changed.emit() diff --git a/services/brightness.py b/services/brightness.py index 49e7040..2c07276 100644 --- a/services/brightness.py +++ b/services/brightness.py @@ -1,115 +1,81 @@ import os -import subprocess - -from fabric.core.service import Property, Service, Signal -from fabric.utils import exec_shell_command, monitor_file -from gi.repository import GLib -from loguru import logger # Import loguru logger - - -# Helper function to execute brightnessctl asynchronously -def exec_brightnessctl_async(args: str): - # Executes brightnessctl command asynchronously, ensuring no resource leaks. - try: - # Use subprocess.Popen to run the command without blocking - process = subprocess.Popen( - f"brightnessctl {args}", - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - # Wait for the process to complete - stdout, stderr = process.communicate() - - if process.returncode != 0: - logger.error(f"Error executing brightnessctl: {stderr.decode().strip()}") - else: - logger.debug( - f"brightnessctl output: {stdout.decode().strip()}" - ) # Optional: Log the output - except Exception as e: - logger.exception(f"Exception in exec_brightnessctl_async: {e}") - - -# Discover screen backlight device -try: - screen_device = str(exec_shell_command("ls -w1 /sys/class/backlight")).split("\n")[ - 0 - ] -except IndexError: - screen_device = None - - -class NoBrightnessError(ImportError): - # Raised when brightness-related dependencies are missing. - def __init__(self, *args): - super().__init__( - "Brightness control not available or missing dependencies", *args - ) +from loguru import logger +from fabric.core import Service, Property, Signal +from fabric.utils import exec_shell_command_async, monitor_file class Brightness(Service): - # Service to manage screen brightness levels. - @Signal - def screen(self, value: int) -> None: - """Signal emitted when screen brightness changes.""" - pass # Implement as needed for your application + def changed(self) -> None: ... - def __init__(self, **kwargs): - super().__init__(**kwargs) - - if not screen_device: - raise NoBrightnessError() - - # Path for screen backlight control - self.screen_backlight_path = f"/sys/class/backlight/{screen_device}" - - # Initialize maximum brightness level - self.max_screen = self._read_max_brightness(self.screen_backlight_path) - - # Monitor screen brightness file - self.screen_monitor = monitor_file(f"{self.screen_backlight_path}/brightness") - self.screen_monitor.connect( - "changed", - lambda _, file, *args: self.emit( - "screen", round(int(file.load_bytes()[0].get_data())) - ), - ) - - # Log the initialization of the service - logger.info(f"Brightness service initialized for device: {screen_device}") - - def _read_max_brightness(self, path: str) -> int: - # Reads the maximum brightness value from the specified path. - max_brightness_path = os.path.join(path, "max_brightness") - if os.path.exists(max_brightness_path): - with open(max_brightness_path) as f: - return int(f.read().strip()) - return -1 # Return -1 if file doesn't exist, indicating an error. + @Property(int, "readable") + def max_brightness(self): + if self._cached_max_brightness is not None: + return self._cached_max_brightness + try: + with open(self._max_brightness_path) as f: + self._cached_max_brightness = int(f.read().strip()) + return self._cached_max_brightness + except Exception as e: + logger.error(f"[Brightness] Failed to read max brightness: {e}") + return -1 @Property(int, "read-write") - def screen_brightness(self) -> int: - # Property to get or set the screen brightness. - brightness_path = os.path.join(self.screen_backlight_path, "brightness") - if os.path.exists(brightness_path): - with open(brightness_path) as f: + def brightness(self): + try: + with open(self._brightness_path) as f: return int(f.read().strip()) - logger.warning(f"Brightness file does not exist: {brightness_path}") - return -1 # Return -1 if file doesn't exist, indicating error. + except Exception as e: + logger.error(f"[Brightness] Failed to read current brightness: {e}") + return -1 - @screen_brightness.setter - def screen_brightness(self, value: int): - # Setter for screen brightness property. - if not (0 <= value <= self.max_screen): - value = max(0, min(value, self.max_screen)) + @brightness.setter + def brightness(self, value: int): + max_brightness = self.max_brightness + if max_brightness <= 0: + logger.error("[Brightness] Invalid max brightness; cannot set brightness.") + return + + value = max(0, min(value, max_brightness)) + try: + exec_shell_command_async( + f"brightnessctl --device '{self._device}' set {value}" + ) + logger.info(f"[Brightness] Set screen brightness to {value}") + except Exception as e: + logger.exception(f"[Brightness] Failed to set brightness: {e}") + + @Property(int, "readable") + def brightness_percentage(self): + max_brightness = self.max_brightness + current_brightness = self.brightness + + if max_brightness <= 0: + return 0 + return int((current_brightness / max_brightness) * 100) + + def __init__(self): + super().__init__() + self._device = None + self._cached_max_brightness = None + + self._backlight_path = "/sys/class/backlight" try: - exec_brightnessctl_async(f"--device '{screen_device}' set {value}") - self.emit("screen", int((value / self.max_screen) * 100)) - logger.info(f"Set screen brightness to {value} (out of {self.max_screen})") - except GLib.Error as e: - logger.error(f"Error setting screen brightness: {e.message}") + devices = os.listdir(self._backlight_path) + if devices: + self._device = devices[0] + else: + raise FileNotFoundError + except FileNotFoundError: + logger.error("[Brightness] No backlight device found; brightness control disabled.") + return + + self._brightness_path = f"{self._backlight_path}/{self._device}/brightness" + self._max_brightness_path = f"{self._backlight_path}/{self._device}/max_brightness" + + try: + self._screen_monitor = monitor_file(self._brightness_path) + self._screen_monitor.connect("changed", lambda *args: self.changed.emit()) except Exception as e: - logger.exception(f"Unexpected error setting screen brightness: {e}") + logger.error(f"[Brightness] Failed to monitor brightness file: {e}") diff --git a/services/notification.py b/services/notification.py deleted file mode 100644 index 4da918b..0000000 --- a/services/notification.py +++ /dev/null @@ -1,226 +0,0 @@ -import json -import os -from typing import List - -from fabric import Signal -from fabric.notifications import Notification, Notifications -from loguru import logger -import gi -gi.require_version('GLib', '2.0') -from gi.repository import GLib - -import config.data as data - -NOTIFICATION_CACHE_FILE = f"{data.CACHE_DIR}/notifications.json" - - -class CustomNotifications(Notifications): - """A service to manage the notifications.""" - - @Signal - def clear_all(self, value: bool) -> None: - """Signal emitted when notifications are emptied.""" - # Implement as needed for your application - - @Signal - def notification_count(self, value: int) -> None: - """Signal emitted when a new notification is added.""" - # Implement as needed for your application - - @Signal - def dnd(self, value: bool) -> None: - """Signal emitted when dnd is toggled.""" - # Implement as needed for your application - - @property - def count(self) -> int: - """Return the count of notifications.""" - return len(self.all_notifications) - - @property - def dont_disturb(self) -> bool: - """Return the pause status.""" - return self._dont_disturb - - @dont_disturb.setter - def dont_disturb(self, value: bool): - """Set the pause status.""" - self._dont_disturb = value - self.emit("dnd", value) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.all_notifications = [] - self._count = 0 # Will be updated to highest ID when loading - self.deserialized_notifications = [] - self._dont_disturb = False - self._load_notifications() - - def _load_notifications(self): - """Read notifications from the cache file.""" - if os.path.exists(NOTIFICATION_CACHE_FILE): - try: - with open(NOTIFICATION_CACHE_FILE, "r") as file: - notifications = json.load(file) - - def validate_with_id(notif): - """Helper to validate and return ID if valid.""" - try: - self._deserialize_notification(notif) - return (True, notif, notif.get("id", 0)) - except Exception as e: - msg = f"[Notification] Invalid: {str(e)[:50]}" - logger.error(msg) - return (False, None, 0) - - # Validate all notifications at once - results = [validate_with_id(n) for n in notifications] - - # Process results and find highest ID - valid_notifications = [] - highest_id = self._count # Start with current count - for is_valid, notif, notif_id in results: - if is_valid: - valid_notifications.append(notif) - highest_id = max(highest_id, notif_id) - - self.all_notifications = valid_notifications - self._count = highest_id # Update to highest ID seen - self._write_notifications(self.all_notifications) - - except (json.JSONDecodeError, KeyError, ValueError, IndexError) as e: - logger.error(f"[Notification] {e}") - self.all_notifications = [] - self._count = 0 - - def remove_notification(self, id: int): - """Remove the notification of given id.""" - item = next((p for p in self.all_notifications if p["id"] == id), None) - if item: - self.all_notifications.remove(item) - self._write_notifications(self.all_notifications) - self.emit("notification_count", len(self.all_notifications)) - - # Emit clear_all signal if there are no notifications left - if len(self.all_notifications) == 0: - self.emit("clear_all", True) - - def cache_notification(self, data: Notification): - """Cache the notification without widget_config and max_count.""" - - # First clean up any invalid notifications - self._cleanup_invalid_notifications() - - # Create the new notification - self._count += 1 - new_id = self._count - timestamp = GLib.get_real_time() / 1000000 # Convert microseconds to seconds - serialized_data = data.serialize() - serialized_data.update({ - "id": new_id, - "app_name": data.app_name, - "timestamp": timestamp - }) - - # Append the new notification - self.all_notifications.append(serialized_data) - - self._write_notifications(self.all_notifications) - self.emit("notification_count", len(self.all_notifications)) - - def _cleanup_invalid_notifications(self): - """Remove any invalid notifications.""" - - def validate_with_id(notif): - """Helper to validate and return result with ID.""" - try: - self._deserialize_notification(notif) - return (True, notif, None) - except Exception as e: - msg = f"[Notification] Removing invalid: {str(e)[:50]}" - logger.debug(msg) - return (False, None, notif.get("id", 0)) - - # Validate all notifications at once - results = [validate_with_id(n) for n in self.all_notifications] - - # Process results - valid_notifications = [] - invalid_count = 0 - for is_valid, notif, invalid_id in results: - if is_valid: - valid_notifications.append(notif) - else: - invalid_count += 1 - self.emit("notification-closed", invalid_id, "dismissed-by-limit") - - if invalid_count > 0: - self.all_notifications = valid_notifications - self._write_notifications(self.all_notifications) - self.emit("notification_count", len(self.all_notifications)) - - def _deserialize_notification(self, notification): - """Deserialize a notification.""" - try: - notif = Notification.deserialize(notification) - # Store the timestamp in the notification object - notif.timestamp = notification.get("timestamp", GLib.get_real_time() / 1000000) - return notif - except Exception as e: - logger.error( - f"[Notification] Deserialize failed for: {notification}, Error: {e}" - ) - raise - - def clear_all_notifications(self): - """Empty the notifications.""" - logger.info("[Notification] Clearing all notifications") - # Clear notifications but preserve the highest ID we've seen - highest_id = self._count - self.all_notifications = [] - self._write_notifications(self.all_notifications) - self.emit("notification_count", 0) - self.emit("clear_all", True) - # Restore the ID counter so new notifications get unique IDs - self._count = highest_id - - def _write_notifications(self, data): - """Write the notifications to the cache file.""" - try: - with open(NOTIFICATION_CACHE_FILE, "w") as f: - json.dump(data, f, indent=4, ensure_ascii=False) - logger.info("[Notification] Notifications written successfully.") - except Exception as e: - logger.error(f"[Notification] Failed to write notifications: {e}") - - def get_deserialized(self) -> List[Notification]: - """Return the notifications.""" - - def deserialize_with_id(notif): - """Helper to deserialize and return result with ID.""" - try: - return (self._deserialize_notification(notif), None) - except Exception as e: - msg = f"[Notification] Deserialize failed: {str(e)[:50]}" - logger.error(msg) - return (None, notif.get("id")) - - # Process all notifications at once - results = [ - deserialize_with_id(notification) for notification in self.all_notifications - ] - - # Split into successful and failed - deserialized = [] - invalid_ids = [] - for result, error_id in results: - if result is not None: - deserialized.append(result) - elif error_id is not None: - invalid_ids.append(error_id) - - # Clean up invalid notifications - for invalid_id in invalid_ids: - self.remove_notification(invalid_id) - - return deserialized diff --git a/services/powerprofile.py b/services/powerprofile.py deleted file mode 100644 index cccf03e..0000000 --- a/services/powerprofile.py +++ /dev/null @@ -1,88 +0,0 @@ -import dbus -from dbus.mainloop.glib import DBusGMainLoop -from fabric import Service, Signal -from loguru import logger - - -class PowerProfiles(Service): - """Service to interact with the PowerProfiles service.""" - - @Signal - def profile(self, value: str) -> None: - """Signal emitted when profile changes.""" - - instance = None - - @staticmethod - def get_default(): - if PowerProfiles.instance is None: - PowerProfiles.instance = PowerProfiles() - - return PowerProfiles.instance - - def __init__( - self, - **kwargs, - ): - super().__init__( - **kwargs, - ) - - self.bus_name = "net.hadess.PowerProfiles" - self.object_path = "/net/hadess/PowerProfiles" - - self.power_profiles = { - "power-saver": { - "name": "Power Saver", - "icon_name": "power-profile-power-saver-symbolic", - }, - "balanced": { - "name": "Balanced", - "icon_name": "power-profile-balanced-symbolic", - }, - "performance": { - "name": "Performance", - "icon_name": "power-profile-performance-symbolic", - }, - } - - # Set up the dbus main loop - DBusGMainLoop(set_as_default=True) - - self.bus = dbus.SystemBus() - - self.power_profiles_obj = self.bus.get_object(self.bus_name, self.object_path) - - self.iface = dbus.Interface( - self.power_profiles_obj, "org.freedesktop.DBus.Properties" - ) - - # Connect the 'g-properties-changed' signal to the handler - self.iface.connect_to_signal("PropertiesChanged", self.handle_property_change) - - def get_current_profile(self): - try: - return self.iface.Get("net.hadess.PowerProfiles", "ActiveProfile").strip() - - except dbus.DBusException as e: - logger.error(f"[PowerProfile] Error retrieving current power profile: {e}") - return "balanced" - - def set_power_profile(self, profile): - try: - self.iface.Set(self.bus_name, "ActiveProfile", dbus.String(profile)) - logger.info(f"[PowerProfile] Power profile set to {profile}") - except dbus.DBusException as e: - logger.error( - f"[PowerProfile] Could not change power level to {profile}: {e}" - ) - - # Function to handle properties change signals - def handle_property_change(self, proxy, changed, invalidated): - # Print the changed ActiveProfile - if "ActiveProfile" in changed: - logger.info(f"Profile changed: {changed['ActiveProfile']}") - self.emit("profile", changed["ActiveProfile"]) - - def get_profile_icon(self, profile): - return self.power_profiles.get(profile, "balanced").get("icon_name") diff --git a/services/screen_record.py b/services/screen_record.py deleted file mode 100644 index 7fbf6d5..0000000 --- a/services/screen_record.py +++ /dev/null @@ -1,142 +0,0 @@ -import datetime -import subprocess - -from fabric.core.service import Property, Service, Signal -from fabric.utils import exec_shell_command, exec_shell_command_async -from gi.repository import Gio, GLib -from loguru import logger - - -class ScreenRecorder(Service): - @Signal - def recording(self, value: bool) -> None: ... - - def __init__(self, **kwargs): - self.screenshot_path = GLib.get_home_dir() + "/Pictures/Screenshots/" - self.screenrecord_path = GLib.get_home_dir() + "/Videos/Screencasting/" - - super().__init__(**kwargs) - - def screenshot(self, fullscreen=False, save_copy=True): - time = datetime.datetime.today().strftime("%Y-%m-%d_%H-%M-%S") - file_path = self.screenshot_path + str(time) + ".png" - command = ( - ["grimblast", "copysave", "screen", file_path] - if save_copy - else ["grimblast", "copyscreen"] - ) - if not fullscreen: - command[2] = "area" - try: - subprocess.run(command, check=True) - self.send_screenshot_notification( - file_path=file_path if file_path else None, - ) - except Exception as e: - logger.error(f"[SCREENSHOT] Failed to run command: {command}") - - def screencast_start(self, fullscreen=False): - if self.is_recording: - logger.error( - "[SCREENRECORD] Another instance of wf-recorder is already running" - ) - return - time = datetime.datetime.today().strftime("%Y-%m-%d_%H-%M-%S") - file_path = self.screenrecord_path + str(time) + ".mp4" - self._current_screencast_path = file_path - area = "" if fullscreen else f"-g '{exec_shell_command('slurp')}'" - command = f"wf-recorder --file={file_path} --pixel-format yuv420p {area}" - exec_shell_command_async(command) - self.emit("recording", True) - - def screencast_stop(self): - exec_shell_command_async("killall -INT wf-recorder") - self.emit("recording", False) - self.send_screencast_notification(self._current_screencast_path) - - def send_screencast_notification(self, file_path): - # TODO: Generate a thumbnail - # exec_shell_command_async(f"totem-video-thumbnailer {file_path} {FABRIC_CACHE}") - - cmd = ["notify-send"] - cmd.extend( - [ - "-A", - "files=Show in Files", - "-A", - "view=View", - "-i", - "camera-video-symbolic", - "-a", - "Fabric Screenshot Utility", - "Screencast Saved", - f"Saved Screencast at {file_path}", - ] - ) - - proc: Gio.Subprocess = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.STDOUT_PIPE) - - def do_callback(process: Gio.Subprocess, task: Gio.Task): - try: - _, stdout, stderr = process.communicate_utf8_finish(task) - except Exception: - logger.error( - f"[SCREENCAST] Failed read notification action with error {stderr}" - ) - return - - match stdout.strip("\n"): - case "files": - exec_shell_command_async(f"xdg-open {self.screenrecord_path}") - case "view": - exec_shell_command_async(f"xdg-open {file_path}") - - proc.communicate_utf8_async(None, None, do_callback) - - def send_screenshot_notification(self, file_path=None): - cmd = ["notify-send"] - cmd.extend( - [ - "-A", - "files=Show in Files", - "-A", - "view=View", - "-A", - "edit=Edit", - "-i", - "camera-photo-symbolic", - "-a", - "Fabric Screenshot Utility", - "-h", - f"STRING:image-path:{file_path}", - "Screenshot Saved", - f"Saved Screenshot at {file_path}", - ] - if file_path - else ["Screenshot Sent to Clipboard"] - ) - - proc: Gio.Subprocess = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.STDOUT_PIPE) - - def do_callback(process: Gio.Subprocess, task: Gio.Task): - try: - _, stdout, stderr = process.communicate_utf8_finish(task) - except Exception: - logger.error( - f"[SCREENSHOT] Failed read notification action with error {stderr}" - ) - return - - match stdout.strip("\n"): - case "files": - exec_shell_command_async(f"xdg-open {self.screenshot_path}") - case "view": - exec_shell_command_async(f"xdg-open {file_path}") - case "edit": - exec_shell_command_async(f"swappy -f {file_path}") - - proc.communicate_utf8_async(None, None, do_callback) - - @Property(bool, "readable", default_value=False) - def is_recording(self): - return False if len(exec_shell_command("pidof wf-recorder")) == 0 else True diff --git a/styles/bar.css b/styles/bar.css index 6f4993c..03a3a6f 100644 --- a/styles/bar.css +++ b/styles/bar.css @@ -1,196 +1,107 @@ #bar { - padding: 0px 5px 0px 5px; - margin: 1px; - transition: all 0.5s cubic-bezier(0.5, 0.25, 0, 1); + padding: 8px; background-color: var(--surface); - /* Bar background */ + border-radius: 0 0 16px 16px; } -#start-container, -#center-container, -#end-container { - padding-top: 2px; - padding-bottom: 2px; + +#bar-container { + transition: all 0.25s cubic-bezier(0.5, 0.25, 0, 1); + opacity: 1; + } + + #bar-container.hide-bar { + margin-top: -45px; + } + + #bar-container.show-bar { + margin-top: 0; + } + + #bar-container.occluded { + margin-top: -45px; + } + + + #bar-corner-left { + margin-left: -16px; + } + + #bar-corner-right { + margin-right: -16px; + } + + #active-window { + padding: 0px 8px; + background-color: var(--surface-container); + border-radius: 8px; + } + + + #workspaces { + padding: 4px; + min-width: 20px; + border-radius: 8px; + background-color: var(--surface-container); + } + + #date-time { + background-color: var(--surface-container); + border-radius: 8px; + padding: 0px 8px; + } + + #tray { + background-color: var(--surface-container); + padding: 4px; + border-radius: 0 8px 8px 0px; + } + +#tray-button{ + background-color: var(--surface-container); + padding: 4px; + border-radius: 8px 0 0 8px; } -#bar-corner * { - background-color: var(--surface); -} + #tray > button { + margin: 0px; + padding: 0px; + } -/** - * ── Workspace ───────────────────────────────────────────────────────── - */ -#workspaces { - font-style: normal; - background-color: var(--surface-container); + menu { + border: solid 1px var(--outline-variant); + border-radius: 16px; + background-color: var(--surface); + padding: 6px; + } + + menu > menuitem { + border-radius: 10px; + padding: 6px 10px; + } + + menu > menuitem:hover { + background-color: var(--surface-bright); + } + + menu > menuitem:hover > label { + color: var(--on-surface); + } + + +#metrics{ padding: 4px; - margin-top: 1px; - margin-bottom: 1px; - border-radius: 8px; -} - -/** - * ── Update ──────────────────────────────────────────────────────────── - */ -#update { - background-color: var(--surface-container); - margin-top: 1px; - margin-bottom: 1px; - border-radius: 8px; - padding: 1px; - min-width: 24px; - min-height: 24px; - transition: background-color 0.1s ease; -} - -#update:hover { - background-color: var(--surface-bright); -} - -#update:active { - color: var(--shadow); - background-color: var(--primary); -} - -#update-icon { - color: var(--primary); - font-size: 20px; - padding: 1px; - border-radius: 11px; - transition: all 0.1s ease; -} - -#updated-icon { - color: var(--primary); - font-size: 20px; - padding: 4px; - border-radius: 8px; - transition: all 0.1s ease; -} - -#update-label { - color: var(--primary); - font-size: 10px; - font-weight: bold; - padding: 0px; - margin-right: 4px; - border-radius: 8px; - transition: all 0.1s ease; -} - -/** - * ── Clock ───────────────────────────────────────────────────────────── - */ -#datetime { - background-color: var(--surface-container); - margin-top: 1px; - margin-bottom: 1px; - padding: 4px; - border-radius: 8px; -} - -#datetime:hover { - background-color: var(--surface-bright); -} - -/** - * ── Tray ────────────────────────────────────────────────────────────── - */ -#tray { - background-color: var(--surface-container); - margin-top: 1px; - margin-bottom: 1px; - padding: 4px; - border-radius: 8px; -} - -#tray > button { - margin: 0px; - padding: 0px; -} - -menu { - border: solid 1px var(--outline-variant); - border-radius: 16px; - background-color: var(--surface); - padding: 6px; -} - -menu > menuitem { - border-radius: 10px; - padding: 6px 10px; -} - -menu > menuitem:hover { - background-color: var(--surface-bright); -} - -menu > menuitem:hover > label { - color: var(--on-surface); -} - -/** - * ── Logo ────────────────────────────────────────────────────────────── - */ -#button-bar { - background-color: var(--surface-container); - margin-top: 1px; - padding: 4px; - border-radius: 8px; - margin-bottom: 1px; - transition: all 0.1s ease; -} - -#button-bar-label { - color: var(--primary); - font-size: 24px; - border-radius: 8px; - transition: all 0.1s ease; -} - -#button-bar:hover #button-bar-label { - background-color: var(--surface_bright); -} - -#button-bar:active #button-bar-label { - color: var(--shadow); - background-color: var(--primary); -} - -/** - * ── System Indicator ───────────────────────────────────────────────── - */ -#system-indicator { - background-color: var(--surface-container); - margin-top: 1px; - padding: 4px; - margin-bottom: 1px; - border-radius: 8px; -} - -#system-indicator-icon { - font-size: 20px; -} - -#system-notif-label { - font-size: 18px; -} - -/** - * ── Metric ──────────────────────────────────────────────────────────── - */ -#metrics { background-color: var(--surface-container); border-radius: 8px; } #metrics-circle { color: var(--surface-bright); - border: 3px solid var(--primary); + border: 2px solid var(--primary); } #metrics-circle.bat { - border: 3px solid var(--green); + border: 2px solid var(--green); } #metrics-circle.alert { @@ -207,8 +118,63 @@ menu > menuitem:hover > label { #metrics-level { font-weight: bold; + padding: 2px; } #metrics-sep { min-width: 6px; } + +#system-indicator { + padding: 4px; + background-color: var(--surface-container); + border-radius: 8px; +} + +#system-indicator-icon { + font-size: 20px; +} + + +#power-menu { + padding: 4px; + border-radius: 8px; + background-color: var(--surface-container); + transition: all 0.2s ease; +} + +#power-menu .power-icon{ + font-size: 20px; +} + +#power-menu.locked { + /* opacity: 0.5; */ + background-color: var(--surface-container); +} + +#power-menu.locked:hover { + opacity: 0.7; + background-color: var(--surface-container); +} + +#power-menu .power-label { + transition: all 0.2s ease; +} + +#power-menu.locked .power-label { + color: var(--on-surface-variant); +} + +#power-menu:not(.locked) .power-label { + color: var(--on-surface); +} + + +.powerlabel{ + margin-left: 4px; +} + +.powericon{ + font-size: 20px; +} + diff --git a/styles/colors.css b/styles/colors.css index 11d719c..1b0a1d8 100644 --- a/styles/colors.css +++ b/styles/colors.css @@ -1,31 +1,31 @@ :vars { - --foreground: #dde4e2; - --background: #0e1514; - --cursor: #dde4e2; - --primary: #81d5ca; - --primary-container: #00504a; - --on-primary: #003732; - --on-primary-container: #9df2e6; - --secondary: #b1ccc7; - --on-secondary: #1c3531; - --secondary-container: #324b48; - --tertiary: #aec9e6; - --tertiary-container: #2e4961; - --on-tertiary: #163349; - --on-tertiary-container: #cde5ff; - --surface: #0e1514; - --surface-container: #1a2120; - --surface-bright: #343a39; - --surface-variant: #3f4947; - --surface-container-low: #161d1c; - --on-surface: #dde4e2; + --foreground: #dde4e3; + --background: #0e1415; + --cursor: #dde4e3; + --primary: #80d4d7; + --primary-container: #004f51; + --on-primary: #003738; + --on-primary-container: #9cf1f3; + --secondary: #b0cccc; + --on-secondary: #1b3435; + --secondary-container: #324b4c; + --tertiary: #b4c7e9; + --tertiary-container: #354863; + --on-tertiary: #1e314c; + --on-tertiary-container: #d4e3ff; + --surface: #0e1415; + --surface-container: #1a2121; + --surface-bright: #343a3a; + --surface-variant: #3f4949; + --surface-container-low: #161d1d; + --on-surface: #dde4e3; --error: #ffb4ab; --error-dim: #ff8678; --on-error: #690005; --error-container: #93000a; --on-error-container: #ffdad6; - --outline: #899390; - --outline-variant: #3f4947; + --outline: #899393; + --outline-variant: #3f4949; --shadow: #000000; --red: #ffb595; --red-dim: #ff9162; @@ -37,8 +37,8 @@ --blue-dim: #7ca2ff; --magenta: #e4b7f3; --magenta-dim: #d48bec; - --cyan: #80d5cf; - --cyan-dim: #5ac8c1; + --cyan: #80d4d5; + --cyan-dim: #5ac7c8; --white: #82d3e0; } diff --git a/styles/dashboard.css b/styles/dashboard.css deleted file mode 100644 index 6d99fc2..0000000 --- a/styles/dashboard.css +++ /dev/null @@ -1,163 +0,0 @@ -#buttons-grid { - min-height: 52px; -} - -#network-button, -#bluetooth-button, -#night-mode-button, -#power-profile-button, -#dark-mode-button, -#caffeine-button { - border-radius: 20px; -} - -#network-button, -#bluetooth-button { - background-color: unset; - padding: 0; -} - -#night-mode-button, -#dark-mode-button, -#power-profile-button, -#caffeine-button { - background-color: var(--primary); - padding: 0 10px; -} - -#night-mode-button.disabled, -#dark-mode-button.disabled, -#power-profile-button.disabled, -#caffeine-button.disabled { - background-color: var(--surface); -} - -#network-status-button, -#network-menu-button, -#bluetooth-status-button, -#bluetooth-menu-button { - background-color: var(--primary); - padding: 0 8px; -} - -#network-menu-button, -#bluetooth-menu-button { - border-left: 1px solid var(--outline); - min-width: 24px; -} - -#network-status-button:hover, -#network-menu-button:hover, -#bluetooth-status-button:hover, -#bluetooth-menu-button:hover, -#bluetooth-button:hover, -#night-mode-button:hover, -#power-profile-button:hover, -#dark-mode-button:hover, -#caffeine-button:hover { - background-color: var(--foreground); -} - -/* Estados Disabled */ -#network-status-button.disabled, -#network-menu-button.disabled, -#bluetooth-status-button.disabled, -#bluetooth-menu-button.disabled { - background-color: var(--surface); -} - -#network-menu-button.disabled, -#bluetooth-menu-button.disabled { - border-left: 1px solid var(--surface-bright); -} - -#network-status-button.disabled:hover, -#network-menu-button.disabled:hover, -#bluetooth-status-button.disabled:hover, -#bluetooth-menu-button.disabled:hover, -#night-mode-button.disabled:hover, -#power-profile-button.disabled:hover, -#dark-mode-button.disabled:hover, -#caffeine-button.disabled:hover { - background-color: var(--surface-bright); -} - -#network-status-button, -#bluetooth-status-button { - border-radius: 20px 0 0 20px; -} - -#network-menu-button, -#bluetooth-menu-button { - border-radius: 0 20px 20px 0; -} - -#network-icon, -#bluetooth-icon, -#night-mode-icon, -#dark-mode-icon, -#power-profile-icon, -#caffeine-icon { - color: var(--shadow); - font-size: 24px; -} - -#network-icon.disabled, -#bluetooth-icon.disabled, -#night-mode-icon.disabled, -#power-profile-icon.disabled, -#dark-mode-icon.disabled, -#caffeine-icon.disabled { - color: var(--outline); -} - -#network-menu-label, -#bluetooth-menu-label { - color: var(--shadow); - font-size: 16px; -} - -#network-menu-label.disabled, -#bluetooth-menu-label.disabled { - color: var(--outline); -} - -#network-label, -#bluetooth-label, -#night-mode-label, -#power-profile-label, -#caffeine-label, -#dark-mode-label { - color: var(--shadow); - font-size: 14px; - font-weight: bold; - margin-bottom: -4px; -} - -#network-label.disabled, -#bluetooth-label.disabled, -#night-mode-label.disabled, -#power-profile-label.disabled, -#dark-mode-label.disabled, -#caffeine-label.disabled { - color: var(--outline); -} - -#network-ssid, -#bluetooth-status, -#night-mode-status, -#power-profile-status, -#dark-mode-status, -#caffeine-status { - color: var(--surface-bright); - font-size: 12px; -} - -#network-ssid.disabled, -#bluetooth-status.disabled, -#night-mode-status.disabled, -#power-profile-status.disabled, -#dark-mode-status.disabled, -#caffeine-status.disabled { - color: var(--outline); -} diff --git a/styles/dock.css b/styles/dock.css index c0a9860..1d1de04 100644 --- a/styles/dock.css +++ b/styles/dock.css @@ -6,21 +6,6 @@ transition: all 0.25s cubic-bezier(0.5, 0.25, 0, 1); } -#dock.vertical { - margin: 8px 79px 8px 8px; - border-radius: 20px 0 0 20px; -} - -#dock.vertical-left { - margin: 8px 39px 8px 0px; - border-radius: 0 20px 20px 0; -} - -#dock.vertical-right{ - margin: 8px 79px 8px 8px; - border-radius: 20px 0 0 20px; -} - #dock-full { transition: all 0.25s cubic-bezier(0.5, 0.25, 0, 1); opacity: 1; @@ -28,6 +13,7 @@ #dock-full.occluded { @apply dock_nmargin(); + /* opacity: 0; */ } #dock-separator { @@ -56,7 +42,7 @@ #dock-app-button.instance { background: linear-gradient(to top, transparent, var(--surface-bright)); - border-bottom: 1px solid var(--surface-container); + border-bottom: 1px solid var(--surface); border-top: 1px solid var(--outline); padding: 3px 4px; } diff --git a/styles/launcher.css b/styles/launcher.css deleted file mode 100644 index 9728485..0000000 --- a/styles/launcher.css +++ /dev/null @@ -1,827 +0,0 @@ -/* - * ── launcher ────────────────────────────────────────────────────────── -*/ -#dashboard { - min-height: 100px; -} - -#launcher { - margin: 18px; - border: 1px solid var(--outline-variant); - background-color: var(--surface); - border-radius: 24px; - padding: 18px; - transition: all 0.5s cubic-bezier(0.5, 0.25, 0, 1); -} - -#launcher entry { - transition: all 0.1s ease; - border-radius: 11px; - padding: 10px; - margin: 12px; - box-shadow: inset 0 0 0 1px var(--outline-variant); -} - -#launcher-content.wallpapers { - padding: 16px; - min-height: 420px; - min-width: 620px; -} - -.applauncher-scrolled-window { - transition: min-height 0.2s ease; -} - -#launcher-content.bluetooth { - min-height: 464px; -} - -#launcher-content.wifi { - min-height: 464px; -} - -#launcher-content.tools { - min-width: 308px; -} - -#launcher-content.calendar { - min-height: 420px; - min-width: 420px; -} - -#launcher-content { - min-width: 485px; -} - -#launcher-content.power { - min-width: 200px; - min-height: 52px; -} - -#launcher-content.kanban { - min-width: 1000px; - min-height: 478px; -} - -#launcher-content.tmux { - min-height: 220px; - min-width: 480px; -} - -#scrolled-window scrollbar { - border-radius: 10px; - background-color: var(--surface); - padding: 4px; - margin-left: 6px; -} - -#scrolled-window slider { - border-radius: 8px; - min-width: 10px; - min-height: 48px; - background-color: var(--primary); -} - -#config-button:active, -#new-session-button:active { - background-color: var(--primary); -} - -#config-label, -#clear-label, -#new-session-label { - color: var(--primary); - font-size: 24px; -} - -#config-button:active #config-label, -#clear-button:active - #clear-label - #new-session-button:active - #new-session-label { - color: var(--shadow); -} - -#config-button, -#clear-button, -#new-session-button { - background-color: var(--surface-variant); - border-radius: 16px; - padding: 8px; - margin-bottom: 12px; - margin-top: 12px; -} - -#config-button:hover, -#config-button:focus, -#clear-button:hover, -#clear-button:focus, -#new-session-button:hover, -#new-session-button:focus { - background-color: var(--surface-bright); -} - -#clear-button:active { - background-color: var(--red-dim); -} - -/* - * ── applauncher ─────────────────────────────────────────────────────── -*/ -#app-desc { - color: var(--outline); - font-size: 12px; - font-style: italic; -} - -#slot-button { - border-radius: 16px; - padding: 16px; - min-height: 0; - transition: padding 0.25s cubic-bezier(0.5, 0.25, 0, 1); - animation: loadSlot 0.5s ease; -} - -@keyframes loadSlot { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } -} - -#slot-button:hover, -#slot-button:focus, -#slot-button:selected, -#slot-button.selected { - background-color: var(--surface-bright); - padding-left: 24px; -} - -#slot-button:active { - background-color: var(--primary); -} - -#app-icon { - margin-right: 8px; -} - -#app-label { - color: var(--foreground); - font-weight: bold; -} - -#slot-button:hover #app-label, -#slot-button:focus #app-label, -#slot-button:selected #app-label, -#slot-button.selected #app-label { - color: var(--primary); -} - -#slot-button:active #app-label { - color: var(--shadow); -} - -/* - * ── wallpaper ───────────────────────────────────────────────────────── -*/ -#search-entry-walls { - min-width: 512px; -} - -#wallpaper-icons:selected { - background-color: var(--surface-bright); - border-radius: 0px; -} - -#wallpaper-icons:hover { - background-color: var(--surface-bright); - border-radius: 0px; -} - -#scheme-dropdown { - background-color: var(--surface-container); - padding: 8px; - border-radius: 11px; - margin-top: 12px; - margin-bottom: 12px; -} - -#scheme-dropdown > * > * { - font-weight: bold; - color: var(--shadow); -} - -#scheme-dropdown.box button.box.cellview { - background-color: var(--primary); -} - -#scheme-dropdown.box button.box.cellview:selected { - background-color: var(--surface-bright); -} - -/* Customize the overall switch appearance */ -#matugen-switcher { - min-width: 40px; - min-height: 20px; - background-color: var(--surface-variant); - border-radius: 15px; - padding: 2px; - transition: background-color 0.3s ease; -} - -/* Style the switch's slider */ -#matugen-switcher slider { - background-color: var(--primary); - border-radius: 16px; - min-width: 20px; - min-height: 20px; - transition: - background-color 0.1s cubic-bezier(0.5, 0.25, 0, 1.25), - transform 0.25s cubic-bezier(0.5, 0.25, 0, 1.25); -} - -/* When the switch is active (checked) */ -#matugen-switcher:checked { - background-color: var(--primary); -} - -/* Optional: additional styling for the slider when active */ -#matugen-switcher:checked slider { - background-color: var(--surface); -} - -#matugen-switcher:checked image { - color: var(--surface); -} - -#mat-label { - font-size: 24px; - margin-left: 4px; -} - -/* - * ── emoji ───────────────────────────────────────────────────────────── -*/ - -#emoji #search-entry { - font-weight: bold; - background-color: var(--surface); - color: var(--foreground); - border-radius: 16px; - padding: 10px; -} - -#emoji #search-entry selection { - color: var(--background); - background-color: var(--primary); -} - -#emoji #close-button { - background-color: var(--surface); - border-radius: 16px; - padding: 8px; -} - -#emoji #close-button:hover, -#emoji #close-button:focus { - background-color: var(--surface-bright); -} - -#emoji #close-button:active { - background-color: var(--red-dim); -} - -#emoji #close-label { - color: var(--red-dim); - font-size: 24px; -} - -#emoji #close-button:active #close-label { - color: var(--shadow); -} - -#emoji #emoji-slot-button { - border-radius: 16px; - padding: 16px; - transition: padding 0.1s ease; -} - -@keyframes loadEmojiSlot { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } -} - -#emoji #emoji-slot-button:hover, -#emoji #emoji-slot-button:focus, -#emoji #emoji-slot-button:selected, -#emoji #emoji-slot-button.selected { - background-color: var(--surface-bright); - text-shadow: 0 0 4px var(--shadow); - animation: highlightEmojiSlot 0.25s cubic-bezier(0, 0, 0.5, 1); -} - -@keyframes highlightEmojiSlot { - 0% { - padding: 16px; - text-shadow: 0 4px 4px var(--shadow); - } - - 50% { - padding: 8px 16px 24px 16px; - text-shadow: 0 12px 4px var(--shadow); - } - - 100% { - padding: 16px; - text-shadow: 0 4px 4px var(--shadow); - } -} - -#emoji #emoji-slot-button:active { - background-color: var(--primary); -} - -#emoji #emoji-name-label { - color: var(--foreground); - font-weight: bold; -} - -#emoji #emoji-slot-button:hover #emoji-name-label, -#emoji #emoji-slot-button:focus #emoji-name-label, -#emoji #emoji-slot-button:selected #emoji-name-label, -#emoji #emoji-slot-button.selected #emoji-name-label { - color: var(--primary); -} - -#emoji #emoji-slot-button:active #emoji-name-label { - color: var(--shadow); -} - -#emoji #emoji-char-label { - font-size: 24px; -} - -/* - * ── powermenu ───────────────────────────────────────────────────────── -*/ - -#power-menu-button { - border-radius: 20px; - min-width: 52px; - min-height: 52px; - padding: 5px; -} - -#power-menu-button label { - font-size: 35px; - color: var(--foreground); -} - -#power-menu-button:hover, -#power-menu-button:focus { - background-color: var(--surface-bright); -} - -#power-menu-button:hover label, -#power-menu-button:focus label { - color: var(--primary); -} - -#power-menu-button:active { - background-color: var(--primary); -} - -#power-menu-button:active label { - color: var(--shadow); -} - -/* - * ── sh runner ───────────────────────────────────────────────────────── -*/ - -#sh-item { - border-radius: 11px; - background-color: transparent; - background-image: none; - box-shadow: none; - padding: 0px; -} - -#sh-item { - font-size: 24px; -} - -#sh-item:hover, -#sh-item:focus { - text-shadow: 2px 2px 2px var(--primary); -} - -/* - * ── bluetooth ───────────────────────────────────────────────────────── -*/ - -#bluetooth-header { - border: 2px solid var(--shadow); - padding: 4px; - background-color: var(--surface-container); - border-radius: 12px; -} - -#bluetooth-device { - border: 2px solid var(--shadow); - background-color: var(--surface-container); - border-radius: 12px; - padding: 4px; -} - -#bluetooth-scan, -#bluetooth-back { - background-color: var(--surface); - border-radius: 8px; - padding: 4px; -} - -#bluetooth-back-label { - font-size: 20px; -} - -#bluetooth-scan:hover, -#bluetooth-back:hover { - background-color: var(--surface-bright); -} - -#bluetooth-scan label, -#bluetooth-toggle label { - font-weight: bold; -} - -#bluetooth-text, -#bluetooth-section { - font-weight: bold; -} - -#bluetooth-section { - background-color: var(--surface-container); - border-radius: 12px; - padding: 8px; -} - -#bluetooth-connection { - font-size: 20px; -} - -#bluetooth-connect { - background-color: var(--surface); - border-radius: 8px; - padding: 8px; -} - -#bluetooth-connect:hover { - background-color: var(--surface-bright); -} - -#bluetooth-paired, -#bluetooth-available { - background-color: var(--surface-container); - border-radius: 20px; - padding: 4px; -} - -#bt-sep { - border-radius: 16px; -} - -#bluetooth-scan-label { - font-size: 20px; - color: var(--primary); -} - -#bluetooth-scan.scanning { - animation: blink 0.5s ease infinite; -} - -#bluetooth-scan-label.scanning { - animation: blink 0.5s ease infinite; -} - -@keyframes blink { - 0% { - background-color: var(--blue); - color: var(--shadow); - } - - 50% { - background-color: var(--shadow); - color: var(--blue); - } - - 100% { - background-color: var(--blue); - color: var(--shadow); - } -} - -/* - * ── wifi ────────────────────────────────────────────────────────────── -*/ - -#wifi-header { - background-color: var(--surface-container); - padding: 4px; - border-radius: 20px; -} - -#wifi-device { - background-color: var(--secondary-container); - border-radius: 16px; - padding: 4px; - margin-bottom: 4px; -} - -#wifi-toggle-icon { - font-size: 24px; -} - -#wifi-scan, -#wifi-toggle { - background-color: var(--surface-container); - margin-left: 0px; - min-width: 44px; - min-height: 44px; - border-radius: 100%; -} - -#wifi-scan:hover, -#wifi-toggle:hover { - background-color: var(--surface-bright); -} - -#wifi-scan label, -#wifi-toggle label { - font-weight: bold; -} - -#wifi-text, -#wifi-section { - font-weight: bold; -} - -#wifi-text { - color: var(--primary); -} - -#wifi-connection { - font-size: 20px; -} - -#wifi-connect { - background-color: var(--surface); - border-radius: 16px; - padding: 8px 16px; -} - -#wifi-connect:hover { - background-color: var(--surface-bright); -} - -#wifi-available { - /*background-color: var(--surface-container);*/ - padding: 4px; - border-radius: 20px; -} - -/* - * ── calendar ────────────────────────────────────────────────────────── -*/ - -#header { - border-radius: 12px; - padding: 4px; -} - -#weekday-row { - background-color: var(--surface); - border-radius: 8px; - padding: 4px; -} - -#month-label, -#weekday-label, -#day-label { - font-weight: bold; -} - -#day-label, -#day-empty { - min-width: 38px; - min-height: 22px; -} - -#day-empty { - color: var(--surface-bright); -} - -#weekday-label { - color: var(--primary); -} - -#calendar-grid { - border-radius: 16px; - padding: 4px; -} - -#day-label.current-day { - background-color: var(--primary); - color: var(--shadow); - border-radius: 20px; -} - -#prev-month-button, -#next-month-button { - background-color: var(--surface); - border-radius: 8px; - padding: 4px; -} - -#prev-month-button:hover, -#next-month-button:hover { - background-color: var(--surface-bright); -} - -#prev-month-button:active, -#next-month-button:active { - background-color: var(--primary); -} - -#month-button-label { - color: var(--primary); - font-size: 20px; -} - -#prev-month-button:active #month-button-label, -#next-month-button:active #month-button-label { - color: var(--shadow); -} - -#prev-month-day, -#next-month-day { - color: mix(var(--on-surface), transparent, 0.6); -} - -/* - * ── tools ───────────────────────────────────────────────────────────── -*/ - -#toolbox-button { - border-radius: 20px; - min-width: 52px; - min-height: 52px; - padding: 5px; -} - -#toolbox-button label { - font-size: 35px; - color: var(--foreground); -} - -#toolbox-button.recording label { - color: var(--red-dim); -} - -#toolbox-button:hover, -#toolbox-button:focus { - background-color: var(--surface-bright); -} - -#toolbox-button:hover label, -#toolbox-button:focus label { - color: var(--primary); -} - -#toolbox-button.recording:hover label, -#toolbox-button.recording:focus label { - color: var(--red-dim); -} - -#toolbox-button:active { - background-color: var(--primary); -} - -#toolbox-button:active label { - color: var(--shadow); -} - -#toolbox-button.recording:active { - background-color: var(--red-dim); -} - -#toolbox-button.recording:active label { - color: var(--shadow); -} - -#tool-sep { - min-width: 4px; - min-height: 4px; - margin: 0px 4px; - border-radius: 16px; - background-color: var(--surface-bright); -} - -/* - * ── kanban ──────────────────────────────────────────────────────────── -*/ - -#kanban-header { - border: 2px solid var(--outline); - border-radius: 12px; - padding: 4px; -} - -#column-header { - padding: 4px; - font-weight: bold; - color: var(--primary); -} - -#kanban-note { - background-color: var(--surface-container); - border-radius: 4px; - padding: 8px; - margin-bottom: 4px; -} - -#kanban-row:first-child > * > * { - border-radius: 16px 16px 4px 4px; -} - -#kanban-row:last-child > * > * { - border-radius: 4px 4px 16px 16px; -} - -#inline-editor { - border: 2px solid var(--surface-bright); - border-radius: 12px; - padding: 8px; -} - -#kanban-btn, -#kanban-btn-add { - background-color: var(--surface-container); - border-radius: 8px; - padding: 4px; -} - -#kanban-btn { - background-color: var(--surface); -} - -#kanban-btn:hover, -#kanban-btn-add:hover { - background-color: var(--surface-bright); -} - -#kanban-btn:active, -#kanban-btn-add:active { - background-color: var(--primary); -} - -#kanban-btn-label, -#kanban-btn-neg { - font-size: 20px; - color: var(--primary); -} - -#kanban-btn-neg { - color: var(--red-dim); -} - -#kanban-btn:active #kanban-btn-label, -#kanban-btn-add:active #kanban-btn-label { - color: var(--surface); -} - -/* tmux */ -#no-notif, -#no-tmux, -#no-clip { - font-size: 96px; - color: var(--surface-container); -} - -#clip-label { - font-weight: bold; -} - -#tmux-icon, -#clip-icon { - font-size: 20px; - color: var(--primary); -} diff --git a/styles/main.css b/styles/main.css index 79e35a8..7493591 100644 --- a/styles/main.css +++ b/styles/main.css @@ -1,26 +1,29 @@ -@import "./colors.css"; -@import "./bar.css"; -@import "./notification.css"; -@import "./osd.css"; -@import "./launcher.css"; -@import "./dashboard.css"; -@import "./dock.css"; -@import "./notif_center.css"; +@import url("./colors.css"); +@import url("./bar.css"); +@import url("./dock.css"); * { all: unset; color: var(--foreground); font-size: 15px; - font-family: "Monaspace Radon"; + font-family: "Zed Sans","Jost*", sans-serif; +} + +#corner { + background-color: var(--surface); + border-radius: 0; +} + +#corner-container { + min-width: 20px; + min-height: 20px; } /* * ── tooltip ─────────────────────────────────────────────────────────── */ tooltip { - border: solid 1px; border-radius: 16px; - border-color: var(--outline); background-color: var(--surface); animation: tooltipShow 0.25s cubic-bezier(0.5, 0.25, 0, 1); } @@ -40,12 +43,4 @@ tooltip > * { border-radius: 10px; } -#corner { - background-color: var(--surface); - border-radius: 0; -} -#corner-container { - min-width: 20px; - min-height: 20px; -} diff --git a/styles/notif_center.css b/styles/notif_center.css deleted file mode 100644 index 85f0f1d..0000000 --- a/styles/notif_center.css +++ /dev/null @@ -1,28 +0,0 @@ -#nhh { - font-weight: bold; -} - -#nhh-button { - border-radius: 8px; - background-color: var(--surface); - padding: 4px; -} - -#nhh-button:hover { - background-color: var(--surface-bright); -} - -#nhh-button-label { - color: var(--red-dim); - font-size: 20px; -} - -#dnd-label { - font-size: 20px; - margin-left: 8px; -} - -#no-notifications-label { - font-size: 96px; - color: var(--surface-container); -} diff --git a/styles/notification.css b/styles/notification.css deleted file mode 100644 index dddf9bf..0000000 --- a/styles/notification.css +++ /dev/null @@ -1,83 +0,0 @@ -#notification-action-buttons button.start-action { - border-radius: 20px; -} - -#notification-action-buttons button { - background-color: var(--surface); - padding: 8px; - border-radius: 20px; - transition: background-color 0.1s ease; -} - -#notification-action-buttons button:hover { - background-color: var(--surface-bright); -} - -#notification-action-buttons button:active { - background-color: var(--primary); -} - -#notification-action-buttons button:active #button-label { - color: var(--shadow); -} - -#notification-image image { - border-radius: 16px; - color: var(--on-surface); -} - -#notification-summary, -#button-label { - font-weight: bold; -} - -#notification-summary { - font-weight: bold; - color: var(--primary); -} - -#notification-app-name { - color: var(--outline); -} - -#action-button { - margin-top: 8px; -} - -#notification-box { - border-radius: 20px; - padding: 16px; - background-color: var(--surface-container-low); - margin: 4px; - min-width: 336px; - transition: background-color 0.1s ease; -} - -#boxed-notification-revealer { - margin-top: -14px; -} - -#notif-close-button { - background-color: var(--surface); - border-radius: 16px; - padding: 4px; - transition: background-color 0.1s ease; -} - -#notif-close-button:hover, -#notif-close-button:focus { - background-color: var(--surface-bright); -} - -#notif-close-button:active { - background-color: var(--red-dim); -} - -#notif-close-label { - color: var(--red-dim); - font-size: 20px; -} - -#notif-close-button:active #notif-close-label { - color: var(--shadow); -} diff --git a/styles/osd.css b/styles/osd.css deleted file mode 100644 index 017865d..0000000 --- a/styles/osd.css +++ /dev/null @@ -1,47 +0,0 @@ -#osd { - background-color: var(--surface); - padding: 10px; - border-radius: 6px; - margin-top: 6px; -} - -#osd scale { - min-width: 150px; -} - -#osd trough { - background: var(--surface-container); - min-height: 10px; - margin-right: 4px; - border-radius: 99px; -} - -#osd highlight { - border-radius: 99px 0px 0px 99px; - background: var(--primary); - margin-right: 6px; -} - -#osd slider { - color: var(--on-primary); - background-color: var(--primary); - background-size: 20px; - min-width: 5px; - min-height: 10px; - border-radius: 12px; - padding: 0px; - background-repeat: no-repeat; - background-position: center; - margin-left: 0px; - margin-right: 0px; - margin-top: -10px; - margin-bottom: -10px; - box-shadow: - -1px 0px 0px 1px var(--surface-container), - 1px 0px 0px 1px var(--surface-container); -} - -#osd-label { - font-weight: bold; - font-size: 17px; -} diff --git a/utils/__init__.py b/utils/__init__.py index 43abad3..e69de29 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,4 +0,0 @@ -from .custom_image import * -from .icon_resolver import * -from .occlusion import * -from .animator import * diff --git a/utils/animator.py b/utils/animator.py deleted file mode 100644 index 1799ae6..0000000 --- a/utils/animator.py +++ /dev/null @@ -1,179 +0,0 @@ -from typing import cast - -from fabric import Property, Service, Signal -from gi.repository import GLib, Gtk - - -class Animator(Service): - @Signal - def finished(self) -> None: ... - - @Property(tuple[float, float, float, float], "read-write") - def bezier_curve(self) -> tuple[float, float, float, float]: - return self._bezier_curve - - @bezier_curve.setter - def bezier_curve(self, value: tuple[float, float, float, float]): - self._bezier_curve = value - return - - @Property(float, "read-write") - def value(self): - return self._value - - @value.setter - def value(self, value: float): - self._value = value - return - - @Property(float, "read-write") - def max_value(self): - return self._max_value - - @max_value.setter - def max_value(self, value: float): - self._max_value = value - return - - @Property(float, "read-write") - def min_value(self): - return self._min_value - - @min_value.setter - def min_value(self, value: float): - self._min_value = value - return - - @Property(bool, "read-write", default_value=False) - def playing(self): - return self._playing - - @playing.setter - def playing(self, value: bool): - self._playing = value - return - - @Property(bool, "read-write", default_value=False) - def repeat(self): - return self._repeat - - @repeat.setter - def repeat(self, value: bool): - self._repeat = value - return - - def __init__( - self, - bezier_curve: tuple[float, float, float, float], - duration: float, - min_value: float = 0.0, - max_value: float = 1.0, - repeat: bool = False, - tick_widget: Gtk.Widget | None = None, - **kwargs, - ): - super().__init__(**kwargs) - self._bezier_curve = (1, 0, 1, 1) - self._duration = 5 - self._value = 0.0 - self._min_value = 0.0 - self._max_value = 1.0 - self._repeat = False - - self.bezier_curve = bezier_curve - self.duration = duration - self.value = min_value - self.min_value = min_value - self.max_value = max_value - self.repeat = repeat - - self.playing = False - self._start_time = None - self._tick_handler = None - self._timeline_pos = 0 - self._tick_widget = tick_widget - - def do_get_time_now(self): - return GLib.get_monotonic_time() / 1_000_000 - - def do_lerp(self, start: float, end: float, time: float) -> float: - return start + (end - start) * time - - def do_interpolate_cubic_bezier(self, time: float) -> float: - y_points = (0, self.bezier_curve[1], self.bezier_curve[3], 1) - return ( - (1 - time) ** 3 * y_points[0] - + 3 * (1 - time) ** 2 * time * y_points[1] - + 3 * (1 - time) * time**2 * y_points[2] - + time**3 * y_points[3] - ) - - def do_ease(self, time: float) -> float: - return self.do_lerp( - self.min_value, self.max_value, self.do_interpolate_cubic_bezier(time) - ) - - def do_update_value(self, delta_time: float): - if not self.playing: - return - - elapsed_time = delta_time - cast(float, self._start_time) - - self._timeline_pos = min(1, elapsed_time / self.duration) - - self.value = self.do_ease(self._timeline_pos) - - if not self._timeline_pos >= 1: - return - - if not self.repeat: - self.value = self.max_value - self.finished() - self.pause() - return - - self._start_time = delta_time - self._timeline_pos = 0 - return - - def do_handle_tick(self, *_): - current_time = self.do_get_time_now() - self.do_update_value(current_time) - return True - - def do_remove_tick_handlers(self): - if self._tick_handler: - if self._tick_widget: - self._tick_widget.remove_tick_callback(self._tick_handler) - else: - GLib.source_remove(self._tick_handler) - self._tick_handler = None - return - - def play(self): - if self.playing: - return - - self._start_time = self.do_get_time_now() - - if not self._tick_handler: - if self._tick_widget: - self._tick_handler = self._tick_widget.add_tick_callback( - self.do_handle_tick - ) - else: - self._tick_handler = GLib.timeout_add(16, self.do_handle_tick) - - self.playing = True - return - - def pause(self): - self.playing = False - return self.do_remove_tick_handlers() - - def stop(self): - if not self._tick_handler: - self._timeline_pos = 0 - self.playing = False - return - return self.do_remove_tick_handlers() diff --git a/utils/custom_image.py b/utils/custom_image.py deleted file mode 100644 index 1aae7f6..0000000 --- a/utils/custom_image.py +++ /dev/null @@ -1,39 +0,0 @@ -import math -from typing import cast - -import cairo -from gi.repository import Gtk - -from fabric.widgets.image import Image - - -class CustomImage(Image): - def do_render_rectangle( - self, cr: cairo.Context, width: int, height: int, radius: int = 0 - ): - cr.move_to(radius, 0) - cr.line_to(width - radius, 0) - cr.arc(width - radius, radius, radius, -(math.pi / 2), 0) - cr.line_to(width, height - radius) - cr.arc(width - radius, height - radius, radius, 0, (math.pi / 2)) - cr.line_to(radius, height) - cr.arc(radius, height - radius, radius, (math.pi / 2), math.pi) - cr.line_to(0, radius) - cr.arc(radius, radius, radius, math.pi, (3 * (math.pi / 2))) - cr.close_path() - - def do_draw(self, cr: cairo.Context): - context = self.get_style_context() - width, height = self.get_allocated_width(), self.get_allocated_height() - cr.save() - - self.do_render_rectangle( - cr, - width, - height, - cast(int, context.get_property("border-radius", Gtk.StateFlags.NORMAL)), - ) - cr.clip() - Image.do_draw(self, cr) - - cr.restore() diff --git a/utils/icon_resolver.py b/utils/icon_resolver.py index b8bedeb..a80c8f6 100644 --- a/utils/icon_resolver.py +++ b/utils/icon_resolver.py @@ -99,4 +99,4 @@ class IconResolver: self._get_icon_from_desktop_file(desktop_file) if desktop_file else self.default_applicaiton_icon - ) \ No newline at end of file + ) diff --git a/utils/popup.py b/utils/popup.py new file mode 100644 index 0000000..f1c9942 --- /dev/null +++ b/utils/popup.py @@ -0,0 +1,279 @@ +from typing import ClassVar + +import gi +from fabric.hyprland.service import HyprlandEvent +from fabric.hyprland.widgets import get_hyprland_connection +from fabric.widgets.box import Box +from fabric.widgets.wayland import WaylandWindow +from fabric.widgets.widget import Widget +from gi.repository import Gdk, GLib, GObject, GtkLayerShell +from loguru import logger + +gi.require_versions( + {"Gtk": "3.0", "Gdk": "3.0", "GtkLayerShell": "0.1", "GObject": "2.0"} +) + + +class PopoverManager: + """Singleton manager to handle shared resources for popovers.""" + + _instance = None + + @classmethod + def get_instance(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def __init__(self): + # Shared overlay window for all popovers + self.overlay = WaylandWindow( + name="popover-overlay", + style_classes="popover-overlay", + title="fabric-shell-popover-overlay", + anchor="left top right bottom", + margin="-50px 0px 0px 0px", + exclusivity="auto", + layer="overlay", + type="top-level", + visible=False, + all_visible=False, + style="background-color: rgba(0,0,0,0.0);", + ) + + # Add empty box so GTK doesn't complain + self.overlay.add(Box()) + + # Keep track of active popovers + self.active_popover = None + self.available_windows = [] + + # Close popover when clicking overlay + self.overlay.connect("button-press-event", self._on_overlay_clicked) + self._hyprland_connection = get_hyprland_connection() + self._hyprland_connection.connect( + "event::focusedmonv2", self._on_monitor_change + ) + + def _on_monitor_change(self, _, event: HyprlandEvent): + if self.active_popover: + self.active_popover.hide_popover() + return True + + def _on_overlay_clicked(self, widget, event): + if self.active_popover: + self.active_popover.hide_popover() + return True + + def get_popover_window(self): + """Get an available popover window or create a new one.""" + if self.available_windows: + return self.available_windows.pop() + + window = WaylandWindow( + type="popup", + layer="overlay", + name="popover-window", + anchor="left top", + visible=False, + all_visible=False, + ) + GtkLayerShell.set_keyboard_interactivity(window, True) + window.set_keep_above(True) + return window + + def return_popover_window(self, window): + """Return a popover window to the pool.""" + # Remove any children + for child in window.get_children(): + window.remove(child) + + window.hide() + # Only keep a reasonable number of windows in the pool + if len(self.available_windows) < 5: + self.available_windows.append(window) + else: + # Let the window be garbage collected + window.destroy() + + def activate_popover(self, popover): + """Set the active popover and show overlay.""" + if self.active_popover and self.active_popover != popover: + self.active_popover.hide_popover() + + self.active_popover = popover + self.overlay.show() + + +@GObject.Signal( + flags=GObject.SignalFlags.RUN_LAST, return_type=GObject.TYPE_NONE, arg_types=() +) +def popover_opened(widget: Widget): ... + + +@GObject.Signal( + flags=GObject.SignalFlags.RUN_LAST, return_type=GObject.TYPE_NONE, arg_types=() +) +def popover_closed(widget: Widget): ... + + +@GObject.type_register +class Popover(Widget): + """Memory-efficient popover implementation.""" + + __gsignals__: ClassVar = { + "popover-opened": (GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, ()), + "popover-closed": (GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, ()), + } + + def __init__( + self, + point_to, + content_factory=None, + content=None, + ): + super().__init__() + """ + Initialize a popover. + + Args: + content_factory: Function that returns content widget when called + point_to: Widget to position the popover next to + """ + self._content_factory = content_factory + self._point_to = point_to + self._content_window = None + self._content = content + self._visible = False + self._destroy_timeout = None + # Use weak reference to avoid circular reference issues + self._manager = PopoverManager.get_instance() + + def set_content_factory(self, content_factory): + """Set the content factory for the popover.""" + self._content_factory = content_factory + + def set_content(self, content): + """Set the content for the popover.""" + self._content = content + + def _on_key_press(self, widget, event): + if event.keyval == Gdk.KEY_Escape and self._manager.active_popover: + self._manager.active_popover.hide_popover() + + def open(self): + if self._destroy_timeout is not None: + GLib.source_remove(self._destroy_timeout) + self._destroy_timeout = None + + if not self._content_window: + try: + self._create_popover() + except Exception as e: + logger.error(f"Could not create popover! Error: {e}") + else: + self._manager.activate_popover(self) + self._content_window.show() + self._content_window.steal_input() + self._visible = True + + self.emit("popover-opened") + + def _calculate_margins(self): + widget_allocation = self._point_to.get_allocation() + popover_size = self._content_window.get_size() + + display = Gdk.Display.get_default() + screen = display.get_default() + monitor_at_window = screen.get_monitor_at_window(self._point_to.get_window()) + monitor_geometry = monitor_at_window.get_geometry() + + x = ( + widget_allocation.x + + (widget_allocation.width / 2) + - (popover_size.width / 2) + ) + # Position below the button instead of above + y = widget_allocation.y + widget_allocation.height + 5 # 5px gap below button + + # Ensure popover stays within monitor bounds + if x <= 0: + x = widget_allocation.x + elif x + popover_size.width >= monitor_geometry.width: + x = widget_allocation.x - popover_size.width + widget_allocation.width + + # If popover would go below monitor, position it above the button + if y + popover_size.height > monitor_geometry.y + monitor_geometry.height: + y = widget_allocation.y - popover_size.height - 5 # 5px gap above button + + return [y, 0, 0, x] + + def set_position(self, position: tuple[int, int, int, int] | None = None): + if position is None: + self._content_window.set_margin(self._calculate_margins()) + return False + + self._content_window.set_margin(position) + return False + + def _on_content_ready(self, widget, event): + self.set_position() + + def _create_popover(self): + if self._content is None and self._content_factory is not None: + self._content = self._content_factory() + + # Get a window from the pool + self._content_window = self._manager.get_popover_window() + + # This is a hack to fix wrong positioning for widgets that are not rendered + # immediately (e.g., Gtk.Calendar()) + self._content.connect("draw", self._on_content_ready) + + # Add content to window + self._content_window.add( + Box(style_classes="popover-content", children=self._content) + ) + + self._content_window.connect("focus-out-event", self._on_popover_focus_out) + + self._content_window.connect("key-press-event", self._on_key_press) + self._manager.activate_popover(self) + self._content_window.show() + self._content_window.steal_input() + self._visible = True + + def _on_popover_focus_out(self, widget, event): + # This helps with keyboard focus issues + GLib.timeout_add(100, self.hide_popover) + return False + + def hide_popover(self): + if not self._visible or not self._content_window: + return False + + self._content_window.hide() + self._manager.overlay.hide() + self._visible = False + + if not self._destroy_timeout: + self._destroy_timeout = GLib.timeout_add(1000 * 5, self._destroy_popover) + + self.emit("popover-closed") + + return False + + def _destroy_popover(self): + """Return resources to the pool and clear references.""" + self._destroy_timeout = None + self._visible = False + + if self._content_window: + # Return window to the pool + self._manager.return_popover_window(self._content_window) + self._content_window = None + + # Allow content to be garbage collected if no longer needed + self._content = None + + return False \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d3dd3fc --- /dev/null +++ b/uv.lock @@ -0,0 +1,154 @@ +version = 1 +revision = 2 +requires-python = ">=3.13" + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fabric" +version = "0.0.2" +source = { git = "https://github.com/Fabric-Development/fabric.git#1831ced4d9bb9f4be3893be55a8d502b47bff29e" } +dependencies = [ + { name = "click" }, + { name = "loguru" }, + { name = "pycairo" }, + { name = "pygobject" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "modusv2" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fabric" }, + { name = "loguru" }, + { name = "psutil" }, + { name = "pydbus" }, + { name = "setproctitle" }, +] + +[package.metadata] +requires-dist = [ + { name = "fabric", git = "https://github.com/Fabric-Development/fabric.git" }, + { name = "loguru", specifier = ">=0.7.3" }, + { name = "psutil", specifier = ">=7.0.0" }, + { name = "pydbus", specifier = ">=0.6.0" }, + { name = "setproctitle", specifier = ">=1.3.6" }, +] + +[[package]] +name = "psutil" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" }, + { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" }, + { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" }, +] + +[[package]] +name = "pycairo" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/d9/412da520de9052b7e80bfc810ec10f5cb3dbfa4aa3e23c2820dc61cdb3d0/pycairo-1.28.0.tar.gz", hash = "sha256:26ec5c6126781eb167089a123919f87baa2740da2cca9098be8b3a6b91cc5fbc", size = 662477, upload-time = "2025-04-14T20:11:08.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/a7/c3e5ed55781dfe1b31eb4a2482aeae707671f3d36b0ea53a1722f4a3dfe9/pycairo-1.28.0-cp313-cp313-win32.whl", hash = "sha256:d13352429d8a08a1cb3607767d23d2fb32e4c4f9faa642155383980ec1478c24", size = 750594, upload-time = "2025-04-14T20:10:59.284Z" }, + { url = "https://files.pythonhosted.org/packages/8b/1c/ebadd290748aff3b6bc35431114d41e7a42f40a4b988c2aaf2dfed5d8156/pycairo-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:082aef6b3a9dcc328fa648d38ed6b0a31c863e903ead57dd184b2e5f86790140", size = 841774, upload-time = "2025-04-14T20:11:01.79Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ce/a3f5f1946613cd8a4654322b878c59f273c6e9b01dfadadd3f609070e0b9/pycairo-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:026afd53b75291917a7412d9fe46dcfbaa0c028febd46ff1132d44a53ac2c8b6", size = 691675, upload-time = "2025-04-16T19:30:26.565Z" }, +] + +[[package]] +name = "pydbus" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/56/3e84f2c1f2e39b9ea132460183f123af41e3b9c8befe222a35636baa6a5a/pydbus-0.6.0.tar.gz", hash = "sha256:4207162eff54223822c185da06c1ba8a34137a9602f3da5a528eedf3f78d0f2c", size = 22079, upload-time = "2016-12-18T16:44:31.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/56/27148014c2f85ce70332f18612f921f682395c7d4e91ec103783be4fce00/pydbus-0.6.0-py2.py3-none-any.whl", hash = "sha256:66b80106352a718d80d6c681dc2a82588048e30b75aab933e4020eb0660bf85e", size = 19580, upload-time = "2016-12-18T16:44:19.565Z" }, +] + +[[package]] +name = "pygobject" +version = "3.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycairo" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/58/d34e67a79631177e3c08e7d02b5165147f590171f2cae6769502af5f7f7e/pygobject-3.50.0.tar.gz", hash = "sha256:4500ad3dbf331773d8dedf7212544c999a76fc96b63a91b3dcac1e5925a1d103", size = 1080367, upload-time = "2024-09-12T12:05:22.579Z" } + +[[package]] +name = "setproctitle" +version = "1.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/af/56efe21c53ac81ac87e000b15e60b3d8104224b4313b6eacac3597bd183d/setproctitle-1.3.6.tar.gz", hash = "sha256:c9f32b96c700bb384f33f7cf07954bb609d35dd82752cef57fb2ee0968409169", size = 26889, upload-time = "2025-04-29T13:35:00.184Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/76/f1a2fdbf9b9602945a7489ba5c52e9863de37381ef1a85a2b9ed0ff8bc79/setproctitle-1.3.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e2a9e62647dc040a76d55563580bf3bb8fe1f5b6ead08447c2ed0d7786e5e794", size = 17392, upload-time = "2025-04-29T13:33:30.925Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5b/4e0db8b10b4543afcb3dbc0827793d46e43ec1de6b377e313af3703d08e0/setproctitle-1.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:751ba352ed922e0af60458e961167fa7b732ac31c0ddd1476a2dfd30ab5958c5", size = 11951, upload-time = "2025-04-29T13:33:32.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fe/d5d00aaa700fe1f6160b6e95c225b29c01f4d9292176d48fd968815163ea/setproctitle-1.3.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7890e291bf4708e3b61db9069ea39b3ab0651e42923a5e1f4d78a7b9e4b18301", size = 32087, upload-time = "2025-04-29T13:33:33.469Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b3/894b827b93ef813c082479bebf88185860f01ac243df737823dd705e7fff/setproctitle-1.3.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2b17855ed7f994f3f259cf2dfbfad78814538536fa1a91b50253d84d87fd88d", size = 33502, upload-time = "2025-04-29T13:33:34.831Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cd/5330734cca1a4cfcb721432c22cb7899ff15a4101ba868b2ef452ffafea1/setproctitle-1.3.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e51ec673513465663008ce402171192a053564865c2fc6dc840620871a9bd7c", size = 30713, upload-time = "2025-04-29T13:33:36.739Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d3/c2590c5daa2e9a008d3f2b16c0f4a351826193be55f147cb32af49c6d814/setproctitle-1.3.6-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63cc10352dc6cf35a33951656aa660d99f25f574eb78132ce41a85001a638aa7", size = 31792, upload-time = "2025-04-29T13:33:37.974Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b1/c553ed5af8cfcecd5ae7737e63af58a17a03d26f3d61868c7eb20bf7e3cf/setproctitle-1.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0dba8faee2e4a96e934797c9f0f2d093f8239bf210406a99060b3eabe549628e", size = 31927, upload-time = "2025-04-29T13:33:39.203Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/2d5385206540127a3dca0ff83225b1ac66873f5cc89d4a6d3806c92f5ae2/setproctitle-1.3.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e3e44d08b61de0dd6f205528498f834a51a5c06689f8fb182fe26f3a3ce7dca9", size = 30981, upload-time = "2025-04-29T13:33:40.431Z" }, + { url = "https://files.pythonhosted.org/packages/31/62/e3e4a4e006d0e549748e53cded4ff3b667be0602860fc61b7de8b412b667/setproctitle-1.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:de004939fc3fd0c1200d26ea9264350bfe501ffbf46c8cf5dc7f345f2d87a7f1", size = 33244, upload-time = "2025-04-29T13:33:41.817Z" }, + { url = "https://files.pythonhosted.org/packages/aa/05/4b223fd4ef94e105dc7aff27fa502fb7200cf52be2bb0c064bd2406b5611/setproctitle-1.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3f8194b4d631b003a1176a75d1acd545e04b1f54b821638e098a93e6e62830ef", size = 31630, upload-time = "2025-04-29T13:33:43.093Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ba/5f68eb969f7336f54b54a599fd3ffbd7662f9733b080bc8598705971b3dd/setproctitle-1.3.6-cp313-cp313-win32.whl", hash = "sha256:d714e002dd3638170fe7376dc1b686dbac9cb712cde3f7224440af722cc9866a", size = 11480, upload-time = "2025-04-29T13:34:01.257Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/7f47f0ca35c9c357f16187cee9229f3eda0237bc6fdd3061441336f361c0/setproctitle-1.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:b70c07409d465f3a8b34d52f863871fb8a00755370791d2bd1d4f82b3cdaf3d5", size = 12198, upload-time = "2025-04-29T13:34:02.293Z" }, + { url = "https://files.pythonhosted.org/packages/39/ad/c3941b8fc6b32a976c9e2d9615a90ae793b69cd010ca8c3575dbc822104f/setproctitle-1.3.6-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:23a57d3b8f1549515c2dbe4a2880ebc1f27780dc126c5e064167563e015817f5", size = 17401, upload-time = "2025-04-29T13:33:44.186Z" }, + { url = "https://files.pythonhosted.org/packages/04/38/a184f857b988d3a9c401e470a4e38182a5c99ee77bf90432d7665e9d35a3/setproctitle-1.3.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81c443310831e29fabbd07b75ebbfa29d0740b56f5907c6af218482d51260431", size = 11959, upload-time = "2025-04-29T13:33:45.71Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/4878ef9d8483adfd1edf6bf95151362aaec0d05aac306a97ff0383f491b5/setproctitle-1.3.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d88c63bd395c787b0aa81d8bbc22c1809f311032ce3e823a6517b711129818e4", size = 33463, upload-time = "2025-04-29T13:33:46.913Z" }, + { url = "https://files.pythonhosted.org/packages/cc/60/3ef49d1931aff2a36a7324a49cca10d77ef03e0278452fd468c33a52d7e3/setproctitle-1.3.6-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d73f14b86d0e2858ece6bf5807c9889670e392c001d414b4293d0d9b291942c3", size = 34959, upload-time = "2025-04-29T13:33:48.216Z" }, + { url = "https://files.pythonhosted.org/packages/81/c6/dee0a973acecefb0db6c9c2e0ea7f18b7e4db773a72e534741ebdee8bbb8/setproctitle-1.3.6-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3393859eb8f19f5804049a685bf286cb08d447e28ba5c6d8543c7bf5500d5970", size = 32055, upload-time = "2025-04-29T13:33:49.443Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a5/5dd5c4192cf18d16349a32a07f728a9a48a2a05178e16966cabd6645903e/setproctitle-1.3.6-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:785cd210c0311d9be28a70e281a914486d62bfd44ac926fcd70cf0b4d65dff1c", size = 32986, upload-time = "2025-04-29T13:33:51.519Z" }, + { url = "https://files.pythonhosted.org/packages/df/a6/1508d37eb8008670d33f13fcdb91cbd8ef54697276469abbfdd3d4428c59/setproctitle-1.3.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c051f46ed1e13ba8214b334cbf21902102807582fbfaf0fef341b9e52f0fafbf", size = 32736, upload-time = "2025-04-29T13:33:52.852Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/c84ec8880d543766a12fcd6b65dbd013770974a40577889f357409b0441e/setproctitle-1.3.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:49498ebf68ca3e75321ffe634fcea5cc720502bfaa79bd6b03ded92ce0dc3c24", size = 31945, upload-time = "2025-04-29T13:33:54.665Z" }, + { url = "https://files.pythonhosted.org/packages/95/0a/126b9ff7a406a69a62825fe5bd6d1ba8671919a7018c4f9e2c63f49bfcb6/setproctitle-1.3.6-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4431629c178193f23c538cb1de3da285a99ccc86b20ee91d81eb5f1a80e0d2ba", size = 34333, upload-time = "2025-04-29T13:33:56.101Z" }, + { url = "https://files.pythonhosted.org/packages/9a/fd/5474b04f1c013ff460129d2bc774557dd6e186da4667865efef9a83bf378/setproctitle-1.3.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d136fbf8ad4321716e44d6d6b3d8dffb4872626010884e07a1db54b7450836cf", size = 32508, upload-time = "2025-04-29T13:33:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/32/21/2503e38520cb076a7ecaef6a35d6a6fa89cf02af3541c84c811fd7500d20/setproctitle-1.3.6-cp313-cp313t-win32.whl", hash = "sha256:d483cc23cc56ab32911ea0baa0d2d9ea7aa065987f47de847a0a93a58bf57905", size = 11482, upload-time = "2025-04-29T13:33:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/65/23/7833d75a27fba25ddc5cd3b54cd03c4bf8e18b8e2dbec622eb6326278ce8/setproctitle-1.3.6-cp313-cp313t-win_amd64.whl", hash = "sha256:74973aebea3543ad033b9103db30579ec2b950a466e09f9c2180089e8346e0ec", size = 12209, upload-time = "2025-04-29T13:33:59.727Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +]