diff --git a/CODING_GUIDELINES.md b/CODING_GUIDELINES.md index e6152ad1..f29ad2e2 100644 --- a/CODING_GUIDELINES.md +++ b/CODING_GUIDELINES.md @@ -70,7 +70,7 @@ This document serves as the **single source of truth** for all RetroLinux develo | Backend core scripts | `scripts/` | `audio_core.sh`, `battery_core.sh`, `benchmark_core.sh`, `bluetooth_core.sh`, `cleanup_core.sh`, `disk_core.sh`, `display_core.sh`, `driver_core.sh`, `fans_core.sh`, `firewall_core.sh`, `font_core.sh`, `grub_core.sh`, `input_core.sh`, `keyring_core.sh`, `log_core.sh`, `network_core.sh`, `polkit_core.sh`, `power_core.sh`, `service_core.sh`, `shell_core.sh`, `ssh_core.sh`, `system_core.sh`, `test_core.sh`, `theme_core.sh`, `timeshift_core.sh`, `variable_core.sh`, `wallpaper_core.sh`, `window_core.sh`, `xdg_core.sh` | | Event daemon core | `daemon/` | `engine.lua`, `event_daemon.lua`, `watcher.lua` | | Event handlers | `daemon/events/` | `battery.lua`, `power.lua`, `notifications.lua`, `wallpaper.lua`, `rotation.lua` | -| Watchers | `daemon/watchers/` | `audio.lua`, `axctl.lua`, `battery.lua`, `bluetooth.lua`, `fans.lua`, `hypridle.lua`, `portal.lua`, `power.lua`, `rotation.lua`, `slideshow.lua`, `ssh.lua`, `timers.lua`, `usb.lua`, `wallpaper.lua` | +| Watchers | `daemon/watchers/` | `audio.lua`, `axctl.lua`, `battery.lua`, `bluetooth.lua`, `hypridle.lua`, `portal.lua`, `power.lua`, `rotation.lua`, `slideshow.lua`, `ssh.lua`, `timers.lua`, `usb.lua`, `wallpaper.lua` | | Standalone scripts | `scripts/lib/` | `system_update.sh` (UI, invoked from events) | | Frontend commands | `cmds/tools/` | `audio.sh`, `app.sh`, `battery.sh`, `benchmark.sh`, `bitwarden.sh`, `bluetooth.sh`, `cleanup.sh`, `daemon.sh`, `disk.sh`, `display.sh`, `driver.sh`, `fans.sh`, `fingerprint.sh`, `firewall.sh`, `font.sh`, `grub.sh`, `input.sh`, `keyring.sh`, `log.sh`, `network.sh`, `polkit.sh`, `power.sh`, `service.sh`, `settings.sh`, `shell.sh`, `ssh.sh`, `system.sh`, `theme.sh`, `timeshift.sh`, `variable.sh`, `wallpaper.sh`, `window.sh`, `xdg.sh` | | Settings GUI | `cmds/tools/settings/` | `main.py`, `window.py`, `core/`, `ui/`, `pages/`, `data/` (+ `retro-settings.pyz` zipapp) | @@ -1642,7 +1642,7 @@ The **Daemon System** is a background daemon written in **Lua** that continuousl | `daemon/engine.lua` | Core engine - loads watchers, dispatches events via coroutines | | `daemon/event_daemon.lua` | CLI interface - loop/trigger/status/list-events/stop | | `daemon/watcher.lua` | Shared watcher utilities - variable persistence, logging, sysfs reading | -| `daemon/watchers/*.lua` | Watcher modules (audio, axctl, battery, bluetooth, fans, hypridle, portal, power, rotation, slideshow, ssh, timers, usb, wallpaper) | +| `daemon/watchers/*.lua` | Watcher modules (audio, axctl, battery, bluetooth, hypridle, portal, power, rotation, slideshow, ssh, timers, usb, wallpaper) | | `daemon/events/*.lua` | Event handler modules (battery, power, notifications, rotation, wallpaper) | | `cmds/tools/daemon.sh` | Frontend command to manage the daemon (`cmd_event`, registered as `retro daemon`) | @@ -1712,7 +1712,7 @@ engine:stop() -- Stop the engine | Slideshow | `watchers/slideshow.lua` | Slideshow timing | | Timers | `watchers/timers.lua` | Package/Retro update checks | | Axctl | `watchers/axctl.lua` | Axctl touchpad gestures (active when `/tmp/axctl-*.sock` exists) | -| Fans | `watchers/fans.lua` | Fan control monitoring | +| Fans | `watchers/fans.lua` | (removed — no writable PWM controls on this hardware) | | Hypridle | `watchers/hypridle.lua` | Hypridle idle daemon monitoring | | Rotation | `watchers/rotation.lua` | Display rotation detection (tick-based) | | SSH | `watchers/ssh.lua` | SSH login/failed/close events | diff --git a/cmds/system/setup/modules.sh b/cmds/system/setup/modules.sh index 3413e88b..76fa672a 100755 --- a/cmds/system/setup/modules.sh +++ b/cmds/system/setup/modules.sh @@ -18,5 +18,5 @@ setup_modules() { "$RETRO_DIR/retro.sh" -i all -a root $type_flag -y "$RETRO_DIR/retro.sh" -i all -a user $type_flag -y - rx_log "success" "Module installation complete" + rx_log "success" "All modules installed successfully" } diff --git a/cmds/system/setup/run.sh b/cmds/system/setup/run.sh index 08f1b7c9..f9d2c492 100755 --- a/cmds/system/setup/run.sh +++ b/cmds/system/setup/run.sh @@ -40,6 +40,18 @@ _ensure_aur_helper() { fi } +_write_shell_pinned_apps() { + local terminal_app="${RETRO_TERMINAL_CMD:-kitty}" + local filemanager="${FILEMANAGER_CHOICE:-nemo}" + local browser="${BROWSER_CHOICE:-firefox}" + [[ $browser == "zen-browser-bin" ]] && browser="zen" + + mkdir -p "$HOME/.local/share/retroshell" + cat > "$HOME/.local/share/retroshell/pinnedapps.json" </dev/null || ! sudo touch "$SETUP_LOG" 2>/dev/null; then @@ -117,6 +129,18 @@ run_postinstall() { retro audio eq download JackHack96 + mkdir -p "$HOME/.config/pipewire/pipewire.conf.d" + cat > "$HOME/.config/pipewire/pipewire.conf.d/99-echo-cancel.conf" <<'EOF' +context.modules = [ + { name = libpipewire-module-echo-cancel + args = { + # Adjust latency if you notice any audio stuttering + node.latency = 1024/48000 + } + } +] +EOF + retro polkit setup --needed -y if [[ ${FIREWALL_ENABLED:-true} == "true" ]]; then sudo systemctl enable nftables 2>/dev/null || true @@ -132,6 +156,8 @@ run_postinstall() { retro shell start + _write_shell_pinned_apps + sudo rm -f /etc/sudoers.d/retro-post-install rm "$HOME/.retro_install" diff --git a/cmds/tools/audio.sh b/cmds/tools/audio.sh index b86f2151..c614aaf2 100755 --- a/cmds/tools/audio.sh +++ b/cmds/tools/audio.sh @@ -642,6 +642,19 @@ cmd_audio() { "setup") rx_setup_parse "$@" + + mkdir -p "$HOME/.config/pipewire/pipewire.conf.d" + cat > "$HOME/.config/pipewire/pipewire.conf.d/99-echo-cancel.conf" <<'EOF' +context.modules = [ + { name = libpipewire-module-echo-cancel + args = { + # Adjust latency if you notice any audio stuttering + node.latency = 1024/48000 + } + } +] +EOF + rx_setup_validate "sink_primary,sink_fallback,source_primary,source_fallback" || return 1 local config_exists=false diff --git a/cmds/tools/fans.sh b/cmds/tools/fans.sh index 3ae31917..bcdde379 100755 --- a/cmds/tools/fans.sh +++ b/cmds/tools/fans.sh @@ -30,6 +30,7 @@ cmd_fans() { case "$engine" in liquidctl) eng_icon="󰣆" ;; lm-sensors) eng_icon="󰔏" ;; + acpi_platform) eng_icon="󰏲"; eng_color="$SUCCESS" ;; sysfs) eng_icon="󰈐" eng_color="$MUTE" @@ -45,64 +46,74 @@ cmd_fans() { "status") local data - data=$(bash "$core" --status 2>/dev/null) + data=$(bash "$core" --json 2>/dev/null) [[ -z $data ]] && rx_log "error" "Failed to get fan status" && return 1 - local engine profile cpu_temp - local -a fan_lines=() - while IFS=: read -r key val; do - case "$key" in - engine) engine="$val" ;; - profile) profile="$val" ;; - cpu_temp) cpu_temp="$val" ;; - fan_*) fan_lines+=("$key=$val") ;; - esac - done <<<"$data" + local engine cpu_temp master profile + engine=$(echo "$data" | jq -r '.engine') + cpu_temp=$(echo "$data" | jq -r '.cpu_temp') + master=$(echo "$data" | jq -r '.master') + profile=$(echo "$data" | jq -r '.profile') local engine_color="$PINK" [[ $engine == "sysfs" ]] && engine_color="$MUTE" - [[ $engine == "auto" || -z $engine ]] && engine="none" && engine_color="$MUTE" + [[ $engine == "acpi_platform" ]] && engine_color="$SUCCESS" + [[ -z $engine || $engine == "null" ]] && engine="none" && engine_color="$MUTE" local prof_color="$SUCCESS" - [[ $profile == "balanced" ]] && prof_color="$SUCCESS" [[ $profile == "performance" ]] && prof_color="$WARN" [[ $profile == "quiet" ]] && prof_color="$SUCCESS" - [[ $profile == "auto" ]] && prof_color="$MUTE" - [[ -z $profile ]] && profile="auto" && prof_color="$MUTE" + [[ -z $profile || $profile == "null" ]] && profile="auto" && prof_color="$MUTE" local temp_color="$SUCCESS" - [[ -n $temp_val ]] && [[ $temp_val -gt 50 ]] && temp_color="$WARN" - [[ -n $temp_val ]] && [[ $temp_val -gt 70 ]] && temp_color="$ERROR" - rx_table_row "󰔏" "CPU Temp:" "$cpu_temp" "$temp_color" "24" + [[ $cpu_temp -gt 50 ]] && temp_color="$WARN" + [[ $cpu_temp -gt 70 ]] && temp_color="$ERROR" + + local master_str="off" + [[ $master == "true" ]] && master_str="on" + + rx_table_row "󰔏" "CPU Temp:" "${cpu_temp}°C" "$temp_color" "24" rx_table_row "󰈐" "Engine:" "${engine}" "$engine_color" "24" rx_table_row "󰥲" "Profile:" "${profile}" "$prof_color" "24" + rx_table_row "󱠝" "Master:" "${master_str}" "$PINK" "24" rx_table_separator - for line in "${fan_lines[@]}"; do - local fk="${line%%=*}" - local fv="${line#*=}" - local display_key="${fk#fan_}" - display_key="${display_key//_/ }" - rx_table_row "󱠝" "${display_key}:" "$fv" "$PINK" "24" - done + local fans_json + fans_json=$(echo "$data" | jq -c '.fans[]') + while IFS= read -r fan; do + local flabel=$(echo "$fan" | jq -r '.label') + local frpm=$(echo "$fan" | jq -r '.rpm') + local fpct=$(echo "$fan" | jq -r '.pct') + local ftemp=$(echo "$fan" | jq -r '.temp') + local fmode=$(echo "$fan" | jq -r '.mode') + local fw=$(echo "$fan" | jq -r '.writable') + local w_color="$MUTE" + [[ $fw == "yes" ]] && w_color="$PINK" + rx_table_row "󱠝" "${flabel}:" "${frpm}rpm (${fpct}%) [${ftemp}] ${fmode}" "$w_color" "24" + done <<<"$fans_json" rx_table_separator rx_table_spacer ;; + "json") + bash "$core" --json 2>/dev/null + ;; + "set") local fan="$subarg" local pct="$1" - [[ -z $fan ]] && rx_log "error" "Usage: retro fans set " && return 1 - [[ -z $pct ]] && rx_log "error" "Usage: retro fans set " && return 1 + [[ -z $fan ]] && rx_log "error" "Usage: retro fans set " && return 1 + [[ -z $pct ]] && rx_log "error" "Usage: retro fans set " && return 1 [[ ! $pct =~ ^[0-9]+$ ]] && rx_log "error" "Percentage must be a number" && return 1 [[ $pct -lt 0 || $pct -gt 100 ]] && rx_log "error" "Percentage must be 0-100" && return 1 - bash "$core" --set-speed "$fan" "$pct" 2>/dev/null - if [[ $? -eq 0 ]]; then + local result + result=$(bash "$core" --set-speed "$fan" "$pct" 2>/dev/null) + if echo "$result" | grep -q "^OK"; then rx_log "success" "Fan ${PINK}${fan}${RESET} set to ${PINK}${pct}%${RESET}" else - rx_log "error" "Failed to set fan speed. Try: ${PINK}retro fans list-fans${RESET}" + rx_log "error" "Failed to set fan speed. Try: ${PINK}retro fans list${RESET}" return 1 fi ;; @@ -113,7 +124,7 @@ cmd_fans() { [[ -z $data ]] && rx_log "error" "No controllable fans detected" && return 1 rx_table_header "󱠝" "Detected Fans" - while IFS='|' read -r hw label rpm pct temp writable; do + while IFS='|' read -r hw_name label rpm pct temp writable hw_id fan_idx; do local w_color="$MUTE" [[ $writable == "yes" ]] && w_color="$PINK" rx_table_row "󱠝" "${label}:" "${rpm}rpm (${pct}%)" "$w_color" "24" @@ -128,16 +139,27 @@ cmd_fans() { [[ -z $data ]] && rx_log "error" "No temperature sensors detected" && return 1 rx_table_header "󰔏" "Temperature Sensors" - while IFS='|' read -r hw label temp; do + while IFS='|' read -r hw_name label temp; do local t_color="$SUCCESS" - [[ -n $t_val ]] && [[ $t_val -gt 50 ]] && t_color="$WARN" - [[ -n $t_val ]] && [[ $t_val -gt 70 ]] && t_color="$ERROR" rx_table_row "󰔏" "${label}:" "$temp" "$t_color" "24" done <<<"$data" rx_table_separator rx_table_spacer ;; + "master") + local val="${subarg,,}" + [[ -z $val ]] && rx_log "error" "Usage: retro fans master " && return 1 + local result + result=$(bash "$core" --set-master "$val" 2>/dev/null) + if echo "$result" | grep -q "^OK"; then + rx_log "success" "Fan master control ${PINK}${val}${RESET}" + else + rx_log "error" "Failed to set master control" + return 1 + fi + ;; + "profile") local profile="$subarg" [[ -z $profile ]] && rx_log "error" "Usage: retro fans profile " && return 1 @@ -147,7 +169,7 @@ cmd_fans() { esac local result - result=$(bash "$core" --profile "$profile" 2>/dev/null) + result=$(bash "$core" --set-profile "$profile" 2>/dev/null) if echo "$result" | grep -q "^OK"; then rx_log "success" "Fan profile set to ${PINK}${profile}${RESET}" else @@ -156,6 +178,34 @@ cmd_fans() { fi ;; + "mode") + local fan="$subarg" + local mode="$1" + [[ -z $fan || -z $mode ]] && rx_log "error" "Usage: retro fans mode " && return 1 + local result + result=$(bash "$core" --set-mode "$fan" "$mode" 2>/dev/null) + if echo "$result" | grep -q "^OK"; then + rx_log "success" "Fan ${PINK}${fan}${RESET} mode set to ${PINK}${mode}${RESET}" + else + rx_log "error" "Failed to set fan mode" + return 1 + fi + ;; + + "curve") + local fan="$subarg" + local curve="$1" + [[ -z $fan || -z $curve ]] && rx_log "error" "Usage: retro fans curve " && return 1 + local result + result=$(bash "$core" --set-curve "$fan" "$curve" 2>/dev/null) + if echo "$result" | grep -q "^OK"; then + rx_log "success" "Fan ${PINK}${fan}${RESET} curve applied" + else + rx_log "error" "Failed to set curve" + return 1 + fi + ;; + "reset") bash "$core" --reset 2>/dev/null rx_log "success" "Fans reset to auto/default mode" @@ -171,18 +221,10 @@ cmd_fans() { local e_color="$PINK" local e_icon="󱠝" case "$eng" in - liquidctl) - e_icon="󰣆" - e_color="$SUCCESS" - ;; - lm-sensors) - e_icon="󰔏" - e_color="$SUCCESS" - ;; - sysfs) - e_icon="󰈐" - e_color="$MUTE" - ;; + liquidctl) e_icon="󰣆"; e_color="$SUCCESS" ;; + lm-sensors) e_icon="󰔏"; e_color="$SUCCESS" ;; + acpi_platform) e_icon="󰏲"; e_color="$SUCCESS" ;; + sysfs) e_icon="󰈐"; e_color="$MUTE" ;; esac rx_table_row "$e_icon" "$eng" "" "$e_color" "18" done <<<"$engines" @@ -192,7 +234,7 @@ cmd_fans() { "setup") rx_setup_parse "${setup_args[@]:1}" - rx_setup_validate "engine,profile" "engine:in=liquidctl,lm-sensors,sysfs|profile:in=quiet,balanced,performance" || return 1 + rx_setup_validate "engine,profile" "engine:in=liquidctl,lm-sensors,sysfs,acpi_platform|profile:in=quiet,balanced,performance" || return 1 local config_data config_data=$(bash "$core" --setup-get 2>/dev/null) @@ -230,13 +272,6 @@ cmd_fans() { fi fi - local detected_engine - detected_engine=$(bash "$core" --detect 2>/dev/null) - local auto_engine - while IFS='=' read -r key val; do - [[ $key == "engine" ]] && auto_engine="$val" - done <<<"$detected_engine" - local -a avail_engines=() local scan_data scan_data=$(bash "$core" --scan-engines 2>/dev/null) @@ -245,12 +280,12 @@ cmd_fans() { done <<<"$scan_data" if [[ ${#avail_engines[@]} -eq 0 ]]; then - rx_log "error" "No cooling engines detected. Install: ${PINK}liquidctl lm-sensors${RESET}" + rx_log "error" "No cooling engines detected" return 1 fi local eng_default="$cur_engine" - [[ $eng_default == "auto" ]] && eng_default="$auto_engine" + [[ $eng_default == "auto" ]] && eng_default="${avail_engines[0]}" engine_input=$(rx_input_choice "" "Select Cooling Engine" "$eng_default" "${avail_engines[@]}") local -a profiles=("quiet" "balanced" "performance") @@ -284,19 +319,23 @@ cmd_fans() { rx_help_cmd "status" "Show fan speeds, temps, and cooling status" 40 rx_help_cmd "detect" "Auto-detect cooling hardware" 40 rx_help_cmd "setup" "Interactive cooling setup wizard" 40 - rx_help_cmd "list-fans" "List all controllable fans" 40 - rx_help_cmd "list-temps" "List all temperature sensors" 40 - rx_help_cmd "set " "Set manual fan speed (0-100%)" 40 + rx_help_cmd "list" "List all controllable fans" 40 + rx_help_cmd "temps" "List all temperature sensors" 40 + rx_help_cmd "set " "Set manual fan speed (0-100%)" 40 rx_help_cmd "profile " "Apply profile: quiet, balanced, performance" 40 + rx_help_cmd "master " "Enable/disable master fan control" 40 + rx_help_cmd "mode " "Set fan mode: auto, curve, manual" 40 + rx_help_cmd "curve " "Set custom fan curve (temp:pct,...)" 40 rx_help_cmd "reset" "Reset fans to auto mode" 40 rx_help_cmd "engines" "List available cooling engines" 40 + rx_help_cmd "json" "Machine-readable JSON status" 40 rx_help_examples rx_help_example "retro fans status" "Show cooling status" 30 rx_help_example "retro fans detect" "Auto-detect hardware" 30 - rx_help_example "retro fans setup" "Interactive setup" 30 - rx_help_example "retro fans setup -o profile=quiet -y" "Non-interactive setup" 30 - rx_help_example "retro fans set cpu 75" "Set CPU fan to 75%" 30 rx_help_example "retro fans profile balanced" "Apply balanced profile" 30 + rx_help_example "retro fans master on" "Enable fan control" 30 + rx_help_example "retro fans set hwmon6_hp_fan1 75" "Set fan to 75%" 30 + rx_help_example "retro fans curve hwmon6_hp_fan1 30:30,50:50,70:75,85:100" "Set curve" 30 rx_help_spacer ;; @@ -308,4 +347,4 @@ cmd_fans() { esac } -register_command "TOOLS" "fans" "Fan and cooling management (liquidctl, lm-sensors, sysfs)" "cmd_fans" +register_command "TOOLS" "fans" "Fan and cooling management (liquidctl, sysfs, ACPI)" "cmd_fans" diff --git a/cmds/tools/settings.sh b/cmds/tools/settings.sh index 129f6d82..2121bcf9 100755 --- a/cmds/tools/settings.sh +++ b/cmds/tools/settings.sh @@ -12,13 +12,18 @@ cmd_settings() { fi if [[ "$1" == "--debug" ]]; then + shift + echo "[settings.sh] RETRO_DIR=$RETRO_DIR" >&2 + echo "[settings.sh] Launching python -m settings $*" >&2 + PYTHONUNBUFFERED=1 \ PYTHONPATH="$RETRO_DIR/cmds/tools:$RETRO_DIR/scripts:$RETRO_DIR:$PYTHONPATH" \ - python -m settings "$@" & + python -m settings "$@" + echo "[settings.sh] Python exited with code $?" >&2 else PYTHONPATH="$RETRO_DIR/cmds/tools:$RETRO_DIR/scripts:$RETRO_DIR:$PYTHONPATH" \ nohup python -m settings "$@" >/dev/null 2>&1 & + disown fi - disown } register_command "TOOLS" "settings" "Open the Retro Settings GUI" "cmd_settings" diff --git a/cmds/tools/settings/__init__.py b/cmds/tools/settings/__init__.py index 05d9ad7f..87979684 100644 --- a/cmds/tools/settings/__init__.py +++ b/cmds/tools/settings/__init__.py @@ -1,3 +1,45 @@ """Retro Settings — GTK4/libadwaita configuration tool for Hyprland.""" +import sys as _sys + +def _dbg(msg: str) -> None: + if "--debug" in _sys.argv or "-d" in _sys.argv: + print(f"[settings.__init__] {msg}", file=_sys.stderr, flush=True) + +_dbg("Package init starting") import settings.gi_setup # noqa: F401 +_dbg("gi_setup imported") + +# --- Monkey-patch hyprland_state to handle both version formats --- +# _detect_version() returns "X.Y.Z" (no v prefix) but hyprland_schema +# keys versions by GitHub tags ("vX.Y.Z"). The upstream _load_schema +# passes the bare version straight through, causing build_options() to +# miss every bundled/cache lookup and fall through to a network fetch. +# Patch _load_schema to normalise the tag so both formats resolve +# instantly from the bundled catalog or disk cache. +try: + import hyprland_state._state as _hs_state + _orig_load_schema = _hs_state._load_schema + + def _patched_load_schema(version): # type: ignore[no-untyped-def] + if version is None: + return _orig_load_schema(version) + tag = version if version.startswith("v") else f"v{version}" + try: + import hyprland_schema + return hyprland_schema.load(tag).options_by_key + except hyprland_schema.MigrationError: + pass + # Caller may already supply a v-prefixed tag; try bare. + bare = version[1:] if version.startswith("v") else version + try: + import hyprland_schema + return hyprland_schema.load(bare).options_by_key + except hyprland_schema.MigrationError: + import hyprland_schema + return hyprland_schema.OPTIONS_BY_KEY + + _hs_state._load_schema = _patched_load_schema + _dbg("patched _load_schema to normalise version tags") +except Exception as _exc: + _dbg(f"could not patch _load_schema: {_exc}") diff --git a/cmds/tools/settings/__main__.py b/cmds/tools/settings/__main__.py index 0c67f9c6..6cdbedbf 100644 --- a/cmds/tools/settings/__main__.py +++ b/cmds/tools/settings/__main__.py @@ -1,4 +1,15 @@ """Allow ``python -m settings`` to launch the app.""" + +import sys +import time as _time + +def _dbg(msg: str) -> None: + if "--debug" in sys.argv or "-d" in sys.argv: + print(f"[settings.__main__] {msg}", file=sys.stderr, flush=True) + +_dbg(f"__main__ entered ({_time.monotonic():.3f})") + from settings.main import main +_dbg(f"main imported, calling main() ({_time.monotonic():.3f})") main() diff --git a/cmds/tools/settings/core/config.py b/cmds/tools/settings/core/config.py index c54034b7..d6838f3b 100644 --- a/cmds/tools/settings/core/config.py +++ b/cmds/tools/settings/core/config.py @@ -198,6 +198,21 @@ "label": "Open Settings", "standard": ("exec", "retro settings"), }, + "retro_zoom_in": { + "lua_fn": "Retro.zoom_in", + "label": "Zoom In", + "standard": ("exec", "hyprctl keyword cursor:zoom_factor 1.5"), + }, + "retro_zoom_out": { + "lua_fn": "Retro.zoom_out", + "label": "Zoom Out", + "standard": ("exec", "hyprctl keyword cursor:zoom_factor 1.0"), + }, + "retro_zoom_toggle": { + "lua_fn": "Retro.zoom_toggle", + "label": "Toggle Zoom", + "standard": ("exec", "hyprctl keyword cursor:zoom_factor 1.0"), + }, } # --- Derived maps (do not edit manually) --- diff --git a/cmds/tools/settings/core/shell_config.py b/cmds/tools/settings/core/shell_config.py index 2d7612ad..8a2a2d90 100644 --- a/cmds/tools/settings/core/shell_config.py +++ b/cmds/tools/settings/core/shell_config.py @@ -474,6 +474,7 @@ def save_overview(data: dict) -> None: "iconSize": 24, "spacing": 4, "margin": 4, + "scale": 1.0, "hoverToReveal": True, "hoverRegionHeight": 16, "pinnedOnStartup": False, diff --git a/cmds/tools/settings/data/fan_curve_data.py b/cmds/tools/settings/data/fan_curve_data.py new file mode 100644 index 00000000..f412b242 --- /dev/null +++ b/cmds/tools/settings/data/fan_curve_data.py @@ -0,0 +1,99 @@ +"""Fan curve data management — named curves for fan control.""" + +import functools +import json +from pathlib import Path + +from settings.core.config import RETRO_SETTINGS_DIR + +FAN_CURVES_PATH = RETRO_SETTINGS_DIR / "fan_curves.json" + +BUILTIN_CURVES: dict[str, list[tuple[int, int]]] = { + "quiet": [(30, 20), (50, 40), (70, 60), (85, 80)], + "balanced": [(30, 30), (50, 50), (70, 75), (85, 100)], + "performance": [(30, 40), (50, 70), (70, 90), (85, 100)], +} + + +def curve_to_str(points: list[tuple[int, int]]) -> str: + return ",".join(f"{t}:{p}" for t, p in points) + + +def curve_from_str(s: str) -> list[tuple[int, int]]: + if not s: + return [] + result = [] + for pair in s.split(","): + pair = pair.strip() + if ":" in pair: + t, p = pair.split(":", 1) + result.append((int(t), int(p))) + return sorted(result, key=lambda x: x[0]) + + +class FanCurveStore: + """Manages named fan curves (user-defined + builtins).""" + + def __init__(self, path: Path): + self._path = path + self._user_curves: dict[str, list[tuple[int, int]]] | None = None + + def _ensure(self) -> dict[str, list[tuple[int, int]]]: + if self._user_curves is None: + self._user_curves = self._read() + return self._user_curves + + def _read(self) -> dict[str, list[tuple[int, int]]]: + if self._path.exists(): + try: + raw = json.loads(self._path.read_text()) + return {k: [(t, p) for t, p in v] for k, v in raw.items()} + except (json.JSONDecodeError, TypeError, ValueError): + pass + return {} + + def _save(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + data = {k: [[t, p] for t, p in v] for k, v in self._ensure().items()} + self._path.write_text(json.dumps(data, indent=2) + "\n") + + def get_user_curve_names(self) -> list[str]: + return sorted(self._ensure().keys()) + + def get_all_curve_names(self) -> list[str]: + return list(BUILTIN_CURVES.keys()) + self.get_user_curve_names() + + def get_curve_points(self, name: str) -> list[tuple[int, int]]: + if name in BUILTIN_CURVES: + return list(BUILTIN_CURVES[name]) + return self._ensure().get(name, []) + + def is_builtin(self, name: str) -> bool: + return name in BUILTIN_CURVES + + def save_user_curve(self, name: str, points: list[tuple[int, int]]) -> None: + self._ensure()[name] = sorted(points, key=lambda x: x[0]) + self._save() + + def delete_user_curve(self, name: str) -> None: + self._ensure().pop(name, None) + self._save() + + def rename_user_curve(self, old: str, new: str) -> None: + curves = self._ensure() + if old in curves: + curves[new] = curves.pop(old) + self._save() + + def next_custom_name(self) -> str: + existing = set(self.get_all_curve_names()) + for i in range(1, 1000): + name = f"Custom {i}" + if name not in existing: + return name + return "Custom 1" + + +@functools.lru_cache(maxsize=1) +def get_fan_curve_store() -> FanCurveStore: + return FanCurveStore(FAN_CURVES_PATH) diff --git a/cmds/tools/settings/data/icons/hicolor/scalable/actions/system-fan-symbolic.svg b/cmds/tools/settings/data/icons/hicolor/scalable/actions/system-fan-symbolic.svg new file mode 100644 index 00000000..a70f0ec9 --- /dev/null +++ b/cmds/tools/settings/data/icons/hicolor/scalable/actions/system-fan-symbolic.svg @@ -0,0 +1,3 @@ + + + diff --git a/cmds/tools/settings/gi_setup.py b/cmds/tools/settings/gi_setup.py index 6878b370..fe029bd9 100644 --- a/cmds/tools/settings/gi_setup.py +++ b/cmds/tools/settings/gi_setup.py @@ -3,11 +3,21 @@ Import this module before any ``from gi.repository import ...`` statements. All version pins live here, so individual modules don't need to repeat them. """ +import sys as _sys +def _dbg(msg: str) -> None: + if "--debug" in _sys.argv or "-d" in _sys.argv: + print(f"[gi_setup] {msg}", file=_sys.stderr, flush=True) + +_dbg("importing gi") import gi +_dbg(f"gi imported ({gi.__version__})") gi.require_version("Adw", "1") +_dbg("Adw 1 loaded") gi.require_version("cairo", "1.0") +_dbg("cairo 1.0 loaded") gi.require_version("Gdk", "4.0") +_dbg("Gdk 4.0 loaded") gi.require_version("Gtk", "4.0") diff --git a/cmds/tools/settings/main.py b/cmds/tools/settings/main.py index 433fb20b..769306c7 100644 --- a/cmds/tools/settings/main.py +++ b/cmds/tools/settings/main.py @@ -2,11 +2,20 @@ import signal import sys +import time as _time -from gi.repository import Adw, Gdk, Gio, GLib, Gtk +def _dbg(msg: str) -> None: + if "--debug" in sys.argv or "-d" in sys.argv: + print(f"[SETTINGS-DBG] {msg}", file=sys.stderr, flush=True) -from settings.constants import APPLICATION_ID, settings_pkg_dir -from settings.window import RetroSettingsWindow +_dbg(f"Starting ({_time.monotonic():.3f})") +from gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402 +_dbg(f"GTK loaded ({_time.monotonic():.3f})") + +from settings.constants import APPLICATION_ID, settings_pkg_dir # noqa: E402 +_dbg(f"constants loaded ({_time.monotonic():.3f})") +from settings.window import RetroSettingsWindow # noqa: E402 +_dbg(f"window module loaded ({_time.monotonic():.3f})") class RetroSettingsApp(Adw.Application): @@ -37,6 +46,7 @@ def main(): debug = "--debug" in sys.argv if debug: sys.argv.remove("--debug") + _dbg(f"Debug mode enabled, argv={sys.argv}") target_page = sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith("-") else None if target_page: @@ -53,6 +63,7 @@ def _on_signal(*_args) -> bool: GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, _on_signal) GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGTERM, _on_signal) + _dbg(f"Entering GTK main loop ({_time.monotonic():.3f})") return app.run(sys.argv) diff --git a/cmds/tools/settings/pages/binds.py b/cmds/tools/settings/pages/binds.py index b5a555b4..9f41eb75 100644 --- a/cmds/tools/settings/pages/binds.py +++ b/cmds/tools/settings/pages/binds.py @@ -148,9 +148,9 @@ def _pre_enrich_retro_binds(self) -> dict: before the normal enrichment pass. """ retro_map: dict = {} - _LITERAL_RETRO = re.compile(r'hl\.bind\("([^"]+)",\s*(Retro\.\w+)\)') + _LITERAL_RETRO = re.compile(r'hl\.bind\("([^"]+)",\s*(Retro\.\w+)(?:\s*,\s*\{[^}]*\})?\)') _EXPR_RETRO = re.compile( - r'hl\.bind\((\w+)\s*\.\.\s*"([^"]+)"\s*,\s*(Retro\.\w+)\)' + r'hl\.bind\((\w+)\s*\.\.\s*"([^"]+)"\s*,\s*(Retro\.\w+)(?:\s*,\s*\{[^}]*\})?\)' ) # Variable → modifier translation from the module's keybinds.lua. _KNOWN_MOD_VARS: dict[str, str] = {"mainMod": "SUPER"} @@ -208,9 +208,9 @@ def _load_retro_binds(self): Actions category when no override exists. """ self._module_retro_binds.clear() - _LITERAL_RETRO = re.compile(r'hl\.bind\("([^"]+)",\s*(Retro\.\w+)\)') + _LITERAL_RETRO = re.compile(r'hl\.bind\("([^"]+)",\s*(Retro\.\w+)(?:\s*,\s*\{[^}]*\})?\)') _EXPR_RETRO = re.compile( - r'hl\.bind\((\w+)\s*\.\.\s*"([^"]+)"\s*,\s*(Retro\.\w+)\)' + r'hl\.bind\((\w+)\s*\.\.\s*"([^"]+)"\s*,\s*(Retro\.\w+)(?:\s*,\s*\{[^}]*\})?\)' ) _KNOWN_MOD_VARS: dict[str, str] = {"mainMod": "SUPER"} diff --git a/cmds/tools/settings/pages/fan_control.py b/cmds/tools/settings/pages/fan_control.py new file mode 100644 index 00000000..3cdccaa6 --- /dev/null +++ b/cmds/tools/settings/pages/fan_control.py @@ -0,0 +1,360 @@ +"""Fan control page — detect fans, set modes, edit curves, live RPM/Temp display.""" + +import json +import os +import subprocess +import threading +from collections.abc import Iterable +from typing import TYPE_CHECKING + +from gi.repository import Adw, GLib, Gtk, Pango + +from settings.core.pending import PendingChange +from settings.data.fan_curve_data import curve_from_str, curve_to_str, get_fan_curve_store +from settings.ui import make_page_layout +from settings.ui.icons import FAN_CONTROL_ICON +from settings.ui.fan_curve_editor import FanCurveEditorDialog + +if TYPE_CHECKING: + from settings.window import RetroSettingsWindow + +_RETRO_DIR = os.environ.get("RETRO_DIR", "/opt/retrolinux") +_FANS_CORE = os.path.join(_RETRO_DIR, "scripts", "fans_core.sh") + +_REFRESH_MS = 2000 +_sudoers_done = False +_profile_change_pending = False + + +def _ensure_fan_sudoers() -> None: + global _sudoers_done + if _sudoers_done: + return + _sudoers_done = True + try: + r = subprocess.run( + ["sudo", "-n", _FANS_CORE, "--json"], + capture_output=True, text=True, timeout=5, stdin=subprocess.DEVNULL, + ) + if r.returncode == 0: + return + except Exception: + pass + try: + subprocess.run( + ["pkexec", "bash", _FANS_CORE, "--ensure-sudoers"], + capture_output=True, text=True, timeout=15, stdin=subprocess.DEVNULL, + ) + except Exception: + pass + + +def _run_core(*args: str) -> str: + try: + r = subprocess.run( + ["bash", _FANS_CORE, *args], + capture_output=True, text=True, timeout=10, stdin=subprocess.DEVNULL, + ) + return r.stdout.strip() + except Exception: + return "" + + +def _run_core_pkexec(*args: str) -> bool: + try: + r = subprocess.run( + ["sudo", "-n", _FANS_CORE, *args], + capture_output=True, text=True, timeout=15, stdin=subprocess.DEVNULL, + ) + if r.returncode == 0 and r.stdout.strip().startswith("OK"): + return True + r = subprocess.run( + ["pkexec", "bash", _FANS_CORE, *args], + capture_output=True, text=True, timeout=15, stdin=subprocess.DEVNULL, + ) + return r.returncode == 0 and r.stdout.strip().startswith("OK") + except Exception: + return False + + +class _FanRow: + def __init__(self, fan: dict, on_change): + self._fan = fan + self._on_change = on_change + self._fan_id = fan["id"] + self._signals: list = [] + + self._row = Adw.ActionRow(title=fan["label"]) + self._row.set_subtitle(f"{fan['rpm']}rpm ({fan['pct']}%) [{fan['temp']}]") + + # RPM/PCT label (live) + self._status_lbl = Gtk.Label(label=f"{fan['rpm']}rpm {fan['pct']}%") + self._status_lbl.set_valign(Gtk.Align.CENTER) + self._status_lbl.add_css_class("dim-label") + self._row.add_suffix(self._status_lbl) + + # Mode selector + self._mode_model = Gtk.StringList.new(["Auto", "Curve", "Manual"]) + self._mode_row = Gtk.DropDown(model=self._mode_model) + mode_idx = {"auto": 0, "curve": 1, "manual": 2}.get(fan.get("mode", "auto"), 0) + self._mode_row.set_selected(mode_idx) + self._mode_row.set_valign(Gtk.Align.CENTER) + sid = self._mode_row.connect("notify::selected", self._on_mode_changed) + self._signals.append((self._mode_row, sid)) + self._row.add_suffix(self._mode_row) + + # Curve edit button + self._curve_btn = Gtk.Button(icon_name="draw-arc-symbolic") + self._curve_btn.set_valign(Gtk.Align.CENTER) + self._curve_btn.add_css_class("flat") + self._curve_btn.set_tooltip_text("Edit fan curve") + self._curve_btn.connect("clicked", self._on_edit_curve) + self._row.add_suffix(self._curve_btn) + + # Speed spin (visible only in manual mode) + self._speed_adj = Gtk.Adjustment(value=fan.get("speed", 100), lower=0, upper=100, step_increment=1) + self._speed_spin = Gtk.SpinButton(adjustment=self._speed_adj, digits=0) + self._speed_spin.set_valign(Gtk.Align.CENTER) + self._speed_spin.set_width_chars(4) + sid2 = self._speed_spin.connect("value-changed", self._on_speed_changed) + self._signals.append((self._speed_spin, sid2)) + self._row.add_suffix(self._speed_spin) + + self._speed_lbl = Gtk.Label(label="%") + self._speed_lbl.add_css_class("dim-label") + self._row.add_suffix(self._speed_lbl) + + self._update_visibility() + + @property + def widget(self): + return self._row + + @property + def fan_id(self): + return self._fan_id + + def update_from(self, fan: dict) -> None: + self._fan = fan + self._status_lbl.set_text(f"{fan['rpm']}rpm {fan['pct']}%") + self._row.set_subtitle(f"{fan['rpm']}rpm ({fan['pct']}%) [{fan['temp']}]") + + def _update_visibility(self) -> None: + mode = self._mode_row.get_selected() + show_curve = mode == 1 + show_speed = mode == 2 + self._curve_btn.set_visible(show_curve) + self._speed_spin.set_visible(show_speed) + self._speed_lbl.set_visible(show_speed) + + def _on_mode_changed(self, _dd, _pspec) -> None: + mode_names = {0: "auto", 1: "curve", 2: "manual"} + mode = mode_names.get(self._mode_row.get_selected(), "auto") + self._update_visibility() + threading.Thread(target=_run_core_pkexec, args=("--set-mode", self._fan_id, mode), daemon=True).start() + self._on_change() + + def _on_speed_changed(self, spin) -> None: + pct = int(spin.get_value()) + threading.Thread(target=_run_core_pkexec, args=("--set-speed", self._fan_id, str(pct)), daemon=True).start() + self._on_change() + + def _on_edit_curve(self, _btn) -> None: + win = self._row.get_root() + while win and not isinstance(win, Adw.Window): + win = win.get_parent() + initial = self._fan.get("curve", "") + FanCurveEditorDialog( + win, initial_curve=initial, on_curve_saved=self._on_curve_saved, + fan_label=self._fan.get("label", ""), + ) + + def _on_curve_saved(self, name: str) -> None: + pts = get_fan_curve_store().get_curve_points(name) + curve_str = curve_to_str(pts) + threading.Thread(target=_run_core_pkexec, args=("--set-curve", self._fan_id, curve_str), daemon=True).start() + self._on_change() + + def disconnect_signals(self) -> None: + for obj, sid in self._signals: + obj.disconnect(sid) + self._signals.clear() + + +class FanControlPage: + """Fan control settings page.""" + + def __init__(self, window: "RetroSettingsWindow"): + self._window = window + self._on_dirty_changed = None + self._dirty = False + self._content_box: Gtk.Box | None = None + self._fan_rows: list[_FanRow] = [] + self._timer = 0 + + # Overview widgets + self._master_switch: Gtk.Switch | None = None + self._engine_lbl: Gtk.Label | None = None + self._temp_lbl: Gtk.Label | None = None + self._profile_dd: Gtk.DropDown | None = None + self._acpi_lbl: Gtk.Label | None = None + + def build(self, header: Adw.HeaderBar) -> Adw.ToolbarView: + _ensure_fan_sudoers() + toolbar_view, _, self._content_box, _ = make_page_layout(header=header) + + refresh_btn = Gtk.Button(icon_name="view-refresh-symbolic") + refresh_btn.set_tooltip_text("Refresh fan data") + refresh_btn.connect("clicked", lambda _: self._refresh()) + header.pack_start(refresh_btn) + + # Master control + master_group = Adw.PreferencesGroup(title="Fan Control") + + self._master_switch = Gtk.Switch() + self._master_switch.set_valign(Gtk.Align.CENTER) + self._master_switch.connect("notify::active", self._on_master_toggled) + master_row = Adw.ActionRow(title="Master Control", subtitle="Enable manual fan curve control") + master_row.add_prefix(Gtk.Image.new_from_icon_name("system-run-symbolic")) + master_row.add_suffix(self._master_switch) + master_group.add(master_row) + + self._engine_lbl = Gtk.Label(label="—", halign=Gtk.Align.START) + self._engine_lbl.set_valign(Gtk.Align.CENTER) + eng_row = Adw.ActionRow(title="Engine") + eng_row.add_suffix(self._engine_lbl) + master_group.add(eng_row) + + self._temp_lbl = Gtk.Label(label="—", halign=Gtk.Align.START) + self._temp_lbl.set_valign(Gtk.Align.CENTER) + temp_row = Adw.ActionRow(title="CPU Temperature") + temp_row.add_suffix(self._temp_lbl) + master_group.add(temp_row) + + self._profile_dd = Gtk.DropDown(model=Gtk.StringList.new(["Quiet", "Balanced", "Performance"])) + self._profile_dd.set_valign(Gtk.Align.CENTER) + self._profile_dd.connect("notify::selected", self._on_profile_changed) + prof_row = Adw.ActionRow(title="Profile") + prof_row.add_suffix(self._profile_dd) + master_group.add(prof_row) + + self._acpi_lbl = Gtk.Label(label="", halign=Gtk.Align.START) + self._acpi_lbl.set_valign(Gtk.Align.CENTER) + acpi_row = Adw.ActionRow(title="ACPI Platform Profile") + acpi_row.add_suffix(self._acpi_lbl) + master_group.add(acpi_row) + + self._content_box.append(master_group) + + # Fan list + self._fans_group = Adw.PreferencesGroup(title="Fans") + self._content_box.append(self._fans_group) + + self._refresh() + return toolbar_view + + def _refresh(self) -> None: + data = _run_core("--json") + if not data: + return + try: + info = json.loads(data) + except (json.JSONDecodeError, ValueError): + return + + # Overview + if self._master_switch is not None: + self._master_switch.handler_block_by_func(self._on_master_toggled) + self._master_switch.set_active(info.get("master", False)) + self._master_switch.handler_unblock_by_func(self._on_master_toggled) + + if self._engine_lbl: + self._engine_lbl.set_text(info.get("engine", "—")) + if self._temp_lbl: + self._temp_lbl.set_text(f"{info.get('cpu_temp', 0)}°C") + + profile_names = {"quiet": 0, "balanced": 1, "performance": 2} + if self._profile_dd and not _profile_change_pending: + self._profile_dd.handler_block_by_func(self._on_profile_changed) + self._profile_dd.set_selected(profile_names.get(info.get("profile", "balanced"), 1)) + self._profile_dd.handler_unblock_by_func(self._on_profile_changed) + + if self._acpi_lbl: + acpi = info.get("acpi_choices", "") + self._acpi_lbl.set_text(acpi if acpi else "Not available") + + # Fans + fans = info.get("fans", []) + existing_ids = {r.fan_id for r in self._fan_rows} + new_ids = {f["id"] for f in fans} + + # Remove stale rows + for row in list(self._fan_rows): + if row.fan_id not in new_ids: + row.disconnect_signals() + self._fans_group.remove(row.widget) + self._fan_rows.remove(row) + + # Add/update rows + for fan in fans: + existing = next((r for r in self._fan_rows if r.fan_id == fan["id"]), None) + if existing: + existing.update_from(fan) + else: + row = _FanRow(fan, self._mark_dirty) + self._fan_rows.append(row) + self._fans_group.add(row.widget) + + def _on_master_toggled(self, switch, _pspec) -> None: + val = "on" if switch.get_active() else "off" + threading.Thread(target=_run_core_pkexec, args=("--set-master", val), daemon=True).start() + self._mark_dirty() + + def _on_profile_changed(self, _dd, _pspec) -> None: + names = {0: "quiet", 1: "balanced", 2: "performance"} + profile = names.get(self._profile_dd.get_selected(), "balanced") + _profile_change_pending = True + # Schedule the flag to be cleared after 3 seconds + GLib.timeout_add(3000, lambda: (_profile_change_pending.__class__.__setattr__("_profile_change_pending", False) if hasattr(_profile_change_pending, "__class__") else False) or True) + threading.Thread(target=_run_core_pkexec, args=("--set-profile", profile), daemon=True).start() + self._mark_dirty() + + def _mark_dirty(self) -> None: + self._dirty = True + if self._on_dirty_changed: + self._on_dirty_changed() + + def on_shown(self) -> None: + self._timer = GLib.timeout_add(_REFRESH_MS, self._on_tick) + + def on_hidden(self) -> None: + if self._timer: + GLib.source_remove(self._timer) + self._timer = 0 + + def _on_tick(self) -> bool: + self._refresh() + return True + + def is_dirty(self) -> bool: + return self._dirty + + def mark_saved(self) -> None: + self._dirty = False + + def discard(self) -> None: + self._dirty = False + self._refresh() + + def iter_pending_changes(self) -> Iterable[PendingChange]: + return [] + + def get_search_entries(self) -> list[dict]: + return [{ + "key": "fan_control:control", + "label": "Fan Control", + "description": "Manage fan speeds, curves and cooling profiles", + "_group_id": "fan_control", + "_group_label": "Fan Control", + "_section_label": "System", + }] diff --git a/cmds/tools/settings/pages/home.py b/cmds/tools/settings/pages/home.py index a3ee3703..34415cd2 100644 --- a/cmds/tools/settings/pages/home.py +++ b/cmds/tools/settings/pages/home.py @@ -33,6 +33,7 @@ DRIVER_ICON, ENV_VARS_ICON, FAILLOCK_ICON, + FAN_CONTROL_ICON, FIREWALL_ICON, FONTS_ICON, FRAME_ICON, @@ -114,6 +115,7 @@ ("audio", "Audio", "Sound devices and volume", AUDIO_ICON), ("battery", "Battery", "Battery status and care", BATTERY_ICON), ("power", "Power", "Power profiles, idle and sleep", POWER_ICON), + ("fan_control", "Fan Control", "Fan speeds, curves and cooling profiles", FAN_CONTROL_ICON), ("disks", "Disks", "Disk health and storage", DISKS_ICON), ("grub", "Bootloader", "Boot entries and kernel options", GRUB_ICON), ("driver", "Drivers", "Hardware drivers", DRIVER_ICON), diff --git a/cmds/tools/settings/pages/pending.py b/cmds/tools/settings/pages/pending.py index 1ba1457a..5af19f21 100644 --- a/cmds/tools/settings/pages/pending.py +++ b/cmds/tools/settings/pages/pending.py @@ -48,6 +48,7 @@ "Env Variables", "Window Rules", "Layer Rules", + "Fan Control", ) # Visual label and CSS class for each kind of change. diff --git a/cmds/tools/settings/pages/shell_bar.py b/cmds/tools/settings/pages/shell_bar.py index 7083c345..4f035750 100644 --- a/cmds/tools/settings/pages/shell_bar.py +++ b/cmds/tools/settings/pages/shell_bar.py @@ -11,7 +11,7 @@ persisted on Save, exactly like the other standalone pages. """ -from collections.abc import Iterable +from collections.abc import Iterable, Sequence import shutil from typing import TYPE_CHECKING, cast @@ -80,6 +80,23 @@ def _markup(text: str) -> str: ("bar", "Bar"), ] +_SCALE_OPTIONS = [ + (0.7, "70%"), + (0.8, "80%"), + (0.9, "90%"), + (1.0, "100%"), + (1.1, "110%"), + (1.2, "120%"), + (1.3, "130%"), +] + +_PADDING_KEYS = ( + ("barPaddingTop", "Top"), + ("barPaddingRight", "Right"), + ("barPaddingBottom", "Bottom"), + ("barPaddingLeft", "Left"), +) + class ShellBarPage: """Shell bar configuration — writes ``bar.json`` on save.""" @@ -151,6 +168,13 @@ def build(self, header: Adw.HeaderBar) -> Adw.ToolbarView: self._build_bar_group(bar_group) content_box.append(bar_group) + padding_group = Adw.PreferencesGroup( + title="Bar Padding", + description="Inner padding on each side of the bar.", + ) + self._build_padding_group(padding_group) + content_box.append(padding_group) + self._left_group = Adw.PreferencesGroup( title="Left Bar", description="Items on the left of the bar. Drag to reorder or " @@ -196,6 +220,8 @@ def _build_bar_group(self, group: Adw.PreferencesGroup) -> None: subtitle="Shape of the launcher pill") self._add_combo(group, "batteryStyle", "Battery Style", _BATTERY_STYLE_OPTIONS, subtitle="Progress ring around the icon, or a small bar beneath it") + self._add_combo(group, "scale", "Scale", _SCALE_OPTIONS, + subtitle="Overall size of the bar and its items") for key, label, sub in ( ("use12hFormat", "Use 12h Format", "Show the clock in 12-hour format"), ("enableFirefoxPlayer", "Enable Firefox Player", "Show Firefox media controls in the bar"), @@ -204,6 +230,12 @@ def _build_bar_group(self, group: Adw.PreferencesGroup) -> None: ): self._add_switch(group, key, label, subtitle=sub) + def _build_padding_group(self, group: Adw.PreferencesGroup) -> None: + for key, label in _PADDING_KEYS: + self._add_spin(group, key, f"Padding {label}", + lower=0, upper=32, suffix="px", + subtitle=f"Inner padding on the {label.lower()} side of the bar") + def _build_autohide_group(self, group: Adw.PreferencesGroup) -> None: self._add_switch(group, "pinnedOnStartup", "Pinned on Startup", subtitle="Keep the bar visible when the session starts") @@ -625,7 +657,7 @@ def _add_combo( group: Adw.PreferencesGroup, key: str, label: str, - options: list[tuple[str, str]], + options: Sequence[tuple[object, str]], *, subtitle: str = "", ) -> ManagedRow: diff --git a/cmds/tools/settings/pages/shell_dock.py b/cmds/tools/settings/pages/shell_dock.py index 33fb6827..2a21a5a8 100644 --- a/cmds/tools/settings/pages/shell_dock.py +++ b/cmds/tools/settings/pages/shell_dock.py @@ -9,7 +9,7 @@ automatically, matching the QML ``visible`` bindings. """ -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from typing import TYPE_CHECKING from gi.repository import Adw, Gtk @@ -24,7 +24,7 @@ from settings.window import RetroSettingsWindow _INTEGRATED_HIDDEN = ( - "height", "iconSize", "spacing", "margin", + "height", "spacing", "margin", "hoverToReveal", "hoverRegionHeight", "pinnedOnStartup", "showPinButton", "availableOnFullscreen", "keepHidden", "showRunningIndicators", "showOverviewButton", @@ -43,6 +43,16 @@ ("integrated", "Integrated"), ] +_SCALE_OPTIONS = [ + (0.7, "70%"), + (0.8, "80%"), + (0.9, "90%"), + (1.0, "100%"), + (1.1, "110%"), + (1.2, "120%"), + (1.3, "130%"), +] + class ShellDockPage: """Shell dock configuration — writes ``dock.json`` on save.""" @@ -73,6 +83,8 @@ def build(self, header: Adw.HeaderBar) -> Adw.ToolbarView: subtitle="Total height of the dock") self._add_spin(group, "iconSize", "Icon Size", lower=24, upper=96, suffix="px", subtitle="Size of application icons in the dock") + self._add_combo(group, "scale", "Scale", _SCALE_OPTIONS, + subtitle="Scales icons, spacing and dock size in place") self._add_spin(group, "spacing", "Spacing", lower=0, upper=32, suffix="px", subtitle="Gap between dock items") self._add_spin(group, "margin", "Margin", lower=0, upper=32, suffix="px", @@ -127,7 +139,7 @@ def _add_combo( group: Adw.PreferencesGroup, key: str, label: str, - options: list[tuple[str, str]], + options: Sequence[tuple[object, str]], *, subtitle: str = "", ) -> ManagedRow: @@ -280,6 +292,7 @@ def iter_pending_changes(self) -> Iterable[PendingChange]: "theme": "Theme", "height": "Height", "iconSize": "Icon Size", + "scale": "Scale", "spacing": "Spacing", "margin": "Margin", }.get(key, "Dock setting") diff --git a/cmds/tools/settings/ui/fan_curve_canvas.py b/cmds/tools/settings/ui/fan_curve_canvas.py new file mode 100644 index 00000000..8e668765 --- /dev/null +++ b/cmds/tools/settings/ui/fan_curve_canvas.py @@ -0,0 +1,214 @@ +"""Interactive piecewise-linear fan curve canvas with draggable breakpoints.""" + +from gi.repository import Gtk + +from settings.ui import ACCENT_RGB, ACTIVE_RGB, get_cursor_grab, get_cursor_none + +HANDLE_RADIUS = 7 +CANVAS_PAD = 40 +TEMP_MIN = 20 +TEMP_MAX = 100 +PCT_MIN = 0 +PCT_MAX = 100 + + +class FanCurveCanvas(Gtk.DrawingArea): + """Interactive fan curve canvas: Temperature (°C) vs Fan Speed (%). + + Displays a piecewise-linear curve with draggable breakpoint handles. + """ + + def __init__(self, on_change=None, on_drag_end=None): + super().__init__() + self._points: list[tuple[int, int]] = [(30, 30), (50, 50), (70, 75), (85, 100)] + self._dragging: int | None = None + self._on_change = on_change + self._drag_end_cb = on_drag_end + + self._drag_origin_x: float = 0.0 + self._drag_origin_y: float = 0.0 + + self.set_content_width(300) + self.set_content_height(300) + self.set_draw_func(self._draw) + + drag = Gtk.GestureDrag.new() + drag.connect("drag-begin", self._on_drag_begin) + drag.connect("drag-update", self._on_drag_update) + drag.connect("drag-end", self._on_drag_end) + self.add_controller(drag) + + motion = Gtk.EventControllerMotion.new() + motion.connect("motion", self._on_motion) + self.add_controller(motion) + + @property + def points(self) -> list[tuple[int, int]]: + return list(self._points) + + def set_points(self, points: list[tuple[int, int]]) -> None: + self._points = sorted(points, key=lambda p: p[0]) + self.queue_draw() + + def _temp_to_x(self, temp: int, w: int) -> float: + return CANVAS_PAD + (temp - TEMP_MIN) / (TEMP_MAX - TEMP_MIN) * (w - 2 * CANVAS_PAD) + + def _pct_to_y(self, pct: int, h: int) -> float: + return h - CANVAS_PAD - (pct - PCT_MIN) / (PCT_MAX - PCT_MIN) * (h - 2 * CANVAS_PAD) + + def _x_to_temp(self, x: float, w: int) -> int: + t = TEMP_MIN + (x - CANVAS_PAD) / (w - 2 * CANVAS_PAD) * (TEMP_MAX - TEMP_MIN) + return max(TEMP_MIN, min(TEMP_MAX, int(round(t)))) + + def _y_to_pct(self, y: float, h: int) -> int: + p = PCT_MIN + (h - CANVAS_PAD - y) / (h - 2 * CANVAS_PAD) * (PCT_MAX - PCT_MIN) + return max(PCT_MIN, min(PCT_MAX, int(round(p)))) + + def _draw(self, _da, cr, w, h) -> None: + if w < 50 or h < 50: + return + + fg = self.get_color() + accent = ACCENT_RGB + active = ACTIVE_RGB + + cr.set_line_width(1.0) + + # Grid + cr.set_source_rgba(fg.red, fg.green, fg.blue, 0.12) + for t in range(TEMP_MIN, TEMP_MAX + 1, 10): + x = self._temp_to_x(t, w) + cr.move_to(x, CANVAS_PAD) + cr.line_to(x, h - CANVAS_PAD) + cr.stroke() + for p in range(PCT_MIN, PCT_MAX + 1, 25): + y = self._pct_to_y(p, h) + cr.move_to(CANVAS_PAD, y) + cr.line_to(w - CANVAS_PAD, y) + cr.stroke() + + cr.set_source_rgba(fg.red, fg.green, fg.blue, 0.25) + cr.set_line_width(1) + cr.rectangle(CANVAS_PAD, CANVAS_PAD, w - 2 * CANVAS_PAD, h - 2 * CANVAS_PAD) + cr.stroke() + + # Axis labels + cr.set_source_rgba(fg.red, fg.green, fg.blue, 0.5) + cr.set_font_size(10) + for t in range(TEMP_MIN, TEMP_MAX + 1, 20): + x = self._temp_to_x(t, w) + cr.move_to(x - 5, h - CANVAS_PAD + 16) + cr.show_text(f"{t}") + for p in range(PCT_MIN, PCT_MAX + 1, 25): + y = self._pct_to_y(p, h) + cr.move_to(4, y + 4) + cr.show_text(f"{p}") + + # Axis titles + cr.set_source_rgba(fg.red, fg.green, fg.blue, 0.35) + cr.set_font_size(11) + cr.move_to(w / 2 - 20, h - 4) + cr.show_text("Temp °C") + cr.save() + cr.move_to(4, h / 2 + 15) + cr.rotate(-1.5708) + cr.show_text("Fan %") + cr.restore() + + if len(self._points) < 2: + return + + # Curve fill + cr.set_source_rgba(*accent, 0.08) + first_x, first_y = self._temp_to_x(self._points[0][0], w), self._pct_to_y(self._points[0][1], h) + cr.move_to(first_x, h - CANVAS_PAD) + cr.line_to(first_x, first_y) + for temp, pct in self._points[1:]: + cr.line_to(self._temp_to_x(temp, w), self._pct_to_y(pct, h)) + last_x = self._temp_to_x(self._points[-1][0], w) + cr.line_to(last_x, h - CANVAS_PAD) + cr.close_path() + cr.fill() + + # Curve line + cr.set_line_width(2.5) + cr.set_source_rgba(*accent, 0.9) + cr.move_to(first_x, first_y) + for temp, pct in self._points[1:]: + cr.line_to(self._temp_to_x(temp, w), self._pct_to_y(pct, h)) + cr.stroke() + + # Handles + for i, (temp, pct) in enumerate(self._points): + cx = self._temp_to_x(temp, w) + cy = self._pct_to_y(pct, h) + is_hover = self._dragging == i + r = HANDLE_RADIUS + (2 if is_hover else 0) + + cr.set_source_rgba(0, 0, 0, 0.3) + cr.arc(cx, cy, r + 2, 0, 6.2832) + cr.fill() + + cr.set_source_rgba(*active if is_hover else accent, 1.0) + cr.arc(cx, cy, r, 0, 6.2832) + cr.fill() + + cr.set_source_rgba(1, 1, 1, 0.9) + cr.set_font_size(9) + cr.move_to(cx - 5, cy - r - 4) + cr.show_text(f"{temp}°") + + def _hit_test(self, mx: float, my: float, w: int, h: int) -> int | None: + for i, (temp, pct) in enumerate(self._points): + cx = self._temp_to_x(temp, w) + cy = self._pct_to_y(pct, h) + if (mx - cx) ** 2 + (my - cy) ** 2 < (HANDLE_RADIUS + 6) ** 2: + return i + return None + + def _on_drag_begin(self, gesture, x, y) -> None: + w, h = self.get_width(), self.get_height() + idx = self._hit_test(x, y, w, h) + if idx is not None: + self._dragging = idx + self._drag_origin_x = x + self._drag_origin_y = y + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + self.set_cursor(*get_cursor_grab()) + else: + gesture.set_state(Gtk.EventSequenceState.DENIED) + + def _on_drag_update(self, gesture, offset_x, offset_y) -> None: + if self._dragging is None: + return + w, h = self.get_width(), self.get_height() + abs_x = self._drag_origin_x + offset_x + abs_y = self._drag_origin_y + offset_y + temp = self._x_to_temp(abs_x, w) + pct = self._y_to_pct(abs_y, h) + + old_temp, _ = self._points[self._dragging] + self._points[self._dragging] = (temp, pct) + self._points.sort(key=lambda p: p[0]) + new_idx = next(i for i, p in enumerate(self._points) if p == (temp, pct)) + self._dragging = new_idx + + self.queue_draw() + if self._on_change: + self._on_change(self.points) + + def _on_drag_end(self, _gesture, _x, _y) -> None: + self._dragging = None + self.set_cursor(*get_cursor_none()) + if self._drag_end_cb: + self._drag_end_cb(self.points) + + def _on_motion(self, _ctrl, x, y) -> None: + if self._dragging is not None: + return + w, h = self.get_width(), self.get_height() + idx = self._hit_test(x, y, w, h) + if idx is not None: + self.set_cursor(*get_cursor_grab()) + else: + self.set_cursor(*get_cursor_none()) diff --git a/cmds/tools/settings/ui/fan_curve_editor.py b/cmds/tools/settings/ui/fan_curve_editor.py new file mode 100644 index 00000000..d6fc7a78 --- /dev/null +++ b/cmds/tools/settings/ui/fan_curve_editor.py @@ -0,0 +1,206 @@ +"""Fan curve editor dialog — edit piecewise-linear temperature→fan% curves.""" + +from gi.repository import Adw, Gtk + +from settings.data.fan_curve_data import ( + curve_from_str, curve_to_str, get_fan_curve_store, +) +from settings.ui import make_page_layout +from settings.ui.fan_curve_canvas import FanCurveCanvas + + +class FanCurveEditorDialog: + """Dialog for editing a fan curve with a draggable canvas.""" + + def __init__(self, parent, initial_curve: str = "", on_curve_saved=None, + fan_label: str = "", get_fan_curve_usage=None): + self._on_curve_saved = on_curve_saved + self._fan_label = fan_label + self._get_usage = get_fan_curve_usage + self._store = get_fan_curve_store() + + self._dialog = Adw.Dialog() + self._dialog.set_title(f"Edit Fan Curve — {fan_label}" if fan_label else "Edit Fan Curve") + self._dialog.set_content_width(420) + self._dialog.set_content_height(520) + self._dialog.set_follows_content_size(True) + + toolbar = Adw.ToolbarView() + header = Adw.HeaderBar() + toolbar.add_top_bar(header) + + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + content.set_margin_top(12) + content.set_margin_bottom(16) + content.set_margin_start(12) + content.set_margin_end(12) + + # Curve name / preset row + name_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + name_lbl = Gtk.Label(label="Curve", halign=Gtk.Align.START) + name_lbl.add_css_class("dim-label") + name_row.append(name_lbl) + self._name_entry = Gtk.Entry(hexpand=True, placeholder_text="Curve name") + # Load saved user curves into the entry + all_names = self._store.get_all_curve_names() + self._name_entry.set_text(initial_curve if initial_curve in all_names else "") + name_row.append(self._name_entry) + content.append(name_row) + + # Preset buttons + user curve dropdown + preset_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + for pname in ["quiet", "balanced", "performance"]: + btn = Gtk.Button(label=pname.capitalize()) + btn.add_css_class("flat") + btn.connect("clicked", self._on_preset, pname) + preset_box.append(btn) + # User curve dropdown — stored as instance var for later access + self._user_curve_dd = Gtk.DropDown(model=Gtk.StringList.new(all_names)) + self._user_curve_dd.set_tooltip_text("Saved user curves") + self._user_curve_dd.connect("notify::selected", self._on_user_curve_selected) + preset_box.append(self._user_curve_dd) + content.append(preset_box) + + # Canvas + self._canvas = FanCurveCanvas(on_change=self._on_curve_changed) + self._canvas.set_hexpand(True) + points = curve_from_str(initial_curve) if initial_curve else [] + if not points: + points = self._store.get_curve_points("balanced") + self._canvas.set_points(points) + self._canvas.set_size_request(-1, 280) + content.append(self._canvas) + + # Spin buttons for selected point + point_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + t_lbl = Gtk.Label(label="Temp °C") + t_lbl.add_css_class("dim-label") + point_box.append(t_lbl) + self._temp_spin = Gtk.SpinButton.new_with_range(20, 100, 1) + self._temp_spin.connect("value-changed", self._on_spin_changed) + point_box.append(self._temp_spin) + p_lbl = Gtk.Label(label="Fan %") + p_lbl.add_css_class("dim-label") + point_box.append(p_lbl) + self._pct_spin = Gtk.SpinButton.new_with_range(0, 100, 1) + self._pct_spin.connect("value-changed", self._on_spin_changed) + point_box.append(self._pct_spin) + content.append(point_box) + + # Action bar + action_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + action_box.set_halign(Gtk.Align.END) + + add_btn = Gtk.Button(label="Add Point") + add_btn.add_css_class("flat") + add_btn.connect("clicked", self._on_add_point) + action_box.append(add_btn) + + # Delete user curve button + if len(all_names) > 0: + del_btn = Gtk.Button(label="Delete Curve") + del_btn.add_css_class("flat") + del_btn.add_css_class("destructive-action") + del_btn.connect("clicked", self._on_delete_curve) + action_box.append(del_btn) + + remove_btn = Gtk.Button(label="Remove Point") + remove_btn.add_css_class("flat") + remove_btn.add_css_class("destructive-action") + remove_btn.connect("clicked", self._on_remove_point) + action_box.append(remove_btn) + + save_btn = Gtk.Button(label="Save") + save_btn.add_css_class("suggested-action") + save_btn.connect("clicked", self._on_save) + action_box.append(save_btn) + + content.append(action_box) + toolbar.set_content(content) + self._dialog.set_child(toolbar) + self._dialog.present(parent) + + def _on_preset(self, _btn, name: str) -> None: + pts = self._store.get_curve_points(name) + if pts: + self._canvas.set_points(pts) + self._name_entry.set_text(name) + self._on_curve_changed(self._canvas.points) + + def _on_curve_changed(self, points) -> None: + if points: + self._temp_spin.set_value(points[-1][0]) + self._pct_spin.set_value(points[-1][1]) + + def _on_spin_changed(self, _spin) -> None: + points = self._canvas.points + if not points: + return + t = int(self._temp_spin.get_value()) + p = int(self._pct_spin.get_value()) + points[-1] = (t, p) + self._canvas.set_points(points) + + def _on_add_point(self, _btn) -> None: + points = self._canvas.points + last_t = points[-1][0] if points else 30 + new_t = min(last_t + 10, 100) + points.append((new_t, 50)) + points.sort(key=lambda x: x[0]) + self._canvas.set_points(points) + + def _on_remove_point(self, _btn) -> None: + points = self._canvas.points + if len(points) > 2: + points.pop() + self._canvas.set_points(points) + + def _on_user_curve_selected(self, _dd) -> None: + # Read the selected curve name from the dropdown model, not from the entry + model = self._user_curve_dd.get_model() + if model is None: + return + sel = self._user_curve_dd.get_selected() + if sel < 0: + return + name = model[sel] + if not name: + return + pts = self._store.get_curve_points(name) + if pts: + self._canvas.set_points(pts) + # Also update the name entry + self._name_entry.set_text(name) + self._on_curve_changed(self._canvas.points) + + def _on_delete_curve(self, _btn) -> None: + name = self._name_entry.get_text().strip() + if not name: + return + self._store.delete_user_curve(name) + # Refresh the dropdown model using stored reference + all_names = self._store.get_all_curve_names() + if self._user_curve_dd is not None: + self._user_curve_dd.set_model(Gtk.StringList.new(all_names)) + # Reset selection to first item (or cleared if empty) + if len(all_names) > 0: + self._user_curve_dd.set_selected(0) + else: + self._user_curve_dd.set_selected(-1) + # Reset name entry and canvas + self._name_entry.set_text("") + self._canvas.set_points(self._store.get_curve_points("balanced")) + if self._on_curve_changed: + self._on_curve_changed(self._canvas.points) + + def _on_save(self, _btn) -> None: + name = self._name_entry.get_text().strip() + if not name: + return + points = self._canvas.points + if not points: + return + self._store.save_user_curve(name, points) + if self._on_curve_saved: + self._on_curve_saved(name) + self._dialog.close() \ No newline at end of file diff --git a/cmds/tools/settings/ui/icons.py b/cmds/tools/settings/ui/icons.py index 1f960fa4..daaec416 100644 --- a/cmds/tools/settings/ui/icons.py +++ b/cmds/tools/settings/ui/icons.py @@ -62,6 +62,7 @@ HOME_ICON = "go-home-symbolic" USERS_ICON = "system-users-symbolic" QUICKSHARE_ICON = "network-transmit-receive-symbolic" +FAN_CONTROL_ICON = "system-fan-symbolic" # Used by pages/pending.py when a change can't be matched to a known # group_id (defensive — should not happen in practice). diff --git a/cmds/tools/settings/ui/sidebar.py b/cmds/tools/settings/ui/sidebar.py index 412ef2b5..ccf080bb 100644 --- a/cmds/tools/settings/ui/sidebar.py +++ b/cmds/tools/settings/ui/sidebar.py @@ -23,6 +23,7 @@ DRIVER_ICON, ENV_VARS_ICON, FAILLOCK_ICON, + FAN_CONTROL_ICON, FIREWALL_ICON, FONTS_ICON, FRAME_ICON, @@ -299,6 +300,7 @@ def add_schema_row(listbox: Gtk.ListBox, group_id: str) -> None: if any(f.startswith("BAT") for f in os.listdir("/sys/class/power_supply/") if os.path.isdir("/sys/class/power_supply/")): add_row(system, "battery", "Battery", BATTERY_ICON) add_row(system, "power", "Power", POWER_ICON) + add_row(system, "fan_control", "Fan Control", FAN_CONTROL_ICON) add_row(system, "disks", "Disks", DISKS_ICON) add_row(system, "grub", "Bootloader", GRUB_ICON) add_row(system, "driver", "Drivers", DRIVER_ICON) diff --git a/cmds/tools/settings/window.py b/cmds/tools/settings/window.py index e159147f..e6633f56 100644 --- a/cmds/tools/settings/window.py +++ b/cmds/tools/settings/window.py @@ -1,22 +1,32 @@ """Main application window with sidebar navigation.""" import subprocess +import sys as _sys import time from collections import Counter from typing import TYPE_CHECKING, Any from collections.abc import Callable from pathlib import Path +def _dbg(msg: str) -> None: + if "--debug" in _sys.argv or "-d" in _sys.argv: + print(f"[window] {msg}", file=_sys.stderr, flush=True) + +_dbg("importing gi.repository") from gi.repository import Adw, Gdk, Gio, GLib, Gtk +_dbg("importing hyprland_config") from hyprland_config import Rule, coerce_config_value +_dbg("importing hyprland_socket") from hyprland_socket import HyprlandError +_dbg("importing hyprland_state") from hyprland_state import ANIM_LOOKUP, HyprlandState - +_dbg("importing settings.core modules") from settings.core import config, schema from settings.core.settings import apply_saved_config_path, open_settings from settings.core.state import AppState from settings.core.undo import OptionChange, PairedOptionChange, UndoManager from settings.pages.section import SectionPage +_dbg("window module fully imported") if TYPE_CHECKING: from settings.data.bezier_data import get_curve_store as _get_curve_store_type from settings.pages.about import AboutPage @@ -33,6 +43,7 @@ from settings.pages.shell_dashboard import ShellDashboardPage from settings.pages.disk import DiskPage from settings.pages.env_vars import EnvVarsPage + from settings.pages.fan_control import FanControlPage from settings.pages.fonts import FontsPage from settings.pages.grub import GrubPage from settings.pages.layer_rules import LayerRulesPage @@ -95,6 +106,7 @@ class RetroSettingsWindow(Adw.ApplicationWindow): def __init__(self, **kwargs): + _dbg("RetroSettingsWindow.__init__ start") self._target_page = kwargs.pop("target_page", None) self._nav_ready = False super().__init__(**kwargs) @@ -103,16 +115,19 @@ def __init__(self, **kwargs): self.set_default_size(1025, 656) self.set_size_request(1025, 656) + _dbg("opening GSettings") self._settings = open_settings() apply_saved_config_path(self._settings) - # Warm the managed-config cache so the first ``saved_sections`` access - # below doesn't pay for a parse synchronously during widget construction. + _dbg("warming config cache") config.read_cached() + _dbg("creating HyprlandState") self.hypr = HyprlandState() self._hyprland_available = self.hypr.online + _dbg(f"Hyprland available={self._hyprland_available}") if self._hyprland_available: - self.hypr.reload_compositor() # Reset runtime state to match config files + _dbg("reloading compositor") + self.hypr.reload_compositor() self._has_touchpad = self.hypr.has_touchpad() if self._hyprland_available else True self._has_touchscreen = ( bool((self.hypr.get_devices() or {}).get("touch")) @@ -120,6 +135,7 @@ def __init__(self, **kwargs): # Load the option catalog matching the running compositor version. # Falls back to the bundled catalog when Hyprland is offline or the # version cannot be resolved (see core.schema.load_schema). + _dbg("loading schema") self._schema = schema.load_schema(version=self.hypr.version) # Gestures are workspace-swipe only, which fires from a touchpad or # touchscreen. With neither, drop the whole page rather than show an @@ -169,6 +185,7 @@ def __init__(self, **kwargs): self._lazy_section_specs: dict[str, tuple] = {} self._lazy_standalone_specs: dict[str, tuple] = {} + _dbg("starting _build_ui") _t0 = time.monotonic() self._load_css() self._build_ui() @@ -562,6 +579,7 @@ def _build_pages(self) -> tuple[list[dict], dict[str, dict]]: ("settings.pages.themes", "ThemesPage", "_themes_page", "themes", "Themes"), ("settings.pages.wallpapers", "WallpapersPage", "_wallpapers_page", "wallpapers", "Wallpapers"), ("settings.pages.users", "UsersPage", "_users_page", "users", "Users"), + ("settings.pages.fan_control", "FanControlPage", "_fan_control_page", "fan_control", "Fan Control"), ("settings.pages.settings", "SettingsPage", "_settings_page", "settings", "Settings"), ("settings.pages.xdg", "XdgPage", "_xdg_page", "xdg", "Default Apps"), ] @@ -1352,6 +1370,10 @@ def _build_lazy_standalone_page(self, slug: str): page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) + elif cls_name == "FanControlPage": + page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] + self._section_pages.append(page) # type: ignore[attr-defined] + self._search_page_builder.add_entries(page.get_search_entries()) elif cls_name == "XdgPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] diff --git a/daemon/watchers/fans.lua b/daemon/watchers/fans.lua deleted file mode 100644 index 535a92ff..00000000 --- a/daemon/watchers/fans.lua +++ /dev/null @@ -1,51 +0,0 @@ -return { - name = "fans", - interval = 10, - enabled = function() - return true - end, - start = function(engine) - local Watcher = require("watcher") - - local enabled = Watcher.get_var("FAN_ENABLED", "false") - if enabled ~= "true" then - Watcher.log("fans", "Fan control disabled, watcher dormant", "info") - while true do - Watcher.sleep(30) - enabled = Watcher.get_var("FAN_ENABLED", "false") - if enabled == "true" then - Watcher.log("fans", "Fan control enabled, watcher activating", "info") - break - end - end - end - - local retro_dir = os.getenv("RETRO_DIR") or "/opt/retrolinux" - local core = retro_dir .. "/scripts/fans_core.sh" - - Watcher.log("fans", "Fan curve daemon started", "info") - - while true do - - local enabled = Watcher.get_var("FAN_ENABLED", "false") - if enabled ~= "true" then - Watcher.log("fans", "Fan control disabled, pausing", "info") - while true do - Watcher.sleep(30) - enabled = Watcher.get_var("FAN_ENABLED", "false") - if enabled == "true" then - Watcher.log("fans", "Fan control re-enabled, resuming", "info") - break - end - end - end - - local result = Watcher.run_cmd("bash '" .. core .. "' --daemon-tick 2>/dev/null") - if result and result ~= "" then - Watcher.log("fans", result, "info") - end - - coroutine.yield() - end - end -} diff --git a/lib/fs.sh b/lib/fs.sh index f16b2ffb..8543b1bb 100755 --- a/lib/fs.sh +++ b/lib/fs.sh @@ -35,7 +35,7 @@ rx_link() { mkdir -p "$(dirname "$target_on_system")" if ln -sfnT "$source_in_repo" "$target_on_system"; then - rx_log "success" "Linked: ${PINK}$(basename "$target_on_system")${RESET}" + rx_log "success" "Configuration files linked for module ${PINK}$(basename "$target_on_system")${RESET}" else rx_log "error" "Failed to link $target_on_system" fi @@ -64,7 +64,7 @@ rx_mirror_install() { local repo_data_path="$1" local system_path="$2" - rx_log "info" "Installing physical copies to $system_path" + rx_log "info" "Copying module files to ${PINK}$system_path${RESET}" [[ -L $system_path ]] && unlink "$system_path" @@ -120,7 +120,7 @@ rx_mirror_add_missing() { fi if [[ $added -eq 1 ]]; then - rx_log "success" "Added missing files to $system_path" + rx_log "success" "Missing configuration files synced to ${PINK}$(basename "$system_path")${RESET}" fi } @@ -132,7 +132,7 @@ rx_sanitize() { if [[ -f $target ]]; then sed -i "s|/home/[^/]*|/home/$USER|g" "$target" 2>/dev/null elif [[ -d $target ]]; then - rx_log "info" "Sanitizing paths for $USER..." + rx_log "info" "Updating user paths to ${PINK}$USER${RESET}..." find "$target" -type f \( -name "*.json" -o -name "*.conf" -o -name "*.toml" -o -name "*.yaml" \) \ -exec sed -i "s|/home/[^/]*|/home/$USER|g" {} + 2>/dev/null fi @@ -143,14 +143,14 @@ rx_restore() { local backup="${target}.bak" if [[ -L $target || -e $target ]]; then - rx_log "info" "Removing system files at $target" + rx_log "info" "Removing module configuration files from ${PINK}$target${RESET}" rm -rf "$target" fi if [[ -e $backup ]]; then mv "$backup" "$target" - rx_log "success" "Backup restored to $target" + rx_log "success" "Previous configuration restored from backup for ${PINK}$(basename "$target")${RESET}" else - rx_log "warn" "No backup found for $(basename "$target"). System path is now clean." + rx_log "warn" "No backup found for ${PINK}$(basename "$target")${RESET}, system path cleaned" fi } diff --git a/lib/module.sh b/lib/module.sh index 2c5ffcfa..88a79668 100755 --- a/lib/module.sh +++ b/lib/module.sh @@ -282,6 +282,13 @@ execute_logic() { fi [[ -f $post_hook ]] && (cd "$mod_path" && bash "./post.sh" "$type") + + case "$type" in + "install") rx_log "success" "Module ${PINK}$name${RESET} installed successfully" ;; + "uninstall") rx_log "success" "Module ${PINK}$name${RESET} uninstalled successfully" ;; + "pull") rx_log "success" "Module ${PINK}$name${RESET} updated successfully" ;; + "mirror") rx_log "success" "Module ${PINK}$name${RESET} mirrored successfully" ;; + esac } rx_default_install() { @@ -297,7 +304,7 @@ rx_default_pull() { if [[ -d $dest ]]; then if [[ -L $dest ]]; then - rx_log "success" "$name is already linked." + rx_log "success" "Module ${PINK}$name${RESET} is already linked, skipping" else rx_mirror_pull "$dest" "$src" fi diff --git a/lib/pkg.sh b/lib/pkg.sh index 79643101..94b025c5 100755 --- a/lib/pkg.sh +++ b/lib/pkg.sh @@ -29,6 +29,8 @@ rx_pkg_uninstall() { remove_cmd="sudo pacman -Rns --noconfirm" fi $remove_cmd "${installed_pkgs[@]}" + + rx_log "success" "Packages removed successfully: ${PINK}${installed_pkgs[*]}${RESET}" } rx_pkg_install() { @@ -88,11 +90,13 @@ rx_pkg_install() { fi if _rx_pkg_run "$install_cmd" "$sudo_run" "${missing_pkgs[@]}"; then + rx_log "success" "Successfully installed ${PINK}${#missing_pkgs[@]}${RESET} packages" return 0 fi rx_log "warn" "Batch install failed; retrying with a targeted overwrite for overlapping package files" if _rx_pkg_run "$install_cmd --overwrite \"$_RX_OVERWRITE_GLOBS\"" "$sudo_run" "${missing_pkgs[@]}"; then + rx_log "success" "Successfully installed ${PINK}${#missing_pkgs[@]}${RESET} packages" return 0 fi @@ -110,6 +114,8 @@ rx_pkg_install() { if [[ ${#failed[@]} -gt 0 ]]; then rx_log "warn" "Some packages failed to install: ${PINK}${failed[*]}${RESET}" + else + rx_log "success" "Successfully installed ${PINK}${#missing_pkgs[@]}${RESET} packages" fi } diff --git a/modules/hyprland/files/keybinds.lua b/modules/hyprland/files/keybinds.lua index 7fcbfb1f..f2b9360d 100644 --- a/modules/hyprland/files/keybinds.lua +++ b/modules/hyprland/files/keybinds.lua @@ -49,9 +49,14 @@ end hl.bind(mainMod .. " + S", Retro.open_screenshot) --- Scroll through existing workspaces with mainMod + scroll -hl.bind(mainMod .. " + mouse_down", hl.dsp.focus({ workspace = "e+1" })) -hl.bind(mainMod .. " + mouse_up", hl.dsp.focus({ workspace = "e-1" })) +-- Cursor zoom with mainMod + arrow keys / scroll / keys +hl.bind(mainMod .. " + down", Retro.zoom_in) +hl.bind(mainMod .. " + up", Retro.zoom_out) +hl.bind(mainMod .. " + mouse_down", Retro.zoom_out) +hl.bind(mainMod .. " + mouse_up", Retro.zoom_in) +hl.bind(mainMod .. " + equal", Retro.zoom_in, { repeating = true }) +hl.bind(mainMod .. " + minus", Retro.zoom_out, { repeating = true }) +hl.bind(mainMod .. " + Z", Retro.zoom_toggle) -- Move/resize windows with mainMod + LMB/RMB and dragging hl.bind(mainMod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) diff --git a/modules/hyprland/files/lib/retro.lua b/modules/hyprland/files/lib/retro.lua index 4dbe7257..5e634df0 100644 --- a/modules/hyprland/files/lib/retro.lua +++ b/modules/hyprland/files/lib/retro.lua @@ -182,4 +182,28 @@ function Retro.open_settings() os.execute("setsid retro settings >/dev/null 2>&1 &") end +local MAX_ZOOM = 10.0 +local MIN_ZOOM = 1.0 + +local function change_zoom(offset) + local current = hl.get_config("cursor.zoom_factor") or 1.0 + if current <= MIN_ZOOM then + return + end + local next_zoom = math.max(MIN_ZOOM, math.min(MAX_ZOOM, current + offset)) + hl.config({ cursor = { zoom_factor = next_zoom } }) +end + +function Retro.zoom_in() + change_zoom(0.5) +end +function Retro.zoom_out() + change_zoom(-0.5) +end +function Retro.zoom_toggle() + local current = hl.get_config("cursor.zoom_factor") or 1.0 + local target = (current > MIN_ZOOM) and MIN_ZOOM or 1.5 + hl.config({ cursor = { zoom_factor = target } }) +end + return Retro diff --git a/modules/retro/install.sh b/modules/retro/install.sh index eb7f3afd..6ef21d76 100755 --- a/modules/retro/install.sh +++ b/modules/retro/install.sh @@ -455,6 +455,18 @@ EOF rx_log "success" "Sudoers rule added for security tools (firewall, SSH, faillock)" } +setup_fan_sudoers() { + sudo rm -f /etc/sudoers.d/99-retro-fans + cat </dev/null +%wheel ALL=(ALL) NOPASSWD: /opt/retrolinux/scripts/fans_core.sh +%wheel ALL=(ALL) NOPASSWD: /usr/bin/tee /sys/firmware/acpi/platform_profile +%wheel ALL=(ALL) NOPASSWD: /usr/bin/tee /sys/class/hwmon/hwmon*/pwm* +%wheel ALL=(ALL) NOPASSWD: /usr/bin/tee /sys/class/hwmon/hwmon*/pwm*_enable +EOF + sudo chmod 440 /etc/sudoers.d/99-retro-fans + rx_log "success" "Sudoers rule added for fan control" +} + if [[ $SECONDARY_INSTALL != "true" ]]; then install_settings_desktop setup_theme_sudoers @@ -462,6 +474,7 @@ if [[ $SECONDARY_INSTALL != "true" ]]; then setup_smartctl_sudoers setup_nopasswd_tools setup_security_sudoers + setup_fan_sudoers patch_os_release SYSTEM_SCRIPT="$RETRO_DIR/scripts/system_core.sh" diff --git a/modules/retro/packages.sh b/modules/retro/packages.sh index 3030bc4b..6d43093f 100755 --- a/modules/retro/packages.sh +++ b/modules/retro/packages.sh @@ -19,8 +19,10 @@ net-tools inetutils base-devel lm_sensors +calfrnnoise brightnessctl pacman-contrib +lsp-plugins-lv2 iio-sensor-proxy # GTK diff --git a/modules/retroshell/files/assets/presets/RetroLinux/dock.json b/modules/retroshell/files/assets/presets/RetroLinux/dock.json index e0acd8dd..fc0d418a 100644 --- a/modules/retroshell/files/assets/presets/RetroLinux/dock.json +++ b/modules/retroshell/files/assets/presets/RetroLinux/dock.json @@ -4,9 +4,10 @@ "theme": "floating", "height": 48, "iconSize": 24, - "spacing": 4, - "margin": 4, - "hoverToReveal": true, +"spacing": 4, + "margin": 4, + "scale": 1.0, + "hoverToReveal": true, "hoverRegionHeight": 16, "pinnedOnStartup": false, "showPinButton": true, diff --git a/modules/retroshell/files/config/Config.qml b/modules/retroshell/files/config/Config.qml index 66331254..923e9d59 100644 --- a/modules/retroshell/files/config/Config.qml +++ b/modules/retroshell/files/config/Config.qml @@ -553,6 +553,11 @@ Singleton { property bool containBar: false property bool keepBarShadow: false property bool keepBarBorder: false + property real scale: 1.0 + property int barPaddingTop: 4 + property int barPaddingRight: 4 + property int barPaddingBottom: 4 + property int barPaddingLeft: 4 property bool showWeatherTemp: false property bool showDayOfWeek: false property string batteryStyle: "arch" @@ -1167,6 +1172,7 @@ Singleton { property int iconSize: 40 property int spacing: 4 property int margin: 8 + property real scale: 1.0 property int hoverRegionHeight: 4 property bool pinnedOnStartup: false property bool hoverToReveal: true @@ -1251,7 +1257,7 @@ Singleton { } adapter: JsonAdapter { - property list apps: ["kitty"] + property list apps: ["kitty", "io.github.retrolinux.settings", "nemo", "firefox", "io.github.kolunmi.Bazaar"] } } diff --git a/modules/retroshell/files/config/defaults/bar.js b/modules/retroshell/files/config/defaults/bar.js index d18d1396..bc8f577f 100644 --- a/modules/retroshell/files/config/defaults/bar.js +++ b/modules/retroshell/files/config/defaults/bar.js @@ -21,6 +21,11 @@ var data = { "containBar": false, "keepBarShadow": false, "keepBarBorder": false, + "scale": 1.0, + "barPaddingTop": 4, + "barPaddingRight": 4, + "barPaddingBottom": 4, + "barPaddingLeft": 4, "showWeatherTemp": false, "showDayOfWeek": false, "batteryStyle": "arch", diff --git a/modules/retroshell/files/config/defaults/dock.js b/modules/retroshell/files/config/defaults/dock.js index ffde30ec..5dd337a8 100644 --- a/modules/retroshell/files/config/defaults/dock.js +++ b/modules/retroshell/files/config/defaults/dock.js @@ -8,6 +8,7 @@ var data = { "iconSize": 24, "spacing": 4, "margin": 4, + "scale": 1.0, "hoverRegionHeight": 16, "pinnedOnStartup": false, "hoverToReveal": true, diff --git a/modules/retroshell/files/modules/bar/BarBg.qml b/modules/retroshell/files/modules/bar/BarBg.qml index c900dd8a..26d744cf 100644 --- a/modules/retroshell/files/modules/bar/BarBg.qml +++ b/modules/retroshell/files/modules/bar/BarBg.qml @@ -24,7 +24,12 @@ Item { // New logic: padding 4 if opaque (>1%), 0 if transparent readonly property real bgOpacity: Config.theme.srBarBg.opacity - readonly property int padding: bgOpacity < 0.01 ? 0 : 4 + readonly property bool barOpaque: bgOpacity >= 0.01 + readonly property real barScale: (Config.bar && Config.bar.scale !== undefined ? Config.bar.scale : 1.0) + readonly property int paddingTop: barOpaque ? Math.round((Config.bar.barPaddingTop !== undefined ? Config.bar.barPaddingTop : 4) * barScale) : 0 + readonly property int paddingRight: barOpaque ? Math.round((Config.bar.barPaddingRight !== undefined ? Config.bar.barPaddingRight : 4) * barScale) : 0 + readonly property int paddingBottom: barOpaque ? Math.round((Config.bar.barPaddingBottom !== undefined ? Config.bar.barPaddingBottom : 4) * barScale) : 0 + readonly property int paddingLeft: barOpaque ? Math.round((Config.bar.barPaddingLeft !== undefined ? Config.bar.barPaddingLeft : 4) * barScale) : 0 // Combined outer margin for screen/frame edges // This margin (4px) should only exist when bar is floating (!effectiveContainBar) @@ -56,7 +61,10 @@ Item { Item { id: contentContainer anchors.fill: parent - anchors.margins: root.padding + anchors.topMargin: root.paddingTop + anchors.rightMargin: root.paddingRight + anchors.bottomMargin: root.paddingBottom + anchors.leftMargin: root.paddingLeft } // Mascara combinada para la bar + corners diff --git a/modules/retroshell/files/modules/bar/BarContent.qml b/modules/retroshell/files/modules/bar/BarContent.qml index b88766de..b72a178b 100644 --- a/modules/retroshell/files/modules/bar/BarContent.qml +++ b/modules/retroshell/files/modules/bar/BarContent.qml @@ -166,7 +166,11 @@ Item { readonly property int frameOffset: (Config.bar && Config.bar.frameEnabled !== undefined ? Config.bar.frameEnabled : false) ? (Config.bar && Config.bar.frameThickness !== undefined ? Config.bar.frameThickness : 6) : 0 // Size derived from barBg properties - readonly property int barPadding: barBg.padding + readonly property real barScale: (Config.bar && Config.bar.scale !== undefined ? Config.bar.scale : 1.0) + readonly property int barPaddingTop: barBg.paddingTop + readonly property int barPaddingRight: barBg.paddingRight + readonly property int barPaddingBottom: barBg.paddingBottom + readonly property int barPaddingLeft: barBg.paddingLeft readonly property int topOuterMargin: (orientation === "vertical" || barPosition === "top") ? barBg.outerMargin : 0 readonly property int bottomOuterMargin: (orientation === "vertical" || barPosition === "bottom") ? barBg.outerMargin : 0 readonly property int leftOuterMargin: (orientation === "horizontal" || barPosition === "left") ? barBg.outerMargin : 0 @@ -174,9 +178,9 @@ Item { readonly property int contentImplicitWidth: orientation === "horizontal" ? (horizontalLoader.item && horizontalLoader.item.implicitWidth !== undefined ? horizontalLoader.item.implicitWidth : 0) : (verticalLoader.item && verticalLoader.item.implicitWidth !== undefined ? verticalLoader.item.implicitWidth : 0) readonly property int contentImplicitHeight: orientation === "horizontal" ? (horizontalLoader.item && horizontalLoader.item.implicitHeight !== undefined ? horizontalLoader.item.implicitHeight : 0) : (verticalLoader.item && verticalLoader.item.implicitHeight !== undefined ? verticalLoader.item.implicitHeight : 0) - - readonly property int barTargetWidth: orientation === "vertical" ? (contentImplicitWidth + 2 * barPadding) : 0 - readonly property int barTargetHeight: orientation === "horizontal" ? (contentImplicitHeight + 2 * barPadding) : 0 + + readonly property int barTargetWidth: orientation === "vertical" ? (Math.round(contentImplicitWidth) + barPaddingLeft + barPaddingRight) : 0 + readonly property int barTargetHeight: orientation === "horizontal" ? (Math.round(contentImplicitHeight) + barPaddingTop + barPaddingBottom) : 0 readonly property bool actualContainBar: (Config.bar && Config.bar.containBar !== undefined ? Config.bar.containBar : false) && (Config.bar && Config.bar.frameEnabled !== undefined ? Config.bar.frameEnabled : false) readonly property int totalBarWidth: barTargetWidth + @@ -357,11 +361,11 @@ Item { active: root.orientation === "horizontal" anchors.fill: parent sourceComponent: RowLayout { - spacing: 4 + spacing: 4 * root.barScale Repeater { model: root.barLeftOrder - delegate: Loader { + delegate: Item { required property string modelData required property int index property string side: "left" @@ -371,11 +375,21 @@ Item { Layout.fillWidth: root.orientation === "vertical" Layout.fillHeight: root.orientation === "horizontal" Layout.alignment: root.barItemAlignment(modelData) - sourceComponent: root.barItemFor(modelData) - onLoaded: { - if (item) { - item.startRadius = Qt.binding(function() { return root.barItemStartRadius(side, index); }); - item.endRadius = Qt.binding(function() { return root.barItemEndRadius(side, index, sideCount); }); + implicitWidth: itemLoader.implicitWidth * root.barScale + implicitHeight: itemLoader.implicitHeight * root.barScale + + Loader { + id: itemLoader + anchors.centerIn: parent + width: parent.width / root.barScale + height: parent.height / root.barScale + scale: root.barScale + sourceComponent: root.barItemFor(modelData) + onLoaded: { + if (item) { + item.startRadius = Qt.binding(function() { return root.barItemStartRadius(side, index); }); + item.endRadius = Qt.binding(function() { return root.barItemEndRadius(side, index, sideCount); }); + } } } } @@ -418,7 +432,7 @@ Item { Repeater { model: root.barRightOrder - delegate: Loader { + delegate: Item { required property string modelData required property int index property string side: "right" @@ -428,11 +442,21 @@ Item { Layout.fillWidth: root.orientation === "vertical" Layout.fillHeight: root.orientation === "horizontal" Layout.alignment: root.barItemAlignment(modelData) - sourceComponent: root.barItemFor(modelData) - onLoaded: { - if (item) { - item.startRadius = Qt.binding(function() { return root.barItemStartRadius(side, index); }); - item.endRadius = Qt.binding(function() { return root.barItemEndRadius(side, index, sideCount); }); + implicitWidth: itemLoader.implicitWidth * root.barScale + implicitHeight: itemLoader.implicitHeight * root.barScale + + Loader { + id: itemLoader + anchors.centerIn: parent + width: parent.width / root.barScale + height: parent.height / root.barScale + scale: root.barScale + sourceComponent: root.barItemFor(modelData) + onLoaded: { + if (item) { + item.startRadius = Qt.binding(function() { return root.barItemStartRadius(side, index); }); + item.endRadius = Qt.binding(function() { return root.barItemEndRadius(side, index, sideCount); }); + } } } } @@ -445,11 +469,11 @@ Item { active: root.orientation === "vertical" anchors.fill: parent sourceComponent: ColumnLayout { - spacing: 4 + spacing: 4 * root.barScale Repeater { model: root.barLeftOrder - delegate: Loader { + delegate: Item { required property string modelData required property int index property string side: "left" @@ -459,11 +483,21 @@ Item { Layout.fillWidth: root.orientation === "vertical" Layout.fillHeight: root.orientation === "horizontal" Layout.alignment: root.barItemAlignment(modelData) - sourceComponent: root.barItemFor(modelData) - onLoaded: { - if (item) { - item.startRadius = Qt.binding(function() { return root.barItemStartRadius(side, index); }); - item.endRadius = Qt.binding(function() { return root.barItemEndRadius(side, index, sideCount); }); + implicitWidth: itemLoader.implicitWidth * root.barScale + implicitHeight: itemLoader.implicitHeight * root.barScale + + Loader { + id: itemLoader + anchors.centerIn: parent + width: parent.width / root.barScale + height: parent.height / root.barScale + scale: root.barScale + sourceComponent: root.barItemFor(modelData) + onLoaded: { + if (item) { + item.startRadius = Qt.binding(function() { return root.barItemStartRadius(side, index); }); + item.endRadius = Qt.binding(function() { return root.barItemEndRadius(side, index, sideCount); }); + } } } } @@ -488,7 +522,7 @@ Item { Repeater { model: root.barRightOrder - delegate: Loader { + delegate: Item { required property string modelData required property int index property string side: "right" @@ -498,11 +532,21 @@ Item { Layout.fillWidth: root.orientation === "vertical" Layout.fillHeight: root.orientation === "horizontal" Layout.alignment: root.barItemAlignment(modelData) - sourceComponent: root.barItemFor(modelData) - onLoaded: { - if (item) { - item.startRadius = Qt.binding(function() { return root.barItemStartRadius(side, index); }); - item.endRadius = Qt.binding(function() { return root.barItemEndRadius(side, index, sideCount); }); + implicitWidth: itemLoader.implicitWidth * root.barScale + implicitHeight: itemLoader.implicitHeight * root.barScale + + Loader { + id: itemLoader + anchors.centerIn: parent + width: parent.width / root.barScale + height: parent.height / root.barScale + scale: root.barScale + sourceComponent: root.barItemFor(modelData) + onLoaded: { + if (item) { + item.startRadius = Qt.binding(function() { return root.barItemStartRadius(side, index); }); + item.endRadius = Qt.binding(function() { return root.barItemEndRadius(side, index, sideCount); }); + } } } } diff --git a/modules/retroshell/files/modules/bar/IntegratedDock.qml b/modules/retroshell/files/modules/bar/IntegratedDock.qml index 201644db..ebd8c77f 100644 --- a/modules/retroshell/files/modules/bar/IntegratedDock.qml +++ b/modules/retroshell/files/modules/bar/IntegratedDock.qml @@ -22,8 +22,10 @@ StyledRect { readonly property string dockPosition: Config.dock?.position ?? "center" // Compact sizing for integrated dock - readonly property int iconSize: 18 - readonly property int itemSpacing: 2 + readonly property real dockScale: Config.dock?.scale ?? 1.0 + readonly property int iconSize: Math.round((Config.dock?.iconSize ?? 18) * dockScale) + readonly property int itemSpacing: Math.round(2 * dockScale) + readonly property int compactSize: Math.round(36 * dockScale) visible: (Config.dock?.enabled ?? false) && isIntegrated @@ -40,11 +42,11 @@ StyledRect { enableShadow: Config.showBackground - implicitWidth: isVertical ? 36 : dockLayout.implicitWidth + 8 - implicitHeight: isVertical ? dockLayoutVertical.implicitHeight + 8 : 36 + implicitWidth: isVertical ? root.compactSize : dockLayout.implicitWidth + 8 + implicitHeight: isVertical ? dockLayoutVertical.implicitHeight + 8 : root.compactSize - Layout.maximumWidth: isVertical ? 36 : -1 - Layout.maximumHeight: isVertical ? -1 : 36 + Layout.maximumWidth: isVertical ? root.compactSize : -1 + Layout.maximumHeight: isVertical ? -1 : root.compactSize Flickable { id: flickable diff --git a/modules/retroshell/files/modules/dock/DockAppButton.qml b/modules/retroshell/files/modules/dock/DockAppButton.qml index 3132417b..85b793be 100644 --- a/modules/retroshell/files/modules/dock/DockAppButton.qml +++ b/modules/retroshell/files/modules/dock/DockAppButton.qml @@ -16,7 +16,7 @@ Button { required property var appToplevel property int lastFocused: -1 - property real iconSize: Config.dock?.iconSize ?? 40 + property real iconSize: (Config.dock?.iconSize ?? 40) * (Config.dock?.scale ?? 1.0) property real countDotWidth: 10 property real countDotHeight: 4 property string dockPosition: "bottom" @@ -35,6 +35,11 @@ Button { readonly property bool showIndicators: !isSeparator && (Config.dock?.showRunningIndicators ?? true) && appIsRunning readonly property int instanceCount: (isSeparator || !appToplevel) ? 0 : appToplevel.toplevelCount + // Drag-and-drop + readonly property string dragAppId: isSeparator ? "" : (appToplevel?.appId ?? "") + readonly property int dragPinnedIndex: TaskbarApps.pinnedIndex(dragAppId) + property alias appDragHandler: appDragHandler + enabled: !isSeparator implicitWidth: isSeparator ? (isVertical ? iconSize * 0.6 : 2) : iconSize + 8 implicitHeight: isSeparator ? (isVertical ? 2 : iconSize * 0.6) : iconSize + 8 @@ -212,6 +217,16 @@ Button { } } + // Drag to reorder / pin within the dock (axis-constrained by DockContent) + DragHandler { + id: appDragHandler + target: null + dragThreshold: 10 + enabled: !root.isSeparator + cursorShape: Qt.ClosedHandCursor + acceptedButtons: Qt.LeftButton + } + // Tooltip StyledToolTip { show: root.hovered && !root.isSeparator diff --git a/modules/retroshell/files/modules/dock/DockContent.qml b/modules/retroshell/files/modules/dock/DockContent.qml index a6b31db3..13da8d7b 100644 --- a/modules/retroshell/files/modules/dock/DockContent.qml +++ b/modules/retroshell/files/modules/dock/DockContent.qml @@ -117,7 +117,121 @@ Item { readonly property int totalMargin: root.windowSideMargin + root.edgeSideMargin readonly property int shadowSpace: 32 - readonly property int dockSize: Config.dock?.height ?? 56 + readonly property real dockScale: Config.dock?.scale ?? 1.0 + readonly property int dockSize: Math.round((Config.dock?.height ?? 56) * dockScale) + + // Drag-and-drop state + property string dragAppId: "" + property int dragPinnedIndex: -1 + property bool appDragging: false + property bool desktopDragging: false + readonly property bool showInsertion: appDragging || desktopDragging + property int insertionIndex: -1 + property point dragCursorPos: Qt.point(0, 0) + + readonly property real dragIconSize: Math.round((Config.dock?.iconSize ?? 40) * (Config.dock?.scale ?? 1.0)) + + function iconForAppId(appId) { + if (!appId) return "image-missing"; + const entry = DesktopEntries.heuristicLookup(appId); + if (entry && entry.icon) return entry.icon; + return AppSearch.guessIcon(appId); + } + + function appIdFromPath(path) { + if (!path) return ""; + const str = path.toString(); + if (!str.endsWith(".desktop")) return ""; + const base = str.substring(str.lastIndexOf("/") + 1); + return base.slice(0, -8); + } + + // Insertion index within the pinned region for a point in dockContainer coords + function insertionIndexAt(pos) { + var rep = root.isVertical ? appsRepeaterVertical : appsRepeaterHorizontal; + var layout = root.isVertical ? dockLayoutVertical : dockLayoutHorizontal; + var local = layout.mapFromItem(dockContainer, pos.x, pos.y); + var cursor = root.isVertical ? local.y : local.x; + var pinnedCount = (Config.pinnedApps?.apps || []).length; + for (var i = 0; i < pinnedCount; i++) { + var it = rep.itemAt(i); + if (!it) return i; + var origin = layout.mapFromItem(it, 0, 0); + var center = root.isVertical ? origin.y + it.height / 2 : origin.x + it.width / 2; + if (cursor < center) return i; + } + return pinnedCount; + } + + // Position (along the dock axis, in dockContainer coords) of the insertion boundary + function insertionBoundary(index) { + var rep = root.isVertical ? appsRepeaterVertical : appsRepeaterHorizontal; + var layout = root.isVertical ? dockLayoutVertical : dockLayoutHorizontal; + var pinnedCount = (Config.pinnedApps?.apps || []).length; + var clamped = Math.max(0, Math.min(index, pinnedCount)); + var p; + if (pinnedCount === 0) { + p = layout.mapToItem(dockContainer, 0, 0); + } else if (clamped >= pinnedCount) { + var last = rep.itemAt(pinnedCount - 1); + p = last.mapToItem(dockContainer, root.isVertical ? last.width / 2 : last.width, root.isVertical ? last.height : last.height / 2); + } else { + var item = rep.itemAt(clamped); + p = item.mapToItem(dockContainer, 0, root.isVertical ? 0 : item.height / 2); + } + return root.isVertical ? p.y : p.x; + } + + // Drops are only valid along the dock's own line + function dragWithinBounds(pos) { + if (root.isVertical) return pos.y >= -8 && pos.y <= dockContainer.height + 8; + return pos.x >= -8 && pos.x <= dockContainer.width + 8; + } + + function updateInsertion(pos) { + root.dragCursorPos = Qt.point(pos.x, pos.y); + root.insertionIndex = root.dragWithinBounds(pos) ? root.insertionIndexAt(pos) : -1; + } + + function beginAppDrag(button) { + root.dragAppId = button.dragAppId; + root.dragPinnedIndex = button.dragPinnedIndex; + root.appDragging = true; + var c = button.appDragHandler.centroid.position; + root.updateInsertion(dockContainer.mapFromItem(button, c.x, c.y)); + } + + function updateAppDrag(button) { + if (!root.appDragging) return; + var c = button.appDragHandler.centroid.position; + root.updateInsertion(dockContainer.mapFromItem(button, c.x, c.y)); + } + + function endAppDrag(button) { + if (!root.appDragging) return; + var c = button.appDragHandler.centroid.position; + var pos = dockContainer.mapFromItem(button, c.x, c.y); + if (root.dragWithinBounds(pos)) { + var computed = root.insertionIndexAt(pos); + var target = computed; + if (root.dragPinnedIndex >= 0 && root.dragPinnedIndex < computed) target = computed - 1; + if (root.dragPinnedIndex >= 0) { + TaskbarApps.reorderPinned(root.dragAppId, target); + } else { + TaskbarApps.pinApp(root.dragAppId, target); + } + } + root.dragAppId = ""; + root.dragPinnedIndex = -1; + root.appDragging = false; + root.insertionIndex = -1; + } + + function pinDroppedApp(appId, dropX, dropY) { + var pos = Qt.point(dropX, dropY); + if (!root.dragWithinBounds(pos)) return; + TaskbarApps.pinApp(appId, root.insertionIndexAt(pos)); + } implicitWidth: root.isVertical ? dockSize + totalMargin + shadowSpace * 2 : dockContent.implicitWidth + shadowSpace * 2 implicitHeight: root.isVertical ? dockContent.implicitHeight + shadowSpace * 2 : dockSize + totalMargin + shadowSpace * 2 @@ -404,7 +518,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top anchors.topMargin: (dockContent.implicitHeight - implicitHeight) / 2 - spacing: Config.dock?.spacing ?? 4 + spacing: Math.round((Config.dock?.spacing ?? 4) * root.dockScale) visible: !root.isVertical Loader { @@ -414,8 +528,8 @@ Item { sourceComponent: Button { id: pinButton - implicitWidth: 32 - implicitHeight: 32 + implicitWidth: Math.round(32 * root.dockScale) + implicitHeight: Math.round(32 * root.dockScale) background: StyledRect { visible: root.pinned || pinButton.hovered @@ -428,7 +542,7 @@ Item { contentItem: Text { text: Icons.pin font.family: Icons.font - font.pixelSize: 16 + font.pixelSize: Math.round(16 * root.dockScale) color: root.pinned ? Styling.srItem("primary") : Colors.overBackground horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter @@ -465,18 +579,34 @@ Item { sourceComponent: Separator { vert: true - implicitHeight: (Config.dock?.iconSize ?? 40) * 0.6 + implicitHeight: (Config.dock?.iconSize ?? 40) * 0.6 * root.dockScale } } Repeater { + id: appsRepeaterHorizontal model: TaskbarApps.apps DockAppButton { + id: appBtnH required property var modelData appToplevel: modelData Layout.alignment: Qt.AlignVCenter dockPosition: "bottom" + appDragHandler.onActiveChanged: { + if (appBtnH.appDragHandler.active) root.beginAppDrag(appBtnH); + else root.endAppDrag(appBtnH); + } + appDragHandler.onCentroidChanged: { + if (appBtnH.appDragHandler.active) root.updateAppDrag(appBtnH); + } + opacity: (root.appDragging && root.dragAppId === appBtnH.dragAppId) ? 0.35 : 1.0 + Behavior on opacity { + enabled: Config.animDuration > 0 + NumberAnimation { + duration: Config.animDuration / 2 + } + } } } @@ -487,7 +617,7 @@ Item { sourceComponent: Separator { vert: true - implicitHeight: (Config.dock?.iconSize ?? 40) * 0.6 + implicitHeight: (Config.dock?.iconSize ?? 40) * 0.6 * root.dockScale } } @@ -498,8 +628,8 @@ Item { sourceComponent: Button { id: overviewButton - implicitWidth: 32 - implicitHeight: 32 + implicitWidth: Math.round(32 * root.dockScale) + implicitHeight: Math.round(32 * root.dockScale) background: StyledRect { visible: overviewButton.hovered @@ -512,7 +642,7 @@ Item { contentItem: Text { text: Icons.overview font.family: Icons.font - font.pixelSize: 18 + font.pixelSize: Math.round(18 * root.dockScale) color: Colors.overBackground horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter @@ -538,7 +668,7 @@ Item { id: dockLayoutVertical anchors.horizontalCenter: parent.horizontalCenter y: dockContainer.cornerSize + (dockContent.implicitHeight - implicitHeight) / 2 - spacing: Config.dock?.spacing ?? 4 + spacing: Math.round((Config.dock?.spacing ?? 4) * root.dockScale) visible: root.isVertical Loader { @@ -548,8 +678,8 @@ Item { sourceComponent: Button { id: pinButtonV - implicitWidth: 32 - implicitHeight: 32 + implicitWidth: Math.round(32 * root.dockScale) + implicitHeight: Math.round(32 * root.dockScale) background: StyledRect { visible: root.pinned || pinButtonV.hovered @@ -562,7 +692,7 @@ Item { contentItem: Text { text: Icons.pin font.family: Icons.font - font.pixelSize: 16 + font.pixelSize: Math.round(16 * root.dockScale) color: root.pinned ? Styling.srItem("primary") : Colors.overBackground horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter @@ -599,18 +729,34 @@ Item { sourceComponent: Separator { vert: false - implicitWidth: (Config.dock?.iconSize ?? 40) * 0.6 + implicitWidth: (Config.dock?.iconSize ?? 40) * 0.6 * root.dockScale } } Repeater { + id: appsRepeaterVertical model: TaskbarApps.apps DockAppButton { + id: appBtnV required property var modelData appToplevel: modelData Layout.alignment: Qt.AlignHCenter dockPosition: root.position + appDragHandler.onActiveChanged: { + if (appBtnV.appDragHandler.active) root.beginAppDrag(appBtnV); + else root.endAppDrag(appBtnV); + } + appDragHandler.onCentroidChanged: { + if (appBtnV.appDragHandler.active) root.updateAppDrag(appBtnV); + } + opacity: (root.appDragging && root.dragAppId === appBtnV.dragAppId) ? 0.35 : 1.0 + Behavior on opacity { + enabled: Config.animDuration > 0 + NumberAnimation { + duration: Config.animDuration / 2 + } + } } } @@ -621,7 +767,7 @@ Item { sourceComponent: Separator { vert: false - implicitWidth: (Config.dock?.iconSize ?? 40) * 0.6 + implicitWidth: (Config.dock?.iconSize ?? 40) * 0.6 * root.dockScale } } @@ -632,8 +778,8 @@ Item { sourceComponent: Button { id: overviewButtonV - implicitWidth: 32 - implicitHeight: 32 + implicitWidth: Math.round(32 * root.dockScale) + implicitHeight: Math.round(32 * root.dockScale) background: StyledRect { visible: overviewButtonV.hovered @@ -646,7 +792,7 @@ Item { contentItem: Text { text: Icons.overview font.family: Icons.font - font.pixelSize: 18 + font.pixelSize: Math.round(18 * root.dockScale) color: Colors.overBackground horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter @@ -667,6 +813,113 @@ Item { } } + // Drop target for desktop icons (pin into dock) + DropArea { + id: dockDropArea + anchors.fill: parent + z: 4000 + keys: ["desktopIcon"] + enabled: root.reveal + + onEntered: { + root.desktopDragging = true; + root.updateInsertion(Qt.point(drag.x, drag.y)); + } + onPositionChanged: drag => { + root.updateInsertion(Qt.point(drag.x, drag.y)); + } + onExited: { + root.desktopDragging = false; + root.insertionIndex = -1; + } + onDropped: drop => { + root.desktopDragging = false; + root.insertionIndex = -1; + var src = drop.source; + if (src && src.isDesktopFile) { + var appId = root.appIdFromPath(src.path); + if (appId) { + root.pinDroppedApp(appId, drop.x, drop.y); + drop.acceptProposedAction(); + } + } + } + } + + // Drag ghost, clamped to the dock's own line + Item { + id: dragGhost + visible: root.appDragging + z: 5001 + width: root.dragIconSize + 8 + height: root.dragIconSize + 8 + x: root.isVertical ? (dockContainer.width - width) / 2 : root.dragCursorPos.x - width / 2 + y: root.isVertical ? root.dragCursorPos.y - height / 2 : (dockContainer.height - height) / 2 + scale: 1.1 + transformOrigin: Item.Center + opacity: 0.85 + + Behavior on x { + enabled: Config.animDuration > 0 + NumberAnimation { + duration: 80 + easing.type: Easing.OutCubic + } + } + Behavior on y { + enabled: Config.animDuration > 0 + NumberAnimation { + duration: 80 + easing.type: Easing.OutCubic + } + } + + StyledRect { + anchors.fill: parent + radius: Styling.radius(-2) + variant: "focus" + } + + Image { + anchors.centerIn: parent + width: root.dragIconSize + height: root.dragIconSize + source: "image://icon/" + root.iconForAppId(root.dragAppId) + sourceSize.width: root.dragIconSize * 2 + sourceSize.height: root.dragIconSize * 2 + fillMode: Image.PreserveAspectFit + mipmap: true + } + } + + // Insertion indicator along the dock's line + Rectangle { + id: insertionLine + visible: root.insertionIndex >= 0 && root.showInsertion + z: 5000 + width: root.isVertical ? Math.round(dockLayoutVertical.width) : 3 + height: root.isVertical ? 3 : Math.round(dockLayoutHorizontal.height) + x: root.isVertical ? (dockContainer.width - width) / 2 : root.insertionBoundary(root.insertionIndex) - width / 2 + y: root.isVertical ? root.insertionBoundary(root.insertionIndex) - height / 2 : (dockContainer.height - height) / 2 + radius: 2 + color: Styling.srItem("overprimary") + + Behavior on x { + enabled: Config.animDuration > 0 + NumberAnimation { + duration: 80 + easing.type: Easing.OutCubic + } + } + Behavior on y { + enabled: Config.animDuration > 0 + NumberAnimation { + duration: 80 + easing.type: Easing.OutCubic + } + } + } + // Unified outline canvas Canvas { id: outlineCanvas diff --git a/modules/retroshell/files/modules/services/TaskbarApps.qml b/modules/retroshell/files/modules/services/TaskbarApps.qml index e987fa25..eb061f2d 100644 --- a/modules/retroshell/files/modules/services/TaskbarApps.qml +++ b/modules/retroshell/files/modules/services/TaskbarApps.qml @@ -33,6 +33,51 @@ Singleton { Config.savePinnedApps(); } + // Index of an app in the pinned list, or -1 if not pinned + function pinnedIndex(appId) { + const pinnedApps = Config.pinnedApps?.apps || []; + const normalized = appId.toLowerCase(); + for (let i = 0; i < pinnedApps.length; i++) { + if (pinnedApps[i].toLowerCase() === normalized) return i; + } + return -1; + } + + // Pin an app at the given index (inserting if missing, moving if present) + function pinApp(appId, index) { + let pinnedApps = (Config.pinnedApps?.apps || []).slice(); + const normalized = appId.toLowerCase(); + const existing = pinnedApps.findIndex(id => id.toLowerCase() === normalized); + const clamped = Math.max(0, Math.min(index, existing >= 0 ? pinnedApps.length - 1 : pinnedApps.length)); + + if (existing >= 0) { + if (existing !== clamped) { + pinnedApps.splice(existing, 1); + pinnedApps.splice(clamped, 0, appId); + } + } else { + pinnedApps.splice(clamped, 0, appId); + } + + Config.pinnedApps.apps = pinnedApps; + Config.savePinnedApps(); + } + + // Reorder a pinned app to the given index within the pinned list + function reorderPinned(appId, toIndex) { + const normalized = appId.toLowerCase(); + let pinnedApps = (Config.pinnedApps?.apps || []).slice(); + const from = pinnedApps.findIndex(id => id.toLowerCase() === normalized); + if (from < 0) return; + + pinnedApps.splice(from, 1); + const clamped = Math.max(0, Math.min(toIndex, pinnedApps.length)); + pinnedApps.splice(clamped, 0, appId); + + Config.pinnedApps.apps = pinnedApps; + Config.savePinnedApps(); + } + // Get entry function getDesktopEntry(appId) { if (!appId) return null; diff --git a/modules/retroshell/files/modules/widgets/overview/Overview.qml b/modules/retroshell/files/modules/widgets/overview/Overview.qml index a0a7f844..00246c4e 100644 --- a/modules/retroshell/files/modules/widgets/overview/Overview.qml +++ b/modules/retroshell/files/modules/widgets/overview/Overview.qml @@ -46,6 +46,9 @@ Item { property var matchingWindows: [] property int selectedMatchIndex: 0 + // Keyboard navigation (arrow keys) — source of truth is the window address + property string keyboardSelectedAddress: "" + // Reset search state function resetSearch() { searchQuery = ""; @@ -54,7 +57,12 @@ Item { } // Update matching windows when search query or window list changes - onSearchQueryChanged: updateMatchingWindows() + onSearchQueryChanged: { + updateMatchingWindows(); + if (searchQuery.length === 0) { + keyboardInitialize(); + } + } onWindowListChanged: updateMatchingWindows() // Fuzzy match: checks if all characters of query appear in order in target @@ -167,9 +175,147 @@ Item { } function isWindowSelected(windowAddress) { - if (matchingWindows.length === 0 || selectedMatchIndex < 0) - return false; - return matchingWindows[selectedMatchIndex]?.address === windowAddress; + if (searchQuery.length > 0) { + if (matchingWindows.length === 0 || selectedMatchIndex < 0) + return false; + return matchingWindows[selectedMatchIndex]?.address === windowAddress; + } + return keyboardSelectedAddress === windowAddress; + } + + // Visible windows in this workspace group, with on-screen centers for spatial navigation + readonly property var navigationWindows: { + const minWs = workspaceGroup * workspacesShown; + const maxWs = (workspaceGroup + 1) * workspacesShown; + const monId = monitorId; + const result = []; + for (const win of windowList) { + if (!win) + continue; + const wsId = win?.workspace?.id; + if (!(wsId > minWs && wsId <= maxWs) || win.monitor !== monId) + continue; + + const colIndex = (wsId - 1) % columns; + const rowIndex = Math.floor(((wsId - 1) % workspacesShown) / columns); + const xOff = Math.round((workspaceImplicitWidth + workspacePadding + workspaceSpacing) * colIndex + workspacePadding / 2); + const yOff = Math.round((workspaceImplicitHeight + workspacePadding + workspaceSpacing) * rowIndex + workspacePadding / 2); + + let baseX = (win.at?.[0] || 0) - (monitorData?.x || 0); + if (barPosition === "left") + baseX -= barReserved; + let baseY = (win.at?.[1] || 0) - (monitorData?.y || 0); + if (barPosition === "top") + baseY -= barReserved; + + const x = Math.round(Math.max(baseX * scale, 0) + xOff); + const y = Math.round(Math.max(baseY * scale, 0) + yOff); + const w = Math.round((win.size?.[0] || 100) * scale); + const h = Math.round((win.size?.[1] || 100) * scale); + + result.push({ + windowData: win, + address: win.address, + row: rowIndex, + centerX: x + w / 2, + centerY: y + h / 2 + }); + } + return result; + } + + function keyboardInitialize() { + const list = navigationWindows; + if (list.length === 0) { + keyboardSelectedAddress = ""; + return; + } + const focused = AxctlService.focusedClient?.address; + if (focused && list.some(w => w.address === focused)) { + keyboardSelectedAddress = focused; + } else { + keyboardSelectedAddress = list[0].address; + } + } + + // For wrap-around: pick the extreme window along the opposite axis, tie-broken by proximity + function shouldWrapReplace(direction, cur, cand, currentBest) { + if (direction === "right" || direction === "left") { + if (cand.centerX === currentBest.centerX) + return Math.abs(cand.centerY - cur.centerY) < Math.abs(currentBest.centerY - cur.centerY); + if (direction === "right") + return cand.centerX < currentBest.centerX; + return cand.centerX > currentBest.centerX; + } + if (cand.centerY === currentBest.centerY) + return Math.abs(cand.centerX - cur.centerX) < Math.abs(currentBest.centerX - cur.centerX); + if (direction === "down") + return cand.centerY < currentBest.centerY; + return cand.centerY > currentBest.centerY; + } + + function keyboardMove(direction) { + const list = navigationWindows; + if (list.length === 0) + return; + + let currentIndex = -1; + if (keyboardSelectedAddress) { + currentIndex = list.findIndex(w => w.address === keyboardSelectedAddress); + } + if (currentIndex < 0) + currentIndex = 0; + const cur = list[currentIndex]; + + // Horizontal moves stay within the same grid row to avoid jumping rows + const isHorizontal = direction === "left" || direction === "right"; + const candidates = isHorizontal ? list.filter(w => w.row === cur.row) : list; + + let best = -1; + let bestDist = Infinity; + for (let i = 0; i < candidates.length; i++) { + const w = candidates[i]; + if (w.address === cur.address) + continue; + const dx = w.centerX - cur.centerX; + const dy = w.centerY - cur.centerY; + const inDirection = direction === "right" ? dx > 0 + : direction === "left" ? dx < 0 + : direction === "down" ? dy > 0 + : dy < 0; + if (!inDirection) + continue; + const dist = dx * dx + dy * dy; + if (dist < bestDist) { + bestDist = dist; + best = i; + } + } + + // Wrap: horizontal wraps within the same row, vertical wraps globally + if (best === -1 && candidates.length > 1) { + best = 0; + for (let i = 1; i < candidates.length; i++) { + if (shouldWrapReplace(direction, cur, candidates[i], candidates[best])) + best = i; + } + } + + // Only window in its row (or nothing to move to) — stay put + if (best === -1) + return; + + keyboardSelectedAddress = candidates[best].address; + } + + function activateSelectedWindow() { + const address = keyboardSelectedAddress; + if (!address) + return; + Visibilities.setActiveModule("", true); + Qt.callLater(() => { + HyprService.focusWindow(address); + }); } // Pre-calculate workspace dimensions once diff --git a/modules/retroshell/files/modules/widgets/overview/OverviewPopup.qml b/modules/retroshell/files/modules/widgets/overview/OverviewPopup.qml index dd62fe4f..8c7a76c6 100644 --- a/modules/retroshell/files/modules/widgets/overview/OverviewPopup.qml +++ b/modules/retroshell/files/modules/widgets/overview/OverviewPopup.qml @@ -192,7 +192,11 @@ PanelWindow { } onAccepted: { - if (overviewLoader.item) { + if (!overviewLoader.item) + return; + if (searchInput.text.length === 0) { + overviewLoader.item.activateSelectedWindow(); + } else { overviewLoader.item.navigateToSelectedWindow(); } } @@ -227,13 +231,21 @@ PanelWindow { onDownPressed: { if (overviewLoader.item) { - overviewLoader.item.selectNextMatch(); + if (searchInput.text.length === 0) { + overviewLoader.item.keyboardMove("down"); + } else { + overviewLoader.item.selectNextMatch(); + } } } onUpPressed: { if (overviewLoader.item) { - overviewLoader.item.selectPrevMatch(); + if (searchInput.text.length === 0) { + overviewLoader.item.keyboardMove("up"); + } else { + overviewLoader.item.selectPrevMatch(); + } } } @@ -249,30 +261,22 @@ PanelWindow { } onLeftPressed: { - if (searchInput.text.length === 0) { - const current = AxctlService.focusedWorkspace?.id || 1; - const prev = current - 1; - if (prev < 1) { - AxctlService.dispatch("workspace " + Config.workspaces.shown); + if (overviewLoader.item) { + if (searchInput.text.length === 0) { + overviewLoader.item.keyboardMove("left"); } else { - AxctlService.dispatch("workspace r-1"); + overviewLoader.item.selectPrevMatch(); } - } else if (overviewLoader.item) { - overviewLoader.item.selectPrevMatch(); } } onRightPressed: { - if (searchInput.text.length === 0) { - const current = AxctlService.focusedWorkspace?.id || 1; - const next = current + 1; - if (next > Config.workspaces.shown) { - AxctlService.dispatch("workspace 1"); + if (overviewLoader.item) { + if (searchInput.text.length === 0) { + overviewLoader.item.keyboardMove("right"); } else { - AxctlService.dispatch("workspace r+1"); + overviewLoader.item.selectNextMatch(); } - } else if (overviewLoader.item) { - overviewLoader.item.selectNextMatch(); } } } @@ -392,6 +396,7 @@ PanelWindow { searchInput.clear(); if (overviewLoader.item) { overviewLoader.item.resetSearch(); + overviewLoader.item.keyboardInitialize(); } searchInput.focusInput(); }); diff --git a/modules/retroshell/files/modules/widgets/overview/OverviewView.qml b/modules/retroshell/files/modules/widgets/overview/OverviewView.qml index 6576d823..d9800436 100644 --- a/modules/retroshell/files/modules/widgets/overview/OverviewView.qml +++ b/modules/retroshell/files/modules/widgets/overview/OverviewView.qml @@ -125,4 +125,22 @@ Item { overviewLoader.item.selectPrevMatch(); } } + + function keyboardInitialize() { + if (overviewLoader.item && overviewLoader.item.keyboardInitialize) { + overviewLoader.item.keyboardInitialize(); + } + } + + function keyboardMove(direction) { + if (overviewLoader.item && overviewLoader.item.keyboardMove) { + overviewLoader.item.keyboardMove(direction); + } + } + + function activateSelectedWindow() { + if (overviewLoader.item && overviewLoader.item.activateSelectedWindow) { + overviewLoader.item.activateSelectedWindow(); + } + } } diff --git a/modules/retroshell/files/modules/widgets/overview/OverviewWindow.qml b/modules/retroshell/files/modules/widgets/overview/OverviewWindow.qml index 9e26a8e4..e12175b3 100644 --- a/modules/retroshell/files/modules/widgets/overview/OverviewWindow.qml +++ b/modules/retroshell/files/modules/widgets/overview/OverviewWindow.qml @@ -145,7 +145,7 @@ Item { anchors.fill: parent radius: root.calculatedRadius color: pressed ? Colors.surfaceBright : hovered ? Colors.surface : Colors.background - border.color: root.isSearchSelected ? Colors.tertiary : root.isSearchMatch ? Styling.srItem("overprimary") : Styling.srItem("overprimary") + border.color: Styling.srItem("overprimary") border.width: root.isSearchSelected ? 3 : root.isSearchMatch ? 2 : (hovered ? 2 : 0) visible: !windowPreview.hasContent || !Config.performance.windowPreview @@ -185,7 +185,7 @@ Item { anchors.fill: parent radius: root.calculatedRadius color: pressed ? Qt.rgba(Colors.surfaceContainerHighest.r, Colors.surfaceContainerHighest.g, Colors.surfaceContainerHighest.b, 0.5) : hovered ? Qt.rgba(Colors.surfaceContainer.r, Colors.surfaceContainer.g, Colors.surfaceContainer.b, 0.2) : "transparent" - border.color: root.isSearchSelected ? Colors.tertiary : root.isSearchMatch ? Styling.srItem("overprimary") : Styling.srItem("overprimary") + border.color: Styling.srItem("overprimary") border.width: root.isSearchSelected ? 3 : root.isSearchMatch ? 2 : (hovered ? 2 : 0) visible: windowPreview.hasContent && Config.performance.windowPreview z: 5 @@ -205,7 +205,7 @@ Item { anchors.margins: -4 radius: root.calculatedRadius + 4 color: "transparent" - border.color: Colors.tertiary + border.color: Styling.srItem("overprimary") border.width: 2 opacity: 0.6 z: -1 diff --git a/modules/retroshell/files/modules/widgets/overview/ScrollingOverview.qml b/modules/retroshell/files/modules/widgets/overview/ScrollingOverview.qml index 66b6a465..4ace472e 100644 --- a/modules/retroshell/files/modules/widgets/overview/ScrollingOverview.qml +++ b/modules/retroshell/files/modules/widgets/overview/ScrollingOverview.qml @@ -44,13 +44,21 @@ Item { property var matchingWindows: [] property int selectedMatchIndex: 0 + // Keyboard navigation (arrow keys) — source of truth is the window address + property string keyboardSelectedAddress: "" + function resetSearch() { searchQuery = ""; matchingWindows = []; selectedMatchIndex = 0; } - onSearchQueryChanged: updateMatchingWindows() + onSearchQueryChanged: { + updateMatchingWindows(); + if (searchQuery.length === 0) { + keyboardInitialize(); + } + } onWindowListChanged: updateMatchingWindows() function fuzzyMatch(query, target) { @@ -147,9 +155,154 @@ Item { } function isWindowSelected(windowAddress) { - if (matchingWindows.length === 0 || selectedMatchIndex < 0) - return false; - return matchingWindows[selectedMatchIndex]?.address === windowAddress; + if (searchQuery.length > 0) { + if (matchingWindows.length === 0 || selectedMatchIndex < 0) + return false; + return matchingWindows[selectedMatchIndex]?.address === windowAddress; + } + return keyboardSelectedAddress === windowAddress; + } + + // All windows on this monitor with on-screen centers (in flickable content coords) + readonly property var navigationWindows: { + const monId = monitorId; + const rowHeight = workspaceRowHeight; + const viewportOffset = workspaceWidth / 3; + const result = []; + for (let ws = 1; ws <= totalWorkspaces; ws++) { + for (const win of windowList) { + if (!win) + continue; + if ((win.workspace ? win.workspace.id : null) !== ws || win.monitor !== monId) + continue; + + let baseX = ((win.at && win.at[0] !== undefined ? win.at[0] : 0) || 0) - ((monitorData && monitorData.x !== undefined ? monitorData.x : 0) || 0); + if (barPosition === "left") + baseX -= barReserved; + const x = (baseX * scale) + viewportOffset; + + let baseY = ((win.at && win.at[1] !== undefined ? win.at[1] : 0) || 0) - ((monitorData && monitorData.y !== undefined ? monitorData.y : 0) || 0); + if (barPosition === "top") + baseY -= barReserved; + const y = Math.max(baseY * scale, 0) + (ws - 1) * rowHeight; + + const w = Math.round(((win.size && win.size[0] !== undefined ? win.size[0] : 100) || 100) * scale); + const h = Math.round(((win.size && win.size[1] !== undefined ? win.size[1] : 100) || 100) * scale); + + result.push({ + windowData: win, + address: win.address, + workspaceId: ws, + row: ws, + centerX: x + w / 2, + centerY: y + h / 2 + }); + } + } + return result; + } + + function scrollToWorkspace(id) { + const targetY = (id - 1) * workspaceRowHeight; + const centeredY = targetY - (workspaceFlickable.height - workspaceHeight) / 2; + workspaceFlickable.contentY = Math.max(0, Math.min(centeredY, workspaceFlickable.contentHeight - workspaceFlickable.height)); + } + + function keyboardInitialize() { + const list = navigationWindows; + if (list.length === 0) { + keyboardSelectedAddress = ""; + return; + } + let chosen = null; + const focused = AxctlService.focusedClient?.address; + if (focused) + chosen = list.find(w => w.address === focused) || null; + if (!chosen) + chosen = list[0]; + keyboardSelectedAddress = chosen.address; + scrollToWorkspace(chosen.workspaceId); + } + + // For wrap-around: pick the extreme window along the opposite axis, tie-broken by proximity + function shouldWrapReplace(direction, cur, cand, currentBest) { + if (direction === "right" || direction === "left") { + if (cand.centerX === currentBest.centerX) + return Math.abs(cand.centerY - cur.centerY) < Math.abs(currentBest.centerY - cur.centerY); + if (direction === "right") + return cand.centerX < currentBest.centerX; + return cand.centerX > currentBest.centerX; + } + if (cand.centerY === currentBest.centerY) + return Math.abs(cand.centerX - cur.centerX) < Math.abs(currentBest.centerX - cur.centerX); + if (direction === "down") + return cand.centerY < currentBest.centerY; + return cand.centerY > currentBest.centerY; + } + + function keyboardMove(direction) { + const list = navigationWindows; + if (list.length === 0) + return; + + let currentIndex = -1; + if (keyboardSelectedAddress) { + currentIndex = list.findIndex(w => w.address === keyboardSelectedAddress); + } + if (currentIndex < 0) + currentIndex = 0; + const cur = list[currentIndex]; + + // Horizontal moves stay within the same workspace to avoid jumping rows + const isHorizontal = direction === "left" || direction === "right"; + const candidates = isHorizontal ? list.filter(w => w.row === cur.row) : list; + + let best = -1; + let bestDist = Infinity; + for (let i = 0; i < candidates.length; i++) { + const w = candidates[i]; + if (w.address === cur.address) + continue; + const dx = w.centerX - cur.centerX; + const dy = w.centerY - cur.centerY; + const inDirection = direction === "right" ? dx > 0 + : direction === "left" ? dx < 0 + : direction === "down" ? dy > 0 + : dy < 0; + if (!inDirection) + continue; + const dist = dx * dx + dy * dy; + if (dist < bestDist) { + bestDist = dist; + best = i; + } + } + + // Wrap: horizontal wraps within the same workspace, vertical wraps globally + if (best === -1 && candidates.length > 1) { + best = 0; + for (let i = 1; i < candidates.length; i++) { + if (shouldWrapReplace(direction, cur, candidates[i], candidates[best])) + best = i; + } + } + + // Only window in its workspace (or nothing to move to) — stay put + if (best === -1) + return; + + keyboardSelectedAddress = candidates[best].address; + scrollToWorkspace(candidates[best].workspaceId); + } + + function activateSelectedWindow() { + const address = keyboardSelectedAddress; + if (!address) + return; + Visibilities.setActiveModule("", true); + Qt.callLater(() => { + AxctlService.dispatch(`focuswindow address:${address}`); + }); } // Calculate workspace dimensions diff --git a/modules/retroshell/files/modules/widgets/overview/ScrollingWorkspace.qml b/modules/retroshell/files/modules/widgets/overview/ScrollingWorkspace.qml index 3047e559..3887bf71 100644 --- a/modules/retroshell/files/modules/widgets/overview/ScrollingWorkspace.qml +++ b/modules/retroshell/files/modules/widgets/overview/ScrollingWorkspace.qml @@ -391,7 +391,7 @@ Item { anchors.fill: parent radius: windowDelegate.calculatedRadius color: windowDelegate.dragging ? Colors.surfaceBright : windowDelegate.hovered ? Colors.surface : Colors.background - border.color: windowDelegate.isSelected ? Colors.tertiary : windowDelegate.isMatched ? Styling.srItem("overprimary") : Styling.srItem("overprimary") + border.color: Styling.srItem("overprimary") border.width: windowDelegate.isSelected ? 3 : windowDelegate.isMatched ? 2 : (windowDelegate.hovered ? 2 : 0) visible: !Config.performance.windowPreview @@ -424,7 +424,7 @@ Item { anchors.fill: parent radius: windowDelegate.calculatedRadius color: windowDelegate.dragging ? Qt.rgba(Colors.surfaceContainerHighest.r, Colors.surfaceContainerHighest.g, Colors.surfaceContainerHighest.b, 0.5) : windowDelegate.hovered ? Qt.rgba(Colors.surfaceContainer.r, Colors.surfaceContainer.g, Colors.surfaceContainer.b, 0.2) : "transparent" - border.color: windowDelegate.isSelected ? Colors.tertiary : windowDelegate.isMatched ? Styling.srItem("overprimary") : Styling.srItem("overprimary") + border.color: Styling.srItem("overprimary") border.width: windowDelegate.isSelected ? 3 : windowDelegate.isMatched ? 2 : (windowDelegate.hovered ? 2 : 0) visible: Config.performance.windowPreview && (windowDelegate.hovered || windowDelegate.dragging || windowDelegate.isMatched || windowDelegate.isSelected) z: 5 diff --git a/scripts/fans_core.sh b/scripts/fans_core.sh index 066c5e75..0fddbdb1 100755 --- a/scripts/fans_core.sh +++ b/scripts/fans_core.sh @@ -4,21 +4,14 @@ source "$RETRO_DIR/lib/variable.sh" source "$RETRO_DIR/scripts/log_core.sh" rx_log_register "fans" -if [[ $EUID -eq 0 ]]; then - SUDO_CMD="" -else - SUDO_CMD="sudo" -fi +SUDO_CMD="" +[[ $EUID -ne 0 ]] && SUDO_CMD="sudo" -_has_liquidctl() { - command -v liquidctl &>/dev/null && return 0 - return 1 -} - -_has_sensors() { - command -v sensors &>/dev/null && return 0 - return 1 +_rx_home() { + [[ $EUID -eq 0 && -n $SUDO_USER ]] && echo "/home/$SUDO_USER" || echo "$HOME" } +_CONFIG_DIR="${XDG_CONFIG_HOME:-$(_rx_home)/.config}/retro" +_CONFIG_FILE="${_CONFIG_DIR}/fan_config.json" _hwmon_dirs() { for d in /sys/class/hwmon/hwmon*; do @@ -26,491 +19,340 @@ _hwmon_dirs() { done } -_has_pwm() { - local dir="$1" - for f in "$dir"/pwm*; do - [[ -f $f ]] && return 0 - done - return 1 -} - -_is_pwm_writable() { - local pwm="$1" - [[ -w $pwm ]] && return 0 - return 1 -} - -_pwm_to_pct() { - local val="$1" - echo "$(( (val * 100 + 127) / 255 ))" -} +_has_liquidctl() { command -v liquidctl &>/dev/null; } +_has_sensors() { command -v sensors &>/dev/null; } +_has_acpi_platform_profile() { [[ -f /sys/firmware/acpi/platform_profile_choices ]]; } +_pwm_to_pct() { echo "$(( ($1 * 100 + 127) / 255 ))"; } _pct_to_pwm() { - local pct="$1" - echo "$(( (pct * 255 + 50) / 100 ))" + local p=$1 + [[ $p -gt 100 ]] && p=100; [[ $p -lt 0 ]] && p=0 + echo "$(( (p * 255 + 50) / 100 ))" } -_detect_engine() { - _has_liquidctl && { echo "liquidctl"; return; } - _has_sensors && { echo "lm-sensors"; return; } - echo "sysfs" +_config_read() { + [[ -f $_CONFIG_FILE ]] && cat "$_CONFIG_FILE" || echo '{"master":false,"profile":"balanced","fans":{}}' } - -_get_liquidctl_devices() { - _has_liquidctl || return 1 - $SUDO_CMD liquidctl list 2>/dev/null | grep -oP 'Device #\d+:.*' | sed 's/Device #\d+: //' +_config_write() { + mkdir -p "$_CONFIG_DIR" + echo "$1" | jq '.' > "${_CONFIG_FILE}.tmp" && mv "${_CONFIG_FILE}.tmp" "$_CONFIG_FILE" } - -_get_liquidctl_status() { - _has_liquidctl || return 1 - $SUDO_CMD liquidctl status --json 2>/dev/null +_config_set_fan() { + local id="$1" mode="$2" curve="$3" speed="$4" + _config_write "$(_config_read | jq --arg id "$id" --arg m "$mode" --arg c "$curve" --argjson s "$speed" '.fans[$id] = {mode:$m,curve:$c,speed:$s}')" } - -_get_liquidctl_fans() { - local status - status=$(_get_liquidctl_status) - [[ -z $status ]] && return - - local count=0 - while true; do - local key="fan${count} speed" - local label_key="fan${count} label" - local speed=$(echo "$status" | grep -oP "\"${key}\" *: *\K[0-9]+" 2>/dev/null || echo "") - local label=$(echo "$status" | grep -oP "\"${label_key}\" *: *\"\K[^\"]+" 2>/dev/null || echo "Fan $count") - [[ -z $speed ]] && break - local temp=$(echo "$status" | grep -oP '"liquid temperature" *: *\K[0-9.]+' 2>/dev/null || echo "0") - echo "liquidctl|${label}|${speed}|0|${temp}C" - count=$((count + 1)) - done +_config_set_master() { + _config_write "$(_config_read | jq --argjson v "$1" '.master = $v')" } - -_liquidctl_set_speed() { - local fan="$1" - local pct="$2" - _has_liquidctl || return 1 - $SUDO_CMD liquidctl set "${fan}" speed "${pct}" 2>/dev/null && rx_log_file "info" "liquidctl: ${fan} set to ${pct}%" +_config_apply_profile_to_fans() { + local c + c=$(_get_default_curve "$1") + _config_write "$(_config_read | jq --arg c "$c" --arg p "$1" '(.profile=$p) | (.fans |= with_entries(.value.curve=$c | .value.mode="curve"))')" } -_liquidctl_reset() { - _has_liquidctl || return 1 - $SUDO_CMD liquidctl initialize 2>/dev/null - rx_log_file "info" "liquidctl: all devices reset to defaults" +_get_default_curve() { + case "${1:-balanced}" in + quiet) echo "30:20,50:40,70:60,85:80" ;; + balanced) echo "30:30,50:50,70:75,85:100" ;; + performance) echo "30:40,50:70,70:90,85:100" ;; + *) echo "30:30,50:50,70:75,85:100" ;; + esac } _parse_curve() { - local curve="$1" - local target_temp="$2" - local prev_pct=30 - local prev_temp=0 - - IFS=',' read -ra points <<<"$curve" + local curve="$1" target_temp="$2" + local prev_pct=30 prev_temp=0 temp pct range pct_range delta result + [[ -z $curve ]] && echo "30" && return + IFS=',' read -ra points <<< "$curve" for point in "${points[@]}"; do - local temp="${point%%:*}" - local pct="${point#*:}" + temp="${point%%:*}"; pct="${point#*:}" if [[ $target_temp -ge $temp ]]; then - prev_pct="$pct" - prev_temp="$temp" + prev_pct="$pct"; prev_temp="$temp" else - if [[ $target_temp -le $prev_temp ]]; then - echo "$prev_pct" - return - fi - local range=$((temp - prev_temp)) + [[ $target_temp -le $prev_temp ]] && echo "$prev_pct" && return + range=$((temp - prev_temp)) [[ $range -eq 0 ]] && echo "$prev_pct" && return - local pct_range=$((pct - prev_pct)) - local delta=$((target_temp - prev_temp)) - local result=$((prev_pct + (pct_range * delta) / range)) - echo "$result" - return + pct_range=$((pct - prev_pct)) + delta=$((target_temp - prev_temp)) + result=$((prev_pct + (pct_range * delta) / range)) + [[ $result -gt 100 ]] && result=100 + [[ $result -lt 0 ]] && result=0 + echo "$result"; return fi done echo "$prev_pct" } -_sysfs_fans() { +_get_cpu_temp() { + local max=0 hw hw_name f val for hw in $(_hwmon_dirs); do - local hw_name=$(basename "$hw") + hw_name=$(cat "$hw/name" 2>/dev/null || echo "") + case "$hw_name" in + coretemp|k10temp|zenpower|it87*|nct*) + for f in "$hw"/temp*_input; do + [[ -f $f ]] || continue + val=$(cat "$f" 2>/dev/null || echo "0") + val=$((val / 1000)) + [[ $val -gt $max ]] && max=$val + done ;; + esac + done + if [[ $max -eq 0 ]]; then + for f in /sys/class/hwmon/hwmon*/temp*_input; do + [[ -f $f ]] || continue + val=$(cat "$f" 2>/dev/null || echo "0"); val=$((val / 1000)) + [[ $val -gt $max ]] && max=$val + done + fi + echo "$max" +} +_sysfs_fans() { + local hw hw_name fi f rpm label pf pv pct temp tval w + for hw in $(_hwmon_dirs); do + hw_name=$(cat "$hw/name" 2>/dev/null || echo "") for f in "$hw"/fan*_input; do [[ -f $f ]] || continue - local fan_idx - fan_idx=$(echo "$f" | grep -oP 'fan\K[0-9]+') - local rpm + fi=$(echo "$f" | grep -oP 'fan\K[0-9]+') rpm=$(cat "$f" 2>/dev/null || echo "0") - local label - label=$(cat "$hw/fan${fan_idx}_label" 2>/dev/null || echo "") - [[ -z $label ]] && label="Fan ${fan_idx}" - label="${hw_name}/${label}" - local pwm_file="$hw/pwm${fan_idx}" - local pwm_val=0 - local pct=0 - if [[ -f $pwm_file ]]; then - pwm_val=$(cat "$pwm_file" 2>/dev/null || echo "0") - pct=$(_pwm_to_pct "$pwm_val") - fi - if [[ $pct -eq 0 && $rpm -gt 0 ]]; then - local max_rpm=5000 - pct=$(( (rpm * 100 + max_rpm / 2) / max_rpm )) - [[ $pct -gt 100 ]] && pct=100 + label=$(cat "$hw/fan${fi}_label" 2>/dev/null || echo "Fan ${fi}") + pf="$hw/pwm${fi}"; pv=0; pct=0 + if [[ -f $pf ]]; then + pv=$(cat "$pf" 2>/dev/null || echo "0") + pct=$(_pwm_to_pct "$pv") fi - local temp="0C" + [[ $pct -eq 0 && $rpm -gt 0 ]] && { pct=$(( (rpm*100+2500)/5000 )); [[ $pct -gt 100 ]] && pct=100; } + temp="0C" for tf in "$hw"/temp*_input; do [[ -f $tf ]] || continue - local tval tval=$(cat "$tf" 2>/dev/null || echo "0") - local tlabel - tlabel=$(cat "${tf%_input}_label" 2>/dev/null || echo "sensor") - temp="$((tval / 1000))C ($tlabel)" - break + temp="$((tval / 1000))C"; break done - local writable="no" - _is_pwm_writable "$pwm_file" && writable="yes" - echo "${hw_name}|${label}|${rpm}|${pct}|${temp}|${writable}" + w="no" + [[ -f "$hw/pwm${fi}_enable" && -w "$hw/pwm${fi}_enable" ]] && w="yes" + echo "${hw_name}|${label}|${rpm}|${pct}|${temp}|${w}|$(basename "$hw")|${fi}" done done } _sysfs_temps() { + local hw hw_name f ti tv tl for hw in $(_hwmon_dirs); do - local hw_name=$(basename "$hw") + hw_name=$(cat "$hw/name" 2>/dev/null || echo "") for f in "$hw"/temp*_input; do [[ -f $f ]] || continue - local tidx - tidx=$(echo "$f" | grep -oP 'temp\K[0-9]+') - local tval - tval=$(cat "$f" 2>/dev/null || echo "0") - local tlabel - tlabel=$(cat "$hw/temp${tidx}_label" 2>/dev/null || echo "Temp ${tidx}") - tlabel="${hw_name}/${tlabel}" - echo "${hw_name}|${tlabel}|$((tval / 1000))C" + ti=$(echo "$f" | grep -oP 'temp\K[0-9]+') + tv=$(cat "$f" 2>/dev/null || echo "0") + tl=$(cat "$hw/temp${ti}_label" 2>/dev/null || echo "Temp ${ti}") + echo "${hw_name}|${tl}|$((tv / 1000))C" done done } _sysfs_set_speed() { - local fan_name="$1" - local pct="$2" - local pwm_val - pwm_val=$(_pct_to_pwm "$pct") - - for hw in $(_hwmon_dirs); do - local hw_name=$(basename "$hw") - for f in "$hw"/fan*_input; do - [[ -f $f ]] || continue - local idx - idx=$(echo "$f" | grep -oP 'fan\K[0-9]+') - local raw_label - raw_label=$(cat "$hw/fan${idx}_label" 2>/dev/null || echo "Fan ${idx}") - local full_label="${hw_name}/${raw_label}" - if [[ "$raw_label" == "$fan_name" || "$full_label" == "$fan_name" || "$raw_label" == "${fan_name//_/ }" || "$full_label" == "${fan_name//_/ }" || "$raw_label" == "${fan_name// /_}" || "$full_label" == "${fan_name// /_}" ]]; then - local pwm_file="$hw/pwm${idx}" - if [[ -f $pwm_file ]]; then - $SUDO_CMD bash -c "echo 1 > '$hw/pwm${idx}_enable' 2>/dev/null; echo ${pwm_val} > '$pwm_file' 2>/dev/null" 2>/dev/null && { - rx_log_file "info" "sysfs: ${fan_name} set to ${pct}% (PWM ${pwm_val})" - return 0 - } - fi - return 1 - fi - done - done - return 1 + local fid="$1" pct="$2" + local hn fn hw pf pv ef + hn=$(echo "$fid" | grep -oP 'hwmon\K[0-9]+') + fn=$(echo "$fid" | grep -oP 'fan\K[0-9]+$') + [[ -z $hn || -z $fn ]] && return 1 + hw="/sys/class/hwmon/hwmon${hn}"; [[ ! -d $hw ]] && return 1 + pf="${hw}/pwm${fn}"; ef="${hw}/pwm${fn}_enable" + [[ -f $pf ]] || return 1 + pv=$(_pct_to_pwm "$pct") + echo 1 > "$ef" 2>/dev/null; echo "$pv" > "$pf" 2>/dev/null } -_sysfs_set_curve() { - local fan_name="$1" - local curve="$2" - - rx_log_file "info" "sysfs: set_curve called for '${fan_name}' with curve '${curve}'" - - for hw in $(_hwmon_dirs); do - local hw_name=$(basename "$hw") - for f in "$hw"/fan*_input; do - [[ -f $f ]] || continue - local idx - idx=$(echo "$f" | grep -oP 'fan\K[0-9]+') - local raw_label - raw_label=$(cat "$hw/fan${idx}_label" 2>/dev/null || echo "Fan ${idx}") - local full_label="${hw_name}/${raw_label}" - if [[ "$raw_label" == "$fan_name" || "$full_label" == "$fan_name" || "$raw_label" == "${fan_name//_/ }" || "$full_label" == "${fan_name//_/ }" || "$raw_label" == "${fan_name// /_}" || "$full_label" == "${fan_name// /_}" ]]; then - local current_temp - current_temp=$(_get_coretemp) - [[ -z $current_temp || $current_temp -eq 0 ]] && current_temp=30 +_sysfs_set_auto() { + local fid="$1" hn fn ef + hn=$(echo "$fid" | grep -oP 'hwmon\K[0-9]+') + fn=$(echo "$fid" | grep -oP 'fan\K[0-9]+$') + [[ -z $hn || -z $fn ]] && return 1 + ef="/sys/class/hwmon/hwmon${hn}/pwm${fn}_enable" + [[ -f $ef ]] || return 1 + echo 2 > "$ef" 2>/dev/null +} - local target_pct - target_pct=$(_parse_curve "$curve" "$current_temp") - local pwm_val - pwm_val=$(_pct_to_pwm "$target_pct") +_sysfs_reset_all() { local hw f; for hw in $(_hwmon_dirs); do for f in "$hw"/pwm*_enable; do [[ -f $f ]] && echo 2 > "$f" 2>/dev/null; done; done; } - local pwm_ctrl="$hw/pwm${idx}" - if [[ -f $pwm_ctrl ]]; then - rx_log_file "info" "sysfs: ${fan_name} → ${target_pct}% (${current_temp}C) at ${pwm_ctrl}" - $SUDO_CMD bash -c "echo 1 > '$hw/pwm${idx}_enable' 2>/dev/null; echo ${pwm_val} > '$pwm_ctrl' 2>/dev/null" 2>/dev/null - local rc=$? - if [[ $rc -eq 0 ]]; then - rx_log_file "success" "sysfs: ${fan_name} set ${target_pct}% (${current_temp}C, PWM ${pwm_val})" - return 0 - else - rx_log_file "error" "sysfs: ${fan_name} PWM write failed (rc=${rc})" - fi - fi - return 1 - fi - done - done - return 1 -} +_has_writable_pwm() { local hw f; for hw in $(_hwmon_dirs); do for f in "$hw"/pwm*_enable; do [[ -w $f ]] && return 0; done; done; return 1; } -_sysfs_reset() { - for hw in $(_hwmon_dirs); do - for f in "$hw"/pwm*_enable; do - [[ -f $f ]] || continue - $SUDO_CMD bash -c "echo 2 > '$f'" 2>/dev/null - done - done - rx_log_file "info" "sysfs: all fans reset to auto mode" +_detect_engine() { + if _has_liquidctl; then + local d; d=$(liquidctl list 2>/dev/null | grep -oP 'Device #\d+' || true) + [[ -n $d ]] && { echo "liquidctl"; return; } + fi + _has_writable_pwm && { echo "sysfs"; return; } + _has_acpi_platform_profile && { echo "acpi_platform"; return; } + echo "sysfs" } -_get_coretemp() { - local max_temp=0 - for f in /sys/class/hwmon/hwmon*/temp*_input; do - [[ -f $f ]] || continue - local val - val=$(cat "$f" 2>/dev/null || echo "0") - val=$((val / 1000)) - [[ $val -gt $max_temp ]] && max_temp=$val - done - echo "$max_temp" +_acpi_get_choices() { cat /sys/firmware/acpi/platform_profile_choices 2>/dev/null || echo ""; } +_acpi_set_profile() { + [[ ! -w /sys/firmware/acpi/platform_profile ]] && return 1 + echo "$1" > /sys/firmware/acpi/platform_profile 2>/dev/null } -_get_default_curve() { - local profile="${1:-balanced}" - case "$profile" in - quiet) echo "30:20,50:40,70:60,85:80" ;; - balanced) echo "30:30,50:50,70:75,85:100" ;; - performance) echo "30:40,50:70,70:90,85:100" ;; - *) echo "30:30,50:50,70:75,85:100" ;; - esac +_output_json() { + local engine cpu_temp master profile pwm_avail acpi_choices acpi_current + engine=$(_detect_engine) + cpu_temp=$(_get_cpu_temp) + master=$(echo "$(_config_read)" | jq -r '.master') + profile=$(echo "$(_config_read)" | jq -r '.profile // "balanced"') + pwm_avail="false"; _has_writable_pwm && pwm_avail="true" + acpi_choices=""; acpi_current="" + _has_acpi_platform_profile && { acpi_choices=$(_acpi_get_choices); acpi_current=$(cat /sys/firmware/acpi/platform_profile 2>/dev/null || echo ""); } + + local fans_json="[]" + local fans_data; fans_data=$(_sysfs_fans) + if [[ -n $fans_data ]]; then + local tmpf; tmpf=$(mktemp) + while IFS='|' read -r hw_name label rpm pct temp writable hw_id fan_idx; do + [[ -z $hw_name ]] && continue + local fid="${hw_id}_${hw_name}_fan${fan_idx}" + local cfg_mode="auto" cfg_curve="" cfg_speed=100 + local fc; fc=$(echo "$(_config_read)" | jq -r ".fans[\"$fid\"] // empty") + if [[ -n $fc ]]; then + cfg_mode=$(echo "$fc" | jq -r '.mode // "auto"') + cfg_curve=$(echo "$fc" | jq -r '.curve // ""') + cfg_speed=$(echo "$fc" | jq -r '.speed // 100') + fi + jq -n --arg id "$fid" --arg label "$label" --arg hw "$hw_name" \ + --arg hid "$hw_id" --argjson fi "$fan_idx" --argjson rpm "$rpm" \ + --argjson pct "$pct" --arg temp "$temp" --arg w "$writable" \ + --arg mode "$cfg_mode" --arg curve "$cfg_curve" --argjson speed "$cfg_speed" \ + '{id:$id,label:$label,hwmon:$hw,hw_id:$hid,fan_idx:$fi,rpm:$rpm,pct:$pct,temp:$temp,writable:$w,mode:$mode,curve:$curve,speed:$speed}' + done <<< "$fans_data" > "$tmpf" + fans_json=$(jq -s '.' "$tmpf") + rm -f "$tmpf" + fi + + local temps_json="[]" + local temps_data; temps_data=$(_sysfs_temps) + if [[ -n $temps_data ]]; then + local tmpf; tmpf=$(mktemp) + while IFS='|' read -r hw_name label temp; do + [[ -z $hw_name ]] && continue + jq -n --arg hw "$hw_name" --arg label "$label" --arg temp "$temp" \ + '{hwmon:$hw,label:$label,temp:$temp}' + done <<< "$temps_data" > "$tmpf" + temps_json=$(jq -s '.' "$tmpf") + rm -f "$tmpf" + fi + + jq -n --arg engine "$engine" --argjson cpu_temp "$cpu_temp" --argjson master "$master" \ + --arg profile "$profile" --argjson fans "$fans_json" --argjson temps "$temps_json" \ + --argjson pwm "$pwm_avail" --arg acpi_choices "$acpi_choices" --arg acpi_current "$acpi_current" \ + '{engine:$engine,cpu_temp:$cpu_temp,master:$master,profile:$profile,fans:$fans,temps:$temps,writable_pwm:$pwm,acpi_choices:$acpi_choices,acpi_current:$acpi_current}' } case "$1" in + "--json") _output_json ;; "--detect") - engine=$(_detect_engine) - devices="" - if [[ $engine == "liquidctl" ]]; then - devices=$(_get_liquidctl_devices | tr '\n' ',' | sed 's/,$//') - elif [[ $engine == "lm-sensors" ]]; then - devices=$(_has_sensors && sensors --version 2>/dev/null | head -1 | xargs || echo "lm-sensors") - else - local count=0 - for hw in $(_hwmon_dirs); do - for f in "$hw"/pwm*; do - [[ -f $f ]] && count=$((count + 1)) - done - done - devices="${count} PWM controls" - fi - echo "engine=${engine}" - echo "devices=${devices}" - rx_log_file "info" "Detected engine: ${engine} (${devices})" - ;; - + engine=$(_detect_engine); echo "engine=${engine}" + case "$engine" in + liquidctl) echo "devices=$(_get_liquidctl_devices | tr '\n' ',' | sed 's/,$//')" ;; + acpi_platform) echo "devices=ACPI platform profile ($(_acpi_get_choices))" ;; + *) n=0; for hw in $(_hwmon_dirs); do for f in "$hw"/pwm*_enable; do [[ -f $f ]] && n=$((n+1)); done; done; echo "devices=${n} PWM controls" ;; + esac ;; "--scan-engines") _has_liquidctl && echo "liquidctl" - _has_sensors && echo "lm-sensors" echo "sysfs" - ;; - + _has_acpi_platform_profile && echo "acpi_platform" ;; "--status") - engine=$(_detect_engine) - echo "engine:${engine}" - - _get_coretemp >/dev/null 2>&1 - cpu_temp=$(_get_coretemp) - echo "cpu_temp:${cpu_temp}C" - - if [[ $engine == "liquidctl" ]]; then - lq_status="" - lq_status=$(_get_liquidctl_status) - if [[ -n $lq_status ]]; then - liq_temp=$(echo "$lq_status" | grep -oP '"liquid temperature" *: *\K[0-9.]+' 2>/dev/null || echo "0") - echo "liquid_temp:${liq_temp}C" - count=0 - while true; do - key="fan${count} speed" - speed=$(echo "$lq_status" | grep -oP "\"${key}\" *: *\K[0-9]+" 2>/dev/null || echo "") - [[ -z $speed ]] && break - dkey="fan${count} duty" - duty=$(echo "$lq_status" | grep -oP "\"${dkey}\" *: *\K[0-9]+" 2>/dev/null || echo "0") - echo "fan_${count}:${speed}rpm (${duty}%)" - count=$((count + 1)) - done - fi - else - while IFS='|' read -r hw label rpm pct temp writable; do - safe_label="${label// /_}" - echo "fan_${safe_label}:${rpm}rpm (${pct}%)" - done < <(_sysfs_fans) - fi - - profile=$(get_var "FAN_PROFILE" "balanced") - echo "profile:${profile}" - rx_log_file "info" "Status reported (engine: ${engine}, profile: ${profile})" - ;; - - "--list-fans") - engine=$(_detect_engine) - if [[ $engine == "liquidctl" ]]; then - _get_liquidctl_fans - else - _sysfs_fans - fi - ;; - - "--list-temps") - _sysfs_temps - ;; - + engine=$(_detect_engine); cpu_temp=$(_get_cpu_temp) + profile=$(echo "$(_config_read)" | jq -r '.profile // "balanced"') + master=$(echo "$(_config_read)" | jq -r '.master') + echo "engine:${engine}"; echo "cpu_temp:${cpu_temp}C"; echo "profile:${profile}"; echo "master:${master}" + while IFS='|' read -r hw_name label rpm pct temp writable hw_id fan_idx; do + [[ -z $hw_name ]] && continue + echo "fan_${hw_id}_fan${fan_idx}:${label}:${rpm}rpm (${pct}%) [${temp}]" + done < <(_sysfs_fans) ;; + "--list-fans") _sysfs_fans ;; + "--list-temps") _sysfs_temps ;; + "--set-master") + [[ -z $2 ]] && { echo "Usage: --set-master on|off"; exit 1; } + case "$2" in + on|true|1) _config_set_master "true"; set_var "FAN_ENABLED" "true" ;; + off|false|0) _config_set_master "false"; set_var "FAN_ENABLED" "false"; _sysfs_reset_all ;; + *) echo "Invalid: use on or off"; exit 1 ;; + esac; echo "OK|master=$2" ;; + "--set-mode") + [[ -z $2 || -z $3 ]] && { echo "Usage: --set-mode auto|curve|manual"; exit 1; } + case "$3" in auto) _sysfs_set_auto "$2" ;; curve|manual) ;; *) echo "Invalid"; exit 1 ;; esac + c= c=$(echo "$(_config_read)" | jq -r ".fans[\"$2\"].curve // \"\""); s=$(echo "$(_config_read)" | jq -r ".fans[\"$2\"].speed // 100") + [[ -z $c ]] && c=$(_get_default_curve "balanced") + _config_set_fan "$2" "$3" "$c" "$s"; echo "OK|fan=$2|mode=$3" ;; "--set-speed") - fan_name="$2" - pct="$3" - [[ -z $fan_name ]] && rx_log_file "error" "Missing fan name" && exit 1 - [[ -z $pct ]] && rx_log_file "error" "Missing speed percentage" && exit 1 - [[ ! $pct =~ ^[0-9]+$ ]] && rx_log_file "error" "Speed must be numeric" && exit 1 - [[ $pct -lt 0 || $pct -gt 100 ]] && rx_log_file "error" "Speed must be 0-100" && exit 1 - - engine=$(_detect_engine) - if [[ $engine == "liquidctl" ]]; then - _liquidctl_set_speed "$fan_name" "$pct" - else - _sysfs_set_speed "$fan_name" "$pct" - fi - set_var "FAN_ENGINE" "$engine" - set_var "FAN_ENABLED" "true" - ;; - + [[ -z $2 || -z $3 ]] && { echo "Usage: --set-speed <0-100>"; exit 1; } + [[ ! $3 =~ ^[0-9]+$ || $3 -lt 0 || $3 -gt 100 ]] && { echo "Speed must be 0-100"; exit 1; } + _sysfs_set_speed "$2" "$3"; c= c=$(echo "$(_config_read)" | jq -r ".fans[\"$2\"].curve // \"\"") + [[ -z $c ]] && c=$(_get_default_curve "balanced") + _config_set_fan "$2" "manual" "$c" "$3"; echo "OK|fan=$2|speed=$3" ;; "--set-curve") - fan_name="$2" - curve="$3" - [[ -z $fan_name ]] && rx_log_file "error" "Missing fan name" && exit 1 - [[ -z $curve ]] && rx_log_file "error" "Missing curve data" && exit 1 - - set_var "FAN_CURVE_${fan_name}" "$curve" - _sysfs_set_curve "$fan_name" "$curve" - ;; - - "--profile") - profile="$2" - curve="" - curve=$(_get_default_curve "${profile:-balanced}") - - engine=$(_detect_engine) - if [[ $engine == "liquidctl" ]]; then - devices="" - devices=$(_get_liquidctl_devices) - count=0 - current_temp=$(_get_coretemp) - [[ -z $current_temp || $current_temp -eq 0 ]] && current_temp=30 - while IFS= read -r dev; do - [[ -z $dev ]] && continue - target_pct=$(_parse_curve "$curve" "$current_temp") - _liquidctl_set_speed "fan${count}" "$target_pct" 2>/dev/null - count=$((count + 1)) - done <<<"$devices" - else - while IFS='|' read -r hw label rpm pct temp writable; do - safe_label="${label// /_}" - _sysfs_set_curve "$label" "$curve" - set_var "FAN_CURVE_${safe_label}" "$curve" - done < <(_sysfs_fans) + [[ -z $2 || -z $3 ]] && { echo "Usage: --set-curve "; exit 1; } + tp=$(_get_cpu_temp); [[ $tp -eq 0 ]] && tp=30 + tpct= tpct=$(_parse_curve "$3" "$tp"); _sysfs_set_speed "$2" "$tpct" 2>/dev/null + s= s=$(echo "$(_config_read)" | jq -r ".fans[\"$2\"].speed // 100") + _config_set_fan "$2" "curve" "$3" "$s"; echo "OK|fan=$2|curve=$3|target=$tpct" ;; + "--set-profile") + [[ -z $2 ]] && { echo "Usage: --set-profile quiet|balanced|performance"; exit 1; } + _config_apply_profile_to_fans "$2"; set_var "FAN_PROFILE" "$2" + if _has_acpi_platform_profile; then + case "$2" in quiet) _acpi_set_profile "quiet" ;; balanced) _acpi_set_profile "balanced" ;; performance) _acpi_set_profile "performance" ;; esac 2>/dev/null fi - - set_var "FAN_PROFILE" "$profile" - set_var "FAN_ENABLED" "true" - rx_log_file "success" "Profile '${profile}' applied" - echo "OK|profile=${profile}" - ;; - + echo "OK|profile=$2" ;; "--reset") - engine=$(_detect_engine) - if [[ $engine == "liquidctl" ]]; then - _liquidctl_reset - else - _sysfs_reset - fi - set_var "FAN_PROFILE" "auto" - set_var "FAN_ENABLED" "false" - rx_log_file "success" "All fans reset to defaults" - ;; - - "--daemon-tick") - enabled=$(get_var "FAN_ENABLED" "false") - [[ $enabled != "true" ]] && exit 0 - - engine=$(get_var "FAN_ENGINE" "auto") - [[ $engine == "auto" ]] && engine=$(_detect_engine) - - profile=$(get_var "FAN_PROFILE" "balanced") - curve=$(_get_default_curve "$profile") - - current_temp=$(_get_coretemp) - [[ -z $current_temp || $current_temp -eq 0 ]] && exit 0 - - fan_count=0 - while IFS='|' read -r hw label rpm pct temp writable; do - safe_label="${label// /_}" - custom_curve=$(get_var "FAN_CURVE_${safe_label}" "") - use_curve="${custom_curve:-$curve}" - target_pct=$(_parse_curve "$use_curve" "$current_temp") - [[ $target_pct -gt 100 ]] && target_pct=100 - [[ $target_pct -lt 0 ]] && target_pct=0 - _sysfs_set_speed "$label" "$target_pct" 2>/dev/null - fan_count=$((fan_count + 1)) + while IFS='|' read -r _ _ _ _ _ _ hw_id fan_idx; do + [[ -n $hw_id ]] && _sysfs_set_auto "${hw_id}_${hw_name}_fan${fan_idx}" 2>/dev/null done < <(_sysfs_fans) - - echo "${current_temp}C: ${fan_count} fans → ${profile}" - ;; - - "--setup-apply") - engine="" - profile="" - for arg in "$@"; do - case "$arg" in - engine=*) engine="${arg#engine=}" ;; - profile=*) profile="${arg#profile=}" ;; + _sysfs_reset_all 2>/dev/null + _config_write '{"master":false,"profile":"balanced","fans":{}}' + set_var "FAN_ENABLED" "false"; set_var "FAN_PROFILE" "balanced" + echo "OK|reset=true" ;; + "--daemon-tick") + master=$(echo "$(_config_read)" | jq -r '.master') + [[ $master != "true" ]] && exit 0 + cpu_temp=$(_get_cpu_temp); [[ $cpu_temp -eq 0 ]] && exit 0 + profile=$(echo "$(_config_read)" | jq -r '.profile // "balanced"') + default_curve=$(_get_default_curve "$profile") + fc=0 + while IFS='|' read -r hw_name label rpm pct temp_val writable hw_id fan_idx; do + [[ -z $hw_name ]] && continue + fid="${hw_id}_${hw_name}_fan${fan_idx}" + fm="auto" fc2="" fs=100 + fdata= fdata=$(echo "$(_config_read)" | jq -r ".fans[\"$fid\"] // empty") + [[ -n $fdata ]] && { fm=$(echo "$fdata" | jq -r '.mode // "auto"'); fc2=$(echo "$fdata" | jq -r '.curve // ""'); fs=$(echo "$fdata" | jq -r '.speed // 100'); } + case "$fm" in + curve) + uc="${fc2:-$default_curve}" + tgt=$(_parse_curve "$uc" "$cpu_temp") + [[ $tgt -gt 100 ]] && tgt=100; [[ $tgt -lt 0 ]] && tgt=0 + _sysfs_set_speed "$fid" "$tgt" 2>/dev/null; fc=$((fc+1)) ;; + manual) _sysfs_set_speed "$fid" "$fs" 2>/dev/null; fc=$((fc+1)) ;; esac - done - - [[ -z $engine ]] && engine=$(_detect_engine) - [[ -z $profile ]] && profile="balanced" - - set_var "FAN_ENGINE" "$engine" - set_var "FAN_PROFILE" "$profile" - set_var "FAN_ENABLED" "true" - - curve="" - curve=$(_get_default_curve "$profile") - - fan_count=0 - while IFS='|' read -r hw label rpm pct temp writable; do - safe_label="${label// /_}" - custom_curve=$(get_var "FAN_CURVE_${safe_label}" "") - use_curve="${custom_curve:-$curve}" - _sysfs_set_curve "$label" "$use_curve" - set_var "FAN_CURVE_${safe_label}" "$use_curve" - fan_count=$((fan_count + 1)) done < <(_sysfs_fans) - - echo "OK|engine=${engine}|profile=${profile}|fans=${fan_count}" - rx_log_file "success" "Fan setup applied: engine=${engine} profile=${profile} fans=${fan_count}" - ;; - - "--cpu-temp") - _get_coretemp - ;; - - *) - echo "Usage: $0 --{detect|status|list-fans|list-temps|set-speed|set-curve|reset|profile|scan-engines|setup-get|setup-apply} [args]" - exit 1 - ;; + echo "${cpu_temp}C: ${fc} fans -> ${profile}" ;; + "--setup-get") + echo "engine=$(echo "$(_config_read)" | jq -r '.engine // "auto"')" + echo "profile=$(echo "$(_config_read)" | jq -r '.profile // "balanced"')" ;; + "--setup-apply") + ep="" pp="" + for arg in "$@"; do case "$arg" in engine=*) ep="${arg#engine=}" ;; profile=*) pp="${arg#profile=}" ;; esac; done + [[ -z $ep ]] && ep=$(_detect_engine); [[ -z $pp ]] && pp="balanced" + _config_apply_profile_to_fans "$pp"; set_var "FAN_PROFILE" "$pp"; set_var "FAN_ENABLED" "true" + n=0; while IFS='|' read -r hw_name _ _ _ _ _ _ _; do [[ -n $hw_name ]] && n=$((n+1)); done < <(_sysfs_fans) + echo "OK|engine=${ep}|profile=${pp}|fans=${n}" ;; + "--ensure-sudoers") + [[ $EUID -ne 0 ]] && { echo "ERR|must be root"; exit 1; } + cat <<'SEOF' > /etc/sudoers.d/99-retro-fans +%wheel ALL=(ALL) NOPASSWD: /opt/retrolinux/scripts/fans_core.sh +%wheel ALL=(ALL) NOPASSWD: /usr/bin/tee /sys/firmware/acpi/platform_profile +%wheel ALL=(ALL) NOPASSWD: /usr/bin/tee /sys/class/hwmon/hwmon*/pwm* +%wheel ALL=(ALL) NOPASSWD: /usr/bin/tee /sys/class/hwmon/hwmon*/pwm*_enable +SEOF + chmod 440 /etc/sudoers.d/99-retro-fans + echo "OK|sudoers=true" ;; + "--cpu-temp") _get_cpu_temp ;; + *) echo "Usage: $0 --{json|detect|status|list-fans|list-temps|set-master|set-mode|set-speed|set-curve|set-profile|reset|cpu-temp|setup-get|setup-apply|scan-engines|ensure-sudoers}" + exit 1 ;; esac