From 1df98a25346dad73f329af1a391d0e6997c54ea3 Mon Sep 17 00:00:00 2001 From: TechDufus Date: Wed, 13 May 2026 12:26:12 -0500 Subject: [PATCH 01/15] feat(awesomewm): add Fabric command deck --- roles/awesomewm/files/config/rc.lua | 91 +- .../files/scripts/ai-usage-monitor.sh | 128 +- .../files/systemd/ai-usage-monitor.service | 2 +- .../awesomewm/tests/test_ai_usage_monitor.sh | 68 + roles/fabric/README.md | 59 + roles/fabric/defaults/main.yml | 31 + roles/fabric/files/bin/fabric-awesomewm | 19 + roles/fabric/files/config/awesomewm/config.py | 1638 +++++++++++++++++ roles/fabric/files/config/awesomewm/style.css | 421 +++++ roles/fabric/tasks/Ubuntu.yml | 153 ++ roles/fabric/tasks/main.yml | 9 + roles/fabric/tests/test_config_helpers.sh | 403 ++++ 12 files changed, 2988 insertions(+), 34 deletions(-) create mode 100644 roles/awesomewm/tests/test_ai_usage_monitor.sh create mode 100644 roles/fabric/README.md create mode 100644 roles/fabric/defaults/main.yml create mode 100644 roles/fabric/files/bin/fabric-awesomewm create mode 100644 roles/fabric/files/config/awesomewm/config.py create mode 100644 roles/fabric/files/config/awesomewm/style.css create mode 100644 roles/fabric/tasks/Ubuntu.yml create mode 100644 roles/fabric/tasks/main.yml create mode 100644 roles/fabric/tests/test_config_helpers.sh diff --git a/roles/awesomewm/files/config/rc.lua b/roles/awesomewm/files/config/rc.lua index 63cd9da5..3f8f7e39 100644 --- a/roles/awesomewm/files/config/rc.lua +++ b/roles/awesomewm/files/config/rc.lua @@ -25,6 +25,19 @@ local has_fdo, freedesktop = pcall(require, "freedesktop") -- Window switcher (Alt+Tab style) local window_switcher = require("window-switcher") +local function file_exists(path) + local file = io.open(path, "r") + if file then + file:close() + return true + end + + return false +end + +local fabric_ui_enabled = file_exists(gears.filesystem.get_configuration_dir() .. "fabric-ui-enabled") +local fabric_bar_height = 37 + -- {{{ Startup commands -- Set keyboard repeat rate to match Hyprland (XXXms delay, XX chars/sec) awful.spawn.once("xset r rate 300 40") @@ -75,6 +88,11 @@ awful.spawn.once("/usr/lib/policykit-1-gnome/polkit-gnome-authentication-agent-1 -- Start NetworkManager applet for WiFi management in systray awful.spawn.once("nm-applet") +-- Start Fabric UI when the opt-in sentinel is deployed by the fabric role. +if fabric_ui_enabled then + awful.spawn.with_shell([[if [ -x "$HOME/.local/bin/fabric-awesomewm" ]; then "$HOME/.local/bin/fabric-awesomewm"; fi]]) +end + -- Flare launcher starts on-demand (Super+Space) - no auto-start to avoid popup -- }}} @@ -171,6 +189,46 @@ end menubar.utils.terminal = terminal -- Set the terminal for applications that require it -- }}} +local function increase_volume(step) + if fabric_ui_enabled then + awful.spawn.with_shell(string.format("pactl set-sink-volume @DEFAULT_SINK@ +%d%%", step or 5)) + else + wibar_config.increase_volume(step) + end +end + +local function decrease_volume(step) + if fabric_ui_enabled then + awful.spawn.with_shell(string.format("pactl set-sink-volume @DEFAULT_SINK@ -%d%%", step or 5)) + else + wibar_config.decrease_volume(step) + end +end + +local function toggle_volume() + if fabric_ui_enabled then + awful.spawn("pactl set-sink-mute @DEFAULT_SINK@ toggle") + else + wibar_config.toggle_volume() + end +end + +local function increase_brightness() + if fabric_ui_enabled then + awful.spawn("brightnessctl set 5%+") + else + wibar_config.increase_brightness() + end +end + +local function decrease_brightness() + if fabric_ui_enabled then + awful.spawn("brightnessctl set 5%-") + else + wibar_config.decrease_brightness() + end +end + -- {{{ Wibar local tasklist_buttons = gears.table.join( awful.button({}, 1, function(c) @@ -211,9 +269,13 @@ end local function configure_screen_metrics(s) s.dpi = screen_dpi_for_geometry(s) + local top_bar_height = beautiful.xresources.apply_dpi(fabric_bar_height, s) if s.mywibox then s.mywibox.height = beautiful.xresources.apply_dpi(28, s) end + if s.fabric_reserve_wibar then + s.fabric_reserve_wibar.height = top_bar_height + end end local function set_wallpaper(s) @@ -243,8 +305,23 @@ awful.screen.connect_for_each_screen(function(s) -- Each screen has its own tag table. awful.tag({ "1", "2", "3", "4", "5", "6", "7", "8", "9" }, s, awful.layout.layouts[1]) - -- Create the modern wibar with all widgets - wibar_config.create_wibar(s, tasklist_buttons, mymainmenu) + if fabric_ui_enabled then + s.mypromptbox = awful.widget.prompt() + s.fabric_reserve_wibar = awful.wibar({ + position = "top", + screen = s, + height = beautiful.xresources.apply_dpi(fabric_bar_height, s), + bg = "#00000000", + fg = "#00000000", + visible = true, + ontop = false, + type = "dock", + input_passthrough = true, + }) + else + -- Create the modern wibar with all widgets + wibar_config.create_wibar(s, tasklist_buttons, mymainmenu) + end end) -- }}} @@ -313,15 +390,15 @@ globalkeys = gears.table.join( -- Volume control keys routed through the active wibar controller awful.key({}, "XF86AudioRaiseVolume", function() - wibar_config.increase_volume(5) + increase_volume(5) end, { description = "increase volume", group = "media" }), awful.key({}, "XF86AudioLowerVolume", function() - wibar_config.decrease_volume(5) + decrease_volume(5) end, { description = "decrease volume", group = "media" }), awful.key({}, "XF86AudioMute", function() - wibar_config.toggle_volume() + toggle_volume() end, { description = "toggle mute", group = "media" }), awful.key({}, "XF86AudioMicMute", function() @@ -330,11 +407,11 @@ globalkeys = gears.table.join( -- Brightness control keys routed through the active wibar controller awful.key({}, "XF86MonBrightnessUp", function() - wibar_config.increase_brightness() + increase_brightness() end, { description = "increase brightness", group = "media" }), awful.key({}, "XF86MonBrightnessDown", function() - wibar_config.decrease_brightness() + decrease_brightness() end, { description = "decrease brightness", group = "media" }), -- Media control keys diff --git a/roles/awesomewm/files/scripts/ai-usage-monitor.sh b/roles/awesomewm/files/scripts/ai-usage-monitor.sh index aea86ccf..b2f57ee4 100755 --- a/roles/awesomewm/files/scripts/ai-usage-monitor.sh +++ b/roles/awesomewm/files/scripts/ai-usage-monitor.sh @@ -3,14 +3,14 @@ set -euo pipefail # --------------------------------------------------------------------------- # AI Usage Monitor -# Polls Claude OAuth usage and local Codex session telemetry, then writes -# a status JSON file for -# consumption by desktop widgets (AwesomeWM, Waybar, etc.). +# Polls the currently selected AI provider, then writes a status JSON file +# for consumption by desktop widgets (AwesomeWM, Waybar, etc.). # --------------------------------------------------------------------------- POLL_INTERVAL="${POLL_INTERVAL:-60}" CACHE_DIR="$HOME/.cache/ai-usage-monitor" STATUS_FILE="$CACHE_DIR/status.json" +PROVIDER_FILE="$CACHE_DIR/provider.txt" CREDENTIALS_FILE="$HOME/.claude/.credentials.json" API_URL="https://api.anthropic.com/api/oauth/usage" @@ -21,6 +21,31 @@ log() { printf '%s [ai-usage-monitor] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2 } +# --------------------------------------------------------------------------- +# Active provider helpers +# --------------------------------------------------------------------------- +normalize_provider() { + local provider="${1:-codex}" + case "$provider" in + claude|codex) + printf '%s\n' "$provider" + ;; + *) + printf 'codex\n' + ;; + esac +} + +selected_provider() { + local provider + if [[ -f "$PROVIDER_FILE" ]]; then + provider="$(head -n1 "$PROVIDER_FILE" 2>/dev/null || true)" + else + provider="codex" + fi + normalize_provider "$provider" +} + # --------------------------------------------------------------------------- # Write JSON atomically (tmp + mv) # --------------------------------------------------------------------------- @@ -140,22 +165,54 @@ claude_error_section() { printf '{"available":false,"five_hour":null,"seven_day":null,"seven_day_opus":null,"seven_day_sonnet":null,"extra_usage":null,"error":"%s"}' "$err" } +inactive_claude_section() { + claude_error_section "inactive" +} + +inactive_codex_section() { + cat <<'CODEX' +{"available":false,"session":null,"weekly":null,"error":"inactive"} +CODEX +} + # --------------------------------------------------------------------------- # Build the full output envelope # --------------------------------------------------------------------------- -build_output() { - local claude_json="$1" - local errors_json="${2:-[]}" +build_output_for_provider() { + local provider active_json errors_json ts claude_json codex_json + provider="$(normalize_provider "${1:-codex}")" + active_json="$2" + errors_json="${3:-[]}" local ts ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" - local codex - codex="$(codex_section)" + + if [[ "$provider" == "claude" ]]; then + claude_json="$active_json" + codex_json="$(inactive_codex_section)" + else + claude_json="$(inactive_claude_section)" + codex_json="$active_json" + fi + jq -n \ --arg ts "$ts" \ + --arg active_provider "$provider" \ --argjson claude "$claude_json" \ - --argjson codex "$codex" \ + --argjson codex "$codex_json" \ --argjson errors "$errors_json" \ - '{timestamp:$ts,claude:$claude,codex:$codex,errors:$errors}' + '{timestamp:$ts,active_provider:$active_provider,claude:$claude,codex:$codex,errors:$errors}' +} + +build_error_output_for_provider() { + local provider err active_json + provider="$(normalize_provider "${1:-codex}")" + err="$2" + if [[ "$provider" == "claude" ]]; then + active_json="$(claude_error_section "$err")" + else + active_json="$(printf '{"available":false,"session":null,"weekly":null,"error":"%s"}' "$err")" + fi + build_output_for_provider "$provider" "$active_json" "[\"$err\"]" } # --------------------------------------------------------------------------- @@ -163,10 +220,9 @@ build_output() { # --------------------------------------------------------------------------- write_error_state() { local err="$1" - local claude - claude="$(claude_error_section "$err")" + local provider="${2:-$(selected_provider)}" local output - output="$(build_output "$claude" "[\"$err\"]")" + output="$(build_error_output_for_provider "$provider" "$err")" write_status "$output" } @@ -180,8 +236,7 @@ cleanup() { fi shutting_down=true log "Caught signal, shutting down..." - write_error_state "monitor_stopped" - log "Wrote final unavailable state. Exiting." + log "Leaving last status snapshot intact. Exiting." exit 0 } trap cleanup SIGTERM SIGINT @@ -236,14 +291,33 @@ main() { log "Starting AI usage monitor (poll every ${POLL_INTERVAL}s)" log "Status file: $STATUS_FILE" - local token expires_at now_ms response http_code body claude_json output + local token expires_at now_ms response http_code body claude_json codex_json output provider provider_error errors_json local curl_ok while true; do + provider="$(selected_provider)" + log "Selected provider: $provider" + + if [[ "$provider" == "codex" ]]; then + codex_json="$(codex_section)" + provider_error="$(jq -r '.error // empty' <<< "$codex_json" 2>/dev/null || true)" + if [[ -n "$provider_error" ]]; then + errors_json="[\"$provider_error\"]" + else + errors_json="[]" + fi + output="$(build_output_for_provider "codex" "$codex_json" "$errors_json")" + write_status "$output" + log "Status updated successfully for codex" + sleep "$POLL_INTERVAL" & + wait $! || true + continue + fi + # -- 1. Read credentials fresh every cycle -------------------------------- if [[ ! -f "$CREDENTIALS_FILE" ]]; then log "Credentials file not found: $CREDENTIALS_FILE" - write_error_state "credentials_not_found" + write_error_state "credentials_not_found" "$provider" sleep "$POLL_INTERVAL" & wait $! || true continue @@ -252,7 +326,7 @@ main() { token="$(jq -r '.claudeAiOauth.accessToken // empty' "$CREDENTIALS_FILE" 2>/dev/null || true)" if [[ -z "$token" ]]; then log "No access token found in credentials file" - write_error_state "no_access_token" + write_error_state "no_access_token" "$provider" sleep "$POLL_INTERVAL" & wait $! || true continue @@ -263,7 +337,7 @@ main() { now_ms="$(date +%s)000" if (( now_ms >= expires_at )); then log "Access token has expired (expiresAt=${expires_at}, now=${now_ms})" - write_error_state "token_expired" + write_error_state "token_expired" "$provider" sleep "$POLL_INTERVAL" & wait $! || true continue @@ -278,7 +352,7 @@ main() { if [[ "$curl_ok" == "false" ]]; then log "curl failed (network error)" - write_error_state "network_error" + write_error_state "network_error" "$provider" sleep "$POLL_INTERVAL" & wait $! || true continue @@ -290,7 +364,7 @@ main() { if [[ "$http_code" != "200" ]]; then log "API returned HTTP $http_code" - write_error_state "api_error_${http_code}" + write_error_state "api_error_${http_code}" "$provider" sleep "$POLL_INTERVAL" & wait $! || true continue @@ -299,7 +373,7 @@ main() { # -- 4. Parse the response ----------------------------------------------- claude_json="$(parse_usage_response "$body" 2>/dev/null)" || { log "Failed to parse API response" - write_error_state "json_parse_error" + write_error_state "json_parse_error" "$provider" sleep "$POLL_INTERVAL" & wait $! || true continue @@ -307,16 +381,16 @@ main() { if [[ -z "$claude_json" || "$claude_json" == "null" ]]; then log "jq produced empty output" - write_error_state "json_parse_error" + write_error_state "json_parse_error" "$provider" sleep "$POLL_INTERVAL" & wait $! || true continue fi # -- 5. Build and write the final status file ---------------------------- - output="$(build_output "$claude_json" "[]")" + output="$(build_output_for_provider "claude" "$claude_json" "[]")" write_status "$output" - log "Status updated successfully" + log "Status updated successfully for claude" # Sleep in background so trap can fire immediately sleep "$POLL_INTERVAL" & @@ -324,4 +398,6 @@ main() { done } -main +if [[ "${AI_USAGE_MONITOR_TEST_MODE:-0}" != "1" ]]; then + main +fi diff --git a/roles/awesomewm/files/systemd/ai-usage-monitor.service b/roles/awesomewm/files/systemd/ai-usage-monitor.service index 1ce0e12a..8cda32d9 100644 --- a/roles/awesomewm/files/systemd/ai-usage-monitor.service +++ b/roles/awesomewm/files/systemd/ai-usage-monitor.service @@ -1,5 +1,5 @@ [Unit] -Description=AI Usage Monitor - polls Claude API usage limits +Description=AI Usage Monitor - polls the active AI provider [Service] Type=simple diff --git a/roles/awesomewm/tests/test_ai_usage_monitor.sh b/roles/awesomewm/tests/test_ai_usage_monitor.sh new file mode 100644 index 00000000..31e450e4 --- /dev/null +++ b/roles/awesomewm/tests/test_ai_usage_monitor.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +script_path="$repo_root/roles/awesomewm/files/scripts/ai-usage-monitor.sh" + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +export HOME="$tmpdir/home" +export AI_USAGE_MONITOR_TEST_MODE=1 +mkdir -p "$HOME" + +# shellcheck source=/dev/null +source "$script_path" + +assert_json() { + local json="$1" + local jq_filter="$2" + local expected="$3" + local actual + actual="$(jq -r "$jq_filter" <<< "$json")" + if [[ "$actual" != "$expected" ]]; then + printf 'expected %s => %s, got %s\n' "$jq_filter" "$expected" "$actual" >&2 + exit 1 + fi +} + +mkdir -p "$CACHE_DIR" + +printf 'codex\n' > "$PROVIDER_FILE" +[[ "$(selected_provider)" == "codex" ]] +printf 'claude\n' > "$PROVIDER_FILE" +[[ "$(selected_provider)" == "claude" ]] +printf 'bogus\n' > "$PROVIDER_FILE" +[[ "$(selected_provider)" == "codex" ]] + +codex_output="$(build_output_for_provider "codex" '{"available":true,"session":{"utilization":17,"resets_at":"2026-05-13T18:01:40Z"},"weekly":null,"error":null}' '[]')" +assert_json "$codex_output" '.active_provider' 'codex' +assert_json "$codex_output" '.codex.available' 'true' +assert_json "$codex_output" '.codex.session.utilization' '17' +assert_json "$codex_output" '.claude.available' 'false' +assert_json "$codex_output" '.claude.error' 'inactive' +assert_json "$codex_output" '.errors | length' '0' + +claude_output="$(build_output_for_provider "claude" '{"available":true,"five_hour":{"utilization":12,"resets_at":"2026-05-13T18:01:40Z"},"seven_day":null,"seven_day_opus":null,"seven_day_sonnet":null,"extra_usage":null,"error":null}' '[]')" +assert_json "$claude_output" '.active_provider' 'claude' +assert_json "$claude_output" '.claude.available' 'true' +assert_json "$claude_output" '.claude.five_hour.utilization' '12' +assert_json "$claude_output" '.codex.available' 'false' +assert_json "$claude_output" '.codex.error' 'inactive' + +codex_error="$(build_error_output_for_provider "codex" "codex_rate_limits_not_found")" +assert_json "$codex_error" '.active_provider' 'codex' +assert_json "$codex_error" '.codex.error' 'codex_rate_limits_not_found' +assert_json "$codex_error" '.claude.error' 'inactive' +assert_json "$codex_error" '.errors[0]' 'codex_rate_limits_not_found' + +claude_error="$(build_error_output_for_provider "claude" "token_expired")" +assert_json "$claude_error" '.active_provider' 'claude' +assert_json "$claude_error" '.claude.error' 'token_expired' +assert_json "$claude_error" '.codex.error' 'inactive' +assert_json "$claude_error" '.errors[0]' 'token_expired' + +stable_status='{"active_provider":"codex","codex":{"available":true,"session":{"utilization":5},"weekly":null,"error":null},"claude":{"available":false,"error":"inactive"},"errors":[]}' +printf '%s\n' "$stable_status" > "$STATUS_FILE" +( cleanup ) +[[ "$(cat "$STATUS_FILE")" == "$stable_status" ]] diff --git a/roles/fabric/README.md b/roles/fabric/README.md new file mode 100644 index 00000000..06f47f71 --- /dev/null +++ b/roles/fabric/README.md @@ -0,0 +1,59 @@ +# Fabric AwesomeWM UI + +This role installs [Fabric](https://wiki.ffpy.org/) as an opt-in UI layer for +the existing AwesomeWM desktop. + +The boundary is intentional: + +- AwesomeWM keeps X11 window management, global key capture, leader/summon + behavior, app focus, tags, and cell placement. +- Fabric owns visible desktop UI surfaces where practical: panel, status + widgets, popups, notification UI, tasklist overlays, and future summon/layout + overlays. + +## Deploy + +```sh +dotfiles -t fabric +``` + +On Ubuntu this role: + +- installs GTK/PyGObject/cairo/Xlib/build dependencies from apt +- installs `uv` with `pipx` when needed +- creates `~/.local/share/fabric-awesomewm/venv` with `uv` +- installs Fabric from `Fabric-Development/fabric` into that venv +- deploys `~/.config/fabric/awesomewm` +- deploys `~/.local/bin/fabric-awesomewm` +- writes `~/.config/awesome/fabric-ui-enabled` after the launcher, Fabric import, + and config self-check pass + +The AwesomeWM role checks that sentinel file at runtime. When present, AwesomeWM +starts Fabric and skips creating the Lua wibar, while the rest of AwesomeWM +continues to own window management. + +## Current UI + +The first Fabric config provides: + +- an X11 dock-style top bar +- workspace placeholders for AwesomeWM tags 1-9 +- command-backed status pills for load, memory, network, battery, volume, AI + usage, DND, settings, and clock +- a settings launcher that reuses the existing rofi settings picker + +## Notes + +Fabric's X11 backend is documented as experimental, and transparent widgets need +an X11 compositor such as `picom`. The first pass intentionally keeps the +integration reversible: remove `~/.config/awesome/fabric-ui-enabled` and restart +AwesomeWM to return to the Lua wibar. + +Launcher output is written to +`~/.cache/fabric-awesomewm/fabric-awesomewm.log` so startup crashes do not fail +silently when AwesomeWM launches Fabric. + +Native GTK/PyGObject/cairo/Xlib Python bindings are intentionally installed with apt +and exposed through a `--system-site-packages` venv. The role uses `uv` for the +venv and Fabric package install, but avoids asking Python packaging tools to +rebuild the desktop binding stack on each Ubuntu host. diff --git a/roles/fabric/defaults/main.yml b/roles/fabric/defaults/main.yml new file mode 100644 index 00000000..aeed511c --- /dev/null +++ b/roles/fabric/defaults/main.yml @@ -0,0 +1,31 @@ +--- +role_name: fabric + +fabric_source_url: "git+https://github.com/Fabric-Development/fabric.git" +fabric_uv_path: "{{ ansible_facts['user_dir'] }}/.local/bin/uv" +fabric_venv_path: "{{ ansible_facts['user_dir'] }}/.local/share/fabric-awesomewm/venv" +fabric_config_name: awesomewm +fabric_config_path: "{{ ansible_facts['user_dir'] }}/.config/fabric/{{ fabric_config_name }}" +fabric_enable_awesomewm_ui: true + +fabric_ubuntu_packages: + - build-essential + - git + - pipx + - pkg-config + - python3 + - python3-click + - python3-dev + - python3-venv + - python3-cairo + - python3-gi + - python3-gi-cairo + - python3-loguru + - python3-psutil + - python3-xlib + - gir1.2-gtk-3.0 + - gobject-introspection + - libgirepository1.0-dev + - libcairo2-dev + - libgtk-3-dev + - libgtk-layer-shell-dev diff --git a/roles/fabric/files/bin/fabric-awesomewm b/roles/fabric/files/bin/fabric-awesomewm new file mode 100644 index 00000000..711191ff --- /dev/null +++ b/roles/fabric/files/bin/fabric-awesomewm @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +config_dir="${FABRIC_AWESOMEWM_CONFIG:-$HOME/.config/fabric/awesomewm}" +venv="${FABRIC_AWESOMEWM_VENV:-$HOME/.local/share/fabric-awesomewm/venv}" +config_file="$config_dir/config.py" +log_dir="${XDG_CACHE_HOME:-$HOME/.cache}/fabric-awesomewm" +log_file="$log_dir/fabric-awesomewm.log" + +if [ ! -x "$venv/bin/python" ] || [ ! -f "$config_file" ]; then + exit 0 +fi + +if pgrep -u "${USER}" -f "$config_file" >/dev/null 2>&1; then + exit 0 +fi + +mkdir -p "$log_dir" +exec "$venv/bin/python" "$config_file" >>"$log_file" 2>&1 diff --git a/roles/fabric/files/config/awesomewm/config.py b/roles/fabric/files/config/awesomewm/config.py new file mode 100644 index 00000000..9037f5c4 --- /dev/null +++ b/roles/fabric/files/config/awesomewm/config.py @@ -0,0 +1,1638 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import calendar +import copy +import json +import re +import subprocess +import sys +import time +from datetime import date, datetime, timezone +from pathlib import Path + +from fabric import Application, Fabricator +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.datetime import DateTime +from fabric.widgets.eventbox import EventBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.scale import Scale +from fabric.widgets.x11 import X11Window as Window +from fabric.utils import get_relative_path + + +HOME = Path.home() +AI_STATUS_PATH = HOME / ".cache" / "ai-usage-monitor" / "status.json" +AI_PROVIDER_PREF_PATH = HOME / ".cache" / "ai-usage-monitor" / "provider.txt" +CODEX_SESSION_DIR = HOME / ".codex" / "sessions" +CODEX_SESSION_FILE_LIMIT = 12 +CODEX_SESSION_TAIL_BYTES = 512 * 1024 +MAX_TASK_LABELS = 5 +BAR_HEIGHT = 37 +VOLUME_POLL_MS = 1000 +AI_POLL_MS = 5000 +AI_PROVIDER_SWITCH_REFRESH_DELAYS_MS = (350, 1250) +AI_USAGE_URLS = { + "codex": "https://chatgpt.com/codex/settings/usage", + "claude": "https://console.anthropic.com/settings/limits", +} +AI_PROVIDER_DEFS = { + "claude": { + "label": "Claude", + "metrics": [ + ("five_hour", "Session (5h)"), + ("seven_day", "Weekly (7d)"), + ("seven_day_sonnet", "Sonnet (7d)"), + ("seven_day_opus", "Opus (7d)"), + ], + }, + "codex": { + "label": "Codex", + "metrics": [ + ("session", "Session"), + ("weekly", "Weekly"), + ("five_hour", "Session (5h)"), + ("seven_day", "Weekly (7d)"), + ], + }, +} + +AWESOME_CLIENTS_LUA = r''' +local out = {} +local focused = client.focus +for _, c in ipairs(client.get()) do + table.insert(out, (c.name or "") .. "\t" .. (c.class or "") .. "\t" .. (c.minimized and "true" or "false") .. "\t" .. tostring(c.window or "") .. "\t" .. ((focused == c) and "true" or "false")) +end +return table.concat(out, "\n") +''' + +APP_LABELS = { + "1password": "1Password", + "chromium": "Chromium", + "chromium-browser": "Chromium", + "code": "Code", + "com.mitchellh.ghostty": "Ghostty", + "discord": "Discord", + "firefox": "Firefox", + "ghostty": "Ghostty", + "google-chrome": "Chrome", + "slack": "Slack", + "spotify": "Spotify", + "steam": "Steam", +} + +ICON_NAMES = { + "1password": "1password", + "brave-browser": "brave-browser", + "chromium": "chromium", + "chromium-browser": "chromium-browser", + "code": "visual-studio-code", + "com.mitchellh.ghostty": "com.mitchellh.ghostty", + "discord": "discord", + "firefox": "firefox", + "ghostty": "com.mitchellh.ghostty", + "google-chrome": "google-chrome", + "slack": "slack", + "spotify": "spotify", + "steam": "steam", +} + +Task = dict[str, object] + + +def bar_size_from_monitor_width(width: int) -> tuple[int, int]: + return (width, BAR_HEIGHT) + + +def primary_monitor_width() -> int: + try: + from gi.repository import Gdk + + display = Gdk.Display.get_default() + monitor = display.get_primary_monitor() if display else None + geometry = monitor.get_geometry() if monitor else None + return int(geometry.width) if geometry else 1920 + except Exception: + return 1920 + + +def run_command(command: list[str]) -> None: + subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def shell_output(command: str, fallback: str = "...") -> str: + try: + result = subprocess.run( + ["sh", "-c", command], + check=False, + capture_output=True, + text=True, + timeout=1, + ) + except Exception: + return fallback + + value = result.stdout.strip() + return value or fallback + + +def command_output(command: list[str], fallback: str = "") -> str: + try: + result = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=1, + ) + except Exception: + return fallback + + return result.stdout.strip() or fallback + + +def nested_value(data: object, *keys: str) -> object: + current = data + for key in keys: + if not isinstance(current, dict): + return None + current = current.get(key) + return current + + +def as_number(value: object) -> float | None: + try: + return float(value) + except Exception: + return None + + +def percent_text(value: object) -> str | None: + try: + return f"{int(float(value))}%" + except Exception: + return None + + +def percent_display(value: object) -> str: + number = as_number(value) + if number is None: + return "N/A" + return f"{int(number + 0.5)}%" + + +def progress_value(value: object) -> float: + number = as_number(value) + if number is None: + return 0.0 + return max(0.0, min(1.0, number / 100.0)) + + +def usage_severity(value: object) -> str: + number = as_number(value) + if number is None: + return "unknown" + if number < 50: + return "cool" + if number <= 70: + return "warm" + if number <= 85: + return "hot" + return "critical" + + +def usage_status(value: object) -> str: + return { + "unknown": "Unknown", + "cool": "Healthy", + "warm": "Warm", + "hot": "Hot", + "critical": "Critical", + }[usage_severity(value)] + + +def normalize_ai_provider(provider: str, fallback: str = "codex") -> str: + value = provider.strip().lower() + if value in AI_PROVIDER_DEFS: + return value + return fallback if fallback in AI_PROVIDER_DEFS else "codex" + + +def load_ai_provider_preference(path: Path | None = None) -> str: + path = AI_PROVIDER_PREF_PATH if path is None else path + try: + return normalize_ai_provider(path.read_text().strip()) + except Exception: + return "codex" + + +def save_ai_provider_preference(provider: str, path: Path | None = None) -> None: + path = AI_PROVIDER_PREF_PATH if path is None else path + value = normalize_ai_provider(provider) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"{value}\n") + except Exception: + return + + +def next_ai_provider(provider: str) -> str: + return "claude" if normalize_ai_provider(provider) == "codex" else "codex" + + +def iso_to_epoch(iso: object) -> float | None: + if not isinstance(iso, str) or not iso: + return None + try: + return datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc).timestamp() + except Exception: + return None + + +def duration_text(seconds: float) -> str: + remaining = max(0, int(seconds)) + days, remaining = divmod(remaining, 86400) + hours, remaining = divmod(remaining, 3600) + minutes = remaining // 60 + parts = [] + if days: + parts.append(f"{days}d") + if hours or days: + parts.append(f"{hours}h") + parts.append(f"{minutes:02d}m" if hours or days else f"{minutes}m") + return " ".join(parts) + + +def local_time_text(epoch: float) -> str: + return datetime.fromtimestamp(epoch).strftime("%-I:%M %p") + + +def format_reset_text(iso: object, now_epoch: float | None = None) -> str: + reset_epoch = iso_to_epoch(iso) + if reset_epoch is None: + return "reset unknown" + now = time.time() if now_epoch is None else now_epoch + remaining = reset_epoch - now + if remaining <= 0: + return f"resets now ({local_time_text(reset_epoch)})" + return f"resets in {duration_text(remaining)} ({local_time_text(reset_epoch)})" + + +def metric_percent_value(metric: object) -> object: + if not isinstance(metric, dict): + return None + for key in ("used_percent", "utilization", "percentage", "percent", "usage"): + value = metric.get(key) + if value is not None: + return value + return None + + +def network_label_from_interface(interface: str) -> str: + value = interface.strip().lower() + if not value: + return "OFF" + if value.startswith(("tailscale", "tun", "tap", "wg", "zt")) or "vpn" in value: + return "VPN" + if value.startswith(("wl", "wifi", "wlan")): + return "WIFI" + if value.startswith(("en", "eth")): + return "LAN" + return "NET" + + +def volume_text() -> str: + return shell_output( + "pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null | awk -F'/' 'NR==1 {gsub(/ /,\"\",$2); print $2}'", + "vol", + ) + + +def toggle_volume() -> None: + run_command(["pactl", "set-sink-mute", "@DEFAULT_SINK@", "toggle"]) + + +def change_volume(delta: int) -> None: + sign = "+" if delta > 0 else "-" + run_command(["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{sign}{abs(delta)}%"]) + + +def open_audio_mixer() -> None: + run_command( + [ + "sh", + "-c", + "command -v pavucontrol >/dev/null && exec pavucontrol; command -v pwvucontrol >/dev/null && exec pwvucontrol; exec x-terminal-emulator -e alsamixer", + ] + ) + + +def audio_devices_listing() -> str: + return command_output( + [ + "sh", + "-c", + "printf 'default_sink\\t%s\\n' \"$(pactl get-default-sink 2>/dev/null)\"; " + "pactl list short sinks 2>/dev/null | awk '{print \"sink\\t\" $2}'; " + "printf 'default_source\\t%s\\n' \"$(pactl get-default-source 2>/dev/null)\"; " + "pactl list short sources 2>/dev/null | awk '{print \"source\\t\" $2}'", + ], + "", + ) + + +def parse_audio_devices(stdout: str) -> dict[str, object]: + parsed: dict[str, object] = { + "default_sink": "", + "default_source": "", + "sinks": [], + "sources": [], + } + for line in stdout.splitlines(): + kind, _, value = line.partition("\t") + if kind == "default_sink": + parsed["default_sink"] = value + elif kind == "default_source": + parsed["default_source"] = value + elif kind == "sink" and value: + parsed["sinks"].append(value) + elif kind == "source" and value: + parsed["sources"].append(value) + return parsed + + +def set_default_audio_device(kind: str, name: str) -> None: + if kind == "sink": + run_command(["pactl", "set-default-sink", name]) + elif kind == "source": + run_command(["pactl", "set-default-source", name]) + + +def short_audio_name(name: str) -> str: + value = name.strip() + for prefix in ("alsa_output.", "alsa_input.", "bluez_output.", "bluez_input."): + if value.startswith(prefix): + value = value[len(prefix):] + return value.replace("_", " ")[:38] if value else "unknown" + + +def calendar_text_from_output(text: str) -> str: + value = text.rstrip() + return value or "calendar unavailable" + + +def shifted_month(year: int, month: int, delta: int) -> tuple[int, int]: + index = (year * 12) + (month - 1) + delta + return index // 12, (index % 12) + 1 + + +def calendar_text_for_months(year: int, month: int) -> str: + renderer = calendar.TextCalendar(calendar.SUNDAY) + rendered = [] + for delta in (-1, 0, 1): + item_year, item_month = shifted_month(year, month, delta) + rendered.append(renderer.formatmonth(item_year, item_month).rstrip()) + return "\n\n".join(rendered) + + +def calendar_text() -> str: + today = date.today() + return calendar_text_for_months(today.year, today.month) + + +def battery_value_from_output(text: str) -> str | None: + value = text.strip() + return value or None + + +def battery_value() -> str | None: + return battery_value_from_output( + shell_output( + 'battery="$(upower -e 2>/dev/null | grep BAT | head -n1)"; ' + 'if [ -n "$battery" ]; then upower -i "$battery" | awk \'/percentage:/ {print $2; exit}\'; fi', + "", + ) + ) + + +def dnd_lua(command: str) -> str: + return f"local dnd = require('notifications').dnd; {command}" + + +def normalize_dnd_text(text: str) -> str: + value = text.strip().lower() + if "true" in value or '"on"' in value or value == "on": + return "on" + return "off" + + +def dnd_text() -> str: + return normalize_dnd_text( + command_output( + ["awesome-client", dnd_lua("return dnd.is_enabled() and 'on' or 'off'")], + "off", + ) + ) + + +def toggle_dnd() -> str: + return normalize_dnd_text( + command_output( + ["awesome-client", dnd_lua("return dnd.toggle() and 'on' or 'off'")], + "off", + ) + ) + + +def focus_window(window_id: str) -> None: + if not window_id.isdigit(): + return + + run_command( + [ + "awesome-client", + ( + "local target = tonumber('%s'); " + "for _, c in ipairs(client.get()) do " + "if c.window == target then " + "c.minimized = false; " + "c:emit_signal('request::activate', 'fabric-taskbar', {raise = true}); " + "return true " + "end " + "end " + "return false" + ) + % window_id, + ] + ) + + +def open_client_menu() -> None: + run_command( + [ + "awesome-client", + 'local awful = require("awful"); awful.menu.client_list({ theme = { width = 250 } })', + ] + ) + + +def battery_text() -> str: + return battery_value() or "" + + +def network_text() -> str: + interface = shell_output( + "ip -o -4 route get 1.1.1.1 2>/dev/null | awk '{for (i=1; i<=NF; i++) if ($i==\"dev\") {print $(i+1); exit}}'", + "", + ) + return network_label_from_interface(interface) + + +def decode_awesome_string(stdout: str) -> str: + value = stdout.strip() + prefix = 'string "' + if value.startswith(prefix): + value = value[len(prefix):] + if value.endswith('"'): + value = value[:-1] + return value.replace("\\n", "\n").replace("\\t", "\t").replace('\\"', '"') + + +def app_label(title: str, class_name: str) -> str: + mapped = APP_LABELS.get(class_name.strip().lower()) + if mapped: + return mapped + + candidate = title.strip() or class_name.strip() or "App" + for separator in (" - ", " | ", " :: "): + if separator in candidate: + candidate = candidate.rsplit(separator, 1)[-1] + break + + candidate = re.sub(r"\s+", " ", candidate).strip() + return candidate[:22] if len(candidate) > 22 else candidate + + +def initials_for_label(label: str) -> str: + clean = re.sub(r"[^A-Za-z0-9]+", " ", label).strip() + if not clean: + return "?" + if clean[0].isdigit(): + return clean[0] + parts = clean.split() + if len(parts) >= 2: + return (parts[0][0] + parts[1][0]).upper() + return clean[0].upper() + + +def icon_name_for_class(class_name: str) -> str: + key = class_name.strip().lower() + return ICON_NAMES.get(key) or key or "application-x-executable" + + +def icon_theme_has_icon(icon_name: str) -> bool: + try: + from gi.repository import Gtk + + theme = Gtk.IconTheme.get_default() + return bool(theme and theme.has_icon(icon_name)) + except Exception: + return False + + +def is_fabric_client(title: str, class_name: str) -> bool: + title_key = title.strip().lower() + class_key = class_name.strip().lower() + if class_key in {"config.py", "fabric", "fabric-awesomewm"}: + return True + if title_key == "fabric" or "fabric-awesomewm" in title_key: + return True + return "config.py" in title_key and "fabric" in title_key + + +def parse_awesome_clients(stdout: str) -> list[Task]: + tasks = [] + for line in decode_awesome_string(stdout).splitlines(): + if not line.strip(): + continue + parts = line.split("\t") + title = parts[0] if len(parts) > 0 else "" + class_name = parts[1] if len(parts) > 1 else "" + minimized = (parts[2] if len(parts) > 2 else "false").strip().lower() == "true" + window_id = (parts[3] if len(parts) > 3 else "").strip() + focused = (parts[4] if len(parts) > 4 else "false").strip().lower() == "true" + if is_fabric_client(title, class_name): + continue + tasks.append( + { + "label": app_label(title, class_name), + "class_name": class_name, + "minimized": minimized, + "window_id": window_id, + "focused": focused, + } + ) + return tasks + + +def group_tasks_for_dock(tasks: list[Task], max_icons: int = MAX_TASK_LABELS) -> list[Task]: + grouped_by_class: dict[str, Task] = {} + ordered: list[Task] = [] + + for task in tasks: + class_name = str(task.get("class_name") or "") + label = str(task.get("label") or "App") + key = class_name.strip().lower() or label.lower() + existing = grouped_by_class.get(key) + window_id = str(task.get("window_id") or "") + if existing is None: + grouped = { + "label": label, + "class_name": class_name, + "window_ids": [window_id], + "count": 1, + "focused": bool(task.get("focused")), + } + grouped_by_class[key] = grouped + ordered.append(grouped) + else: + existing["window_ids"].append(window_id) + existing["count"] = int(existing["count"]) + 1 + existing["focused"] = bool(existing.get("focused")) or bool(task.get("focused")) + + return ordered[:max_icons] + + +def overflow_count_for_tasks(tasks: list[Task], max_icons: int = MAX_TASK_LABELS) -> int: + unique_count = len(group_tasks_for_dock(tasks, max_icons=1000)) + return max(0, unique_count - max_icons) + + +def task_button_labels(tasks: list[Task]) -> list[str]: + counts = {} + labels = [] + for task in tasks[:MAX_TASK_LABELS]: + label = str(task.get("label") or "App") + counts[label] = counts.get(label, 0) + 1 + labels.append(label if counts[label] == 1 else f"{label} {counts[label]}") + return labels + + +def tasks_text(tasks: list[Task]) -> str: + if not tasks: + return "none" + + rendered = task_button_labels(tasks) + hidden_count = max(0, len(tasks) - MAX_TASK_LABELS) + if hidden_count: + rendered.append(f"+{hidden_count}") + return " ".join(rendered) + + +def running_tasks() -> list[Task]: + stdout = command_output(["awesome-client", AWESOME_CLIENTS_LUA]) + return parse_awesome_clients(stdout) + + +def running_apps_text() -> str: + return tasks_text(running_tasks()) + + +def ai_usage_from_status(data: object) -> str: + codex_session = nested_value(data, "codex", "session", "utilization") + claude_five_hour = nested_value(data, "claude", "five_hour", "utilization") + value = codex_session if codex_session is not None else claude_five_hour + if value is None: + return "AI" + + return percent_text(value) or "AI" + + +def ai_summary_from_status(data: object) -> dict[str, object]: + status = data if isinstance(data, dict) else {} + codex = status.get("codex", {}) if isinstance(status.get("codex"), dict) else {} + claude = status.get("claude", {}) if isinstance(status.get("claude"), dict) else {} + errors = status.get("errors", []) + normalized_errors = errors if isinstance(errors, list) else [] + + if codex.get("available"): + return { + "provider": "codex", + "session": percent_text(nested_value(codex, "session", "utilization")) or "AI", + "weekly": percent_text(nested_value(codex, "weekly", "utilization")) or "AI", + "session_resets_at": nested_value(codex, "session", "resets_at") or "", + "weekly_resets_at": nested_value(codex, "weekly", "resets_at") or "", + "timestamp": status.get("timestamp", ""), + "errors": normalized_errors, + } + + return { + "provider": "claude", + "session": percent_text(nested_value(claude, "five_hour", "utilization")) or "AI", + "weekly": percent_text(nested_value(claude, "seven_day", "utilization")) or "AI", + "session_resets_at": nested_value(claude, "five_hour", "resets_at") or "", + "weekly_resets_at": nested_value(claude, "seven_day", "resets_at") or "", + "timestamp": status.get("timestamp", ""), + "errors": normalized_errors, + } + + +def metric_from_provider(provider: object, metric_key: str) -> dict[str, object] | None: + if not isinstance(provider, dict): + return None + + metric = provider.get(metric_key) + if isinstance(metric, dict): + return metric + + value = None + for key in (f"{metric_key}_utilization", f"{metric_key}_percentage", f"{metric_key}_percent", f"{metric_key}_usage"): + if provider.get(key) is not None: + value = provider.get(key) + break + if value is None: + return None + + reset = None + for key in (f"{metric_key}_resets_at", f"{metric_key}_reset_at", f"{metric_key}_resets_on", f"{metric_key}_reset_on"): + if provider.get(key) is not None: + reset = provider.get(key) + break + + return { + "utilization": value, + "resets_at": reset, + } + + +def metric_percent(metric: object) -> float | None: + if not isinstance(metric, dict): + return None + for key in ("utilization", "percentage", "percent", "usage"): + value = as_number(metric.get(key)) + if value is not None: + return value + return None + + +def metric_reset(metric: object) -> object: + if not isinstance(metric, dict): + return None + return metric.get("resets_at") or metric.get("reset_at") or metric.get("resets_on") or metric.get("reset_on") or metric.get("window_end") + + +def ai_provider_tabs(data: object, active_provider: str) -> list[dict[str, object]]: + status = data if isinstance(data, dict) else {} + tabs = [] + for provider_key in ("claude", "codex"): + provider = status.get(provider_key) + provider_data = provider if isinstance(provider, dict) else {} + tabs.append( + { + "provider": provider_key, + "label": str(AI_PROVIDER_DEFS[provider_key]["label"]), + "active": provider_key == active_provider, + "available": bool(provider_data.get("available")), + "error": str(provider_data.get("error") or ""), + } + ) + return tabs + + +def ai_metric_rows(data: object, provider_key: str, now_epoch: float | None = None) -> list[dict[str, object]]: + status = data if isinstance(data, dict) else {} + provider = status.get(provider_key) + provider_data = provider if isinstance(provider, dict) else {} + rows = [] + seen_keys = set() + provider_def = AI_PROVIDER_DEFS[provider_key] + for metric_key, metric_label in provider_def["metrics"]: + if metric_key in seen_keys: + continue + metric = metric_from_provider(provider_data, metric_key) + if metric is None: + continue + percent = metric_percent(metric) + rows.append( + { + "key": metric_key, + "label": metric_label, + "percent": percent, + "percent_text": percent_display(percent), + "progress": progress_value(percent), + "severity": usage_severity(percent), + "status": usage_status(percent), + "reset_text": format_reset_text(metric_reset(metric), now_epoch=now_epoch), + } + ) + seen_keys.add(metric_key) + return rows + + +def claude_credit_row(data: object) -> dict[str, object] | None: + if not isinstance(data, dict): + return None + extra = nested_value(data, "claude", "extra_usage") + if not isinstance(extra, dict) or not extra.get("is_enabled"): + return None + percent = as_number(extra.get("utilization")) + used = extra.get("used_credits", 0) + limit = extra.get("monthly_limit", 0) + return { + "key": "credits", + "label": "Credits", + "percent": percent, + "percent_text": percent_display(percent), + "progress": progress_value(percent), + "severity": usage_severity(percent), + "status": usage_status(percent), + "reset_text": f"{used} / {limit}", + } + + +def ai_dashboard_model(data: object, provider: str, now_epoch: float | None = None) -> dict[str, object]: + active_provider = normalize_ai_provider(provider) + provider_label = str(AI_PROVIDER_DEFS[active_provider]["label"]) + status = data if isinstance(data, dict) else {} + provider_data = status.get(active_provider) + provider_data = provider_data if isinstance(provider_data, dict) else {} + provider_pending = ai_provider_status_pending(status, active_provider) + + rows = ai_metric_rows(status, active_provider, now_epoch=now_epoch) + if active_provider == "claude": + credits = claude_credit_row(status) + if credits is not None: + rows.append(credits) + + status_messages = [] + provider_error = provider_data.get("error") + if not rows and provider_pending: + status_messages.append(f"Refreshing {provider_label} usage...") + elif not rows and provider_data.get("available") is False: + status_messages.append(f"{provider_label} unavailable: {provider_error or 'no usage data'}") + elif not rows: + status_messages.append(f"{provider_label} usage unavailable") + + primary_percent = rows[0]["percent"] if rows else None + return { + "active_provider": active_provider, + "title": f"{provider_label} Usage", + "tabs": ai_provider_tabs(status, active_provider), + "rows": rows, + "status_messages": status_messages, + "primary_percent": primary_percent, + "primary_percent_text": percent_display(primary_percent), + "primary_severity": usage_severity(primary_percent), + "primary_status": usage_status(primary_percent), + } + + +def ai_compact_usage_from_status(data: object, provider: str, live_codex: str | None = None) -> str: + active_provider = normalize_ai_provider(provider) + if active_provider == "codex" and live_codex is not None: + return live_codex + + rows = ai_metric_rows(data, active_provider) + if not rows: + if ai_provider_status_pending(data, active_provider): + return "..." + return "--" + return percent_display(rows[0].get("percent")) + + +def ai_provider_status_pending(data: object, active_provider: str) -> bool: + active_provider = normalize_ai_provider(active_provider) + status = data if isinstance(data, dict) else {} + status_provider = status.get("active_provider") + if isinstance(status_provider, str) and status_provider in AI_PROVIDER_DEFS and status_provider != active_provider: + return True + + provider_data = status.get(active_provider) + if isinstance(provider_data, dict) and provider_data.get("error") == "monitor_stopped": + return True + + return False + + +def percent_number_from_text(text: str | None) -> float | None: + if not text: + return None + match = re.search(r"(\d+(?:\.\d+)?)\s*%", text) + return as_number(match.group(1)) if match else None + + +def status_with_live_codex_usage(data: object, live_codex: str | None) -> object: + percent = percent_number_from_text(live_codex) + if percent is None or not isinstance(data, dict): + return data + updated = copy.deepcopy(data) + codex = updated.setdefault("codex", {}) + if not isinstance(codex, dict): + return data + session = codex.setdefault("session", {}) + if isinstance(session, dict): + session["utilization"] = percent + codex["available"] = True + return updated + + +def ai_status_data() -> object: + try: + return json.loads(AI_STATUS_PATH.read_text()) + except Exception: + return {} + + +def current_ai_summary() -> dict[str, object]: + summary = ai_summary_from_status(ai_status_data()) + provider = load_ai_provider_preference() + live_codex = codex_live_usage_text() if provider == "codex" else None + if provider == "codex" and live_codex is not None: + summary["provider"] = "codex" + summary["session"] = live_codex + return summary + + +def restart_ai_usage_monitor() -> None: + run_command(["systemctl", "--user", "restart", "ai-usage-monitor.service"]) + + +def schedule_ai_provider_refreshes(callback, scheduler=None, delays: tuple[int, ...] = AI_PROVIDER_SWITCH_REFRESH_DELAYS_MS) -> bool: + if scheduler is None: + try: + from gi.repository import GLib + + scheduler = GLib.timeout_add + except Exception: + return False + + for delay in delays: + def run_once(callback=callback): + callback() + return False + + scheduler(int(delay), run_once) + + return bool(delays) + + +def open_ai_usage_url(provider: str) -> None: + url = AI_USAGE_URLS.get(provider) or AI_USAGE_URLS["codex"] + run_command(["xdg-open", url]) + + +def event_has_shift(event: object) -> bool: + try: + from gi.repository import Gdk + + return bool(int(getattr(event, "state", 0)) & int(Gdk.ModifierType.SHIFT_MASK)) + except Exception: + return "shift" in str(getattr(event, "state", "")).lower() + + +def codex_usage_from_rate_limits(rate_limits: object) -> str | None: + if isinstance(rate_limits, list): + for item in reversed(rate_limits): + usage = codex_usage_from_rate_limits(item) + if usage is not None: + return usage + return None + + if not isinstance(rate_limits, dict): + return None + + limit_id = rate_limits.get("limit_id") + if limit_id not in (None, "codex"): + return None + + for source in (rate_limits.get("primary"), rate_limits.get("session"), rate_limits): + value = metric_percent_value(source) + if value is not None: + return percent_text(value) + return None + + +def codex_usage_text_from_lines(lines: list[str]) -> str | None: + for line in reversed(lines): + if "token_count" not in line or "rate_limit" not in line: + continue + try: + event = json.loads(line) + except Exception: + continue + if not isinstance(event, dict) or event.get("type") != "event_msg": + continue + + payload = event.get("payload") + if not isinstance(payload, dict) or payload.get("type") != "token_count": + continue + + info = payload.get("info") if isinstance(payload.get("info"), dict) else {} + rate_limits = info.get("rate_limits") or payload.get("rate_limits") + usage = codex_usage_from_rate_limits(rate_limits) + if usage is not None: + return usage + return None + + +def recent_codex_session_files(session_dir: Path = CODEX_SESSION_DIR) -> list[Path]: + try: + files = [path for path in session_dir.rglob("*.jsonl") if path.is_file()] + except Exception: + return [] + + def file_mtime(path: Path) -> float: + try: + return path.stat().st_mtime + except Exception: + return 0 + + return sorted(files, key=file_mtime, reverse=True)[:CODEX_SESSION_FILE_LIMIT] + + +def tail_lines(path: Path, max_bytes: int = CODEX_SESSION_TAIL_BYTES) -> list[str]: + try: + with path.open("rb") as handle: + handle.seek(0, 2) + size = handle.tell() + handle.seek(max(0, size - max_bytes)) + return handle.read().decode("utf-8", errors="replace").splitlines() + except Exception: + return [] + + +def codex_live_usage_text() -> str | None: + for path in recent_codex_session_files(): + usage = codex_usage_text_from_lines(tail_lines(path)) + if usage is not None: + return usage + return None + + +def ai_usage_text() -> str: + provider = load_ai_provider_preference() + live_codex = codex_live_usage_text() if provider == "codex" else None + try: + data = json.loads(AI_STATUS_PATH.read_text()) + except Exception: + data = {} + + return ai_compact_usage_from_status(data, provider, live_codex=live_codex) + + +def run_self_check() -> int: + for check in (volume_text, battery_value, network_text, ai_usage_text, running_apps_text, dnd_text): + check() + return 0 + + +class StatusPill(Box): + def __init__(self, label: str, initial: str = "...", **kwargs): + self.label = Label(name="pill-label", label=label) + self.value = Label(name="pill-value", label=initial) + super().__init__( + name="status-pill", + orientation="h", + spacing=5, + children=[self.label, self.value], + **kwargs, + ) + + def set_value(self, value: str) -> None: + self.value.set_label(value) + + +class TaskStrip(Box): + def __init__(self, **kwargs): + self.task_buttons = Box( + name="task-buttons", + orientation="h", + spacing=5, + children=[Label(name="task-empty", label="idle")], + ) + super().__init__( + name="task-strip", + orientation="h", + spacing=0, + children=[self.task_buttons], + **kwargs, + ) + + def task_child(self, task: Task) -> Box | Label: + label = str(task.get("label") or "App") + class_name = str(task.get("class_name") or "") + icon_name = icon_name_for_class(class_name) + count = int(task.get("count") or 1) + if icon_theme_has_icon(icon_name): + app_child = Image(icon_name=icon_name, icon_size=16) + else: + app_child = Label(name="task-initials", label=initials_for_label(label)) + + if count <= 1: + return app_child + + return Box( + name="task-button-inner", + orientation="h", + spacing=1, + children=[ + app_child, + Label(name="task-count-badge", label=str(count)), + ], + ) + + def set_tasks(self, tasks: list[Task]) -> None: + if not tasks: + self.task_buttons.children = [Label(name="task-empty", label="idle")] + return + + grouped_tasks = group_tasks_for_dock(tasks) + children = [] + for task in grouped_tasks: + window_ids = task.get("window_ids") if isinstance(task.get("window_ids"), list) else [] + window_id = str(window_ids[0]) if window_ids else "" + label = str(task.get("label") or "App") + class_name = str(task.get("class_name") or label) + button = Button( + name="task-button", + style_classes=["focused"] if bool(task.get("focused")) else [], + child=self.task_child(task), + on_clicked=lambda *_args, target=window_id: focus_window(target), + ) + button.set_tooltip_text(class_name) + children.append(button) + + hidden_count = overflow_count_for_tasks(tasks) + if hidden_count: + children.append( + Button( + name="task-overflow", + child=Label(label=f"+{hidden_count}"), + on_clicked=lambda *_: open_client_menu(), + ) + ) + + self.task_buttons.children = children + + +class PopupManager: + def __init__(self, clock=time.monotonic, reopen_suppression_seconds: float = 0.18): + self.clock = clock + self.reopen_suppression_seconds = reopen_suppression_seconds + self.popups: dict[str, object] = {} + self.recent_focus_close: dict[str, float] = {} + self.active_name: str | None = None + + def register(self, name: str, popup: object) -> None: + self.popups[name] = popup + + def is_visible(self, name: str) -> bool: + popup = self.popups.get(name) + if popup is None: + return False + get_visible = getattr(popup, "get_visible", None) + return bool(get_visible()) if callable(get_visible) else False + + def suppress_reopen(self, name: str) -> bool: + closed_at = self.recent_focus_close.get(name) + if closed_at is None: + return False + if self.clock() - closed_at <= self.reopen_suppression_seconds: + self.recent_focus_close.pop(name, None) + return True + self.recent_focus_close.pop(name, None) + return False + + def open(self, name: str) -> None: + popup = self.popups.get(name) + if popup is None: + return + self.close_all(except_name=name) + refresh = getattr(popup, "refresh", None) + if callable(refresh): + refresh() + show_all = getattr(popup, "show_all", None) + if callable(show_all): + show_all() + present = getattr(popup, "present", None) + if callable(present): + present() + self.active_name = name + + def close(self, name: str, reason: str = "manual") -> None: + popup = self.popups.get(name) + if popup is None: + return + hide = getattr(popup, "hide", None) + if callable(hide): + hide() + if self.active_name == name: + self.active_name = None + if reason == "focus-out": + self.recent_focus_close[name] = self.clock() + + def close_all(self, except_name: str | None = None) -> None: + for name in list(self.popups): + if name != except_name: + self.close(name) + + def toggle(self, name: str) -> None: + if self.is_visible(name) or self.active_name == name: + self.close(name) + return + if self.suppress_reopen(name): + return + self.open(name) + + +class AudioDevicePopout(Window): + def __init__(self): + self.rows = Box(name="audio-popout-rows", orientation="v", spacing=4) + super().__init__( + name="audio-popout", + layer="top", + geometry="top-right", + margin="37px 10px 0px 0px", + type_hint="dialog", + visible=False, + child=Box( + name="popout-panel", + orientation="v", + spacing=8, + children=[ + Label(name="popout-title", label="AUDIO"), + self.rows, + ], + ), + ) + + def refresh(self) -> None: + devices = parse_audio_devices(audio_devices_listing()) + default_sink = str(devices.get("default_sink") or "") + default_source = str(devices.get("default_source") or "") + children = [ + Label(name="popout-section", label="OUTPUT"), + *self.device_rows("sink", devices.get("sinks"), default_sink), + Label(name="popout-section", label="INPUT"), + *self.device_rows("source", devices.get("sources"), default_source), + ] + self.rows.children = children + + def device_rows(self, kind: str, devices: object, active: str) -> list[Button | Label]: + if not isinstance(devices, list) or not devices: + return [Label(name="popout-muted", label="none")] + + rows: list[Button | Label] = [] + for device in devices: + name = str(device) + marker = ">" if name == active else " " + row = Button( + name="audio-device-row", + style_classes=["active"] if name == active else [], + child=Label(label=f"{marker} {short_audio_name(name)}"), + on_clicked=lambda *_args, target=name, target_kind=kind: set_default_audio_device(target_kind, target), + ) + rows.append(row) + return rows + + def toggle(self) -> None: + if self.get_visible(): + self.hide() + return + self.refresh() + self.show_all() + + +class AIUsagePopout(Window): + def __init__(self, on_provider_changed=None): + self.on_provider_changed = on_provider_changed + self.panel = Box(name="ai-panel", orientation="v", spacing=9) + super().__init__( + name="ai-popout", + layer="top", + geometry="top-right", + margin="37px 10px 0px 0px", + type_hint="dialog", + visible=False, + child=self.panel, + ) + + def refresh(self) -> None: + provider = load_ai_provider_preference() + live_codex = codex_live_usage_text() if provider == "codex" else None + status = status_with_live_codex_usage(ai_status_data(), live_codex) + model = ai_dashboard_model(status, provider) + children = [ + self.header(model), + self.provider_tabs(model), + ] + if model["rows"]: + children.extend(self.metric_row(row) for row in model["rows"]) + else: + children.extend(self.status_message(message) for message in model["status_messages"]) + children.append(self.footer(model)) + self.panel.children = children + + def header(self, model: dict[str, object]) -> Box: + return Box( + name="ai-header", + orientation="h", + spacing=10, + children=[ + Label(name="ai-title", label=str(model["title"])), + Label( + name="ai-status-badge", + style_classes=[str(model["primary_severity"])], + label=str(model["primary_status"]), + ), + Label(name="ai-primary-percent", label=str(model["primary_percent_text"])), + ], + ) + + def provider_tabs(self, model: dict[str, object]) -> Box: + tabs = [] + for tab in model["tabs"]: + provider = str(tab["provider"]) + style_classes = ["active"] if tab["active"] else [] + if not tab["available"]: + style_classes.append("unavailable") + button = Button( + name="ai-provider-tab", + style_classes=style_classes, + child=Label(label=str(tab["label"])), + on_clicked=lambda *_args, target=provider: self.select_provider(target), + ) + tabs.append(button) + + return Box(name="ai-provider-tabs", orientation="h", spacing=6, children=tabs) + + def metric_row(self, row: dict[str, object]) -> Box: + progress = Scale( + name="ai-progress", + style_classes=[str(row["severity"])], + value=float(row["progress"]), + min_value=0, + max_value=1, + draw_value=False, + size=(238, 8), + ) + progress.set_sensitive(False) + return Box( + name="ai-metric-row", + orientation="v", + spacing=4, + children=[ + Box( + name="ai-metric-head", + orientation="h", + spacing=8, + children=[ + Label(name="ai-metric-label", h_expand=True, label=str(row["label"])), + Label(name="ai-metric-value", style_classes=[str(row["severity"])], label=str(row["percent_text"])), + ], + ), + progress, + Box( + name="ai-metric-foot", + orientation="h", + spacing=8, + children=[ + Label(name="ai-metric-reset", h_expand=True, label=str(row["reset_text"])), + Label(name="ai-metric-status", style_classes=[str(row["severity"])], label=str(row["status"])), + ], + ), + ], + ) + + def status_message(self, message: str) -> Box: + return Box(name="ai-status-message", children=[Label(label=message)]) + + def footer(self, model: dict[str, object]) -> Box: + provider = str(model["active_provider"]) + return Box( + name="ai-footer", + orientation="h", + spacing=6, + children=[ + Button( + name="ai-footer-button", + child=Label(label="Open Usage"), + on_clicked=lambda *_: open_ai_usage_url(provider), + ), + Button( + name="ai-footer-button", + child=Label(label="Refresh"), + on_clicked=lambda *_: self.restart_monitor_and_refresh(), + ), + Button( + name="ai-footer-button", + child=Label(label="Switch"), + on_clicked=lambda *_: self.select_provider(next_ai_provider(provider)), + ), + ], + ) + + def select_provider(self, provider: str) -> None: + save_ai_provider_preference(provider) + self.restart_monitor_and_refresh() + + def refresh_after_provider_change(self) -> None: + if self.on_provider_changed is not None: + self.on_provider_changed() + if self.get_visible(): + self.refresh() + + def restart_monitor_and_refresh(self) -> None: + restart_ai_usage_monitor() + self.refresh_after_provider_change() + schedule_ai_provider_refreshes(self.refresh_after_provider_change) + + def toggle(self) -> None: + if self.get_visible(): + self.hide() + return + self.refresh() + self.show_all() + + +class CalendarPopout(Window): + def __init__(self): + self.calendar_label = Label(name="calendar-label", label="") + super().__init__( + name="calendar-popout", + layer="top", + geometry="top", + margin="37px 0px 0px 0px", + type_hint="dialog", + visible=False, + child=Box( + name="calendar-panel", + orientation="v", + spacing=6, + children=[ + Label(name="popout-title", label="CALENDAR"), + self.calendar_label, + ], + ), + ) + + def toggle(self) -> None: + if self.get_visible(): + self.hide() + return + self.calendar_label.set_label(calendar_text()) + self.show_all() + + +class StatusBar(Window): + def __init__(self): + super().__init__( + name="fabric-awesomewm-bar", + layer="top", + geometry="top", + type_hint="dock", + size=bar_size_from_monitor_width(primary_monitor_width()), + visible=False, + ) + + self.tasks = TaskStrip() + self.popup_manager = PopupManager() + self.network = StatusPill("NET", "...") + self.volume = StatusPill("VOL", "...") + self.audio_popout = AudioDevicePopout() + self.volume_button = EventBox( + name="volume-button", + events=["button-press", "scroll"], + child=self.volume, + ) + self.volume_button.connect("button-press-event", self.on_volume_button_press) + self.volume_button.connect("scroll-event", self.on_volume_scroll) + self.ai = StatusPill("AI", "AI") + self.ai_popout = AIUsagePopout(on_provider_changed=self.refresh_ai_usage) + self.ai_button = EventBox( + name="ai-button", + events=["button-press"], + child=self.ai, + ) + self.ai_button.connect("button-press-event", self.on_ai_button_press) + self.calendar_popout = CalendarPopout() + self.register_popup("audio", self.audio_popout) + self.register_popup("ai", self.ai_popout) + self.register_popup("calendar", self.calendar_popout) + self.dnd = StatusPill("DND", "off") + self.dnd_button = Button( + name="dnd-button", + child=self.dnd, + on_clicked=lambda *_: self.refresh_dnd(toggle_dnd()), + ) + battery_initial = battery_value() + self.battery = StatusPill("BAT", battery_initial) if battery_initial is not None else None + + end_children = [ + self.network, + self.volume_button, + self.ai_button, + ] + if self.battery is not None: + end_children.append(self.battery) + end_children.extend( + [ + self.dnd_button, + Button( + name="settings-button", + child=Label(label="SET"), + on_clicked=lambda *_: run_command( + [ + "sh", + "-c", + "printf '%s\\n' 'Audio (pavucontrol)' 'Display (arandr)' 'GTK Themes (lxappearance)' 'Bluetooth (blueman-manager)' 'Network (nm-connection-editor)' 'Power (xfce4-power-manager-settings)' | rofi -dmenu -i -p Settings | sed 's/.*(\\(.*\\))/\\1/' | xargs -r -I{} sh -c '{}'", + ] + ), + ), + ] + ) + + self.children = CenterBox( + name="bar-inner", + h_expand=True, + start_children=Box( + name="start-container", + orientation="h", + spacing=8, + h_expand=True, + children=[ + Button( + name="launcher-button", + tooltip_text="Launcher", + child=Label(label=">"), + on_clicked=lambda *_: run_command([str(HOME / ".local/bin/flare")]), + ), + self.tasks, + ], + ), + center_children=Box( + name="center-container", + h_expand=True, + children=[ + Button( + name="clock-button", + child=DateTime(name="date-time", formatters="%a %-d %H:%M"), + on_clicked=lambda *_: self.popup_manager.toggle("calendar"), + ) + ], + ), + end_children=Box( + name="end-container", + orientation="h", + spacing=8, + h_expand=True, + children=end_children, + ), + ) + + self.pollers = [ + Fabricator(interval=2000, poll_from=lambda _: running_tasks(), on_changed=lambda _, value: self.tasks.set_tasks(value)), + Fabricator(interval=10000, poll_from=lambda _: network_text(), on_changed=lambda _, value: self.network.set_value(value)), + Fabricator(interval=VOLUME_POLL_MS, poll_from=lambda _: volume_text(), on_changed=lambda _, value: self.volume.set_value(value)), + Fabricator(interval=AI_POLL_MS, poll_from=lambda _: ai_usage_text(), on_changed=lambda _, value: self.ai.set_value(value)), + Fabricator(interval=3000, poll_from=lambda _: dnd_text(), on_changed=lambda _, value: self.refresh_dnd(value)), + ] + if self.battery is not None: + self.pollers.append( + Fabricator(interval=30000, poll_from=lambda _: battery_text(), on_changed=lambda _, value: self.battery.set_value(value)) + ) + + self.tasks.set_tasks(running_tasks()) + self.network.set_value(network_text()) + self.volume.set_value(volume_text()) + self.refresh_ai_usage() + self.refresh_dnd(dnd_text()) + + def windows(self) -> list[Window]: + return [self, self.audio_popout, self.ai_popout, self.calendar_popout] + + def register_popup(self, name: str, popup: Window) -> None: + self.popup_manager.register(name, popup) + popup.connect("focus-out-event", lambda *_args, target=name: self.on_popup_focus_out(target)) + popup.connect("key-press-event", lambda _widget, event, target=name: self.on_popup_key_press(target, event)) + + def on_popup_focus_out(self, name: str) -> bool: + self.popup_manager.close(name, reason="focus-out") + return False + + def on_popup_key_press(self, name: str, event) -> bool: + try: + from gi.repository import Gdk + + if int(getattr(event, "keyval", 0)) == int(Gdk.KEY_Escape): + self.popup_manager.close(name) + return True + except Exception: + if str(getattr(event, "keyval", "")).lower() == "escape": + self.popup_manager.close(name) + return True + return False + + def on_volume_button_press(self, _widget, event) -> bool: + button = int(getattr(event, "button", 0)) + if button == 1: + toggle_volume() + elif button == 2: + open_audio_mixer() + elif button == 3: + self.popup_manager.toggle("audio") + return True + + def on_volume_scroll(self, _widget, event) -> bool: + direction = str(getattr(event, "direction", "")).lower() + if "up" in direction: + change_volume(5) + elif "down" in direction: + change_volume(-5) + return True + + def on_ai_button_press(self, _widget, event) -> bool: + button = int(getattr(event, "button", 0)) + if button == 1 and event_has_shift(event): + restart_ai_usage_monitor() + elif button == 1: + self.popup_manager.toggle("ai") + elif button == 2: + self.switch_ai_provider() + elif button == 3: + provider = load_ai_provider_preference() + open_ai_usage_url(provider) + return True + + def refresh_ai_usage(self) -> None: + self.ai.set_value(ai_usage_text()) + + def switch_ai_provider(self) -> None: + save_ai_provider_preference(next_ai_provider(load_ai_provider_preference())) + restart_ai_usage_monitor() + self.refresh_after_ai_provider_change() + schedule_ai_provider_refreshes(self.refresh_after_ai_provider_change) + + def refresh_after_ai_provider_change(self) -> None: + self.refresh_ai_usage() + if self.ai_popout.get_visible(): + self.ai_popout.refresh() + + def refresh_dnd(self, value: str) -> None: + state = normalize_dnd_text(value) + self.dnd.set_value(state) + self.dnd_button.set_style_classes(["dnd-on"] if state == "on" else ["dnd-off"]) + + +if __name__ == "__main__": + if "--check" in sys.argv: + raise SystemExit(run_self_check()) + + bar = StatusBar() + app = Application("fabric-awesomewm", *bar.windows()) + app.set_stylesheet_from_file(get_relative_path("./style.css")) + bar.show_all() + app.run() diff --git a/roles/fabric/files/config/awesomewm/style.css b/roles/fabric/files/config/awesomewm/style.css new file mode 100644 index 00000000..937bdfe9 --- /dev/null +++ b/roles/fabric/files/config/awesomewm/style.css @@ -0,0 +1,421 @@ +:vars { + --base: #05070d; + --rail: #090f19; + --surface0: #0d1420; + --surface1: #131d2b; + --surface2: #253247; + --text: #e8f0fb; + --subtext: #91a5bc; + --muted: #5f7289; + --cyan: #55e6ff; + --blue: #6aa8ff; + --violet: #b49cff; + --green: #8ff0a4; + --yellow: #f6d365; + --red: #ff6b8a; +} + +* { + all: unset; + color: var(--text); + font-family: "BerkeleyMono Nerd Font"; + font-size: 10px; +} + +#fabric-awesomewm-bar { + background-color: transparent; +} + +#bar-inner { + min-height: 29px; + padding: 3px 10px; + background-color: alpha(var(--base), 0.97); + border-bottom: 2px solid alpha(var(--cyan), 0.55); +} + +#start-container, +#center-container, +#end-container { + min-height: 23px; +} + +#start-container { + padding-left: 0; +} + +#end-container { + padding-right: 0; +} + +#launcher-button, +#settings-button, +#task-overflow, +#task-button, +#status-pill, +#dnd-button, +#volume-button, +#ai-button, +#clock-button { + min-height: 22px; + border-radius: 3px; + background-color: alpha(var(--surface0), 0.92); + border: 1px solid alpha(var(--surface2), 0.82); +} + +#launcher-button { + min-width: 28px; + padding: 0 7px; + border-color: alpha(var(--cyan), 0.9); + color: var(--cyan); + background-color: alpha(var(--surface1), 0.75); +} + +#settings-button { + min-width: 27px; + border-color: alpha(var(--blue), 0.8); + color: var(--blue); +} + +#status-pill { + padding: 0 7px; +} + +#status-pill #pill-label { + color: var(--subtext); + font-weight: 700; +} + +#status-pill #pill-value { + color: var(--text); +} + +#task-strip { + min-height: 23px; + padding: 0; +} + +#task-strip-label { + color: var(--violet); + font-weight: 700; +} + +#task-buttons { + min-height: 23px; +} + +#task-button { + min-width: 23px; + min-height: 23px; + padding: 0; + border-radius: 4px; + color: var(--text); + background-color: alpha(var(--surface1), 0.72); + border: 1px solid alpha(var(--surface2), 0.62); +} + +#task-button:hover, +#status-pill:hover, +#settings-button:hover, +#launcher-button:hover, +#task-overflow:hover, +#volume-button:hover, +#ai-button:hover, +#clock-button:hover, +#dnd-button:hover { + background-color: alpha(var(--surface1), 0.96); + border-color: alpha(var(--cyan), 0.75); +} + +#task-button.focused { + border-color: alpha(var(--cyan), 0.95); + border-bottom: 2px solid var(--cyan); +} + +#task-button-inner { + min-height: 16px; +} + +#task-initials { + color: var(--text); + font-weight: 700; +} + +#task-count-badge { + min-width: 8px; + padding: 0 2px; + color: var(--base); + background-color: var(--cyan); + border-radius: 3px; + font-size: 7px; + font-weight: 700; +} + +#task-empty, +#task-overflow { + color: var(--muted); +} + +#task-overflow { + min-width: 25px; + padding: 0 6px; +} + +#date-time { + padding: 0 12px; + color: var(--cyan); + font-weight: 700; +} + +#clock-button { + border-color: alpha(var(--cyan), 0.42); + background-color: alpha(var(--rail), 0.84); +} + +#center-container { + border-bottom: 1px solid alpha(var(--cyan), 0.28); +} + +#dnd-button { + padding: 0; +} + +#dnd-button #status-pill { + padding: 0 7px; + background-color: transparent; + border: none; +} + +#dnd-button.dnd-on { + border-color: alpha(var(--yellow), 0.88); + background-color: alpha(var(--yellow), 0.14); +} + +#dnd-button.dnd-on #pill-value { + color: var(--yellow); +} + +#dnd-button.dnd-off #pill-value { + color: var(--muted); +} + +#popout-panel { + min-width: 260px; + padding: 10px; + background-color: alpha(var(--base), 0.98); + border: 1px solid alpha(var(--cyan), 0.55); + border-bottom: 2px solid alpha(var(--cyan), 0.72); + border-radius: 4px; +} + +#calendar-panel { + padding: 10px; + background-color: alpha(var(--base), 0.98); + border: 1px solid alpha(var(--cyan), 0.55); + border-bottom: 2px solid alpha(var(--cyan), 0.72); + border-radius: 4px; +} + +#calendar-label { + color: var(--text); +} + +#popout-title { + color: var(--cyan); + font-weight: 700; +} + +#popout-section { + color: var(--violet); + font-weight: 700; +} + +#popout-muted { + color: var(--muted); +} + +#audio-device-row { + min-height: 22px; + padding: 0 6px; + border-radius: 3px; + color: var(--text); + background-color: alpha(var(--surface0), 0.78); + border: 1px solid alpha(var(--surface2), 0.6); +} + +#audio-device-row:hover, +#audio-device-row.active { + border-color: alpha(var(--cyan), 0.86); + background-color: alpha(var(--surface1), 0.92); +} + +tooltip { + background-color: var(--base); + border: 1px solid var(--cyan); + border-radius: 3px; +} + +#ai-panel { + min-width: 290px; + padding: 11px; + background-color: alpha(var(--base), 0.985); + border: 1px solid alpha(var(--blue), 0.62); + border-bottom: 2px solid alpha(var(--cyan), 0.82); + border-radius: 4px; +} + +#ai-header { + min-height: 23px; +} + +#ai-title { + color: var(--text); + font-weight: 700; +} + +#ai-primary-percent { + color: var(--cyan); + font-weight: 700; +} + +#ai-status-badge { + padding: 0 6px; + border-radius: 3px; + font-weight: 700; + background-color: alpha(var(--surface1), 0.9); + border: 1px solid alpha(var(--surface2), 0.8); +} + +#ai-provider-tabs { + min-height: 24px; +} + +#ai-provider-tab { + min-height: 23px; + padding: 0 10px; + border-radius: 3px; + color: var(--subtext); + background-color: alpha(var(--surface0), 0.88); + border: 1px solid alpha(var(--surface2), 0.72); +} + +#ai-provider-tab.active { + color: var(--base); + background-color: var(--cyan); + border-color: alpha(var(--cyan), 0.95); + font-weight: 700; +} + +#ai-provider-tab.unavailable { + color: var(--muted); + border-color: alpha(var(--red), 0.36); +} + +#ai-metric-row { + padding: 7px; + border-radius: 4px; + background-color: alpha(var(--surface0), 0.72); + border: 1px solid alpha(var(--surface2), 0.64); +} + +#ai-metric-head, +#ai-metric-foot { + min-height: 14px; +} + +#ai-metric-label, +#ai-metric-reset { + color: var(--subtext); +} + +#ai-metric-value, +#ai-metric-status { + color: var(--text); + font-weight: 700; +} + +#ai-progress { + min-height: 8px; +} + +#ai-progress trough { + min-height: 4px; + border-radius: 2px; + background-color: alpha(var(--surface2), 0.62); +} + +#ai-progress highlight { + min-height: 4px; + border-radius: 2px; + background-color: var(--green); +} + +#ai-progress slider { + min-width: 0; + min-height: 0; + background-color: transparent; + border: none; +} + +#ai-progress.warm highlight { + background-color: var(--yellow); +} + +#ai-progress.hot highlight { + background-color: var(--violet); +} + +#ai-progress.critical highlight { + background-color: var(--red); +} + +#ai-status-badge.cool, +#ai-metric-value.cool, +#ai-metric-status.cool { + color: var(--green); +} + +#ai-status-badge.warm, +#ai-metric-value.warm, +#ai-metric-status.warm { + color: var(--yellow); +} + +#ai-status-badge.hot, +#ai-metric-value.hot, +#ai-metric-status.hot { + color: var(--violet); +} + +#ai-status-badge.critical, +#ai-metric-value.critical, +#ai-metric-status.critical { + color: var(--red); +} + +#ai-status-message { + padding: 8px; + border-radius: 4px; + color: var(--muted); + background-color: alpha(var(--surface0), 0.7); + border: 1px solid alpha(var(--red), 0.38); +} + +#ai-footer { + min-height: 23px; +} + +#ai-footer-button { + min-height: 22px; + padding: 0 8px; + border-radius: 3px; + color: var(--subtext); + background-color: alpha(var(--surface0), 0.88); + border: 1px solid alpha(var(--surface2), 0.72); +} + +#ai-footer-button:hover, +#ai-provider-tab:hover { + color: var(--text); + border-color: alpha(var(--cyan), 0.76); + background-color: alpha(var(--surface1), 0.94); +} diff --git a/roles/fabric/tasks/Ubuntu.yml b/roles/fabric/tasks/Ubuntu.yml new file mode 100644 index 00000000..3eba9483 --- /dev/null +++ b/roles/fabric/tasks/Ubuntu.yml @@ -0,0 +1,153 @@ +--- +- name: "{{ role_name }} | Ubuntu | Install Fabric system dependencies" + ansible.builtin.apt: + name: "{{ fabric_ubuntu_packages }}" + state: present + update_cache: true + become: true + +- name: "{{ role_name }} | Ubuntu | Ensure Fabric state directory exists" + ansible.builtin.file: + path: "{{ fabric_venv_path | dirname }}" + state: directory + mode: "0755" + +- name: "{{ role_name }} | Ubuntu | Ensure local bin directory exists" + ansible.builtin.file: + path: "{{ ansible_facts['user_dir'] }}/.local/bin" + state: directory + mode: "0755" + +- name: "{{ role_name }} | Ubuntu | Install uv" + ansible.builtin.command: + argv: + - pipx + - install + - uv + args: + creates: "{{ fabric_uv_path }}" + environment: + PIPX_HOME: "{{ ansible_facts['user_dir'] }}/.local/share/pipx" + PIPX_BIN_DIR: "{{ ansible_facts['user_dir'] }}/.local/bin" + when: not ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Skip uv install in check mode" + ansible.builtin.debug: + msg: "Skipping uv install in check mode." + when: ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Create Fabric virtualenv with uv" + ansible.builtin.command: + argv: + - "{{ fabric_uv_path }}" + - venv + - --system-site-packages + - "{{ fabric_venv_path }}" + args: + creates: "{{ fabric_venv_path }}/bin/python" + when: not ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Install Fabric into virtualenv with uv" + ansible.builtin.command: + argv: + - "{{ fabric_uv_path }}" + - pip + - install + - --python + - "{{ fabric_venv_path }}/bin/python" + - --upgrade + - --no-deps + - "{{ fabric_source_url }}" + register: fabric_uv_install + changed_when: > + 'Installed ' in (fabric_uv_install.stdout ~ fabric_uv_install.stderr) or + 'Updated ' in (fabric_uv_install.stdout ~ fabric_uv_install.stderr) or + 'Uninstalled ' in (fabric_uv_install.stdout ~ fabric_uv_install.stderr) + when: not ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Skip Fabric uv install in check mode" + ansible.builtin.debug: + msg: "Skipping Fabric uv install in check mode." + when: ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Ensure Fabric config directory exists" + ansible.builtin.file: + path: "{{ fabric_config_path }}" + state: directory + mode: "0755" + +- name: "{{ role_name }} | Ubuntu | Deploy AwesomeWM Fabric config" + ansible.builtin.copy: + src: "config/{{ fabric_config_name }}/" + dest: "{{ fabric_config_path }}/" + mode: "0644" + +- name: "{{ role_name }} | Ubuntu | Deploy AwesomeWM Fabric launcher" + ansible.builtin.copy: + src: "bin/fabric-awesomewm" + dest: "{{ ansible_facts['user_dir'] }}/.local/bin/fabric-awesomewm" + mode: "0755" + +- name: "{{ role_name }} | Ubuntu | Check installed Fabric launcher" + ansible.builtin.stat: + path: "{{ ansible_facts['user_dir'] }}/.local/bin/fabric-awesomewm" + register: fabric_launcher + +- name: "{{ role_name }} | Ubuntu | Check Fabric import" + ansible.builtin.command: + argv: + - "{{ fabric_venv_path }}/bin/python" + - -c + - "import fabric" + register: fabric_import + changed_when: false + failed_when: false + when: not ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Check AwesomeWM Fabric config" + ansible.builtin.command: + argv: + - "{{ fabric_venv_path }}/bin/python" + - "{{ fabric_config_path }}/config.py" + - --check + register: fabric_config_check + changed_when: false + failed_when: false + when: not ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Skip Fabric import check in check mode" + ansible.builtin.debug: + msg: "Skipping Fabric import/config checks in check mode." + when: ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Ensure AwesomeWM config directory exists" + ansible.builtin.file: + path: "{{ ansible_facts['user_dir'] }}/.config/awesome" + state: directory + mode: "0755" + when: fabric_enable_awesomewm_ui | bool + +- name: "{{ role_name }} | Ubuntu | Enable Fabric UI for AwesomeWM" + ansible.builtin.copy: + content: | + managed-by=dotfiles + fabric-config={{ fabric_config_name }} + dest: "{{ ansible_facts['user_dir'] }}/.config/awesome/fabric-ui-enabled" + mode: "0644" + when: + - fabric_enable_awesomewm_ui | bool + - fabric_launcher.stat.exists | bool + - fabric_import.rc | default(1) == 0 + - fabric_config_check.rc | default(1) == 0 + +- name: "{{ role_name }} | Ubuntu | Disable Fabric UI for AwesomeWM when validation fails" + ansible.builtin.file: + path: "{{ ansible_facts['user_dir'] }}/.config/awesome/fabric-ui-enabled" + state: absent + when: + - not ansible_check_mode + - fabric_enable_awesomewm_ui | bool + - > + not fabric_launcher.stat.exists | bool or + fabric_import.rc | default(1) != 0 or + fabric_config_check.rc | default(1) != 0 diff --git a/roles/fabric/tasks/main.yml b/roles/fabric/tasks/main.yml new file mode 100644 index 00000000..24345a1f --- /dev/null +++ b/roles/fabric/tasks/main.yml @@ -0,0 +1,9 @@ +--- +- name: "{{ role_name }} | Checking for Distribution Config: {{ ansible_facts['distribution'] }}" + ansible.builtin.stat: + path: "{{ role_path }}/tasks/{{ ansible_facts['distribution'] }}.yml" + register: distribution_config + +- name: "{{ role_name }} | Run Tasks: {{ ansible_facts['distribution'] }}" + ansible.builtin.include_tasks: "{{ ansible_facts['distribution'] }}.yml" + when: distribution_config.stat.exists diff --git a/roles/fabric/tests/test_config_helpers.sh b/roles/fabric/tests/test_config_helpers.sh new file mode 100644 index 00000000..8a796fed --- /dev/null +++ b/roles/fabric/tests/test_config_helpers.sh @@ -0,0 +1,403 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +python_bin="${FABRIC_TEST_PYTHON:-$HOME/.local/share/fabric-awesomewm/venv/bin/python}" +config_path="$repo_root/roles/fabric/files/config/awesomewm/config.py" +css_path="$repo_root/roles/fabric/files/config/awesomewm/style.css" + +if [ ! -x "$python_bin" ]; then + echo "missing test Python: $python_bin" >&2 + exit 1 +fi + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +cat > "$tmpdir/status.json" <<'JSON' +{ + "claude": { + "available": false, + "five_hour": null, + "error": "token_expired" + }, + "codex": { + "available": true, + "session": { + "utilization": 13.0 + } + } +} +JSON + +PYTHONDONTWRITEBYTECODE=1 "$python_bin" - "$config_path" "$tmpdir/status.json" "$css_path" <<'PY' +from __future__ import annotations + +import importlib.util +import json +import pathlib +import re +import sys + +config_path = pathlib.Path(sys.argv[1]) +status_path = pathlib.Path(sys.argv[2]) +css_path = pathlib.Path(sys.argv[3]) + +spec = importlib.util.spec_from_file_location("fabric_awesomewm_config", config_path) +module = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(module) + +module.AI_STATUS_PATH = status_path +module.AI_PROVIDER_PREF_PATH = status_path.parent / "provider.txt" +module.AI_PROVIDER_PREF_PATH.write_text("codex\n") +module.codex_live_usage_text = lambda: None +assert module.ai_usage_text() == "13%" +assert module.VOLUME_POLL_MS <= 1000 +assert module.AI_POLL_MS <= 5000 +assert module.bar_size_from_monitor_width(1920) == (1920, 37) +assert module.bar_size_from_monitor_width(1) == (1, 37) + +codex_rate_limits = { + "limit_id": "codex", + "primary": { + "used_percent": 42.0, + "resets_at": 1778679000, + }, + "secondary": { + "used_percent": 3.0, + "resets_at": 1779283800, + }, +} +token_count_line = json.dumps( + { + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "rate_limits": codex_rate_limits, + }, + }, + } +) +assert module.codex_usage_from_rate_limits(codex_rate_limits) == "42%" +assert module.codex_usage_text_from_lines(["{}", token_count_line]) == "42%" +assert module.codex_usage_from_rate_limits({"limit_id": "codex", "primary": {"used_percent": 0.0}}) == "0%" + +ai_summary = module.ai_summary_from_status( + { + "timestamp": "2026-05-13T13:30:00Z", + "codex": { + "available": True, + "session": {"utilization": 13.0, "resets_at": "2026-05-13T18:06:12Z"}, + "weekly": {"utilization": 3.0, "resets_at": "2026-05-19T01:20:52Z"}, + "error": None, + }, + "claude": {"available": False, "five_hour": None, "error": "token_expired"}, + "errors": ["token_expired"], + } +) +assert ai_summary["provider"] == "codex" +assert ai_summary["session"] == "13%" +assert ai_summary["weekly"] == "3%" +assert ai_summary["session_resets_at"] == "2026-05-13T18:06:12Z" +assert ai_summary["weekly_resets_at"] == "2026-05-19T01:20:52Z" +assert ai_summary["timestamp"] == "2026-05-13T13:30:00Z" +assert ai_summary["errors"] == ["token_expired"] + +provider_pref = tmpdir_path = status_path.parent / "provider.txt" +assert module.normalize_ai_provider("claude") == "claude" +assert module.normalize_ai_provider("codex") == "codex" +assert module.normalize_ai_provider("bogus") == "codex" +assert module.load_ai_provider_preference(provider_pref) == "codex" +provider_pref.write_text("claude\n") +assert module.load_ai_provider_preference(provider_pref) == "claude" +module.save_ai_provider_preference("codex", provider_pref) +assert provider_pref.read_text() == "codex\n" +assert module.next_ai_provider("codex") == "claude" +assert module.next_ai_provider("claude") == "codex" + +assert module.usage_severity(None) == "unknown" +assert module.usage_severity(12.4) == "cool" +assert module.usage_severity(64) == "warm" +assert module.usage_severity(76) == "hot" +assert module.usage_severity(96) == "critical" + +assert module.percent_display(13.0) == "13%" +assert module.percent_display(13.4) == "13%" +assert module.percent_display(13.5) == "14%" +assert module.progress_value(42.0) == 0.42 +assert module.progress_value(142.0) == 1.0 +assert module.progress_value(-3.0) == 0.0 +assert module.metric_percent({"utilization": 0.0}) == 0.0 + +reset_text = module.format_reset_text("2026-05-13T18:06:12Z", now_epoch=1778691912) +assert reset_text.startswith("resets in 1h 01m") +assert "(" in reset_text and ")" in reset_text +assert module.format_reset_text(None, now_epoch=1778691912) == "reset unknown" + +dashboard_status = { + "timestamp": "2026-05-13T16:53:06Z", + "claude": { + "available": False, + "five_hour": None, + "seven_day": None, + "seven_day_opus": None, + "seven_day_sonnet": None, + "extra_usage": None, + "error": "token_expired", + }, + "codex": { + "available": True, + "session": {"utilization": 13.0, "resets_at": "2026-05-13T18:06:12Z"}, + "weekly": {"utilization": 3.0, "resets_at": "2026-05-19T01:20:52Z"}, + "error": None, + }, + "errors": ["token_expired"], +} +codex_model = module.ai_dashboard_model(dashboard_status, "codex", now_epoch=1778691912) +assert codex_model["active_provider"] == "codex" +assert codex_model["title"] == "Codex Usage" +assert codex_model["status_messages"] == [] +assert [row["label"] for row in codex_model["rows"]] == ["Session", "Weekly"] +assert codex_model["rows"][0]["percent_text"] == "13%" +assert codex_model["rows"][0]["severity"] == "cool" +assert codex_model["rows"][0]["reset_text"].startswith("resets in 1h 01m") +assert codex_model["tabs"] == [ + {"provider": "claude", "label": "Claude", "active": False, "available": False, "error": "token_expired"}, + {"provider": "codex", "label": "Codex", "active": True, "available": True, "error": ""}, +] + +claude_model = module.ai_dashboard_model(dashboard_status, "claude", now_epoch=1778691912) +assert claude_model["active_provider"] == "claude" +assert claude_model["title"] == "Claude Usage" +assert claude_model["rows"] == [] +assert claude_model["status_messages"] == ["Claude unavailable: token_expired"] + +stale_claude_status = { + "active_provider": "claude", + "claude": {"available": False, "five_hour": None, "error": "token_expired"}, + "codex": {"available": False, "session": None, "weekly": None, "error": "inactive"}, + "errors": ["token_expired"], +} +codex_pending_model = module.ai_dashboard_model(stale_claude_status, "codex", now_epoch=1778691912) +assert codex_pending_model["rows"] == [] +assert codex_pending_model["status_messages"] == ["Refreshing Codex usage..."] +assert module.ai_compact_usage_from_status(stale_claude_status, "codex", live_codex=None) == "..." + +assert module.ai_compact_usage_from_status(dashboard_status, "codex", live_codex="42%") == "42%" +assert module.ai_compact_usage_from_status(dashboard_status, "codex", live_codex=None) == "13%" +assert module.ai_compact_usage_from_status(dashboard_status, "claude", live_codex="42%") == "--" +live_model = module.ai_dashboard_model(module.status_with_live_codex_usage(dashboard_status, "42%"), "codex", now_epoch=1778691912) +assert live_model["rows"][0]["percent_text"] == "42%" +assert live_model["primary_percent_text"] == "42%" + +scheduled_refreshes = [] +refresh_calls = [] + + +def fake_scheduler(delay, callback): + scheduled_refreshes.append(delay) + refresh_calls.append(callback()) + return len(scheduled_refreshes) + + +assert module.schedule_ai_provider_refreshes(lambda: None, scheduler=fake_scheduler) is True +assert scheduled_refreshes == list(module.AI_PROVIDER_SWITCH_REFRESH_DELAYS_MS) +assert refresh_calls == [False, False] + +module.AI_PROVIDER_PREF_PATH.write_text("claude\n") +live_scan_calls = {"count": 0} + + +def fail_if_live_codex_scans(): + live_scan_calls["count"] += 1 + raise AssertionError("Codex live scanner should not run while Claude is active") + + +module.codex_live_usage_text = fail_if_live_codex_scans +assert module.ai_usage_text() == "--" +assert live_scan_calls["count"] == 0 + +module.AI_PROVIDER_PREF_PATH.write_text("codex\n") +module.codex_live_usage_text = lambda: "42%" +assert module.ai_usage_text() == "42%" + +awesome_stdout = ''' string "fabric\tConfig.py\tfalse\t41943040\tfalse +Family - HomeLab - 1Password\t1Password\tfalse\t41943041\tfalse +tmux\tcom.mitchellh.ghostty\tfalse\t41943042\ttrue +tmux\tcom.mitchellh.ghostty\ttrue\t41943043\tfalse +Untitled - Chromium\tChromium-browser\tfalse\t41943044\tfalse +/home/techdufus/.config/fabric/awesomewm/config.py\tpython3\tfalse\t41943045\tfalse"''' +tasks = module.parse_awesome_clients(awesome_stdout) +assert tasks == [ + {"label": "1Password", "class_name": "1Password", "minimized": False, "window_id": "41943041", "focused": False}, + {"label": "Ghostty", "class_name": "com.mitchellh.ghostty", "minimized": False, "window_id": "41943042", "focused": True}, + {"label": "Ghostty", "class_name": "com.mitchellh.ghostty", "minimized": True, "window_id": "41943043", "focused": False}, + {"label": "Chromium", "class_name": "Chromium-browser", "minimized": False, "window_id": "41943044", "focused": False}, +] +assert module.task_button_labels(tasks) == ["1Password", "Ghostty", "Ghostty 2", "Chromium"] +assert module.tasks_text(tasks) == "1Password Ghostty Ghostty 2 Chromium" +assert module.parse_awesome_clients('string "Fabric\tfabric-awesomewm\tfalse\t11\tfalse"') == [] + +grouped = module.group_tasks_for_dock(tasks, max_icons=3) +assert grouped == [ + { + "label": "1Password", + "class_name": "1Password", + "window_ids": ["41943041"], + "count": 1, + "focused": False, + }, + { + "label": "Ghostty", + "class_name": "com.mitchellh.ghostty", + "window_ids": ["41943042", "41943043"], + "count": 2, + "focused": True, + }, + { + "label": "Chromium", + "class_name": "Chromium-browser", + "window_ids": ["41943044"], + "count": 1, + "focused": False, + }, +] +assert module.overflow_count_for_tasks(tasks, max_icons=2) == 1 +assert module.icon_name_for_class("com.mitchellh.ghostty") in {"com.mitchellh.ghostty", "utilities-terminal", "terminal"} +assert module.icon_name_for_class("") == "application-x-executable" +assert module.initials_for_label("1Password") == "1" +assert module.initials_for_label("Chromium") == "C" +assert module.initials_for_label("Visual Studio Code") == "VS" + +assert module.network_label_from_interface("enp5s0") == "LAN" +assert module.network_label_from_interface("wlp0s20f3") == "WIFI" +assert module.network_label_from_interface("tailscale0") == "VPN" +assert module.network_label_from_interface("tun0") == "VPN" +assert module.network_label_from_interface("") == "OFF" + +assert module.normalize_dnd_text('string "on"') == "on" +assert module.normalize_dnd_text('string "off"') == "off" +assert module.normalize_dnd_text("true") == "on" +assert module.normalize_dnd_text("") == "off" + +assert module.battery_value_from_output("83%") == "83%" +assert module.battery_value_from_output("") is None +assert module.battery_value_from_output(" ") is None + +audio_listing = """default_sink\talsa_output.usb.DAC +sink\talsa_output.usb.DAC +sink\talsa_output.pci.hdmi +default_source\talsa_input.usb.Mic +source\talsa_input.usb.Mic +source\talsa_input.pci.analog +""" +parsed_audio = module.parse_audio_devices(audio_listing) +assert parsed_audio["default_sink"] == "alsa_output.usb.DAC" +assert parsed_audio["sinks"] == ["alsa_output.usb.DAC", "alsa_output.pci.hdmi"] +assert parsed_audio["default_source"] == "alsa_input.usb.Mic" +assert parsed_audio["sources"] == ["alsa_input.usb.Mic", "alsa_input.pci.analog"] + +calendar_output = " May 2026\\nSu Mo Tu We Th Fr Sa\\n 1 2" +assert module.calendar_text_from_output(calendar_output) == calendar_output +assert module.calendar_text_from_output("") == "calendar unavailable" +rendered_calendar = module.calendar_text_for_months(2026, 5) +assert "May 2026" in rendered_calendar +assert "June 2026" in rendered_calendar +assert "1 2" in rendered_calendar + +now = [100.0] + + +class DummyPopup: + def __init__(self): + self.visible = False + self.refresh_count = 0 + self.show_count = 0 + self.hide_count = 0 + self.present_count = 0 + + def get_visible(self): + return self.visible + + def refresh(self): + self.refresh_count += 1 + + def show_all(self): + self.visible = True + self.show_count += 1 + + def hide(self): + self.visible = False + self.hide_count += 1 + + def present(self): + self.present_count += 1 + + +audio_popup = DummyPopup() +ai_popup = DummyPopup() +calendar_popup = DummyPopup() +manager = module.PopupManager(clock=lambda: now[0], reopen_suppression_seconds=0.5) +manager.register("audio", audio_popup) +manager.register("ai", ai_popup) +manager.register("calendar", calendar_popup) + +manager.toggle("audio") +assert audio_popup.visible is True +assert audio_popup.refresh_count == 1 +assert audio_popup.present_count == 1 +assert manager.active_name == "audio" + +manager.toggle("ai") +assert audio_popup.visible is False +assert ai_popup.visible is True +assert manager.active_name == "ai" + +manager.toggle("ai") +assert ai_popup.visible is False +assert manager.active_name is None + +manager.open("calendar") +manager.close("calendar", reason="focus-out") +assert calendar_popup.visible is False +manager.toggle("calendar") +assert calendar_popup.visible is False +now[0] += 0.6 +manager.toggle("calendar") +assert calendar_popup.visible is True + +manager.close_all() +assert audio_popup.visible is False +assert ai_popup.visible is False +assert calendar_popup.visible is False +assert manager.active_name is None + +css = css_path.read_text() +assert "#fabric-awesomewm-bar" in css +assert "#bar-inner" in css +assert "border-bottom" in css +assert "min-width: 248px" not in css +assert "border-radius: 999px" not in css + +bar_inner = re.search(r"#bar-inner\s*\{(?P.*?)\}", css, re.S) +assert bar_inner is not None +bar_body = bar_inner.group("body") +assert "background-color: alpha(var(--base), 0.97)" in bar_body +assert "border-bottom: 2px solid alpha(var(--cyan), 0.55)" in bar_body +assert "padding: 3px 10px" in bar_body + +task_strip = re.search(r"#task-strip\s*\{(?P.*?)\}", css, re.S) +assert task_strip is not None +assert "min-width" not in task_strip.group("body") +for selector in ( + "#ai-panel", + "#ai-provider-tabs", + "#ai-provider-tab", + "#ai-metric-row", + "#ai-progress", + "#ai-footer-button", +): + assert selector in css +PY From ba0caf3900cedde61db44b45f02469691a1661df Mon Sep 17 00:00:00 2001 From: TechDufus Date: Wed, 13 May 2026 15:56:29 -0500 Subject: [PATCH 02/15] fix(awesomewm): speed up Flare launcher reveal --- roles/awesomewm/files/config/rc.lua | 86 ++++++++++++++++++- roles/awesomewm/tests/test_flare_launcher.sh | 15 ++++ roles/fabric/files/config/awesomewm/config.py | 11 ++- roles/fabric/tests/test_config_helpers.sh | 1 + 4 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 roles/awesomewm/tests/test_flare_launcher.sh diff --git a/roles/awesomewm/files/config/rc.lua b/roles/awesomewm/files/config/rc.lua index 3f8f7e39..22737d77 100644 --- a/roles/awesomewm/files/config/rc.lua +++ b/roles/awesomewm/files/config/rc.lua @@ -149,6 +149,11 @@ editor_cmd = terminal .. " -e " .. editor -- I suggest you to remap Mod4 to another key using xmodmap or other tools. -- However, you can use another modifier like Mod1, but it may interact with others. modkey = "Mod4" +local flare_launcher_command = os.getenv("HOME") .. "/.local/bin/flare" +local flare_single_instance_service = "org.dev_byteatatime_flare.SingleInstance" +local flare_single_instance_path = "/org/dev_byteatatime_flare/SingleInstance" +local flare_single_instance_interface = "org.SingleInstance.DBus" +local flare_callback_cwd = os.getenv("HOME") or "/" -- CELL MANAGEMENT MODE: Only floating layout enabled -- All window positioning is handled by the cell-based layout system @@ -157,6 +162,65 @@ awful.layout.layouts = { } -- }}} +local function is_flare_client(c) + return c + and ((c.class or "") == "Flare" + or (c.instance or "") == "flare" + or (c.name or "") == "Flare") +end + +local function center_flare_clients() + local centered = false + for _, c in ipairs(client.get()) do + if is_flare_client(c) then + c.floating = true + c.screen = awful.screen.focused() + awful.placement.centered(c, { honor_workarea = true }) + c:emit_signal("request::activate", "flare-launcher", { raise = true }) + centered = true + end + end + return centered +end + +local function schedule_flare_centering() + for _, delay in ipairs({ 0.12, 0.35, 0.8 }) do + gears.timer.start_new(delay, function() + center_flare_clients() + return false + end) + end +end + +local function reveal_flare_via_dbus(callback) + awful.spawn.easy_async({ + "busctl", "--user", "--quiet", "call", + flare_single_instance_service, + flare_single_instance_path, + flare_single_instance_interface, + "ExecuteCallback", + "ass", "1", "flare", flare_callback_cwd, + }, function(_, _, _, exit_code) + callback(exit_code == 0) + end) +end + +local function launch_flare_centered() + if center_flare_clients() then + schedule_flare_centering() + return + end + + reveal_flare_via_dbus(function(revealed) + if not revealed then + awful.spawn(flare_launcher_command) + end + schedule_flare_centering() + end) +end + +awesome.connect_signal("techdufus::launch_flare", launch_flare_centered) + -- {{{ Menu -- Create a launcher widget and a main menu myawesomemenu = { @@ -454,7 +518,7 @@ globalkeys = gears.table.join( -- Flare launcher (Raycast-like: clipboard, calculator, extensions, AI) awful.key({ modkey }, "space", function() - awful.spawn("/home/techdufus/.local/bin/flare") + launch_flare_centered() end, { description = "flare launcher", group = "launcher" }), -- Clipboard manager (CopyQ) @@ -620,6 +684,26 @@ awful.rules.rules = { } }, + -- Flare launcher should behave like a centered command palette. + { + rule_any = { + class = { "Flare" }, + instance = { "flare" }, + name = { "Flare" }, + }, + properties = { + floating = true, + titlebars_enabled = false, + }, + callback = function(c) + gears.timer.delayed_call(function() + if c.valid then + center_flare_clients() + end + end) + end + }, + -- Floating clients. { rule_any = { diff --git a/roles/awesomewm/tests/test_flare_launcher.sh b/roles/awesomewm/tests/test_flare_launcher.sh new file mode 100644 index 00000000..4452c5b2 --- /dev/null +++ b/roles/awesomewm/tests/test_flare_launcher.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +config_path="$repo_root/roles/awesomewm/files/config/rc.lua" + +grep -q "techdufus::launch_flare" "$config_path" +grep -q "launch_flare_centered" "$config_path" +grep -q "center_flare_clients" "$config_path" +grep -q "org.dev_byteatatime_flare.SingleInstance" "$config_path" +grep -q "ExecuteCallback" "$config_path" +grep -q "awful.spawn.easy_async" "$config_path" +grep -q "awful.spawn(flare_launcher_command)" "$config_path" +grep -q "awful.placement.centered(c" "$config_path" +grep -q 'class = { "Flare" }' "$config_path" diff --git a/roles/fabric/files/config/awesomewm/config.py b/roles/fabric/files/config/awesomewm/config.py index 9037f5c4..fcbcc624 100644 --- a/roles/fabric/files/config/awesomewm/config.py +++ b/roles/fabric/files/config/awesomewm/config.py @@ -35,6 +35,7 @@ VOLUME_POLL_MS = 1000 AI_POLL_MS = 5000 AI_PROVIDER_SWITCH_REFRESH_DELAYS_MS = (350, 1250) +LAUNCHER_SIGNAL = "techdufus::launch_flare" AI_USAGE_URLS = { "codex": "https://chatgpt.com/codex/settings/usage", "claude": "https://console.anthropic.com/settings/limits", @@ -123,6 +124,14 @@ def run_command(command: list[str]) -> None: subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +def launcher_command() -> list[str]: + return ["awesome-client", f"awesome.emit_signal('{LAUNCHER_SIGNAL}')"] + + +def open_launcher() -> None: + run_command(launcher_command()) + + def shell_output(command: str, fallback: str = "...") -> str: try: result = subprocess.run( @@ -1508,7 +1517,7 @@ def __init__(self): name="launcher-button", tooltip_text="Launcher", child=Label(label=">"), - on_clicked=lambda *_: run_command([str(HOME / ".local/bin/flare")]), + on_clicked=lambda *_: open_launcher(), ), self.tasks, ], diff --git a/roles/fabric/tests/test_config_helpers.sh b/roles/fabric/tests/test_config_helpers.sh index 8a796fed..28ab4594 100644 --- a/roles/fabric/tests/test_config_helpers.sh +++ b/roles/fabric/tests/test_config_helpers.sh @@ -52,6 +52,7 @@ module.AI_STATUS_PATH = status_path module.AI_PROVIDER_PREF_PATH = status_path.parent / "provider.txt" module.AI_PROVIDER_PREF_PATH.write_text("codex\n") module.codex_live_usage_text = lambda: None +assert module.launcher_command() == ["awesome-client", "awesome.emit_signal('techdufus::launch_flare')"] assert module.ai_usage_text() == "13%" assert module.VOLUME_POLL_MS <= 1000 assert module.AI_POLL_MS <= 5000 From 8ae8b8366f37797964b201e591bd154dd8521e6c Mon Sep 17 00:00:00 2001 From: TechDufus Date: Thu, 14 May 2026 09:00:36 -0500 Subject: [PATCH 03/15] feat(vicinae): add AwesomeWM launcher integration --- group_vars/all.yml | 1 + group_vars/all.yml.example | 3 + roles/awesomewm/README.md | 5 +- roles/awesomewm/files/config/rc.lua | 128 ++--- roles/awesomewm/tests/test_flare_launcher.sh | 15 - .../awesomewm/tests/test_vicinae_launcher.sh | 26 + roles/fabric/README.md | 3 +- roles/fabric/files/config/awesomewm/config.py | 26 +- roles/fabric/tests/test_config_helpers.sh | 3 +- roles/vicinae/README.md | 70 +++ roles/vicinae/defaults/main.yml | 32 ++ .../files/scripts/ai-open-claude-usage | 17 + .../vicinae/files/scripts/ai-open-codex-usage | 17 + .../vicinae/files/scripts/ai-restart-monitor | 9 + roles/vicinae/files/scripts/ai-status | 54 ++ roles/vicinae/files/scripts/ai-switch-claude | 12 + roles/vicinae/files/scripts/ai-switch-codex | 12 + roles/vicinae/files/scripts/audio-settings | 14 + .../vicinae/files/scripts/bluetooth-settings | 14 + roles/vicinae/files/scripts/display-settings | 14 + roles/vicinae/files/scripts/gtk-appearance | 14 + roles/vicinae/files/scripts/network-settings | 14 + roles/vicinae/files/scripts/power-settings | 14 + roles/vicinae/files/scripts/restart-awesomewm | 14 + roles/vicinae/files/scripts/restart-fabric | 20 + roles/vicinae/tasks/Ubuntu.yml | 494 ++++++++++++++++++ roles/vicinae/tasks/main.yml | 9 + roles/vicinae/templates/dotfiles.json.j2 | 55 ++ roles/vicinae/templates/settings.json.j2 | 5 + roles/vicinae/tests/test_vicinae_role.sh | 22 + 30 files changed, 1045 insertions(+), 91 deletions(-) delete mode 100644 roles/awesomewm/tests/test_flare_launcher.sh create mode 100755 roles/awesomewm/tests/test_vicinae_launcher.sh create mode 100644 roles/vicinae/README.md create mode 100644 roles/vicinae/defaults/main.yml create mode 100755 roles/vicinae/files/scripts/ai-open-claude-usage create mode 100755 roles/vicinae/files/scripts/ai-open-codex-usage create mode 100755 roles/vicinae/files/scripts/ai-restart-monitor create mode 100755 roles/vicinae/files/scripts/ai-status create mode 100755 roles/vicinae/files/scripts/ai-switch-claude create mode 100755 roles/vicinae/files/scripts/ai-switch-codex create mode 100755 roles/vicinae/files/scripts/audio-settings create mode 100755 roles/vicinae/files/scripts/bluetooth-settings create mode 100755 roles/vicinae/files/scripts/display-settings create mode 100755 roles/vicinae/files/scripts/gtk-appearance create mode 100755 roles/vicinae/files/scripts/network-settings create mode 100755 roles/vicinae/files/scripts/power-settings create mode 100755 roles/vicinae/files/scripts/restart-awesomewm create mode 100755 roles/vicinae/files/scripts/restart-fabric create mode 100644 roles/vicinae/tasks/Ubuntu.yml create mode 100644 roles/vicinae/tasks/main.yml create mode 100644 roles/vicinae/templates/dotfiles.json.j2 create mode 100644 roles/vicinae/templates/settings.json.j2 create mode 100755 roles/vicinae/tests/test_vicinae_role.sh diff --git a/group_vars/all.yml b/group_vars/all.yml index 0ed8f860..26edbb57 100644 --- a/group_vars/all.yml +++ b/group_vars/all.yml @@ -4,6 +4,7 @@ default_roles: - aldente - asciiquarium - awesomewm + - vicinae - bash - bat - borders diff --git a/group_vars/all.yml.example b/group_vars/all.yml.example index 82c705f1..f1610518 100644 --- a/group_vars/all.yml.example +++ b/group_vars/all.yml.example @@ -95,6 +95,9 @@ default_roles: # - aldente # Battery charge limiter for macOS # === Linux Specific === + # - awesomewm # X11 window manager and keyboard-driven desktop + # - vicinae # Raycast-style launcher for Linux/AwesomeWM + # - fabric # AwesomeWM top command deck UI # - flatpak # Universal Linux package manager # - nala # Better apt frontend for Ubuntu/Debian diff --git a/roles/awesomewm/README.md b/roles/awesomewm/README.md index 765b4b70..a3c88294 100644 --- a/roles/awesomewm/README.md +++ b/roles/awesomewm/README.md @@ -123,7 +123,8 @@ graph LR - `thunar` - Lightweight file manager - `ristretto` - Image viewer - `rofimoji` - Emoji picker (via pipx) -- Flare launcher (AppImage) +- Flare launcher (AppImage, retained temporarily during Vicinae migration) +- Vicinae primary launcher support when the separate `vicinae` role is installed **Media & System:** - `playerctl` - Media key controls @@ -202,7 +203,7 @@ graph LR └── catppuccin-mocha-blue-standard+default/ # GTK theme ~/.local/bin/ -├── flare # Application launcher +├── flare # Legacy launcher kept during Vicinae migration └── rofimoji # Emoji picker ``` diff --git a/roles/awesomewm/files/config/rc.lua b/roles/awesomewm/files/config/rc.lua index 22737d77..180e9aa6 100644 --- a/roles/awesomewm/files/config/rc.lua +++ b/roles/awesomewm/files/config/rc.lua @@ -93,7 +93,7 @@ if fabric_ui_enabled then awful.spawn.with_shell([[if [ -x "$HOME/.local/bin/fabric-awesomewm" ]; then "$HOME/.local/bin/fabric-awesomewm"; fi]]) end --- Flare launcher starts on-demand (Super+Space) - no auto-start to avoid popup +-- Vicinae launcher server starts after launcher helpers are defined below. -- }}} -- {{{ Error handling @@ -149,11 +149,9 @@ editor_cmd = terminal .. " -e " .. editor -- I suggest you to remap Mod4 to another key using xmodmap or other tools. -- However, you can use another modifier like Mod1, but it may interact with others. modkey = "Mod4" -local flare_launcher_command = os.getenv("HOME") .. "/.local/bin/flare" -local flare_single_instance_service = "org.dev_byteatatime_flare.SingleInstance" -local flare_single_instance_path = "/org/dev_byteatatime_flare/SingleInstance" -local flare_single_instance_interface = "org.SingleInstance.DBus" -local flare_callback_cwd = os.getenv("HOME") or "/" +local settings_picker_command = [[ +printf '%s\n' 'Audio (pavucontrol)' 'Display (arandr)' 'GTK Themes (lxappearance)' 'Bluetooth (blueman-manager)' 'Network (nm-connection-editor)' 'Power (xfce4-power-manager-settings)' | rofi -dmenu -i -p Settings | sed 's/.*(\(.*\))/\1/' | xargs -r -I{} sh -c '{}' +]] -- CELL MANAGEMENT MODE: Only floating layout enabled -- All window positioning is handled by the cell-based layout system @@ -162,64 +160,62 @@ awful.layout.layouts = { } -- }}} -local function is_flare_client(c) - return c - and ((c.class or "") == "Flare" - or (c.instance or "") == "flare" - or (c.name or "") == "Flare") +local function notify_vicinae_fallback() + naughty.notify({ + title = "Vicinae unavailable", + text = "Falling back to rofi. Run dotfiles -t vicinae to install it.", + }) end -local function center_flare_clients() - local centered = false - for _, c in ipairs(client.get()) do - if is_flare_client(c) then - c.floating = true - c.screen = awful.screen.focused() - awful.placement.centered(c, { honor_workarea = true }) - c:emit_signal("request::activate", "flare-launcher", { raise = true }) - centered = true - end - end - return centered +local function launch_rofi_apps() + awful.spawn("rofi -show drun -show-icons") end -local function schedule_flare_centering() - for _, delay in ipairs({ 0.12, 0.35, 0.8 }) do - gears.timer.start_new(delay, function() - center_flare_clients() - return false - end) - end +local function launch_copyq_clipboard() + awful.spawn("copyq toggle") +end + +local function launch_rofi_settings() + awful.spawn.with_shell(settings_picker_command) end -local function reveal_flare_via_dbus(callback) - awful.spawn.easy_async({ - "busctl", "--user", "--quiet", "call", - flare_single_instance_service, - flare_single_instance_path, - flare_single_instance_interface, - "ExecuteCallback", - "ass", "1", "flare", flare_callback_cwd, - }, function(_, _, _, exit_code) - callback(exit_code == 0) +local function launch_vicinae(uri, fallback) + awful.spawn.easy_async({ "sh", "-lc", "command -v vicinae >/dev/null 2>&1" }, function(_, _, _, exit_code) + if exit_code == 0 then + awful.spawn({ "vicinae", uri }) + else + notify_vicinae_fallback() + if fallback then + fallback() + end + end end) end -local function launch_flare_centered() - if center_flare_clients() then - schedule_flare_centering() - return - end +local function launch_vicinae_root() + launch_vicinae("vicinae://toggle", launch_rofi_apps) +end + +local function launch_vicinae_open_root() + launch_vicinae("vicinae://open?popToRoot=true", launch_rofi_apps) +end - reveal_flare_via_dbus(function(revealed) - if not revealed then - awful.spawn(flare_launcher_command) +local function start_vicinae_server() + awful.spawn.easy_async({ "sh", "-lc", "command -v vicinae >/dev/null 2>&1" }, function(_, _, _, exit_code) + if exit_code == 0 then + awful.spawn.once("vicinae server --replace") end - schedule_flare_centering() end) end -awesome.connect_signal("techdufus::launch_flare", launch_flare_centered) +awesome.connect_signal("techdufus::launcher_root", launch_vicinae_root) +awesome.connect_signal("techdufus::launcher_open_root", launch_vicinae_open_root) +awesome.connect_signal("techdufus::launcher_apps", launch_rofi_apps) +awesome.connect_signal("techdufus::launcher_clipboard", launch_copyq_clipboard) +awesome.connect_signal("techdufus::launcher_settings", launch_rofi_settings) +awesome.connect_signal("techdufus::launch_flare", launch_vicinae_root) + +start_vicinae_server() -- {{{ Menu -- Create a launcher widget and a main menu @@ -512,18 +508,19 @@ globalkeys = gears.table.join( awful.spawn("flameshot full -p " .. os.getenv("HOME") .. "/Pictures") end, { description = "screenshot full screen to file", group = "screenshot" }), - -- Prompt (rofi application launcher) - awful.key({ "Mod1" }, "space", function() awful.spawn("rofi -show drun -show-icons") end, - { description = "application launcher (rofi)", group = "launcher" }), + -- Fallback application launcher. + awful.key({ "Mod1" }, "space", function() + awesome.emit_signal("techdufus::launcher_apps") + end, { description = "application launcher", group = "launcher" }), - -- Flare launcher (Raycast-like: clipboard, calculator, extensions, AI) + -- Primary command launcher. awful.key({ modkey }, "space", function() - launch_flare_centered() - end, { description = "flare launcher", group = "launcher" }), + awesome.emit_signal("techdufus::launcher_root") + end, { description = "vicinae launcher", group = "launcher" }), - -- Clipboard manager (CopyQ) + -- Clipboard manager. CopyQ remains the first-phase fallback. awful.key({ modkey }, "v", function() - awful.spawn("copyq toggle") + awesome.emit_signal("techdufus::launcher_clipboard") end, { description = "clipboard history", group = "launcher" }), awful.key({ modkey }, "x", @@ -684,21 +681,26 @@ awful.rules.rules = { } }, - -- Flare launcher should behave like a centered command palette. + -- Vicinae launcher should behave like a centered command palette. { rule_any = { - class = { "Flare" }, - instance = { "flare" }, - name = { "Flare" }, + class = { "Vicinae", "vicinae" }, + instance = { "command", "Vicinae", "vicinae" }, + name = { "Vicinae Launcher", "Vicinae", "vicinae" }, }, properties = { floating = true, titlebars_enabled = false, + skip_taskbar = true, + ontop = true, }, callback = function(c) gears.timer.delayed_call(function() if c.valid then - center_flare_clients() + c.floating = true + c.screen = awful.screen.focused() + awful.placement.centered(c, { honor_workarea = true }) + c:emit_signal("request::activate", "vicinae-launcher", { raise = true }) end end) end diff --git a/roles/awesomewm/tests/test_flare_launcher.sh b/roles/awesomewm/tests/test_flare_launcher.sh deleted file mode 100644 index 4452c5b2..00000000 --- a/roles/awesomewm/tests/test_flare_launcher.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" -config_path="$repo_root/roles/awesomewm/files/config/rc.lua" - -grep -q "techdufus::launch_flare" "$config_path" -grep -q "launch_flare_centered" "$config_path" -grep -q "center_flare_clients" "$config_path" -grep -q "org.dev_byteatatime_flare.SingleInstance" "$config_path" -grep -q "ExecuteCallback" "$config_path" -grep -q "awful.spawn.easy_async" "$config_path" -grep -q "awful.spawn(flare_launcher_command)" "$config_path" -grep -q "awful.placement.centered(c" "$config_path" -grep -q 'class = { "Flare" }' "$config_path" diff --git a/roles/awesomewm/tests/test_vicinae_launcher.sh b/roles/awesomewm/tests/test_vicinae_launcher.sh new file mode 100755 index 00000000..1dbb2e56 --- /dev/null +++ b/roles/awesomewm/tests/test_vicinae_launcher.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +config_path="$repo_root/roles/awesomewm/files/config/rc.lua" + +grep -q "techdufus::launcher_root" "$config_path" +grep -q "techdufus::launcher_apps" "$config_path" +grep -q "techdufus::launcher_clipboard" "$config_path" +grep -q "techdufus::launcher_settings" "$config_path" +grep -q "techdufus::launch_flare" "$config_path" +grep -q "launch_vicinae_root" "$config_path" +grep -q "launch_rofi_apps" "$config_path" +grep -q "launch_copyq_clipboard" "$config_path" +grep -q "start_vicinae_server" "$config_path" +grep -q "vicinae://toggle" "$config_path" +grep -q "vicinae server --replace" "$config_path" +grep -q 'class = { "Vicinae", "vicinae" }' "$config_path" +grep -q 'instance = { "command", "Vicinae", "vicinae" }' "$config_path" +grep -q 'name = { "Vicinae Launcher", "Vicinae", "vicinae" }' "$config_path" +grep -q 'awful.placement.centered(c' "$config_path" + +if grep -q "org.dev_byteatatime_flare.SingleInstance" "$config_path"; then + echo "Flare DBus launcher path should not remain active in rc.lua" >&2 + exit 1 +fi diff --git a/roles/fabric/README.md b/roles/fabric/README.md index 06f47f71..1dd9d1af 100644 --- a/roles/fabric/README.md +++ b/roles/fabric/README.md @@ -40,7 +40,8 @@ The first Fabric config provides: - workspace placeholders for AwesomeWM tags 1-9 - command-backed status pills for load, memory, network, battery, volume, AI usage, DND, settings, and clock -- a settings launcher that reuses the existing rofi settings picker +- launcher and settings buttons that emit AwesomeWM signals, allowing AwesomeWM + to route to Vicinae or fallbacks consistently with keyboard shortcuts ## Notes diff --git a/roles/fabric/files/config/awesomewm/config.py b/roles/fabric/files/config/awesomewm/config.py index fcbcc624..39d923ef 100644 --- a/roles/fabric/files/config/awesomewm/config.py +++ b/roles/fabric/files/config/awesomewm/config.py @@ -35,7 +35,9 @@ VOLUME_POLL_MS = 1000 AI_POLL_MS = 5000 AI_PROVIDER_SWITCH_REFRESH_DELAYS_MS = (350, 1250) -LAUNCHER_SIGNAL = "techdufus::launch_flare" +ROOT_LAUNCHER_SIGNAL = "techdufus::launcher_root" +SETTINGS_LAUNCHER_SIGNAL = "techdufus::launcher_settings" +LAUNCHER_SIGNAL = ROOT_LAUNCHER_SIGNAL AI_USAGE_URLS = { "codex": "https://chatgpt.com/codex/settings/usage", "claude": "https://console.anthropic.com/settings/limits", @@ -124,14 +126,26 @@ def run_command(command: list[str]) -> None: subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +def awesome_signal_command(signal: str) -> list[str]: + return ["awesome-client", f"awesome.emit_signal('{signal}')"] + + def launcher_command() -> list[str]: - return ["awesome-client", f"awesome.emit_signal('{LAUNCHER_SIGNAL}')"] + return awesome_signal_command(LAUNCHER_SIGNAL) + + +def settings_command() -> list[str]: + return awesome_signal_command(SETTINGS_LAUNCHER_SIGNAL) def open_launcher() -> None: run_command(launcher_command()) +def open_settings_launcher() -> None: + run_command(settings_command()) + + def shell_output(command: str, fallback: str = "...") -> str: try: result = subprocess.run( @@ -1493,13 +1507,7 @@ def __init__(self): Button( name="settings-button", child=Label(label="SET"), - on_clicked=lambda *_: run_command( - [ - "sh", - "-c", - "printf '%s\\n' 'Audio (pavucontrol)' 'Display (arandr)' 'GTK Themes (lxappearance)' 'Bluetooth (blueman-manager)' 'Network (nm-connection-editor)' 'Power (xfce4-power-manager-settings)' | rofi -dmenu -i -p Settings | sed 's/.*(\\(.*\\))/\\1/' | xargs -r -I{} sh -c '{}'", - ] - ), + on_clicked=lambda *_: open_settings_launcher(), ), ] ) diff --git a/roles/fabric/tests/test_config_helpers.sh b/roles/fabric/tests/test_config_helpers.sh index 28ab4594..0142fb4b 100644 --- a/roles/fabric/tests/test_config_helpers.sh +++ b/roles/fabric/tests/test_config_helpers.sh @@ -52,7 +52,8 @@ module.AI_STATUS_PATH = status_path module.AI_PROVIDER_PREF_PATH = status_path.parent / "provider.txt" module.AI_PROVIDER_PREF_PATH.write_text("codex\n") module.codex_live_usage_text = lambda: None -assert module.launcher_command() == ["awesome-client", "awesome.emit_signal('techdufus::launch_flare')"] +assert module.launcher_command() == ["awesome-client", "awesome.emit_signal('techdufus::launcher_root')"] +assert module.settings_command() == ["awesome-client", "awesome.emit_signal('techdufus::launcher_settings')"] assert module.ai_usage_text() == "13%" assert module.VOLUME_POLL_MS <= 1000 assert module.AI_POLL_MS <= 5000 diff --git a/roles/vicinae/README.md b/roles/vicinae/README.md new file mode 100644 index 00000000..fce2a825 --- /dev/null +++ b/roles/vicinae/README.md @@ -0,0 +1,70 @@ +# Vicinae + +This role installs [Vicinae](https://github.com/vicinaehq/vicinae) as the +primary Raycast-style launcher for the Ubuntu AwesomeWM desktop. + +Vicinae is intentionally separate from `awesomewm`: + +- `awesomewm` owns global key capture, leader/summon bindings, focus, layout, + and fallback behavior. +- `fabric` owns the visible command deck and emits AwesomeWM signals. +- `vicinae` owns the searchable command launcher, script commands, app search, + file search, calculator, and eventual clipboard/search workflows. + +## Install + +```sh +dotfiles -t vicinae +``` + +The role mirrors the official install script without running `curl | bash`: + +- resolves the latest GitHub release or a pinned `vicinae_version` +- downloads the x86_64 AppImage +- extracts it into `{{ vicinae_prefix }}/lib/vicinae` +- symlinks `{{ vicinae_prefix }}/bin/vicinae` +- installs bundled themes, desktop files, and the user systemd unit +- optionally sets input-server capabilities for snippets/paste support + +The user service is installed but not enabled by default. AwesomeWM starts +`vicinae server --replace` after the X11 session environment exists. + +## Managed Config + +The role writes: + +```text +~/.config/vicinae/dotfiles.json +``` + +If `~/.config/vicinae/settings.json` does not exist, the role creates it with +an import: + +```json +{ + "imports": [ + "./dotfiles.json" + ] +} +``` + +If the settings file already exists and does not import `dotfiles.json`, the +role leaves it untouched and prints a warning. This avoids overwriting settings +that Vicinae's GUI writes itself. + +## Script Commands + +Scripts are deployed to: + +```text +~/.local/share/vicinae/scripts/techdufus +``` + +Initial scripts cover settings launchers, restarting AwesomeWM/Fabric, and +quick AI usage actions. They are intentionally simple script commands; build a +TypeScript extension only after scripts or dmenu prove insufficient. + +## Rollout Boundary + +This role does not remove Flare, rofi, CopyQ, or bemoji. Those remain recovery +paths until Vicinae has passed the live validation gates in the migration plan. diff --git a/roles/vicinae/defaults/main.yml b/roles/vicinae/defaults/main.yml new file mode 100644 index 00000000..4e577db4 --- /dev/null +++ b/roles/vicinae/defaults/main.yml @@ -0,0 +1,32 @@ +--- +role_name: vicinae + +vicinae_repo: vicinaehq/vicinae +vicinae_version: latest +vicinae_prefix: /usr/local +vicinae_install_dir: "{{ vicinae_prefix }}/lib/vicinae" +vicinae_bin_dir: "{{ vicinae_prefix }}/bin" +vicinae_themes_dir: "{{ vicinae_prefix }}/share/vicinae/themes" +vicinae_applications_dir: "{{ vicinae_prefix }}/share/applications" +vicinae_systemd_user_dir: "{{ vicinae_prefix }}/lib/systemd/user" +vicinae_modules_load_dir: "{{ vicinae_prefix }}/lib/modules-load.d" + +vicinae_install_input_server: true +vicinae_install_browser_manifests: false +vicinae_enable_user_service: false +vicinae_start_from_awesomewm: true +vicinae_remove_flare_after_migration: false + +vicinae_install_qalc: true +vicinae_appimage_asset_regex: "^Vicinae.*x86_64.*AppImage$" +vicinae_appimage_fuse_package: "{{ 'libfuse2t64' if (ansible_facts['distribution_major_version'] | int) >= 24 else 'libfuse2' }}" +vicinae_ubuntu_packages: + - ca-certificates + - curl + - desktop-file-utils + - jq + - libcap2-bin + - xz-utils + +vicinae_config_dir: "{{ ansible_facts['user_dir'] }}/.config/vicinae" +vicinae_scripts_dir: "{{ ansible_facts['user_dir'] }}/.local/share/vicinae/scripts/techdufus" diff --git a/roles/vicinae/files/scripts/ai-open-claude-usage b/roles/vicinae/files/scripts/ai-open-claude-usage new file mode 100755 index 00000000..500e8c65 --- /dev/null +++ b/roles/vicinae/files/scripts/ai-open-claude-usage @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title AI Usage: Open Claude +# @vicinae.mode compact +# @vicinae.keywords ["ai", "claude", "usage", "limits"] + +set -euo pipefail + +url="https://console.anthropic.com/settings/limits" + +if command -v xdg-open >/dev/null 2>&1; then + nohup xdg-open "$url" >/dev/null 2>&1 & + exit 0 +fi + +printf 'xdg-open is not installed\n' >&2 +exit 1 diff --git a/roles/vicinae/files/scripts/ai-open-codex-usage b/roles/vicinae/files/scripts/ai-open-codex-usage new file mode 100755 index 00000000..a4015442 --- /dev/null +++ b/roles/vicinae/files/scripts/ai-open-codex-usage @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title AI Usage: Open Codex +# @vicinae.mode compact +# @vicinae.keywords ["ai", "codex", "usage", "limits"] + +set -euo pipefail + +url="https://chatgpt.com/codex/settings/usage" + +if command -v xdg-open >/dev/null 2>&1; then + nohup xdg-open "$url" >/dev/null 2>&1 & + exit 0 +fi + +printf 'xdg-open is not installed\n' >&2 +exit 1 diff --git a/roles/vicinae/files/scripts/ai-restart-monitor b/roles/vicinae/files/scripts/ai-restart-monitor new file mode 100755 index 00000000..f4864652 --- /dev/null +++ b/roles/vicinae/files/scripts/ai-restart-monitor @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title AI Usage: Restart Monitor +# @vicinae.mode compact +# @vicinae.keywords ["ai", "usage", "monitor", "restart"] + +set -euo pipefail + +systemctl --user restart ai-usage-monitor.service >/dev/null 2>&1 diff --git a/roles/vicinae/files/scripts/ai-status b/roles/vicinae/files/scripts/ai-status new file mode 100755 index 00000000..58c2b5d5 --- /dev/null +++ b/roles/vicinae/files/scripts/ai-status @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title AI Usage: Status +# @vicinae.mode fullOutput +# @vicinae.keywords ["ai", "usage", "status", "codex", "claude"] + +set -euo pipefail + +cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/ai-usage-monitor" +status_file="$cache_dir/status.json" +provider_file="$cache_dir/provider.txt" +provider="codex" + +if [ -f "$provider_file" ]; then + provider="$(tr -d '[:space:]' <"$provider_file")" +fi + +case "$provider" in + codex|claude) ;; + *) provider="codex" ;; +esac + +if [ ! -f "$status_file" ]; then + printf 'No AI usage status file found at %s\n' "$status_file" + exit 0 +fi + +if ! command -v jq >/dev/null 2>&1; then + cat "$status_file" + exit 0 +fi + +jq -r --arg provider "$provider" ' + def metric_value($metric): + if ($metric | type) == "object" then $metric.utilization + elif $metric == null then null + else $metric + end; + def pct($value): + if $value == null then "n/a" + else (($value | tonumber | round | tostring) + "%") + end; + . as $root + | ($root[$provider] // {}) as $active + | [ + ("Provider: " + $provider), + ("Available: " + (($active.available // false) | tostring)), + ("Session: " + pct(metric_value($active.session // $active.five_hour))), + ("Weekly: " + pct(metric_value($active.weekly // $active.seven_day))), + ("Updated: " + ($root.timestamp // "unknown")), + ("Error: " + (($active.error // $root.errors[0]? // "none") | tostring)) + ] + | .[] +' "$status_file" diff --git a/roles/vicinae/files/scripts/ai-switch-claude b/roles/vicinae/files/scripts/ai-switch-claude new file mode 100755 index 00000000..aac4a2d7 --- /dev/null +++ b/roles/vicinae/files/scripts/ai-switch-claude @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title AI Usage: Switch To Claude +# @vicinae.mode compact +# @vicinae.keywords ["ai", "claude", "usage", "provider"] + +set -euo pipefail + +cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/ai-usage-monitor" +mkdir -p "$cache_dir" +printf 'claude\n' >"$cache_dir/provider.txt" +systemctl --user restart ai-usage-monitor.service >/dev/null 2>&1 || true diff --git a/roles/vicinae/files/scripts/ai-switch-codex b/roles/vicinae/files/scripts/ai-switch-codex new file mode 100755 index 00000000..f67e23f9 --- /dev/null +++ b/roles/vicinae/files/scripts/ai-switch-codex @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title AI Usage: Switch To Codex +# @vicinae.mode compact +# @vicinae.keywords ["ai", "codex", "usage", "provider"] + +set -euo pipefail + +cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/ai-usage-monitor" +mkdir -p "$cache_dir" +printf 'codex\n' >"$cache_dir/provider.txt" +systemctl --user restart ai-usage-monitor.service >/dev/null 2>&1 || true diff --git a/roles/vicinae/files/scripts/audio-settings b/roles/vicinae/files/scripts/audio-settings new file mode 100755 index 00000000..0d9aa13e --- /dev/null +++ b/roles/vicinae/files/scripts/audio-settings @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title Audio Settings +# @vicinae.mode compact +# @vicinae.keywords ["settings", "sound", "volume", "microphone"] + +set -euo pipefail + +if command -v pavucontrol >/dev/null 2>&1; then + exec pavucontrol +fi + +printf 'pavucontrol is not installed\n' >&2 +exit 1 diff --git a/roles/vicinae/files/scripts/bluetooth-settings b/roles/vicinae/files/scripts/bluetooth-settings new file mode 100755 index 00000000..2f1b74bf --- /dev/null +++ b/roles/vicinae/files/scripts/bluetooth-settings @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title Bluetooth Settings +# @vicinae.mode compact +# @vicinae.keywords ["settings", "bluetooth", "devices"] + +set -euo pipefail + +if command -v blueman-manager >/dev/null 2>&1; then + exec blueman-manager +fi + +printf 'blueman-manager is not installed\n' >&2 +exit 1 diff --git a/roles/vicinae/files/scripts/display-settings b/roles/vicinae/files/scripts/display-settings new file mode 100755 index 00000000..d6fd492f --- /dev/null +++ b/roles/vicinae/files/scripts/display-settings @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title Display Settings +# @vicinae.mode compact +# @vicinae.keywords ["settings", "display", "monitor", "screen"] + +set -euo pipefail + +if command -v arandr >/dev/null 2>&1; then + exec arandr +fi + +printf 'arandr is not installed\n' >&2 +exit 1 diff --git a/roles/vicinae/files/scripts/gtk-appearance b/roles/vicinae/files/scripts/gtk-appearance new file mode 100755 index 00000000..8953e682 --- /dev/null +++ b/roles/vicinae/files/scripts/gtk-appearance @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title GTK Appearance +# @vicinae.mode compact +# @vicinae.keywords ["settings", "theme", "appearance", "gtk"] + +set -euo pipefail + +if command -v lxappearance >/dev/null 2>&1; then + exec lxappearance +fi + +printf 'lxappearance is not installed\n' >&2 +exit 1 diff --git a/roles/vicinae/files/scripts/network-settings b/roles/vicinae/files/scripts/network-settings new file mode 100755 index 00000000..96400c60 --- /dev/null +++ b/roles/vicinae/files/scripts/network-settings @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title Network Settings +# @vicinae.mode compact +# @vicinae.keywords ["settings", "network", "wifi", "ethernet"] + +set -euo pipefail + +if command -v nm-connection-editor >/dev/null 2>&1; then + exec nm-connection-editor +fi + +printf 'nm-connection-editor is not installed\n' >&2 +exit 1 diff --git a/roles/vicinae/files/scripts/power-settings b/roles/vicinae/files/scripts/power-settings new file mode 100755 index 00000000..b7e4e01a --- /dev/null +++ b/roles/vicinae/files/scripts/power-settings @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title Power Settings +# @vicinae.mode compact +# @vicinae.keywords ["settings", "power", "battery", "sleep"] + +set -euo pipefail + +if command -v xfce4-power-manager-settings >/dev/null 2>&1; then + exec xfce4-power-manager-settings +fi + +printf 'xfce4-power-manager-settings is not installed\n' >&2 +exit 1 diff --git a/roles/vicinae/files/scripts/restart-awesomewm b/roles/vicinae/files/scripts/restart-awesomewm new file mode 100755 index 00000000..ad9a3dca --- /dev/null +++ b/roles/vicinae/files/scripts/restart-awesomewm @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title Restart AwesomeWM +# @vicinae.mode compact +# @vicinae.keywords ["desktop", "awesome", "restart", "reload", "window manager"] + +set -euo pipefail + +if command -v awesome-client >/dev/null 2>&1; then + exec awesome-client 'awesome.restart()' +fi + +printf 'awesome-client is not installed\n' >&2 +exit 1 diff --git a/roles/vicinae/files/scripts/restart-fabric b/roles/vicinae/files/scripts/restart-fabric new file mode 100755 index 00000000..a4c1c6b8 --- /dev/null +++ b/roles/vicinae/files/scripts/restart-fabric @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title Restart Fabric Bar +# @vicinae.mode compact +# @vicinae.keywords ["desktop", "fabric", "bar", "restart", "reload"] + +set -euo pipefail + +config_file="${FABRIC_AWESOMEWM_CONFIG:-$HOME/.config/fabric/awesomewm}/config.py" +launcher="$HOME/.local/bin/fabric-awesomewm" + +pkill -u "$USER" -f "$config_file" >/dev/null 2>&1 || true + +if [ -x "$launcher" ]; then + nohup "$launcher" >/dev/null 2>&1 & + exit 0 +fi + +printf 'fabric-awesomewm launcher is not installed\n' >&2 +exit 1 diff --git a/roles/vicinae/tasks/Ubuntu.yml b/roles/vicinae/tasks/Ubuntu.yml new file mode 100644 index 00000000..a1ef61ec --- /dev/null +++ b/roles/vicinae/tasks/Ubuntu.yml @@ -0,0 +1,494 @@ +--- +- name: "{{ role_name }} | Ubuntu | Install Vicinae prerequisites" + ansible.builtin.apt: + name: "{{ vicinae_ubuntu_packages + [vicinae_appimage_fuse_package] + ((vicinae_install_qalc | bool) | ternary(['qalc'], [])) }}" + state: present + update_cache: true + become: true + +- name: "{{ role_name }} | Ubuntu | Ensure Vicinae config directory exists" + ansible.builtin.file: + path: "{{ vicinae_config_dir }}" + state: directory + mode: "0755" + +- name: "{{ role_name }} | Ubuntu | Deploy managed Vicinae config import" + ansible.builtin.template: + src: dotfiles.json.j2 + dest: "{{ vicinae_config_dir }}/dotfiles.json" + mode: "0644" + +- name: "{{ role_name }} | Ubuntu | Check Vicinae user settings" + ansible.builtin.stat: + path: "{{ vicinae_config_dir }}/settings.json" + register: vicinae_settings + +- name: "{{ role_name }} | Ubuntu | Create Vicinae user settings import file" + ansible.builtin.template: + src: settings.json.j2 + dest: "{{ vicinae_config_dir }}/settings.json" + mode: "0644" + when: not vicinae_settings.stat.exists + +- name: "{{ role_name }} | Ubuntu | Read existing Vicinae user settings" + ansible.builtin.slurp: + src: "{{ vicinae_config_dir }}/settings.json" + register: vicinae_settings_content + when: vicinae_settings.stat.exists + +- name: "{{ role_name }} | Ubuntu | Warn when user settings do not import dotfiles config" + ansible.builtin.debug: + msg: >- + Existing {{ vicinae_config_dir }}/settings.json does not import ./dotfiles.json. + Leaving it untouched; add the import manually if you want repo-managed Vicinae defaults. + when: + - vicinae_settings.stat.exists + - "'dotfiles.json' not in (vicinae_settings_content.content | b64decode)" + +- name: "{{ role_name }} | Ubuntu | Ensure Vicinae script command directory exists" + ansible.builtin.file: + path: "{{ vicinae_scripts_dir }}" + state: directory + mode: "0755" + +- name: "{{ role_name }} | Ubuntu | Deploy Vicinae script commands" + ansible.builtin.copy: + src: scripts/ + dest: "{{ vicinae_scripts_dir }}/" + mode: "0755" + +- name: "{{ role_name }} | Ubuntu | Note Vicinae script command refresh behavior" + ansible.builtin.debug: + msg: "Vicinae scans script commands at startup and periodically; restart the server only if immediate reindexing is required." + +- name: "{{ role_name }} | Ubuntu | Skip Vicinae binary install in check mode" + ansible.builtin.debug: + msg: "Skipping Vicinae release lookup and extracted AppImage install in check mode." + when: ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Get latest Vicinae release" + ansible.builtin.uri: + url: "https://api.github.com/repos/{{ vicinae_repo }}/releases/latest" + headers: + Accept: application/vnd.github.v3+json + return_content: true + register: vicinae_latest_release_response + changed_when: false + when: + - not ansible_check_mode + - vicinae_version == "latest" + +- name: "{{ role_name }} | Ubuntu | Get pinned Vicinae release" + ansible.builtin.uri: + url: "https://api.github.com/repos/{{ vicinae_repo }}/releases/tags/{{ vicinae_version }}" + headers: + Accept: application/vnd.github.v3+json + return_content: true + register: vicinae_pinned_release_response + changed_when: false + when: + - not ansible_check_mode + - vicinae_version != "latest" + +- name: "{{ role_name }} | Ubuntu | Set Vicinae release response" + ansible.builtin.set_fact: + vicinae_release_json: "{{ vicinae_latest_release_response.json if vicinae_version == 'latest' else vicinae_pinned_release_response.json }}" + when: not ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Select Vicinae AppImage asset" + ansible.builtin.set_fact: + vicinae_target_tag: "{{ vicinae_release_json.tag_name }}" + vicinae_target_version: "{{ vicinae_release_json.tag_name | regex_replace('^v', '') }}" + vicinae_appimage_assets: "{{ vicinae_release_json.assets | selectattr('name', 'match', vicinae_appimage_asset_regex) | list }}" + when: not ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Validate Vicinae AppImage asset" + ansible.builtin.assert: + that: + - vicinae_appimage_assets | length > 0 + fail_msg: "Could not find a Vicinae x86_64 AppImage asset in {{ vicinae_target_tag }}." + when: not ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Set Vicinae AppImage asset" + ansible.builtin.set_fact: + vicinae_appimage_asset: "{{ vicinae_appimage_assets | first }}" + when: not ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Check installed Vicinae version" + ansible.builtin.command: + argv: + - "{{ vicinae_bin_dir }}/vicinae" + - version + register: vicinae_installed_version + changed_when: false + failed_when: false + when: not ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Extract installed Vicinae version" + ansible.builtin.set_fact: + vicinae_installed_version_number: "{{ (vicinae_installed_version.stdout ~ vicinae_installed_version.stderr) | regex_search('[0-9]+\\.[0-9]+\\.[0-9]+') | default('none', true) | string | trim }}" + when: not ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Determine whether Vicinae install is needed" + ansible.builtin.set_fact: + vicinae_needs_install: "{{ (vicinae_installed_version.rc | int != 0) or (vicinae_installed_version_number != (vicinae_target_version | string | trim)) }}" + when: not ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Show Vicinae install decision" + ansible.builtin.debug: + msg: + - "Target: {{ vicinae_target_tag }}" + - "Installed: {{ vicinae_installed_version_number }}" + - "Install needed: {{ vicinae_needs_install }}" + when: not ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Install Vicinae from extracted AppImage" + when: + - not ansible_check_mode + - vicinae_needs_install | bool + block: + - name: "{{ role_name }} | Ubuntu | Create Vicinae install temp directory" + ansible.builtin.tempfile: + state: directory + suffix: vicinae + register: vicinae_temp_dir + + - name: "{{ role_name }} | Ubuntu | Download Vicinae AppImage" + ansible.builtin.get_url: + url: "{{ vicinae_appimage_asset.browser_download_url }}" + dest: "{{ vicinae_temp_dir.path }}/{{ vicinae_appimage_asset.name }}" + mode: "0755" + force: true + + - name: "{{ role_name }} | Ubuntu | Extract Vicinae AppImage" + ansible.builtin.command: + argv: + - "{{ vicinae_temp_dir.path }}/{{ vicinae_appimage_asset.name }}" + - --appimage-extract + args: + chdir: "{{ vicinae_temp_dir.path }}" + changed_when: true + + - name: "{{ role_name }} | Ubuntu | Check extracted Vicinae binary" + ansible.builtin.stat: + path: "{{ vicinae_temp_dir.path }}/squashfs-root/usr/bin/vicinae" + register: vicinae_extracted_bin + + - name: "{{ role_name }} | Ubuntu | Check extracted Vicinae AppRun fallback" + ansible.builtin.stat: + path: "{{ vicinae_temp_dir.path }}/squashfs-root/AppRun" + register: vicinae_extracted_apprun + + - name: "{{ role_name }} | Ubuntu | Validate extracted Vicinae binary" + ansible.builtin.assert: + that: + - vicinae_extracted_bin.stat.exists or vicinae_extracted_apprun.stat.exists + fail_msg: "Vicinae binary was not found after AppImage extraction." + + - name: "{{ role_name }} | Ubuntu | Ensure Vicinae system directories exist" + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ vicinae_bin_dir }}" + - "{{ vicinae_prefix }}/lib" + - "{{ vicinae_themes_dir }}" + - "{{ vicinae_applications_dir }}" + - "{{ vicinae_systemd_user_dir }}" + become: true + + - name: "{{ role_name }} | Ubuntu | Remove previous managed Vicinae install" + ansible.builtin.file: + path: "{{ vicinae_install_dir }}" + state: absent + become: true + + - name: "{{ role_name }} | Ubuntu | Move extracted Vicinae tree into place" + ansible.builtin.command: + argv: + - mv + - "{{ vicinae_temp_dir.path }}/squashfs-root" + - "{{ vicinae_install_dir }}" + changed_when: true + become: true + + - name: "{{ role_name }} | Ubuntu | Normalize Vicinae install tree ownership" + ansible.builtin.file: + path: "{{ vicinae_install_dir }}" + state: directory + owner: root + group: root + recurse: true + become: true + + - name: "{{ role_name }} | Ubuntu | Set installed Vicinae binary path" + ansible.builtin.set_fact: + vicinae_installed_binary_path: "{{ vicinae_install_dir }}/{{ 'usr/bin/vicinae' if vicinae_extracted_bin.stat.exists else 'AppRun' }}" + + - name: "{{ role_name }} | Ubuntu | Symlink Vicinae binary" + ansible.builtin.file: + src: "{{ vicinae_installed_binary_path }}" + dest: "{{ vicinae_bin_dir }}/vicinae" + state: link + force: true + become: true + + - name: "{{ role_name }} | Ubuntu | Check bundled Vicinae node" + ansible.builtin.stat: + path: "{{ vicinae_install_dir }}/usr/bin/node" + register: vicinae_node + + - name: "{{ role_name }} | Ubuntu | Symlink bundled Vicinae node" + ansible.builtin.file: + src: "{{ vicinae_install_dir }}/usr/bin/node" + dest: "{{ vicinae_bin_dir }}/vicinae-node" + state: link + force: true + become: true + when: vicinae_node.stat.exists + + - name: "{{ role_name }} | Ubuntu | Remove stale bundled Vicinae node symlink" + ansible.builtin.file: + path: "{{ vicinae_bin_dir }}/vicinae-node" + state: absent + become: true + when: not vicinae_node.stat.exists + + - name: "{{ role_name }} | Ubuntu | Check bundled Vicinae themes" + ansible.builtin.stat: + path: "{{ vicinae_install_dir }}/usr/share/vicinae/themes" + register: vicinae_bundled_themes + + - name: "{{ role_name }} | Ubuntu | Install bundled Vicinae themes" + ansible.builtin.copy: + src: "{{ vicinae_install_dir }}/usr/share/vicinae/themes/" + dest: "{{ vicinae_themes_dir }}/" + remote_src: true + mode: preserve + become: true + when: vicinae_bundled_themes.stat.isdir | default(false) + + - name: "{{ role_name }} | Ubuntu | Check bundled Vicinae desktop file directory" + ansible.builtin.stat: + path: "{{ vicinae_install_dir }}/usr/share/applications" + register: vicinae_applications_source + + - name: "{{ role_name }} | Ubuntu | Find bundled Vicinae desktop files" + ansible.builtin.find: + paths: "{{ vicinae_install_dir }}/usr/share/applications" + patterns: "vicinae*.desktop" + file_type: file + register: vicinae_desktop_files + when: vicinae_applications_source.stat.isdir | default(false) + + - name: "{{ role_name }} | Ubuntu | Install Vicinae desktop files" + ansible.builtin.copy: + src: "{{ item.path }}" + dest: "{{ vicinae_applications_dir }}/{{ item.path | basename }}" + remote_src: true + mode: preserve + loop: "{{ vicinae_desktop_files.files if vicinae_desktop_files is defined else [] }}" + become: true + + - name: "{{ role_name }} | Ubuntu | Update desktop database" + ansible.builtin.command: + argv: + - update-desktop-database + - "{{ vicinae_applications_dir }}" + changed_when: false + failed_when: false + become: true + when: + - vicinae_desktop_files is defined + - vicinae_desktop_files.matched | default(0) | int > 0 + + - name: "{{ role_name }} | Ubuntu | Check bundled Vicinae systemd service" + ansible.builtin.stat: + path: "{{ vicinae_install_dir }}/usr/lib/systemd/user/vicinae.service" + register: vicinae_systemd_service + + - name: "{{ role_name }} | Ubuntu | Install Vicinae systemd user service" + ansible.builtin.copy: + src: "{{ vicinae_install_dir }}/usr/lib/systemd/user/vicinae.service" + dest: "{{ vicinae_systemd_user_dir }}/vicinae.service" + remote_src: true + mode: "0644" + become: true + when: vicinae_systemd_service.stat.exists + + - name: "{{ role_name }} | Ubuntu | Pin Vicinae service ExecStart to installed binary" + ansible.builtin.replace: + path: "{{ vicinae_systemd_user_dir }}/vicinae.service" + regexp: "^ExecStart=vicinae" + replace: "ExecStart={{ vicinae_bin_dir }}/vicinae" + become: true + when: vicinae_systemd_service.stat.exists + + - name: "{{ role_name }} | Ubuntu | Reload user systemd after Vicinae service install" + ansible.builtin.command: + argv: + - systemctl + - --user + - daemon-reload + changed_when: false + failed_when: false + when: vicinae_systemd_service.stat.exists + + - name: "{{ role_name }} | Ubuntu | Check bundled Vicinae modules-load directory" + ansible.builtin.stat: + path: "{{ vicinae_install_dir }}/usr/lib/modules-load.d" + register: vicinae_modules_load_dir_source + when: vicinae_install_input_server | bool + + - name: "{{ role_name }} | Ubuntu | Check bundled Vicinae modules-load config" + ansible.builtin.find: + paths: "{{ vicinae_install_dir }}/usr/lib/modules-load.d" + file_type: file + register: vicinae_modules_load_files + when: + - vicinae_install_input_server | bool + - vicinae_modules_load_dir_source.stat.isdir | default(false) + + - name: "{{ role_name }} | Ubuntu | Ensure Vicinae modules-load directory exists" + ansible.builtin.file: + path: "{{ vicinae_modules_load_dir }}" + state: directory + mode: "0755" + become: true + when: + - vicinae_install_input_server | bool + - vicinae_modules_load_files.matched | default(0) | int > 0 + + - name: "{{ role_name }} | Ubuntu | Install Vicinae modules-load config" + ansible.builtin.copy: + src: "{{ item.path }}" + dest: "{{ vicinae_modules_load_dir }}/{{ item.path | basename }}" + remote_src: true + mode: preserve + loop: "{{ vicinae_modules_load_files.files if vicinae_modules_load_files is defined else [] }}" + become: true + when: vicinae_install_input_server | bool + + - name: "{{ role_name }} | Ubuntu | Load uinput module for Vicinae input server" + ansible.builtin.command: + argv: + - modprobe + - uinput + changed_when: false + failed_when: false + become: true + when: + - vicinae_install_input_server | bool + - vicinae_modules_load_files.matched | default(0) | int > 0 + + - name: "{{ role_name }} | Ubuntu | Check Vicinae input server binary" + ansible.builtin.stat: + path: "{{ vicinae_install_dir }}/usr/libexec/vicinae/vicinae-input-server" + register: vicinae_input_server + when: vicinae_install_input_server | bool + + - name: "{{ role_name }} | Ubuntu | Check Vicinae input server capabilities" + ansible.builtin.command: + argv: + - getcap + - "{{ vicinae_install_dir }}/usr/libexec/vicinae/vicinae-input-server" + register: vicinae_input_server_caps + changed_when: false + failed_when: false + when: + - vicinae_install_input_server | bool + - vicinae_input_server.stat.exists | default(false) + + - name: "{{ role_name }} | Ubuntu | Set Vicinae input server capabilities" + ansible.builtin.command: + argv: + - setcap + - cap_dac_override=ep + - "{{ vicinae_install_dir }}/usr/libexec/vicinae/vicinae-input-server" + changed_when: true + become: true + when: + - vicinae_install_input_server | bool + - vicinae_input_server.stat.exists | default(false) + - "'cap_dac_override=ep' not in (vicinae_input_server_caps.stdout | default(''))" + + - name: "{{ role_name }} | Ubuntu | Warn when browser manifests are requested" + ansible.builtin.debug: + msg: "Browser native messaging manifest install is intentionally deferred in this role." + when: vicinae_install_browser_manifests | bool + + always: + - name: "{{ role_name }} | Ubuntu | Remove Vicinae install temp directory" + ansible.builtin.file: + path: "{{ vicinae_temp_dir.path }}" + state: absent + failed_when: false + when: + - vicinae_temp_dir is defined + - vicinae_temp_dir.path is defined + +- name: "{{ role_name }} | Ubuntu | Check managed Vicinae install directory" + ansible.builtin.stat: + path: "{{ vicinae_install_dir }}" + register: vicinae_managed_install_dir + when: not ansible_check_mode + +- name: "{{ role_name }} | Ubuntu | Ensure managed Vicinae install tree ownership" + ansible.builtin.file: + path: "{{ vicinae_install_dir }}" + state: directory + owner: root + group: root + recurse: true + become: true + when: + - not ansible_check_mode + - vicinae_managed_install_dir.stat.isdir | default(false) + +- name: "{{ role_name }} | Ubuntu | Check installed Vicinae input server binary" + ansible.builtin.stat: + path: "{{ vicinae_install_dir }}/usr/libexec/vicinae/vicinae-input-server" + register: vicinae_installed_input_server + when: + - not ansible_check_mode + - vicinae_install_input_server | bool + +- name: "{{ role_name }} | Ubuntu | Check installed Vicinae input server capabilities" + ansible.builtin.command: + argv: + - getcap + - "{{ vicinae_install_dir }}/usr/libexec/vicinae/vicinae-input-server" + register: vicinae_installed_input_server_caps + changed_when: false + failed_when: false + when: + - not ansible_check_mode + - vicinae_install_input_server | bool + - vicinae_installed_input_server.stat.exists | default(false) + +- name: "{{ role_name }} | Ubuntu | Ensure installed Vicinae input server capabilities" + ansible.builtin.command: + argv: + - setcap + - cap_dac_override=ep + - "{{ vicinae_install_dir }}/usr/libexec/vicinae/vicinae-input-server" + changed_when: true + become: true + when: + - not ansible_check_mode + - vicinae_install_input_server | bool + - vicinae_installed_input_server.stat.exists | default(false) + - "'cap_dac_override=ep' not in (vicinae_installed_input_server_caps.stdout | default(''))" + +- name: "{{ role_name }} | Ubuntu | Enable Vicinae user service when requested" + ansible.builtin.systemd: + name: vicinae.service + scope: user + state: started + enabled: true + daemon_reload: true + failed_when: false + when: + - not ansible_check_mode + - vicinae_enable_user_service | bool diff --git a/roles/vicinae/tasks/main.yml b/roles/vicinae/tasks/main.yml new file mode 100644 index 00000000..24345a1f --- /dev/null +++ b/roles/vicinae/tasks/main.yml @@ -0,0 +1,9 @@ +--- +- name: "{{ role_name }} | Checking for Distribution Config: {{ ansible_facts['distribution'] }}" + ansible.builtin.stat: + path: "{{ role_path }}/tasks/{{ ansible_facts['distribution'] }}.yml" + register: distribution_config + +- name: "{{ role_name }} | Run Tasks: {{ ansible_facts['distribution'] }}" + ansible.builtin.include_tasks: "{{ ansible_facts['distribution'] }}.yml" + when: distribution_config.stat.exists diff --git a/roles/vicinae/templates/dotfiles.json.j2 b/roles/vicinae/templates/dotfiles.json.j2 new file mode 100644 index 00000000..f3f1bcf6 --- /dev/null +++ b/roles/vicinae/templates/dotfiles.json.j2 @@ -0,0 +1,55 @@ +{ + "telemetry": { + "system_info": false + }, + "search_files_in_root": false, + "close_on_focus_loss": true, + "pop_to_root_on_close": true, + "escape_key_behavior": "close_window", + "favicon_service": "none", + "font": { + "rendering": "qt", + "normal": { + "family": "auto", + "size": 10.5 + } + }, + "theme": { + "dark": { + "name": "vicinae-dark", + "icon_theme": "auto" + }, + "light": { + "name": "vicinae-light", + "icon_theme": "auto" + } + }, + "launcher_window": { + "opacity": 0.98, + "blur": { + "enabled": false + }, + "client_side_decorations": { + "enabled": true, + "rounding": 10, + "border_width": 1 + }, + "compact_mode": { + "enabled": false + }, + "size": { + "width": 770, + "height": 480 + }, + "screen": "auto", + "layer_shell": { + "enabled": false + } + }, + "favorites": [ + "clipboard:history" + ], + "fallbacks": [ + "files:search" + ] +} diff --git a/roles/vicinae/templates/settings.json.j2 b/roles/vicinae/templates/settings.json.j2 new file mode 100644 index 00000000..d7f11e76 --- /dev/null +++ b/roles/vicinae/templates/settings.json.j2 @@ -0,0 +1,5 @@ +{ + "imports": [ + "./dotfiles.json" + ] +} diff --git a/roles/vicinae/tests/test_vicinae_role.sh b/roles/vicinae/tests/test_vicinae_role.sh new file mode 100755 index 00000000..d52235c5 --- /dev/null +++ b/roles/vicinae/tests/test_vicinae_role.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +role_dir="$repo_root/roles/vicinae" + +grep -q "vicinae_prefix: /usr/local" "$role_dir/defaults/main.yml" +grep -q "vicinae_install_input_server: true" "$role_dir/defaults/main.yml" +grep -q "https://api.github.com/repos/{{ vicinae_repo }}/releases/latest" "$role_dir/tasks/Ubuntu.yml" +grep -q "vicinae_appimage_asset_regex" "$role_dir/tasks/Ubuntu.yml" +grep -q "cap_dac_override=ep" "$role_dir/tasks/Ubuntu.yml" +grep -q "vicinae_enable_user_service" "$role_dir/tasks/Ubuntu.yml" +grep -q '"./dotfiles.json"' "$role_dir/templates/settings.json.j2" +grep -q '"search_files_in_root": false' "$role_dir/templates/dotfiles.json.j2" +grep -q '"favicon_service": "none"' "$role_dir/templates/dotfiles.json.j2" + +for script in "$role_dir"/files/scripts/*; do + grep -q "@vicinae.schemaVersion 1" "$script" + grep -q "@vicinae.title" "$script" + grep -q "@vicinae.mode" "$script" + bash -n "$script" +done From 0766f5603ace14a30450775f6257ea9198d3e0d4 Mon Sep 17 00:00:00 2001 From: TechDufus Date: Thu, 14 May 2026 12:03:28 -0500 Subject: [PATCH 04/15] feat(awesomewm): retire legacy launcher fallbacks --- roles/awesomewm/README.md | 34 +-- roles/awesomewm/defaults/main.yml | 10 + .../config/cell-management/keybindings.lua | 10 +- .../config/cell-management/layout-manager.lua | 209 ++++++++++-------- roles/awesomewm/files/config/rc.lua | 57 +++-- .../awesomewm/files/copyq/copyq-commands.ini | 2 - roles/awesomewm/files/copyq/copyq.conf | 104 --------- .../files/rofi/catppuccin-mocha.rasi | 123 ----------- roles/awesomewm/files/rofi/config.rasi | 13 -- roles/awesomewm/tasks/Ubuntu.yml | 83 +++---- .../awesomewm/tests/test_vicinae_launcher.sh | 38 +++- roles/vicinae/README.md | 5 +- 12 files changed, 237 insertions(+), 451 deletions(-) delete mode 100644 roles/awesomewm/files/copyq/copyq-commands.ini delete mode 100644 roles/awesomewm/files/copyq/copyq.conf delete mode 100644 roles/awesomewm/files/rofi/catppuccin-mocha.rasi delete mode 100644 roles/awesomewm/files/rofi/config.rasi diff --git a/roles/awesomewm/README.md b/roles/awesomewm/README.md index a3c88294..38f34d2b 100644 --- a/roles/awesomewm/README.md +++ b/roles/awesomewm/README.md @@ -11,7 +11,7 @@ This role provides a fully-configured AwesomeWM desktop environment with: - **Intelligent app positioning** with multi-resolution support - **Catppuccin Mocha theme** across all UI components - **Standalone settings tools** (no GNOME dependencies) -- **Complete desktop utilities** (launcher, clipboard, screenshots, notifications) +- **Complete desktop utilities** (Vicinae launcher hooks, screenshots, notifications) Perfect for developers migrating from macOS Hammerspoon or seeking advanced tiling functionality on Ubuntu. @@ -50,7 +50,7 @@ graph TD - Pre-configured layouts for different workflows - Assign apps to specific cells - Optional auto-launch on layout activation -- Interactive layout picker (Hyper+p) +- Keyboard-native layout picker (Hyper+p) ### Modal Application Summoning @@ -92,14 +92,14 @@ graph LR A --> C[Status Bar] A --> D[Notifications] - E[Utilities] --> F[Rofi Launcher] - E --> G[CopyQ Clipboard] + E[Utilities] --> F[Vicinae Launcher Signals] + E --> G[Vicinae Clipboard/Emoji] E --> H[Flameshot Screenshots] E --> I[Settings Tools] J[Theme] --> K[Catppuccin Mocha] K --> L[GTK Apps] - K --> M[Rofi] + K --> M[Fabric] K --> N[Status Bar] style A fill:#89b4fa @@ -114,17 +114,14 @@ graph LR **Window Management:** - `awesome` - AwesomeWM window manager - `xdotool` - Window manipulation tool -- `rofi` - Application launcher and layout picker - `i3lock` - Screen locker **Desktop Utilities:** - `flameshot` - Screenshot tool -- `copyq` - Clipboard manager - `thunar` - Lightweight file manager - `ristretto` - Image viewer -- `rofimoji` - Emoji picker (via pipx) - Flare launcher (AppImage, retained temporarily during Vicinae migration) -- Vicinae primary launcher support when the separate `vicinae` role is installed +- Vicinae primary launcher, clipboard, app search, emoji, and settings support when the separate `vicinae` role is installed **Media & System:** - `playerctl` - Media key controls @@ -180,10 +177,6 @@ graph LR ├── awesome-wm-widgets/ # Cloned from GitHub └── cyclefocus/ # Cloned from GitHub -~/.config/rofi/ -├── config.rasi # Rofi configuration -└── catppuccin-mocha.rasi # Catppuccin theme - ~/.config/gtk-3.0/ └── settings.ini # GTK3 dark theme @@ -195,16 +188,11 @@ graph LR ~/.config/flameshot/ └── flameshot.ini # Screenshot tool config -~/.config/copyq/ -├── copyq.conf # Clipboard manager config -└── copyq-commands.ini # Custom commands - ~/.themes/ └── catppuccin-mocha-blue-standard+default/ # GTK theme ~/.local/bin/ -├── flare # Legacy launcher kept during Vicinae migration -└── rofimoji # Emoji picker +└── flare # Legacy launcher kept during Vicinae migration ``` ### Theming @@ -212,7 +200,6 @@ graph LR **Catppuccin Mocha** applied consistently across: - AwesomeWM (status bar, window borders, notifications) - GTK3/GTK4 applications -- Rofi launcher - Papirus-Dark icon theme **Theme Colors:** @@ -403,12 +390,13 @@ None. This role is self-contained. **Automatically installed:** - Flatpak (for Discord, Spotify, Obsidian) -- Python 3 + pipx (for rofimoji) -- Git (for cloning widget repositories) +- Desktop utility packages listed above **Not installed by this role:** - Ghostty terminal (install via [ghostty](../ghostty/) role) - 1Password (install via [1password](../1password/) role) +- Vicinae launcher binary (install via [vicinae](../vicinae/) role) +- Git client, which is required by the widget repository clone tasks ## Troubleshooting @@ -492,7 +480,7 @@ M.hyper = { 'Mod4', 'Shift' } -- Super+Shift (easier) **Complete removal:** ```bash -sudo apt remove awesome xdotool flameshot rofi i3lock copyq +sudo apt remove awesome xdotool flameshot i3lock rm -rf ~/.config/awesome rm -rf ~/.themes/catppuccin-mocha-* ``` diff --git a/roles/awesomewm/defaults/main.yml b/roles/awesomewm/defaults/main.yml index 5a7b4ab7..c5878d40 100644 --- a/roles/awesomewm/defaults/main.yml +++ b/roles/awesomewm/defaults/main.yml @@ -1,3 +1,13 @@ --- # AwesomeWM role defaults role_name: "awesomewm" + +awesomewm_remove_legacy_launcher_tools: true +awesomewm_legacy_launcher_packages: + - rofi + - copyq +awesomewm_legacy_launcher_paths: + - "{{ ansible_facts['user_dir'] }}/.config/rofi" + - "{{ ansible_facts['user_dir'] }}/.config/copyq" + - "{{ ansible_facts['user_dir'] }}/.local/bin/bemoji" + - "{{ ansible_facts['user_dir'] }}/.local/share/bemoji" diff --git a/roles/awesomewm/files/config/cell-management/keybindings.lua b/roles/awesomewm/files/config/cell-management/keybindings.lua index 296d7a3f..de6dc45a 100644 --- a/roles/awesomewm/files/config/cell-management/keybindings.lua +++ b/roles/awesomewm/files/config/cell-management/keybindings.lua @@ -123,11 +123,11 @@ macro_modal = awful.keygrabber { return end - -- e: Emoji picker with bemoji + -- e: Emoji picker if key == 'e' then self:stop() gears.timer.delayed_call(function() - awful.spawn(os.getenv("HOME") .. "/.local/bin/bemoji -cn --hist-limit 5") + awesome.emit_signal("techdufus::launcher_emoji") end) return end @@ -141,13 +141,11 @@ macro_modal = awful.keygrabber { return end - -- g: GUI Settings menu (rofi picker) + -- g: GUI Settings command deck if key == 'g' then self:stop() gears.timer.delayed_call(function() - awful.spawn.easy_async_with_shell([[ - printf '%s\n' "Audio (pavucontrol)" "Display (arandr)" "GTK Themes (lxappearance)" "Bluetooth (blueman-manager)" "Network (nm-connection-editor)" "Power (xfce4-power-manager-settings)" | rofi -dmenu -i -p "Settings" | sed 's/.*(\(.*\))/\1/' | xargs -I{} sh -c '{}' - ]], function() end) + awesome.emit_signal("techdufus::launcher_settings") end) return end diff --git a/roles/awesomewm/files/config/cell-management/layout-manager.lua b/roles/awesomewm/files/config/cell-management/layout-manager.lua index 3587e5b8..badf6d60 100644 --- a/roles/awesomewm/files/config/cell-management/layout-manager.lua +++ b/roles/awesomewm/files/config/cell-management/layout-manager.lua @@ -1,10 +1,24 @@ -- layout-manager.lua - Layout switching and manual cell binding local awful = require("awful") +local gears = require("gears") +local naughty = require("naughty") local state = require("cell-management.state") local helpers = require("cell-management.helpers") local M = {} +local key_to_number = { + ["1"] = 1, ["KP_1"] = 1, + ["2"] = 2, ["KP_2"] = 2, + ["3"] = 3, ["KP_3"] = 3, + ["4"] = 4, ["KP_4"] = 4, + ["5"] = 5, ["KP_5"] = 5, + ["6"] = 6, ["KP_6"] = 6, + ["7"] = 7, ["KP_7"] = 7, + ["8"] = 8, ["KP_8"] = 8, + ["9"] = 9, ["KP_9"] = 9, +} + local function get_relative_screen(base_screen, offset) if not base_screen or screen.count() < 2 then return base_screen @@ -15,10 +29,6 @@ local function get_relative_screen(base_screen, offset) return screen[target_index] or base_screen end -local function escape_rofi_prompt(text) - return tostring(text):gsub('"', '\\"') -end - local function reapply_layout_for_screen(target_screen) target_screen = state.resolve_screen(target_screen) if not target_screen then @@ -46,6 +56,85 @@ local function reapply_layout_for_screen(target_screen) end end +local function notify_picker(title, lines) + naughty.notify({ + title = title, + text = table.concat(lines, "\n"), + timeout = 2, + }) +end + +local function start_numeric_picker(title, lines, max_index, callback) + notify_picker(title, lines) + + local picker = awful.keygrabber { + stop_key = "Escape", + stop_event = "press", + timeout = 2, + autostart = false, + keypressed_callback = function(self, mod, key) + local index = key_to_number[key] + + if index and index <= max_index then + self:stop() + gears.timer.delayed_call(function() + callback(index) + end) + return + end + + self:stop() + end, + } + + picker:start() +end + +local function move_client_to_cell(c, cell_index, target_screen, layout) + if not c or not c.valid then + return + end + + cell_index = tonumber(cell_index) + if not cell_index then + print("[WARN] Invalid cell index: " .. tostring(cell_index)) + return + end + + target_screen = target_screen or c.screen + layout = layout or state.get_current_layout(target_screen) + + if not layout or not layout.cells then + print("[WARN] No layout configured for " .. helpers.get_screen_label(target_screen)) + return + end + + if cell_index < 1 or cell_index > #layout.cells then + print("[WARN] Invalid cell index: " .. tostring(cell_index)) + return + end + + local app_name = c.class and helpers.find_app_by_class(c.class) or nil + if app_name and layout.apps and layout.apps[app_name] then + state.set_app_cell_override(app_name, cell_index, target_screen) + helpers.position_client_in_cell(c, app_name, layout) + return + end + + local cell_def = layout.cells[cell_index] + local geom = require("cell-management.grid").cell_to_geometry(cell_def, c.screen) + + c.fullscreen = false + c.maximized = false + c.maximized_vertical = false + c.maximized_horizontal = false + c.floating = true + c.x = geom.x + c.y = geom.y + c.width = geom.width + c.height = geom.height +end + local function move_client_to_screen(c, target_screen, follow) if not c or not target_screen or c.screen == target_screen then return @@ -112,49 +201,25 @@ function M.move_client_to_previous_screen(c, follow) move_client_to_screen(c, get_relative_screen(c.screen, -1), follow ~= false) end --- Interactive layout picker (Hyper+p) - Uses rofi for visual selection +-- Interactive layout picker (Hyper+p) function M.select_layout(target_screen) target_screen = state.resolve_screen(target_screen) local layouts = state.get_all_layouts() local current_index = state.get_current_layout_index(target_screen) local screen_label = helpers.get_screen_label(target_screen) - - -- Build rofi menu with layout names - local menu_items = {} + local picker_lines = {} for i, layout in ipairs(layouts) do - local marker = (i == current_index) and "* " or " " - local menu_line = string.format("%s%d. %s", marker, i, layout.name) - table.insert(menu_items, menu_line) - end - - -- Create temporary file with menu items - local menu_file = "/tmp/awesomewm-layout-menu" - local f = io.open(menu_file, "w") - if f then - f:write(table.concat(menu_items, "\n")) - f:close() - end - - -- Launch rofi and handle selection - awful.spawn.easy_async_with_shell( - string.format( - 'rofi -dmenu -i -p "%s" -format s < %s', - escape_rofi_prompt("Layout for " .. screen_label), - menu_file - ), - function(stdout, stderr, reason, exit_code) - -- Parse selection: " 2. My Layout" -> extract "2" - local index = stdout:match("^%s*%*?%s*(%d+)%.") - if index then - index = tonumber(index) - if index then - M.switch_layout(index, target_screen) - end - end + local marker = (i == current_index) and "*" or " " + table.insert(picker_lines, string.format("%s %d %s", marker, i, layout.name)) + end - -- Clean up temp file - os.remove(menu_file) + start_numeric_picker( + "Layout for " .. screen_label, + picker_lines, + #layouts, + function(index) + M.switch_layout(index, target_screen) end ) end @@ -169,8 +234,8 @@ function M.select_next_variant(target_screen) M.switch_layout(next_index, target_screen) end --- Bind window to cell (Hyper+u) - Uses rofi for visual selection -function M.bind_to_cell() +-- Bind window to cell (Hyper+u) +function M.bind_to_cell(cell_index) local c = client.focus if not c then print("[WARN] No focused client") @@ -185,10 +250,13 @@ function M.bind_to_cell() return end - -- Build rofi menu with cell information - local menu_items = {} + if cell_index then + move_client_to_cell(c, cell_index, target_screen, layout) + return + end + + local picker_lines = {} for i, _ in ipairs(layout.cells) do - -- Find which apps are assigned to this cell local apps_in_cell = {} for app_name, app_config in pairs(layout.apps or {}) do if app_config.cell == i then @@ -196,53 +264,16 @@ function M.bind_to_cell() end end - -- Build menu line: "Cell 1: Terminal, Browser" or "Cell 1: (empty)" local app_list = #apps_in_cell > 0 and table.concat(apps_in_cell, ", ") or "(empty)" - local menu_line = string.format("Cell %d: %s", i, app_list) - table.insert(menu_items, menu_line) - end - - -- Create temporary file with menu items - local menu_file = "/tmp/awesomewm-cell-menu" - local f = io.open(menu_file, "w") - if f then - f:write(table.concat(menu_items, "\n")) - f:close() - end - - -- Launch rofi and handle selection - awful.spawn.easy_async_with_shell( - string.format( - 'rofi -dmenu -i -p "%s" -format s < %s', - escape_rofi_prompt(string.format("Move %s on %s to cell", c.class or "window", helpers.get_screen_label(target_screen))), - menu_file - ), - function(stdout, stderr, reason, exit_code) - -- Parse selection: "Cell 3: Spotify" -> extract "3" - local cell_index = stdout:match("^Cell (%d+):") - if cell_index then - cell_index = tonumber(cell_index) - - if cell_index and cell_index >= 1 and cell_index <= #layout.cells then - local app_name = c.class and helpers.find_app_by_class(c.class) or nil - if app_name and layout.apps and layout.apps[app_name] then - state.set_app_cell_override(app_name, cell_index, target_screen) - helpers.position_client_in_cell(c, app_name, layout) - else - local cell_def = layout.cells[cell_index] - local geom = require("cell-management.grid").cell_to_geometry(cell_def, c.screen) - - c.floating = true - c.x = geom.x - c.y = geom.y - c.width = geom.width - c.height = geom.height - end - end - end + table.insert(picker_lines, string.format("%d %s", i, app_list)) + end - -- Clean up temp file - os.remove(menu_file) + start_numeric_picker( + string.format("Move %s on %s to cell", c.class or "window", helpers.get_screen_label(target_screen)), + picker_lines, + #layout.cells, + function(index) + move_client_to_cell(c, index, target_screen, layout) end ) end diff --git a/roles/awesomewm/files/config/rc.lua b/roles/awesomewm/files/config/rc.lua index 180e9aa6..9b37b313 100644 --- a/roles/awesomewm/files/config/rc.lua +++ b/roles/awesomewm/files/config/rc.lua @@ -79,9 +79,6 @@ fi xmodmap -e 'keycode 66 = F13' ]]) --- Start clipboard manager daemon (CopyQ) -awful.spawn.once("copyq") - -- Start polkit authentication agent for 1Password system auth awful.spawn.once("/usr/lib/policykit-1-gnome/polkit-gnome-authentication-agent-1") @@ -149,9 +146,6 @@ editor_cmd = terminal .. " -e " .. editor -- I suggest you to remap Mod4 to another key using xmodmap or other tools. -- However, you can use another modifier like Mod1, but it may interact with others. modkey = "Mod4" -local settings_picker_command = [[ -printf '%s\n' 'Audio (pavucontrol)' 'Display (arandr)' 'GTK Themes (lxappearance)' 'Bluetooth (blueman-manager)' 'Network (nm-connection-editor)' 'Power (xfce4-power-manager-settings)' | rofi -dmenu -i -p Settings | sed 's/.*(\(.*\))/\1/' | xargs -r -I{} sh -c '{}' -]] -- CELL MANAGEMENT MODE: Only floating layout enabled -- All window positioning is handled by the cell-based layout system @@ -163,41 +157,42 @@ awful.layout.layouts = { local function notify_vicinae_fallback() naughty.notify({ title = "Vicinae unavailable", - text = "Falling back to rofi. Run dotfiles -t vicinae to install it.", + text = "Run dotfiles -t vicinae to install it, or use Super+Return for a terminal.", }) end -local function launch_rofi_apps() - awful.spawn("rofi -show drun -show-icons") -end - -local function launch_copyq_clipboard() - awful.spawn("copyq toggle") -end - -local function launch_rofi_settings() - awful.spawn.with_shell(settings_picker_command) -end - -local function launch_vicinae(uri, fallback) +local function launch_vicinae(uri) awful.spawn.easy_async({ "sh", "-lc", "command -v vicinae >/dev/null 2>&1" }, function(_, _, _, exit_code) if exit_code == 0 then awful.spawn({ "vicinae", uri }) else notify_vicinae_fallback() - if fallback then - fallback() - end end end) end local function launch_vicinae_root() - launch_vicinae("vicinae://toggle", launch_rofi_apps) + launch_vicinae("vicinae://toggle") end local function launch_vicinae_open_root() - launch_vicinae("vicinae://open?popToRoot=true", launch_rofi_apps) + launch_vicinae("vicinae://open?popToRoot=true") +end + +local function launch_vicinae_apps() + launch_vicinae("vicinae://launch/applications?toggle=true") +end + +local function launch_vicinae_clipboard() + launch_vicinae("vicinae://launch/clipboard/history?toggle=true") +end + +local function launch_vicinae_emoji() + launch_vicinae("vicinae://launch/core/search-emojis?toggle=true") +end + +local function launch_vicinae_settings() + launch_vicinae("vicinae://launch/scripts?fallbackText=settings&toggle=true") end local function start_vicinae_server() @@ -210,9 +205,10 @@ end awesome.connect_signal("techdufus::launcher_root", launch_vicinae_root) awesome.connect_signal("techdufus::launcher_open_root", launch_vicinae_open_root) -awesome.connect_signal("techdufus::launcher_apps", launch_rofi_apps) -awesome.connect_signal("techdufus::launcher_clipboard", launch_copyq_clipboard) -awesome.connect_signal("techdufus::launcher_settings", launch_rofi_settings) +awesome.connect_signal("techdufus::launcher_apps", launch_vicinae_apps) +awesome.connect_signal("techdufus::launcher_clipboard", launch_vicinae_clipboard) +awesome.connect_signal("techdufus::launcher_emoji", launch_vicinae_emoji) +awesome.connect_signal("techdufus::launcher_settings", launch_vicinae_settings) awesome.connect_signal("techdufus::launch_flare", launch_vicinae_root) start_vicinae_server() @@ -508,7 +504,7 @@ globalkeys = gears.table.join( awful.spawn("flameshot full -p " .. os.getenv("HOME") .. "/Pictures") end, { description = "screenshot full screen to file", group = "screenshot" }), - -- Fallback application launcher. + -- Application launcher. awful.key({ "Mod1" }, "space", function() awesome.emit_signal("techdufus::launcher_apps") end, { description = "application launcher", group = "launcher" }), @@ -518,7 +514,7 @@ globalkeys = gears.table.join( awesome.emit_signal("techdufus::launcher_root") end, { description = "vicinae launcher", group = "launcher" }), - -- Clipboard manager. CopyQ remains the first-phase fallback. + -- Clipboard manager. awful.key({ modkey }, "v", function() awesome.emit_signal("techdufus::launcher_clipboard") end, { description = "clipboard history", group = "launcher" }), @@ -711,7 +707,6 @@ awful.rules.rules = { rule_any = { instance = { "DTA", -- Firefox addon DownThemAll. - "copyq", -- CopyQ clipboard manager "pinentry", }, class = { diff --git a/roles/awesomewm/files/copyq/copyq-commands.ini b/roles/awesomewm/files/copyq/copyq-commands.ini deleted file mode 100644 index 11e99b58..00000000 --- a/roles/awesomewm/files/copyq/copyq-commands.ini +++ /dev/null @@ -1,2 +0,0 @@ -[Commands] -size=0 diff --git a/roles/awesomewm/files/copyq/copyq.conf b/roles/awesomewm/files/copyq/copyq.conf deleted file mode 100644 index e7085e9e..00000000 --- a/roles/awesomewm/files/copyq/copyq.conf +++ /dev/null @@ -1,104 +0,0 @@ -[General] -plugin_priority=itemimage, itemencrypted, itemfakevim, itemnotes, itempinned, itemsync, itemtags, itemtext - -[Options] -activate_closes=true -activate_focuses=true -activate_item_with_single_click=false -activate_pastes=true -always_on_top=false -autocompletion=true -autostart=true -check_clipboard=true -check_selection=false -clipboard_notification_lines=0 -clipboard_tab=&clipboard -close_on_unfocus=true -close_on_unfocus_delay_ms=500 -confirm_exit=true -disable_tray=false -hide_main_window=false -hide_main_window_in_task_bar=false -hide_tabs=true -hide_toolbar=true -hide_toolbar_labels=true -item_popup_interval=0 -language=en -maxitems=200 -move=true -native_notifications=true -open_windows_on_current_screen=true -restore_geometry=true -save_filter_history=false -tab_tree=false -tabs=&clipboard -text_wrap=true -transparency=0 -transparency_focused=0 -tray_images=true -tray_item_paste=true -tray_items=5 -vi=true - -[Plugins] -itemencrypted\enabled=true -itemfakevim\enabled=true -itemimage\enabled=true -itemimage\max_image_height=200 -itemimage\max_image_width=300 -itemnotes\enabled=true -itempinned\enabled=true -itemsync\enabled=false -itemtags\enabled=true -itemtext\enabled=true - -[Shortcuts] -exit=ctrl+q -find_items=ctrl+f -move_down=ctrl+j -move_up=ctrl+k -new=ctrl+n -paste_selected_items=ctrl+v -preferences=ctrl+p -show_clipboard_content=ctrl+shift+c - -[Tabs] -1\icon= -1\max_item_count=0 -1\name=&clipboard -1\store_items=true -size=1 - -[Theme] -alt_bg=#313244 -alt_item_css= -bg=#1e1e2e -css= -cur_item_css= -edit_bg=#313244 -edit_fg=#cdd6f4 -edit_font="BerkeleyMono Nerd Font,10,-1,5,50,0,0,0,0,0" -fg=#cdd6f4 -find_bg=#89b4fa -find_fg=#1e1e2e -find_font="BerkeleyMono Nerd Font,10,-1,5,50,0,0,0,0,0" -font="BerkeleyMono Nerd Font,10,-1,5,50,0,0,0,0,0" -font_antialiasing=true -hover_item_css= -icon_size=16 -item_css=padding:0.5em -item_spacing= -notes_bg=#313244 -notes_css= -notes_fg=#a6adc8 -notes_font="BerkeleyMono Nerd Font,9,-1,5,50,0,0,0,0,0" -num_fg=#585b70 -num_font="BerkeleyMono Nerd Font,7,-1,5,25,0,0,0,0,0" -num_margin=2 -sel_bg=#45475a -sel_fg=#cdd6f4 -sel_item_css=";border-radius: 4px;border: 1px solid #89b4fa" -show_number=false -show_scrollbars=false -style_main_window=false -use_system_icons=false diff --git a/roles/awesomewm/files/rofi/catppuccin-mocha.rasi b/roles/awesomewm/files/rofi/catppuccin-mocha.rasi deleted file mode 100644 index 4122b295..00000000 --- a/roles/awesomewm/files/rofi/catppuccin-mocha.rasi +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Catppuccin Mocha theme for Rofi - * Matches AwesomeWM Catppuccin theme - */ - -* { - /* Catppuccin Mocha colors */ - bg-col: #1e1e2e; - bg-col-light: #313244; - border-col: #89b4fa; - selected-col: #313244; - blue: #89b4fa; - fg-col: #cdd6f4; - fg-col2: #f38ba8; - grey: #6c7086; - overlay0: #6c7086; - surface0: #313244; - text: #cdd6f4; - subtext0: #a6adc8; - - width: 600px; -} - -element-text, element-icon , mode-switcher { - background-color: inherit; - text-color: inherit; -} - -window { - height: 400px; - border: 2px; - border-color: @border-col; - background-color: @bg-col; - border-radius: 8px; -} - -mainbox { - background-color: @bg-col; -} - -inputbar { - children: [prompt,entry]; - background-color: @bg-col; - border-radius: 5px; - padding: 8px; -} - -prompt { - background-color: @blue; - padding: 8px 12px; - text-color: @bg-col; - border-radius: 4px; - margin: 0px 8px 0px 0px; -} - -textbox-prompt-colon { - expand: false; - str: ":"; -} - -entry { - padding: 8px; - text-color: @fg-col; - background-color: @bg-col; - placeholder-color: @grey; - placeholder: "Search applications..."; -} - -listview { - border: 0px 0px 0px; - padding: 8px 0px 0px; - margin: 8px 0px 0px; - columns: 1; - lines: 8; - background-color: @bg-col; -} - -element { - padding: 8px; - background-color: @bg-col; - text-color: @fg-col; - border-radius: 4px; -} - -element-icon { - size: 28px; -} - -element selected { - background-color: @selected-col; - text-color: @blue; -} - -mode-switcher { - spacing: 0; -} - -button { - padding: 10px; - background-color: @bg-col-light; - text-color: @grey; - vertical-align: 0.5; - horizontal-align: 0.5; -} - -button selected { - background-color: @bg-col; - text-color: @blue; -} - -message { - background-color: @bg-col-light; - margin: 8px; - padding: 8px; - border-radius: 5px; -} - -textbox { - padding: 6px; - margin: 20px 0px 0px 20px; - text-color: @blue; - background-color: @bg-col-light; -} diff --git a/roles/awesomewm/files/rofi/config.rasi b/roles/awesomewm/files/rofi/config.rasi deleted file mode 100644 index 0f397146..00000000 --- a/roles/awesomewm/files/rofi/config.rasi +++ /dev/null @@ -1,13 +0,0 @@ -configuration { - modi: "drun,run,window"; - show-icons: true; - icon-theme: "Papirus-Dark"; - font: "BerkeleyMono Nerd Font"; - drun-display-format: "{name}"; - display-drun: " Apps"; - display-run: " Run"; - display-window: " Windows"; - sidebar-mode: false; -} - -@theme "catppuccin-mocha" diff --git a/roles/awesomewm/tasks/Ubuntu.yml b/roles/awesomewm/tasks/Ubuntu.yml index a9ae57d4..443216d0 100644 --- a/roles/awesomewm/tasks/Ubuntu.yml +++ b/roles/awesomewm/tasks/Ubuntu.yml @@ -11,9 +11,8 @@ - thunar # Lightweight file manager - tumbler # Thumbnail service for Thunar - ristretto # Lightweight image viewer (Xfce) - - curl # For bemoji installation - - xclip # Clipboard tool for bemoji and other apps - - rofi # Application launcher, layout picker + - curl # AI usage monitor HTTP client + - xclip # General X11 clipboard CLI - i3lock # Screen locker - playerctl # Media key controls (play/pause/next/prev) - pulseaudio-utils # Provides pactl for volume and mic controls @@ -23,7 +22,6 @@ - papirus-icon-theme # Icon theme for UI - procps # Provides top, free for widgets - iproute2 # Provides ip command for network widgets - - copyq # Clipboard manager (Super + v) # Standalone settings tools (no GNOME deps) - pavucontrol # Audio/mic/speaker settings - arandr # Display/monitor layout @@ -37,6 +35,31 @@ update_cache: true become: true +- name: "awesomewm | Ubuntu | Stop legacy CopyQ process" + ansible.builtin.command: + argv: + - copyq + - exit + changed_when: false + failed_when: false + when: awesomewm_remove_legacy_launcher_tools | bool + +- name: "awesomewm | Ubuntu | Remove legacy launcher packages" + ansible.builtin.apt: + name: "{{ awesomewm_legacy_launcher_packages }}" + state: absent + become: true + when: + - awesomewm_remove_legacy_launcher_tools | bool + - awesomewm_legacy_launcher_packages | length > 0 + +- name: "awesomewm | Ubuntu | Remove legacy launcher managed files" + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: "{{ awesomewm_legacy_launcher_paths }}" + when: awesomewm_remove_legacy_launcher_tools | bool + - name: "awesomewm | Ubuntu | Add user to video group for backlight control" ansible.builtin.user: name: "{{ ansible_facts['user_id'] }}" @@ -50,22 +73,6 @@ state: present become: true -- name: "awesomewm | Ubuntu | Install bemoji emoji picker" - ansible.builtin.get_url: - url: https://raw.githubusercontent.com/marty-oehme/bemoji/main/bemoji - dest: "{{ ansible_facts['user_dir'] }}/.local/bin/bemoji" - mode: "0755" - -- name: "awesomewm | Ubuntu | Download bemoji emoji database" - ansible.builtin.shell: | - {{ ansible_facts['user_dir'] }}/.local/bin/bemoji -D all - args: - creates: "{{ ansible_facts['user_dir'] }}/.local/share/bemoji/emojis.txt" - become: false - changed_when: true - # bemoji -D outputs to stderr and returns exit code 1 even on success - failed_when: false - - name: "awesomewm | Ubuntu | Download Flare launcher" ansible.builtin.get_url: url: https://github.com/ByteAtATime/flare/releases/download/v0.1.0/flare_0.1.0_amd64.AppImage @@ -149,24 +156,6 @@ mode: "0644" notify: "awesomewm | Ubuntu | Restart AwesomeWM" -- name: "awesomewm | Ubuntu | Ensure rofi config directory exists" - ansible.builtin.file: - path: "{{ ansible_facts['user_dir'] }}/.config/rofi" - state: directory - mode: "0755" - -- name: "awesomewm | Ubuntu | Deploy rofi configuration" - ansible.builtin.copy: - src: "rofi/config.rasi" - dest: "{{ ansible_facts['user_dir'] }}/.config/rofi/config.rasi" - mode: "0644" - -- name: "awesomewm | Ubuntu | Deploy rofi Catppuccin theme" - ansible.builtin.copy: - src: "rofi/catppuccin-mocha.rasi" - dest: "{{ ansible_facts['user_dir'] }}/.config/rofi/catppuccin-mocha.rasi" - mode: "0644" - - name: "awesomewm | Ubuntu | Install GTK theme prerequisites" ansible.builtin.apt: name: @@ -241,24 +230,6 @@ dest: "{{ ansible_facts['user_dir'] }}/.config/flameshot/flameshot.ini" mode: "0644" -- name: "awesomewm | Ubuntu | Ensure CopyQ config directory exists" - ansible.builtin.file: - path: "{{ ansible_facts['user_dir'] }}/.config/copyq" - state: directory - mode: "0755" - -- name: "awesomewm | Ubuntu | Deploy CopyQ configuration" - ansible.builtin.copy: - src: "copyq/copyq.conf" - dest: "{{ ansible_facts['user_dir'] }}/.config/copyq/copyq.conf" - mode: "0644" - -- name: "awesomewm | Ubuntu | Deploy CopyQ commands" - ansible.builtin.copy: - src: "copyq/copyq-commands.ini" - dest: "{{ ansible_facts['user_dir'] }}/.config/copyq/copyq-commands.ini" - mode: "0644" - - name: "awesomewm | Ubuntu | Copy AI usage monitor script" ansible.builtin.copy: src: scripts/ai-usage-monitor.sh diff --git a/roles/awesomewm/tests/test_vicinae_launcher.sh b/roles/awesomewm/tests/test_vicinae_launcher.sh index 1dbb2e56..6e0d0f21 100755 --- a/roles/awesomewm/tests/test_vicinae_launcher.sh +++ b/roles/awesomewm/tests/test_vicinae_launcher.sh @@ -3,18 +3,29 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" config_path="$repo_root/roles/awesomewm/files/config/rc.lua" +layout_manager_path="$repo_root/roles/awesomewm/files/config/cell-management/layout-manager.lua" +tasks_path="$repo_root/roles/awesomewm/tasks/Ubuntu.yml" +defaults_path="$repo_root/roles/awesomewm/defaults/main.yml" grep -q "techdufus::launcher_root" "$config_path" grep -q "techdufus::launcher_apps" "$config_path" grep -q "techdufus::launcher_clipboard" "$config_path" +grep -q "techdufus::launcher_emoji" "$config_path" grep -q "techdufus::launcher_settings" "$config_path" grep -q "techdufus::launch_flare" "$config_path" grep -q "launch_vicinae_root" "$config_path" -grep -q "launch_rofi_apps" "$config_path" -grep -q "launch_copyq_clipboard" "$config_path" +grep -q "launch_vicinae_apps" "$config_path" +grep -q "launch_vicinae_clipboard" "$config_path" +grep -q "launch_vicinae_emoji" "$config_path" +grep -q "launch_vicinae_settings" "$config_path" grep -q "start_vicinae_server" "$config_path" grep -q "vicinae://toggle" "$config_path" +grep -q "vicinae://launch/applications?toggle=true" "$config_path" +grep -q "vicinae://launch/clipboard/history?toggle=true" "$config_path" +grep -q "vicinae://launch/core/search-emojis?toggle=true" "$config_path" +grep -q "vicinae://launch/scripts?fallbackText=settings&toggle=true" "$config_path" grep -q "vicinae server --replace" "$config_path" +grep -q "Run dotfiles -t vicinae" "$config_path" grep -q 'class = { "Vicinae", "vicinae" }' "$config_path" grep -q 'instance = { "command", "Vicinae", "vicinae" }' "$config_path" grep -q 'name = { "Vicinae Launcher", "Vicinae", "vicinae" }' "$config_path" @@ -24,3 +35,26 @@ if grep -q "org.dev_byteatatime_flare.SingleInstance" "$config_path"; then echo "Flare DBus launcher path should not remain active in rc.lua" >&2 exit 1 fi + +if grep -Eq "\\b(rofi|copyq|bemoji|rofimoji)\\b" "$config_path" "$layout_manager_path"; then + echo "Legacy rofi/CopyQ/bemoji runtime references should not remain" >&2 + exit 1 +fi + +if grep -Eq "Install bemoji|Download bemoji|Deploy rofi|Deploy CopyQ|rofi[[:space:]]+#|copyq[[:space:]]+#" "$tasks_path"; then + echo "Legacy rofi/CopyQ/bemoji install or deploy tasks should not remain" >&2 + exit 1 +fi + +grep -q "awesomewm_remove_legacy_launcher_tools: true" "$defaults_path" +grep -q "awesomewm_legacy_launcher_packages:" "$defaults_path" +grep -q "rofi" "$defaults_path" +grep -q "copyq" "$defaults_path" +grep -q "bemoji" "$defaults_path" +grep -q "awful.keygrabber" "$layout_manager_path" +grep -q "function M.bind_to_cell(cell_index)" "$layout_manager_path" +grep -q "move_client_to_cell" "$layout_manager_path" + +keybindings_path="$repo_root/roles/awesomewm/files/config/cell-management/keybindings.lua" +grep -q "techdufus::launcher_emoji" "$keybindings_path" +grep -q "techdufus::launcher_settings" "$keybindings_path" diff --git a/roles/vicinae/README.md b/roles/vicinae/README.md index fce2a825..0388df4a 100644 --- a/roles/vicinae/README.md +++ b/roles/vicinae/README.md @@ -66,5 +66,6 @@ TypeScript extension only after scripts or dmenu prove insufficient. ## Rollout Boundary -This role does not remove Flare, rofi, CopyQ, or bemoji. Those remain recovery -paths until Vicinae has passed the live validation gates in the migration plan. +This role only installs and configures Vicinae. The AwesomeWM role owns desktop +keybindings and removes the retired rofi, CopyQ, and bemoji fallback tools once +the Vicinae launcher, clipboard, app search, emoji, and settings flows validate. From 0a597d19632a7e656e90203b664ee33fcd64fe4a Mon Sep 17 00:00:00 2001 From: TechDufus Date: Sat, 16 May 2026 14:14:30 -0500 Subject: [PATCH 05/15] feat(fabric): add tactical calendar popout --- .gitignore | 4 + roles/fabric/files/bin/fabric-awesomewm | 13 +- roles/fabric/files/config/awesomewm/config.py | 349 ++++++++++++++++-- roles/fabric/files/config/awesomewm/style.css | 90 +++++ roles/fabric/tests/test_config_helpers.sh | 51 +++ 5 files changed, 469 insertions(+), 38 deletions(-) diff --git a/.gitignore b/.gitignore index 1729ad0c..451dd26e 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,7 @@ node_modules/ # Task files tasks.json + +# Superpowers local state +.superpowers/ +docs/superpowers/ diff --git a/roles/fabric/files/bin/fabric-awesomewm b/roles/fabric/files/bin/fabric-awesomewm index 711191ff..dd5d5340 100644 --- a/roles/fabric/files/bin/fabric-awesomewm +++ b/roles/fabric/files/bin/fabric-awesomewm @@ -6,13 +6,24 @@ venv="${FABRIC_AWESOMEWM_VENV:-$HOME/.local/share/fabric-awesomewm/venv}" config_file="$config_dir/config.py" log_dir="${XDG_CACHE_HOME:-$HOME/.cache}/fabric-awesomewm" log_file="$log_dir/fabric-awesomewm.log" +replace=false + +if [ "${1:-}" = "--replace" ]; then + replace=true + shift +fi if [ ! -x "$venv/bin/python" ] || [ ! -f "$config_file" ]; then exit 0 fi if pgrep -u "${USER}" -f "$config_file" >/dev/null 2>&1; then - exit 0 + if [ "$replace" = false ]; then + exit 0 + fi + + pkill -u "${USER}" -f "$config_file" >/dev/null 2>&1 || true + sleep 0.2 fi mkdir -p "$log_dir" diff --git a/roles/fabric/files/config/awesomewm/config.py b/roles/fabric/files/config/awesomewm/config.py index 39d923ef..48619cb2 100644 --- a/roles/fabric/files/config/awesomewm/config.py +++ b/roles/fabric/files/config/awesomewm/config.py @@ -8,7 +8,8 @@ import subprocess import sys import time -from datetime import date, datetime, timezone +from dataclasses import dataclass +from datetime import date, datetime, timedelta, timezone from pathlib import Path from fabric import Application, Fabricator @@ -106,20 +107,68 @@ Task = dict[str, object] +@dataclass(frozen=True) +class MonitorGeometry: + index: int + x: int + y: int + width: int + height: int + scale_factor: int = 1 + primary: bool = False + name: str = "" + + def bar_size_from_monitor_width(width: int) -> tuple[int, int]: return (width, BAR_HEIGHT) -def primary_monitor_width() -> int: +def fallback_monitor_geometries() -> list[MonitorGeometry]: + return [MonitorGeometry(index=0, x=0, y=0, width=1920, height=1080, primary=True, name="fallback")] + + +def monitor_geometries() -> list[MonitorGeometry]: try: from gi.repository import Gdk display = Gdk.Display.get_default() - monitor = display.get_primary_monitor() if display else None - geometry = monitor.get_geometry() if monitor else None - return int(geometry.width) if geometry else 1920 + if display is None: + return fallback_monitor_geometries() + + primary_monitor = display.get_primary_monitor() + monitors = [] + for index in range(display.get_n_monitors()): + monitor = display.get_monitor(index) + if monitor is None: + continue + geometry = monitor.get_geometry() + monitors.append( + MonitorGeometry( + index=index, + x=int(geometry.x), + y=int(geometry.y), + width=max(1, int(geometry.width)), + height=max(1, int(geometry.height)), + scale_factor=max(1, int(monitor.get_scale_factor())), + primary=monitor == primary_monitor, + name=str(monitor.get_model() or f"monitor-{index}"), + ) + ) + + return monitors or fallback_monitor_geometries() except Exception: - return 1920 + return fallback_monitor_geometries() + + +def monitor_rectangle(monitor: MonitorGeometry): + from gi.repository import Gdk + + rectangle = Gdk.Rectangle() + rectangle.x = monitor.x + rectangle.y = monitor.y + rectangle.width = monitor.width + rectangle.height = monitor.height + return rectangle def run_command(command: list[str]) -> None: @@ -426,6 +475,121 @@ def calendar_text() -> str: return calendar_text_for_months(today.year, today.month) +def shifted_date_by_days(value: date, delta: int) -> date: + return value + timedelta(days=delta) + + +def calendar_day_style_classes( + day: date, + today: date, + selected: date, + marker_days: set[date] | None = None, +) -> list[str]: + marker_days = marker_days or set() + classes = ["current-month"] if day.month == selected.month else ["adjacent-month"] + if day == today: + classes.append("today") + if day == selected: + classes.append("selected") + if day in marker_days: + classes.append("has-marker") + if day.weekday() >= 5: + classes.append("weekend") + return classes + + +def calendar_month_model( + year: int, + month: int, + today: date | None = None, + selected: date | None = None, + marker_days: set[date] | None = None, +) -> dict[str, object]: + today = today or date.today() + selected = selected or today + marker_days = marker_days or set() + renderer = calendar.Calendar(calendar.SUNDAY) + weeks = [] + for week in renderer.monthdatescalendar(year, month): + weeks.append( + [ + { + "date": day, + "day": str(day.day), + "style_classes": calendar_day_style_classes(day, today, selected, marker_days), + } + for day in week + ] + ) + + while len(weeks) < 6: + last_day = weeks[-1][-1]["date"] + weeks.append( + [ + { + "date": shifted_date_by_days(last_day, offset), + "day": str(shifted_date_by_days(last_day, offset).day), + "style_classes": calendar_day_style_classes( + shifted_date_by_days(last_day, offset), + today, + selected, + marker_days, + ), + } + for offset in range(1, 8) + ] + ) + + return { + "title": f"{calendar.month_name[month]} {year}", + "year": year, + "month": month, + "today": today, + "selected": selected, + "selected_label": selected.strftime("%a %b %-d, %Y"), + "weekdays": ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"], + "weeks": weeks[:6], + } + + +def calendar_action_for_key(key_name: str) -> str | None: + normalized = str(key_name or "").strip() + actions = { + "h": "month-prev", + "H": "month-prev", + "Left": "month-prev", + "Page_Up": "month-prev", + "l": "month-next", + "L": "month-next", + "Right": "month-next", + "Page_Down": "month-next", + "k": "week-prev", + "K": "week-prev", + "Up": "week-prev", + "j": "week-next", + "J": "week-next", + "Down": "week-next", + "t": "today", + "T": "today", + "Home": "today", + "Return": "today", + "KP_Enter": "today", + } + return actions.get(normalized) + + +def key_name_from_event(event: object) -> str: + try: + from gi.repository import Gdk + + key_name = Gdk.keyval_name(int(getattr(event, "keyval", 0))) + if key_name: + return str(key_name) + except Exception: + pass + return str(getattr(event, "keyval", "")) + + def battery_value_from_output(text: str) -> str | None: value = text.strip() return value or None @@ -1211,10 +1375,26 @@ def toggle(self, name: str) -> None: self.open(name) -class AudioDevicePopout(Window): - def __init__(self): +class MonitorWindow(Window): + def __init__(self, monitor: MonitorGeometry, *args, **kwargs): + self.monitor = monitor + super().__init__(*args, **kwargs) + + def do_get_display_props(self): + from gi.repository import Gdk + + display = Gdk.Display.get_default() + if display is None: + raise RuntimeError("GDK display unavailable") + return display, monitor_rectangle(self.monitor), self.monitor.scale_factor + + +class AudioDevicePopout(MonitorWindow): + def __init__(self, monitor: MonitorGeometry): self.rows = Box(name="audio-popout-rows", orientation="v", spacing=4) super().__init__( + monitor, + title="fabric-audio-popout", name="audio-popout", layer="top", geometry="top-right", @@ -1269,11 +1449,13 @@ def toggle(self) -> None: self.show_all() -class AIUsagePopout(Window): - def __init__(self, on_provider_changed=None): +class AIUsagePopout(MonitorWindow): + def __init__(self, monitor: MonitorGeometry, on_provider_changed=None): self.on_provider_changed = on_provider_changed self.panel = Box(name="ai-panel", orientation="v", spacing=9) super().__init__( + monitor, + title="fabric-ai-popout", name="ai-popout", layer="top", geometry="top-right", @@ -1421,10 +1603,17 @@ def toggle(self) -> None: self.show_all() -class CalendarPopout(Window): - def __init__(self): - self.calendar_label = Label(name="calendar-label", label="") +class CalendarPopout(MonitorWindow): + def __init__(self, monitor: MonitorGeometry): + self.selected_date = date.today() + self.view_year = self.selected_date.year + self.view_month = self.selected_date.month + self.title_label = Label(name="calendar-title", h_expand=True, label="") + self.day_grid = Box(name="calendar-grid", orientation="v", spacing=4) + self.selected_label = Label(name="calendar-selected", label="") super().__init__( + monitor, + title="fabric-calendar-popout", name="calendar-popout", layer="top", geometry="top", @@ -1434,30 +1623,113 @@ def __init__(self): child=Box( name="calendar-panel", orientation="v", - spacing=6, + spacing=8, children=[ - Label(name="popout-title", label="CALENDAR"), - self.calendar_label, + Box( + name="calendar-header", + orientation="h", + spacing=8, + children=[ + Button(name="calendar-nav", child=Label(label="<"), on_clicked=lambda *_: self.shift_month(-1)), + self.title_label, + Button(name="calendar-nav", child=Label(label=">"), on_clicked=lambda *_: self.shift_month(1)), + ], + ), + Box( + name="calendar-weekdays", + orientation="h", + spacing=4, + children=[Label(name="calendar-weekday", label=day) for day in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]], + ), + self.day_grid, + self.selected_label, + Label(name="calendar-hints", label="h/l month k/j week t today esc close"), ], ), ) + def refresh(self) -> None: + model = calendar_month_model(self.view_year, self.view_month, today=date.today(), selected=self.selected_date) + self.title_label.set_label(str(model["title"]).upper()) + self.selected_label.set_label(str(model["selected_label"])) + self.day_grid.children = [self.week_row(week) for week in model["weeks"]] + + def week_row(self, week: list[dict[str, object]]) -> Box: + return Box( + name="calendar-week", + orientation="h", + spacing=4, + children=[self.day_button(day) for day in week], + ) + + def day_button(self, day: dict[str, object]) -> Button: + day_date = day["date"] + return Button( + name="calendar-day", + style_classes=list(day["style_classes"]), + child=Label(label=str(day["day"])), + on_clicked=lambda *_args, target=day_date: self.select_date(target), + ) + + def select_date(self, target: date) -> None: + self.selected_date = target + self.view_year = target.year + self.view_month = target.month + self.refresh() + + def shift_month(self, delta: int) -> None: + self.view_year, self.view_month = shifted_month(self.view_year, self.view_month, delta) + month_last_day = calendar.monthrange(self.view_year, self.view_month)[1] + selected_day = min(self.selected_date.day, month_last_day) + self.selected_date = date(self.view_year, self.view_month, selected_day) + self.refresh() + + def shift_week(self, delta: int) -> None: + self.select_date(shifted_date_by_days(self.selected_date, delta * 7)) + + def go_today(self) -> None: + self.select_date(date.today()) + + def handle_key_press(self, event) -> bool: + action = calendar_action_for_key(key_name_from_event(event)) + if action == "month-prev": + self.shift_month(-1) + return True + if action == "month-next": + self.shift_month(1) + return True + if action == "week-prev": + self.shift_week(-1) + return True + if action == "week-next": + self.shift_week(1) + return True + if action == "today": + self.go_today() + return True + return False + def toggle(self) -> None: if self.get_visible(): self.hide() return - self.calendar_label.set_label(calendar_text()) + self.refresh() self.show_all() -class StatusBar(Window): - def __init__(self): +class StatusBar(MonitorWindow): + def __init__(self, monitor: MonitorGeometry): + self.monitor = monitor super().__init__( + monitor, + title="fabric-bar", name="fabric-awesomewm-bar", layer="top", - geometry="top", + geometry="top-left", type_hint="dock", - size=bar_size_from_monitor_width(primary_monitor_width()), + type="popup", + focusable=False, + size=bar_size_from_monitor_width(monitor.width), visible=False, ) @@ -1465,7 +1737,7 @@ def __init__(self): self.popup_manager = PopupManager() self.network = StatusPill("NET", "...") self.volume = StatusPill("VOL", "...") - self.audio_popout = AudioDevicePopout() + self.audio_popout = AudioDevicePopout(monitor) self.volume_button = EventBox( name="volume-button", events=["button-press", "scroll"], @@ -1474,14 +1746,14 @@ def __init__(self): self.volume_button.connect("button-press-event", self.on_volume_button_press) self.volume_button.connect("scroll-event", self.on_volume_scroll) self.ai = StatusPill("AI", "AI") - self.ai_popout = AIUsagePopout(on_provider_changed=self.refresh_ai_usage) + self.ai_popout = AIUsagePopout(monitor, on_provider_changed=self.refresh_ai_usage) self.ai_button = EventBox( name="ai-button", events=["button-press"], child=self.ai, ) self.ai_button.connect("button-press-event", self.on_ai_button_press) - self.calendar_popout = CalendarPopout() + self.calendar_popout = CalendarPopout(monitor) self.register_popup("audio", self.audio_popout) self.register_popup("ai", self.ai_popout) self.register_popup("calendar", self.calendar_popout) @@ -1550,6 +1822,9 @@ def __init__(self): ), ) + self.set_default_size(monitor.width, BAR_HEIGHT) + self.set_size_request(monitor.width, BAR_HEIGHT) + self.pollers = [ Fabricator(interval=2000, poll_from=lambda _: running_tasks(), on_changed=lambda _, value: self.tasks.set_tasks(value)), Fabricator(interval=10000, poll_from=lambda _: network_text(), on_changed=lambda _, value: self.network.set_value(value)), @@ -1581,16 +1856,14 @@ def on_popup_focus_out(self, name: str) -> bool: return False def on_popup_key_press(self, name: str, event) -> bool: - try: - from gi.repository import Gdk - - if int(getattr(event, "keyval", 0)) == int(Gdk.KEY_Escape): - self.popup_manager.close(name) - return True - except Exception: - if str(getattr(event, "keyval", "")).lower() == "escape": - self.popup_manager.close(name) - return True + key_name = key_name_from_event(event) + if key_name == "Escape" or key_name.lower() == "escape": + self.popup_manager.close(name) + return True + popup = self.popup_manager.popups.get(name) + handle_key_press = getattr(popup, "handle_key_press", None) + if callable(handle_key_press): + return bool(handle_key_press(event)) return False def on_volume_button_press(self, _widget, event) -> bool: @@ -1648,8 +1921,10 @@ def refresh_dnd(self, value: str) -> None: if "--check" in sys.argv: raise SystemExit(run_self_check()) - bar = StatusBar() - app = Application("fabric-awesomewm", *bar.windows()) + bars = [StatusBar(monitor) for monitor in monitor_geometries()] + windows = [window for bar in bars for window in bar.windows()] + app = Application("fabric-awesomewm", *windows) app.set_stylesheet_from_file(get_relative_path("./style.css")) - bar.show_all() + for bar in bars: + bar.show_all() app.run() diff --git a/roles/fabric/files/config/awesomewm/style.css b/roles/fabric/files/config/awesomewm/style.css index 937bdfe9..fc1728f8 100644 --- a/roles/fabric/files/config/awesomewm/style.css +++ b/roles/fabric/files/config/awesomewm/style.css @@ -208,6 +208,7 @@ } #calendar-panel { + min-width: 284px; padding: 10px; background-color: alpha(var(--base), 0.98); border: 1px solid alpha(var(--cyan), 0.55); @@ -219,6 +220,95 @@ color: var(--text); } +#calendar-header { + min-height: 28px; +} + +#calendar-title { + color: var(--cyan); + font-weight: 800; +} + +#calendar-nav { + min-width: 26px; + min-height: 24px; + padding: 0; + color: var(--cyan); + background-color: alpha(var(--surface0), 0.82); + border: 1px solid alpha(var(--cyan), 0.28); + border-radius: 3px; +} + +#calendar-nav:hover { + background-color: alpha(var(--cyan), 0.14); + border-color: alpha(var(--cyan), 0.62); +} + +#calendar-weekdays, +#calendar-grid, +#calendar-week { + min-height: 28px; +} + +#calendar-weekday, +#calendar-day { + min-width: 34px; + min-height: 28px; +} + +#calendar-weekday { + color: var(--muted); + font-weight: 700; +} + +#calendar-day { + padding: 0; + color: var(--text); + background-color: alpha(var(--surface0), 0.68); + border: 1px solid alpha(var(--surface2), 0.38); + border-radius: 3px; +} + +#calendar-day:hover { + border-color: alpha(var(--cyan), 0.55); + background-color: alpha(var(--cyan), 0.12); +} + +#calendar-day.adjacent-month { + color: alpha(var(--muted), 0.68); + background-color: alpha(var(--surface0), 0.28); +} + +#calendar-day.weekend { + color: var(--mauve); +} + +#calendar-day.has-marker { + border-bottom-color: var(--mauve); + border-bottom-width: 2px; +} + +#calendar-day.today { + color: var(--base); + background-color: alpha(var(--cyan), 0.88); + border-color: var(--cyan); + font-weight: 900; +} + +#calendar-day.selected { + border-color: var(--yellow); + box-shadow: inset 0 0 0 1px alpha(var(--yellow), 0.9); +} + +#calendar-selected { + color: var(--yellow); + font-weight: 700; +} + +#calendar-hints { + color: var(--muted); +} + #popout-title { color: var(--cyan); font-weight: 700; diff --git a/roles/fabric/tests/test_config_helpers.sh b/roles/fabric/tests/test_config_helpers.sh index 0142fb4b..e257f977 100644 --- a/roles/fabric/tests/test_config_helpers.sh +++ b/roles/fabric/tests/test_config_helpers.sh @@ -38,6 +38,7 @@ import json import pathlib import re import sys +from datetime import date config_path = pathlib.Path(sys.argv[1]) status_path = pathlib.Path(sys.argv[2]) @@ -46,6 +47,7 @@ css_path = pathlib.Path(sys.argv[3]) spec = importlib.util.spec_from_file_location("fabric_awesomewm_config", config_path) module = importlib.util.module_from_spec(spec) assert spec.loader is not None +sys.modules[spec.name] = module spec.loader.exec_module(module) module.AI_STATUS_PATH = status_path @@ -59,6 +61,10 @@ assert module.VOLUME_POLL_MS <= 1000 assert module.AI_POLL_MS <= 5000 assert module.bar_size_from_monitor_width(1920) == (1920, 37) assert module.bar_size_from_monitor_width(1) == (1, 37) +fallback_monitors = module.fallback_monitor_geometries() +assert fallback_monitors == [ + module.MonitorGeometry(index=0, x=0, y=0, width=1920, height=1080, scale_factor=1, primary=True, name="fallback") +] codex_rate_limits = { "limit_id": "codex", @@ -308,6 +314,39 @@ rendered_calendar = module.calendar_text_for_months(2026, 5) assert "May 2026" in rendered_calendar assert "June 2026" in rendered_calendar assert "1 2" in rendered_calendar +assert callable(getattr(module.CalendarPopout, "refresh", None)) + +calendar_model = module.calendar_month_model( + 2026, + 5, + today=date(2026, 5, 14), + selected=date(2026, 5, 14), + marker_days={date(2026, 5, 1), date(2026, 5, 15)}, +) +assert calendar_model["title"] == "May 2026" +assert calendar_model["selected_label"] == "Thu May 14, 2026" +assert len(calendar_model["weeks"]) == 6 +assert all(len(week) == 7 for week in calendar_model["weeks"]) +assert calendar_model["weeks"][0][0]["date"] == date(2026, 4, 26) +selected_day = calendar_model["weeks"][2][4] +assert selected_day["day"] == "14" +assert selected_day["style_classes"] == ["current-month", "today", "selected"] +marked_day = calendar_model["weeks"][0][5] +assert marked_day["date"] == date(2026, 5, 1) +assert "has-marker" in marked_day["style_classes"] +assert module.shifted_date_by_days(date(2026, 5, 1), -7) == date(2026, 4, 24) +assert module.shifted_date_by_days(date(2026, 12, 29), 7) == date(2027, 1, 5) +assert module.calendar_action_for_key("h") == "month-prev" +assert module.calendar_action_for_key("Left") == "month-prev" +assert module.calendar_action_for_key("l") == "month-next" +assert module.calendar_action_for_key("Right") == "month-next" +assert module.calendar_action_for_key("k") == "week-prev" +assert module.calendar_action_for_key("Up") == "week-prev" +assert module.calendar_action_for_key("j") == "week-next" +assert module.calendar_action_for_key("Down") == "week-next" +assert module.calendar_action_for_key("t") == "today" +assert module.calendar_action_for_key("Return") == "today" +assert module.calendar_action_for_key("space") is None now = [100.0] @@ -400,6 +439,18 @@ for selector in ( "#ai-metric-row", "#ai-progress", "#ai-footer-button", + "#calendar-header", + "#calendar-grid", + "#calendar-day", + "#calendar-day.today", + "#calendar-day.selected", + "#calendar-day.has-marker", + "#calendar-selected", + "#calendar-hints", ): assert selector in css PY + +bash -n "$repo_root/roles/fabric/files/bin/fabric-awesomewm" +grep -q -- "--replace" "$repo_root/roles/fabric/files/bin/fabric-awesomewm" +grep -q "pkill -u" "$repo_root/roles/fabric/files/bin/fabric-awesomewm" From 59ddcfe3b34c78b49b932195ff4a5e05fc696d1f Mon Sep 17 00:00:00 2001 From: TechDufus Date: Sat, 16 May 2026 15:10:20 -0500 Subject: [PATCH 06/15] refactor(flatpak): stop installing signal --- roles/flatpak/README.md | 8 +++----- roles/flatpak/tasks/Ubuntu.yml | 1 - 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/roles/flatpak/README.md b/roles/flatpak/README.md index 038acb8f..796861e3 100644 --- a/roles/flatpak/README.md +++ b/roles/flatpak/README.md @@ -26,7 +26,6 @@ When a graphical desktop environment is detected, the following applications are |------------|------------|-------------| | **Brave Browser** | `com.brave.Browser` | Privacy-focused web browser | | **Discord** | `com.discordapp.Discord` | Voice, video, and text communication | -| **Signal** | `org.signal.Signal` | Private messenger (Flathub community redistribution) | | **Spotify** | `com.spotify.Client` | Music streaming service | | **Obsidian** | `md.obsidian.Obsidian` | Markdown-based knowledge base | | **Steam** | `com.valvesoftware.Steam` | Gaming platform | @@ -114,10 +113,9 @@ graph LR D -->|No| F[Skip Apps] E --> G[Brave Browser] E --> H[Discord] - E --> I[Signal] - E --> J[Spotify] - E --> K[Obsidian] - E --> L[Steam] + E --> I[Spotify] + E --> J[Obsidian] + E --> K[Steam] ``` ## Key Implementation Details diff --git a/roles/flatpak/tasks/Ubuntu.yml b/roles/flatpak/tasks/Ubuntu.yml index 2731080a..881aa1a9 100644 --- a/roles/flatpak/tasks/Ubuntu.yml +++ b/roles/flatpak/tasks/Ubuntu.yml @@ -31,7 +31,6 @@ loop: - com.brave.Browser - com.discordapp.Discord - - org.signal.Signal - com.spotify.Client - md.obsidian.Obsidian - com.valvesoftware.Steam From ea981ed4f3adc52d54ac73e887e04997ace02ec9 Mon Sep 17 00:00:00 2001 From: TechDufus Date: Sat, 16 May 2026 15:10:56 -0500 Subject: [PATCH 07/15] feat(signal): install signal from apt repo --- group_vars/all.yml | 1 + .../files/config/cell-management/apps.lua | 2 +- roles/signal/tasks/Ubuntu.yml | 57 +++++++++++++++++++ roles/signal/tasks/main.yml | 9 +++ roles/signal/tests/test_signal_role.sh | 23 ++++++++ 5 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 roles/signal/tasks/Ubuntu.yml create mode 100644 roles/signal/tasks/main.yml create mode 100755 roles/signal/tests/test_signal_role.sh diff --git a/group_vars/all.yml b/group_vars/all.yml index 26edbb57..dab025c1 100644 --- a/group_vars/all.yml +++ b/group_vars/all.yml @@ -46,6 +46,7 @@ default_roles: - raycast # - ruby # - rust + - signal - slides - spotify - superwhisper diff --git a/roles/awesomewm/files/config/cell-management/apps.lua b/roles/awesomewm/files/config/cell-management/apps.lua index c1482691..966e8df7 100644 --- a/roles/awesomewm/files/config/cell-management/apps.lua +++ b/roles/awesomewm/files/config/cell-management/apps.lua @@ -20,7 +20,7 @@ return { Signal = { class = "signal", summon = "C", - exec = "flatpak run org.signal.Signal", + exec = "signal-desktop", }, Spotify = { class = "Spotify", -- Note: Spotify uses capital S diff --git a/roles/signal/tasks/Ubuntu.yml b/roles/signal/tasks/Ubuntu.yml new file mode 100644 index 00000000..c59fb1f4 --- /dev/null +++ b/roles/signal/tasks/Ubuntu.yml @@ -0,0 +1,57 @@ +--- +- name: "{{ role_name }} | Ubuntu | Remove Signal Flatpak" + community.general.flatpak: + name: org.signal.Signal + state: absent + become: true + failed_when: false + +- name: "{{ role_name }} | Ubuntu | Install Signal APT prerequisites" + ansible.builtin.apt: + name: + - ca-certificates + - gnupg + state: present + update_cache: true + become: true + +- name: "{{ role_name }} | Ubuntu | Download Signal signing key" + ansible.builtin.get_url: + url: https://updates.signal.org/desktop/apt/keys.asc + dest: /usr/share/keyrings/signal-desktop-keyring.asc + mode: "0644" + become: true + register: signal_key_download + +- name: "{{ role_name }} | Ubuntu | Check Signal keyring" + ansible.builtin.stat: + path: /usr/share/keyrings/signal-desktop-keyring.gpg + register: signal_keyring + +- name: "{{ role_name }} | Ubuntu | Install Signal signing keyring" + ansible.builtin.command: + argv: + - gpg + - --dearmor + - --yes + - --output + - /usr/share/keyrings/signal-desktop-keyring.gpg + - /usr/share/keyrings/signal-desktop-keyring.asc + become: true + changed_when: signal_key_download.changed or not signal_keyring.stat.exists + when: signal_key_download.changed or not signal_keyring.stat.exists + +- name: "{{ role_name }} | Ubuntu | Add Signal APT repository" + ansible.builtin.get_url: + url: https://updates.signal.org/static/desktop/apt/signal-desktop.sources + dest: /etc/apt/sources.list.d/signal-desktop.sources + mode: "0644" + become: true + register: signal_sources + +- name: "{{ role_name }} | Ubuntu | Install Signal Desktop" + ansible.builtin.apt: + name: signal-desktop + state: present + update_cache: "{{ signal_sources.changed or signal_key_download.changed or not signal_keyring.stat.exists }}" + become: true diff --git a/roles/signal/tasks/main.yml b/roles/signal/tasks/main.yml new file mode 100644 index 00000000..24345a1f --- /dev/null +++ b/roles/signal/tasks/main.yml @@ -0,0 +1,9 @@ +--- +- name: "{{ role_name }} | Checking for Distribution Config: {{ ansible_facts['distribution'] }}" + ansible.builtin.stat: + path: "{{ role_path }}/tasks/{{ ansible_facts['distribution'] }}.yml" + register: distribution_config + +- name: "{{ role_name }} | Run Tasks: {{ ansible_facts['distribution'] }}" + ansible.builtin.include_tasks: "{{ ansible_facts['distribution'] }}.yml" + when: distribution_config.stat.exists diff --git a/roles/signal/tests/test_signal_role.sh b/roles/signal/tests/test_signal_role.sh new file mode 100755 index 00000000..4abf16de --- /dev/null +++ b/roles/signal/tests/test_signal_role.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +role_dir="$repo_root/roles/signal" +flatpak_tasks="$repo_root/roles/flatpak/tasks/Ubuntu.yml" +apps_path="$repo_root/roles/awesomewm/files/config/cell-management/apps.lua" +defaults_path="$repo_root/group_vars/all.yml" + +grep -q "include_tasks" "$role_dir/tasks/main.yml" +grep -q "updates.signal.org/desktop/apt/keys.asc" "$role_dir/tasks/Ubuntu.yml" +grep -q "updates.signal.org/static/desktop/apt/signal-desktop.sources" "$role_dir/tasks/Ubuntu.yml" +grep -q "signal-desktop-keyring.gpg" "$role_dir/tasks/Ubuntu.yml" +grep -q "name: signal-desktop" "$role_dir/tasks/Ubuntu.yml" +grep -q "name: org.signal.Signal" "$role_dir/tasks/Ubuntu.yml" +grep -q "state: absent" "$role_dir/tasks/Ubuntu.yml" +grep -q " - signal" "$defaults_path" +grep -q 'exec = "signal-desktop"' "$apps_path" + +if grep -q "org.signal.Signal" "$flatpak_tasks"; then + echo "Signal should be installed by the signal role, not the Flatpak role" >&2 + exit 1 +fi From 0e7d6fa2c2760ab73e4e7b864c32d79c907855b3 Mon Sep 17 00:00:00 2001 From: TechDufus Date: Sat, 16 May 2026 15:11:29 -0500 Subject: [PATCH 08/15] fix(awesomewm): harden caps summon remap --- .../config/cell-management/keybindings.lua | 21 +++++++ roles/awesomewm/files/config/rc.lua | 36 +---------- .../files/scripts/remap-caps-to-f13.sh | 60 +++++++++++++++++++ roles/awesomewm/tasks/Ubuntu.yml | 7 +++ roles/awesomewm/tests/test_caps_f13_remap.sh | 19 ++++++ roles/awesomewm/tests/test_summon_bindings.sh | 17 ++++++ 6 files changed, 126 insertions(+), 34 deletions(-) create mode 100755 roles/awesomewm/files/scripts/remap-caps-to-f13.sh create mode 100755 roles/awesomewm/tests/test_caps_f13_remap.sh create mode 100755 roles/awesomewm/tests/test_summon_bindings.sh diff --git a/roles/awesomewm/files/config/cell-management/keybindings.lua b/roles/awesomewm/files/config/cell-management/keybindings.lua index de6dc45a..b73cdf13 100644 --- a/roles/awesomewm/files/config/cell-management/keybindings.lua +++ b/roles/awesomewm/files/config/cell-management/keybindings.lua @@ -16,6 +16,23 @@ local summon_trigger_keys = { Caps_Lock = true, } +local modifier_keys = { + Shift_L = true, + Shift_R = true, + Control_L = true, + Control_R = true, + Alt_L = true, + Alt_R = true, + Super_L = true, + Super_R = true, + Meta_L = true, + Meta_R = true, + Hyper_L = true, + Hyper_R = true, + ISO_Level3_Shift = true, + ISO_Level5_Shift = true, +} + local function has_modifier(modifiers, target) for _, modifier in ipairs(modifiers or {}) do if modifier == target then @@ -77,6 +94,10 @@ summon_modal = awful.keygrabber { timeout = 1, -- 1 second timeout for modal auto-close autostart = false, keypressed_callback = function(self, mod, key, event) + if modifier_keys[key] then + return + end + local binding_key = binding_key_for_event(mod, key) -- Treat any recognized summon trigger as a second tap to enter macro mode. diff --git a/roles/awesomewm/files/config/rc.lua b/roles/awesomewm/files/config/rc.lua index 9b37b313..66d20140 100644 --- a/roles/awesomewm/files/config/rc.lua +++ b/roles/awesomewm/files/config/rc.lua @@ -44,40 +44,8 @@ awful.spawn.once("xset r rate 300 40") -- Remap Caps Lock to F13 for laptop keyboard support -- This allows the summon modal (F13) to work on keyboards without F13 key --- Must run in sequence: setxkbmap first (preserving the system XKB layout), then xmodmap --- Runs on every startup/reload to ensure remapping persists after any setxkbmap changes --- Small delay ensures X server is ready to accept the remapping -awful.spawn.with_shell([[ -sleep 0.2 - -layout="$(localectl status | awk -F': *' '/X11 Layout:/ {print $2}')" -model="$(localectl status | awk -F': *' '/X11 Model:/ {print $2}')" -variant="$(localectl status | awk -F': *' '/X11 Variant:/ {print $2}')" -options="$(localectl status | awk -F': *' '/X11 Options:/ {print $2}')" - -if [ -z "$layout" ]; then layout="us"; fi -if [ -z "$model" ]; then model="pc105"; fi - -case ",$options," in - *,caps:none,*) - ;; - *) - if [ -n "$options" ]; then - options="$options,caps:none" - else - options="caps:none" - fi - ;; -esac - -if [ -n "$variant" ]; then - setxkbmap -model "$model" -layout "$layout" -variant "$variant" -option "$options" -else - setxkbmap -model "$model" -layout "$layout" -option "$options" -fi - -xmodmap -e 'keycode 66 = F13' -]]) +-- Runs on every startup/reload to ensure remapping persists after any XKB changes. +awful.spawn.with_shell([[sleep 0.2; if [ -x "$HOME/.local/bin/remap-caps-to-f13.sh" ]; then "$HOME/.local/bin/remap-caps-to-f13.sh"; fi]]) -- Start polkit authentication agent for 1Password system auth awful.spawn.once("/usr/lib/policykit-1-gnome/polkit-gnome-authentication-agent-1") diff --git a/roles/awesomewm/files/scripts/remap-caps-to-f13.sh b/roles/awesomewm/files/scripts/remap-caps-to-f13.sh new file mode 100755 index 00000000..f70841c5 --- /dev/null +++ b/roles/awesomewm/files/scripts/remap-caps-to-f13.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +localectl_field() { + local field="$1" + localectl status 2>/dev/null | awk -F': *' -v field="$field" '{ gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1); if ($1 == field) print $2 }' +} + +setxkbmap_field() { + local field="$1" + setxkbmap -query 2>/dev/null | awk -v field="$field:" '$1 == field { print $2 }' +} + +caps_lock_is_on() { + command -v xset >/dev/null 2>&1 && xset q 2>/dev/null | grep -q 'Caps Lock:[[:space:]]*on' +} + +turn_caps_lock_off() { + if caps_lock_is_on && command -v xdotool >/dev/null 2>&1; then + xdotool key Caps_Lock >/dev/null 2>&1 || true + fi +} + +layout="$(localectl_field "X11 Layout")" +model="$(localectl_field "X11 Model")" +variant="$(localectl_field "X11 Variant")" +options="$(localectl_field "X11 Options")" + +if [ -z "$layout" ]; then layout="$(setxkbmap_field "layout")"; fi +if [ -z "$model" ]; then model="$(setxkbmap_field "model")"; fi +if [ -z "$variant" ]; then variant="$(setxkbmap_field "variant")"; fi +if [ -z "$options" ]; then options="$(setxkbmap_field "options")"; fi + +if [ -z "$layout" ]; then layout="us"; fi +if [ -z "$model" ]; then model="pc105"; fi + +case ",$options," in + *,caps:none,*) + ;; + *) + if [ -n "$options" ]; then + options="$options,caps:none" + else + options="caps:none" + fi + ;; +esac + +turn_caps_lock_off + +setxkbmap -option + +if [ -n "$variant" ]; then + setxkbmap -model "$model" -layout "$layout" -variant "$variant" -option "$options" +else + setxkbmap -model "$model" -layout "$layout" -option "$options" +fi + +xmodmap -e 'keycode 66 = F13' +turn_caps_lock_off diff --git a/roles/awesomewm/tasks/Ubuntu.yml b/roles/awesomewm/tasks/Ubuntu.yml index 443216d0..b5993562 100644 --- a/roles/awesomewm/tasks/Ubuntu.yml +++ b/roles/awesomewm/tasks/Ubuntu.yml @@ -149,6 +149,13 @@ mode: "0644" notify: "awesomewm | Ubuntu | Restart AwesomeWM" +- name: "awesomewm | Ubuntu | Deploy CapsLock to F13 remap script" + ansible.builtin.copy: + src: "scripts/remap-caps-to-f13.sh" + dest: "{{ ansible_facts['user_dir'] }}/.local/bin/remap-caps-to-f13.sh" + mode: "0755" + notify: "awesomewm | Ubuntu | Restart AwesomeWM" + - name: "awesomewm | Ubuntu | Deploy rc.lua" ansible.builtin.copy: src: "config/rc.lua" diff --git a/roles/awesomewm/tests/test_caps_f13_remap.sh b/roles/awesomewm/tests/test_caps_f13_remap.sh new file mode 100755 index 00000000..96113d42 --- /dev/null +++ b/roles/awesomewm/tests/test_caps_f13_remap.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +script_path="$repo_root/roles/awesomewm/files/scripts/remap-caps-to-f13.sh" +config_path="$repo_root/roles/awesomewm/files/config/rc.lua" +tasks_path="$repo_root/roles/awesomewm/tasks/Ubuntu.yml" + +bash -n "$script_path" + +grep -q "xmodmap -e 'keycode 66 = F13'" "$script_path" +grep -q "setxkbmap -option" "$script_path" +grep -q "Caps Lock:" "$script_path" +grep -q "xdotool key Caps_Lock" "$script_path" +grep -q "localectl_field \"X11 Layout\"" "$script_path" +grep -q "setxkbmap_field \"layout\"" "$script_path" +grep -q "remap-caps-to-f13.sh" "$config_path" +grep -q "Deploy CapsLock to F13 remap script" "$tasks_path" +grep -q "scripts/remap-caps-to-f13.sh" "$tasks_path" diff --git a/roles/awesomewm/tests/test_summon_bindings.sh b/roles/awesomewm/tests/test_summon_bindings.sh new file mode 100755 index 00000000..ef56986e --- /dev/null +++ b/roles/awesomewm/tests/test_summon_bindings.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +apps_path="$repo_root/roles/awesomewm/files/config/cell-management/apps.lua" +keybindings_path="$repo_root/roles/awesomewm/files/config/cell-management/keybindings.lua" + +grep -q 'Signal = {' "$apps_path" +grep -q 'summon = "C"' "$apps_path" +grep -q 'exec = "signal-desktop"' "$apps_path" + +grep -q "local modifier_keys = {" "$keybindings_path" +grep -q "Shift_L = true" "$keybindings_path" +grep -q "Shift_R = true" "$keybindings_path" +grep -q "if modifier_keys\\[key\\] then" "$keybindings_path" +grep -q "binding_key_for_event(mod, key)" "$keybindings_path" +grep -q "return key:upper()" "$keybindings_path" From 41f9d26b2c2762daf5a53401c1757d0219482d21 Mon Sep 17 00:00:00 2001 From: TechDufus Date: Sun, 17 May 2026 21:10:40 -0500 Subject: [PATCH 09/15] fix(fabric): hide bar for focused fullscreen Detect fullscreen-covering AwesomeWM clients per monitor and hide only that monitor's Fabric bar while the covering client has focus. This keeps the bar available after alt-tabbing to another app while preserving game fullscreen behavior. Validated with: - bash roles/fabric/tests/test_config_helpers.sh - python roles/fabric/files/config/awesomewm/config.py --check --- roles/fabric/files/config/awesomewm/config.py | 140 ++++++++++++++++++ roles/fabric/tests/test_config_helpers.sh | 66 +++++++++ 2 files changed, 206 insertions(+) diff --git a/roles/fabric/files/config/awesomewm/config.py b/roles/fabric/files/config/awesomewm/config.py index 48619cb2..499b5b24 100644 --- a/roles/fabric/files/config/awesomewm/config.py +++ b/roles/fabric/files/config/awesomewm/config.py @@ -35,6 +35,8 @@ BAR_HEIGHT = 37 VOLUME_POLL_MS = 1000 AI_POLL_MS = 5000 +BAR_VISIBILITY_POLL_MS = 500 +SCREEN_MATCH_TOLERANCE_PX = 4 AI_PROVIDER_SWITCH_REFRESH_DELAYS_MS = (350, 1250) ROOT_LAUNCHER_SIGNAL = "techdufus::launcher_root" SETTINGS_LAUNCHER_SIGNAL = "techdufus::launcher_settings" @@ -73,6 +75,46 @@ return table.concat(out, "\n") ''' +AWESOME_BAR_VISIBILITY_LUA = r''' +local tolerance = 4 + +local function covers_screen(cg, sg) + return cg.x <= sg.x + tolerance + and cg.y <= sg.y + tolerance + and cg.x + cg.width >= sg.x + sg.width - tolerance + and cg.y + cg.height >= sg.y + sg.height - tolerance +end + +local function can_cover_bar(c) + if not c.valid or c.minimized or c.hidden then + return false + end + if not c:isvisible() then + return false + end + local client_type = c.type or "" + return client_type ~= "desktop" and client_type ~= "dock" +end + +local out = {} +local focused = client.focus +for s in screen do + local sg = s.geometry + local hidden = false + for _, c in ipairs(client.get()) do + if c == focused and c.screen == s and can_cover_bar(c) then + local cg = c:geometry() + if c.fullscreen or covers_screen(cg, sg) then + hidden = true + break + end + end + end + table.insert(out, string.format("%d\t%d\t%d\t%d\t%s", sg.x, sg.y, sg.width, sg.height, hidden and "hidden" or "visible")) +end +return table.concat(out, "\n") +''' + APP_LABELS = { "1password": "1Password", "chromium": "Chromium", @@ -105,6 +147,7 @@ } Task = dict[str, object] +BarVisibilityState = dict[str, object] @dataclass(frozen=True) @@ -828,6 +871,75 @@ def running_apps_text() -> str: return tasks_text(running_tasks()) +def parse_awesome_bar_visibility(stdout: str) -> list[BarVisibilityState]: + states = [] + for line in decode_awesome_string(stdout).splitlines(): + if not line.strip(): + continue + parts = line.split("\t") + if len(parts) < 5: + continue + try: + x = int(parts[0]) + y = int(parts[1]) + width = int(parts[2]) + height = int(parts[3]) + except ValueError: + continue + if width <= 0 or height <= 0: + continue + + raw_visibility = parts[4].strip().lower() + if raw_visibility in {"hidden", "true", "1", "covered"}: + visibility = "hidden" + elif raw_visibility in {"visible", "false", "0", "clear"}: + visibility = "visible" + else: + continue + + states.append( + { + "x": x, + "y": y, + "width": width, + "height": height, + "visibility": visibility, + } + ) + return states + + +def screen_state_matches_monitor( + monitor: MonitorGeometry, + state: BarVisibilityState, + tolerance: int = SCREEN_MATCH_TOLERANCE_PX, +) -> bool: + try: + return ( + abs(int(state.get("x")) - monitor.x) <= tolerance + and abs(int(state.get("y")) - monitor.y) <= tolerance + and abs(int(state.get("width")) - monitor.width) <= tolerance + and abs(int(state.get("height")) - monitor.height) <= tolerance + ) + except Exception: + return False + + +def screen_bar_states() -> list[BarVisibilityState]: + return parse_awesome_bar_visibility(command_output(["awesome-client", AWESOME_BAR_VISIBILITY_LUA], "")) + + +def bar_visibility_for_monitor( + monitor: MonitorGeometry, + states: list[BarVisibilityState] | None = None, +) -> str: + candidate_states = states if states is not None else screen_bar_states() + for state in candidate_states: + if screen_state_matches_monitor(monitor, state): + return str(state.get("visibility") or "unknown") + return "unknown" + + def ai_usage_from_status(data: object) -> str: codex_session = nested_value(data, "codex", "session", "utilization") claude_five_hour = nested_value(data, "claude", "five_hour", "utilization") @@ -1735,6 +1847,7 @@ def __init__(self, monitor: MonitorGeometry): self.tasks = TaskStrip() self.popup_manager = PopupManager() + self.hidden_for_fullscreen = False self.network = StatusPill("NET", "...") self.volume = StatusPill("VOL", "...") self.audio_popout = AudioDevicePopout(monitor) @@ -1826,6 +1939,11 @@ def __init__(self, monitor: MonitorGeometry): self.set_size_request(monitor.width, BAR_HEIGHT) self.pollers = [ + Fabricator( + interval=BAR_VISIBILITY_POLL_MS, + poll_from=lambda _: bar_visibility_for_monitor(self.monitor), + on_changed=lambda _, value: self.set_fullscreen_visibility(str(value)), + ), Fabricator(interval=2000, poll_from=lambda _: running_tasks(), on_changed=lambda _, value: self.tasks.set_tasks(value)), Fabricator(interval=10000, poll_from=lambda _: network_text(), on_changed=lambda _, value: self.network.set_value(value)), Fabricator(interval=VOLUME_POLL_MS, poll_from=lambda _: volume_text(), on_changed=lambda _, value: self.volume.set_value(value)), @@ -1842,6 +1960,7 @@ def __init__(self, monitor: MonitorGeometry): self.volume.set_value(volume_text()) self.refresh_ai_usage() self.refresh_dnd(dnd_text()) + self.set_fullscreen_visibility(bar_visibility_for_monitor(self.monitor)) def windows(self) -> list[Window]: return [self, self.audio_popout, self.ai_popout, self.calendar_popout] @@ -1851,6 +1970,27 @@ def register_popup(self, name: str, popup: Window) -> None: popup.connect("focus-out-event", lambda *_args, target=name: self.on_popup_focus_out(target)) popup.connect("key-press-event", lambda _widget, event, target=name: self.on_popup_key_press(target, event)) + def show_all(self) -> None: + if self.hidden_for_fullscreen: + return + super().show_all() + + def set_fullscreen_visibility(self, visibility: str) -> None: + if visibility not in {"hidden", "visible"}: + return + + hidden = visibility == "hidden" + if hidden == self.hidden_for_fullscreen: + return + + self.hidden_for_fullscreen = hidden + if hidden: + self.popup_manager.close_all() + self.hide() + return + + self.show_all() + def on_popup_focus_out(self, name: str) -> bool: self.popup_manager.close(name, reason="focus-out") return False diff --git a/roles/fabric/tests/test_config_helpers.sh b/roles/fabric/tests/test_config_helpers.sh index e257f977..05ab94d7 100644 --- a/roles/fabric/tests/test_config_helpers.sh +++ b/roles/fabric/tests/test_config_helpers.sh @@ -66,6 +66,72 @@ assert fallback_monitors == [ module.MonitorGeometry(index=0, x=0, y=0, width=1920, height=1080, scale_factor=1, primary=True, name="fallback") ] +assert "c.fullscreen" in module.AWESOME_BAR_VISIBILITY_LUA +assert "c:isvisible()" in module.AWESOME_BAR_VISIBILITY_LUA +assert "c:geometry()" in module.AWESOME_BAR_VISIBILITY_LUA +assert "local focused = client.focus" in module.AWESOME_BAR_VISIBILITY_LUA +assert "c == focused" in module.AWESOME_BAR_VISIBILITY_LUA + +bar_visibility_stdout = ''' string "0\t0\t1920\t1080\thidden +1920\t0\t2560\t1440\tvisible"''' +bar_states = module.parse_awesome_bar_visibility(bar_visibility_stdout) +assert bar_states == [ + {"x": 0, "y": 0, "width": 1920, "height": 1080, "visibility": "hidden"}, + {"x": 1920, "y": 0, "width": 2560, "height": 1440, "visibility": "visible"}, +] +assert module.parse_awesome_bar_visibility("not parseable") == [] +assert module.bar_visibility_for_monitor( + module.MonitorGeometry(index=0, x=0, y=0, width=1920, height=1080), + bar_states, +) == "hidden" +assert module.bar_visibility_for_monitor( + module.MonitorGeometry(index=1, x=1921, y=0, width=2559, height=1440), + bar_states, +) == "visible" +assert module.bar_visibility_for_monitor( + module.MonitorGeometry(index=2, x=4480, y=0, width=1920, height=1080), + bar_states, +) == "unknown" + + +class DummyPopupManager: + def __init__(self): + self.close_count = 0 + + def close_all(self): + self.close_count += 1 + + +class DummyBar: + def __init__(self): + self.hidden_for_fullscreen = False + self.popup_manager = DummyPopupManager() + self.hide_count = 0 + self.show_count = 0 + + def hide(self): + self.hide_count += 1 + + def show_all(self): + self.show_count += 1 + + +dummy_bar = DummyBar() +module.StatusBar.set_fullscreen_visibility(dummy_bar, "hidden") +assert dummy_bar.hidden_for_fullscreen is True +assert dummy_bar.popup_manager.close_count == 1 +assert dummy_bar.hide_count == 1 +assert dummy_bar.show_count == 0 +module.StatusBar.set_fullscreen_visibility(dummy_bar, "hidden") +assert dummy_bar.popup_manager.close_count == 1 +assert dummy_bar.hide_count == 1 +module.StatusBar.set_fullscreen_visibility(dummy_bar, "unknown") +assert dummy_bar.hidden_for_fullscreen is True +assert dummy_bar.show_count == 0 +module.StatusBar.set_fullscreen_visibility(dummy_bar, "visible") +assert dummy_bar.hidden_for_fullscreen is False +assert dummy_bar.show_count == 1 + codex_rate_limits = { "limit_id": "codex", "primary": { From 8e61bc52ff11ecbb99613156d0b9f75141b213ff Mon Sep 17 00:00:00 2001 From: TechDufus Date: Sat, 16 May 2026 16:10:58 -0500 Subject: [PATCH 10/15] feat(desktop): refine AwesomeWM panel UX Update AwesomeWM launch bindings so Super+Space opens clipboard history, Shift+Super+Space opens 1Password Quick Access, and Fabric restarts with --replace. Add Fabric popouts for network settings and laptop battery power profiles, fix the Signal dock icon, make the AI button left-click only, and give clickable status pills consistent hover styling. Install required desktop helpers for these flows, including full 1Password, rofi for layout pickers, and power-profiles-daemon. Validation: ran the role smoke tests, Fabric helper checks, config.py py_compile and --check, and git diff --cached --check. --- roles/1password/tasks/Ubuntu.yml | 4 +- roles/1password/tests/test_1password_role.sh | 9 + roles/awesomewm/defaults/main.yml | 2 - .../config/cell-management/layout-manager.lua | 101 +++--- roles/awesomewm/files/config/rc.lua | 18 +- .../files/rofi/catppuccin-mocha.rasi | 123 +++++++ roles/awesomewm/files/rofi/config.rasi | 13 + roles/awesomewm/tasks/Ubuntu.yml | 20 ++ .../tests/test_power_profile_dependencies.sh | 7 + .../awesomewm/tests/test_vicinae_launcher.sh | 29 +- roles/fabric/files/config/awesomewm/config.py | 307 +++++++++++++++--- roles/fabric/files/config/awesomewm/style.css | 77 ++++- roles/fabric/tests/test_config_helpers.sh | 54 +++ 13 files changed, 649 insertions(+), 115 deletions(-) create mode 100644 roles/1password/tests/test_1password_role.sh create mode 100644 roles/awesomewm/files/rofi/catppuccin-mocha.rasi create mode 100644 roles/awesomewm/files/rofi/config.rasi create mode 100644 roles/awesomewm/tests/test_power_profile_dependencies.sh diff --git a/roles/1password/tasks/Ubuntu.yml b/roles/1password/tasks/Ubuntu.yml index 0cf482ac..5a05079e 100644 --- a/roles/1password/tasks/Ubuntu.yml +++ b/roles/1password/tasks/Ubuntu.yml @@ -42,7 +42,9 @@ - name: "1Password | Install 1Password" ansible.builtin.apt: - name: 1password-cli + name: + - 1password + - 1password-cli state: present update_cache: true become: true diff --git a/roles/1password/tests/test_1password_role.sh b/roles/1password/tests/test_1password_role.sh new file mode 100644 index 00000000..ca24727d --- /dev/null +++ b/roles/1password/tests/test_1password_role.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +ubuntu_tasks="$repo_root/roles/1password/tasks/Ubuntu.yml" + +grep -q "name:" "$ubuntu_tasks" +grep -q "1password-cli" "$ubuntu_tasks" +grep -q -- "- 1password$" "$ubuntu_tasks" diff --git a/roles/awesomewm/defaults/main.yml b/roles/awesomewm/defaults/main.yml index c5878d40..a70105fe 100644 --- a/roles/awesomewm/defaults/main.yml +++ b/roles/awesomewm/defaults/main.yml @@ -4,10 +4,8 @@ role_name: "awesomewm" awesomewm_remove_legacy_launcher_tools: true awesomewm_legacy_launcher_packages: - - rofi - copyq awesomewm_legacy_launcher_paths: - - "{{ ansible_facts['user_dir'] }}/.config/rofi" - "{{ ansible_facts['user_dir'] }}/.config/copyq" - "{{ ansible_facts['user_dir'] }}/.local/bin/bemoji" - "{{ ansible_facts['user_dir'] }}/.local/share/bemoji" diff --git a/roles/awesomewm/files/config/cell-management/layout-manager.lua b/roles/awesomewm/files/config/cell-management/layout-manager.lua index badf6d60..eb7dad56 100644 --- a/roles/awesomewm/files/config/cell-management/layout-manager.lua +++ b/roles/awesomewm/files/config/cell-management/layout-manager.lua @@ -1,24 +1,10 @@ -- layout-manager.lua - Layout switching and manual cell binding local awful = require("awful") -local gears = require("gears") -local naughty = require("naughty") local state = require("cell-management.state") local helpers = require("cell-management.helpers") local M = {} -local key_to_number = { - ["1"] = 1, ["KP_1"] = 1, - ["2"] = 2, ["KP_2"] = 2, - ["3"] = 3, ["KP_3"] = 3, - ["4"] = 4, ["KP_4"] = 4, - ["5"] = 5, ["KP_5"] = 5, - ["6"] = 6, ["KP_6"] = 6, - ["7"] = 7, ["KP_7"] = 7, - ["8"] = 8, ["KP_8"] = 8, - ["9"] = 9, ["KP_9"] = 9, -} - local function get_relative_screen(base_screen, offset) if not base_screen or screen.count() < 2 then return base_screen @@ -29,6 +15,22 @@ local function get_relative_screen(base_screen, offset) return screen[target_index] or base_screen end +local function shell_quote(text) + return "'" .. tostring(text):gsub("'", "'\\''") .. "'" +end + +local function escape_rofi_prompt(text) + return shell_quote(text) +end + +local function build_rofi_input(lines) + local quoted_lines = {} + for _, line in ipairs(lines) do + table.insert(quoted_lines, shell_quote(line)) + end + return table.concat(quoted_lines, " ") +end + local function reapply_layout_for_screen(target_screen) target_screen = state.resolve_screen(target_screen) if not target_screen then @@ -56,38 +58,27 @@ local function reapply_layout_for_screen(target_screen) end end -local function notify_picker(title, lines) - naughty.notify({ - title = title, - text = table.concat(lines, "\n"), - timeout = 2, - }) -end +local function start_rofi_picker(prompt, lines, callback) + if #lines == 0 then + return + end -local function start_numeric_picker(title, lines, max_index, callback) - notify_picker(title, lines) - - local picker = awful.keygrabber { - stop_key = "Escape", - stop_event = "press", - timeout = 2, - autostart = false, - keypressed_callback = function(self, mod, key) - local index = key_to_number[key] - - if index and index <= max_index then - self:stop() - gears.timer.delayed_call(function() - callback(index) - end) - return - end + local command = string.format( + "printf '%%s\\n' %s | rofi -dmenu -i -p %s -format s", + build_rofi_input(lines), + escape_rofi_prompt(prompt) + ) - self:stop() - end, - } + awful.spawn.easy_async_with_shell(command, function(stdout, stderr, reason, exit_code) + if exit_code ~= 0 then + return + end - picker:start() + local selection = stdout:gsub("%s+$", "") + if selection ~= "" then + callback(selection) + end + end) end local function move_client_to_cell(c, cell_index, target_screen, layout) @@ -210,16 +201,18 @@ function M.select_layout(target_screen) local screen_label = helpers.get_screen_label(target_screen) local picker_lines = {} for i, layout in ipairs(layouts) do - local marker = (i == current_index) and "*" or " " - table.insert(picker_lines, string.format("%s %d %s", marker, i, layout.name)) + local marker = (i == current_index) and "* " or " " + table.insert(picker_lines, string.format("%s%d. %s", marker, i, layout.name)) end - start_numeric_picker( + start_rofi_picker( "Layout for " .. screen_label, picker_lines, - #layouts, - function(index) - M.switch_layout(index, target_screen) + function(selection) + local index = selection:match("^%s*%*?%s*(%d+)%.") + if index then + M.switch_layout(tonumber(index), target_screen) + end end ) end @@ -268,12 +261,14 @@ function M.bind_to_cell(cell_index) table.insert(picker_lines, string.format("%d %s", i, app_list)) end - start_numeric_picker( + start_rofi_picker( string.format("Move %s on %s to cell", c.class or "window", helpers.get_screen_label(target_screen)), picker_lines, - #layout.cells, - function(index) - move_client_to_cell(c, index, target_screen, layout) + function(selection) + local index = selection:match("^(%d+)%s+") or selection:match("^Cell%s+(%d+):") + if index then + move_client_to_cell(c, tonumber(index), target_screen, layout) + end end ) end diff --git a/roles/awesomewm/files/config/rc.lua b/roles/awesomewm/files/config/rc.lua index 66d20140..dab6f188 100644 --- a/roles/awesomewm/files/config/rc.lua +++ b/roles/awesomewm/files/config/rc.lua @@ -55,7 +55,7 @@ awful.spawn.once("nm-applet") -- Start Fabric UI when the opt-in sentinel is deployed by the fabric role. if fabric_ui_enabled then - awful.spawn.with_shell([[if [ -x "$HOME/.local/bin/fabric-awesomewm" ]; then "$HOME/.local/bin/fabric-awesomewm"; fi]]) + awful.spawn.with_shell([[if [ -x "$HOME/.local/bin/fabric-awesomewm" ]; then "$HOME/.local/bin/fabric-awesomewm" --replace; fi]]) end -- Vicinae launcher server starts after launcher helpers are defined below. @@ -163,6 +163,10 @@ local function launch_vicinae_settings() launch_vicinae("vicinae://launch/scripts?fallbackText=settings&toggle=true") end +local function launch_1password_quick_access() + awful.spawn.with_shell("command -v 1password >/dev/null 2>&1 && 1password --quick-access") +end + local function start_vicinae_server() awful.spawn.easy_async({ "sh", "-lc", "command -v vicinae >/dev/null 2>&1" }, function(_, _, _, exit_code) if exit_code == 0 then @@ -478,15 +482,19 @@ globalkeys = gears.table.join( end, { description = "application launcher", group = "launcher" }), -- Primary command launcher. - awful.key({ modkey }, "space", function() - awesome.emit_signal("techdufus::launcher_root") - end, { description = "vicinae launcher", group = "launcher" }), + awful.key({ modkey, "Shift" }, "space", launch_1password_quick_access, + { description = "1Password Quick Access", group = "launcher" }), -- Clipboard manager. - awful.key({ modkey }, "v", function() + awful.key({ modkey }, "space", function() awesome.emit_signal("techdufus::launcher_clipboard") end, { description = "clipboard history", group = "launcher" }), + -- Primary command launcher. + awful.key({ modkey }, "v", function() + awesome.emit_signal("techdufus::launcher_root") + end, { description = "vicinae launcher", group = "launcher" }), + awful.key({ modkey }, "x", function() awful.prompt.run { diff --git a/roles/awesomewm/files/rofi/catppuccin-mocha.rasi b/roles/awesomewm/files/rofi/catppuccin-mocha.rasi new file mode 100644 index 00000000..4122b295 --- /dev/null +++ b/roles/awesomewm/files/rofi/catppuccin-mocha.rasi @@ -0,0 +1,123 @@ +/** + * Catppuccin Mocha theme for Rofi + * Matches AwesomeWM Catppuccin theme + */ + +* { + /* Catppuccin Mocha colors */ + bg-col: #1e1e2e; + bg-col-light: #313244; + border-col: #89b4fa; + selected-col: #313244; + blue: #89b4fa; + fg-col: #cdd6f4; + fg-col2: #f38ba8; + grey: #6c7086; + overlay0: #6c7086; + surface0: #313244; + text: #cdd6f4; + subtext0: #a6adc8; + + width: 600px; +} + +element-text, element-icon , mode-switcher { + background-color: inherit; + text-color: inherit; +} + +window { + height: 400px; + border: 2px; + border-color: @border-col; + background-color: @bg-col; + border-radius: 8px; +} + +mainbox { + background-color: @bg-col; +} + +inputbar { + children: [prompt,entry]; + background-color: @bg-col; + border-radius: 5px; + padding: 8px; +} + +prompt { + background-color: @blue; + padding: 8px 12px; + text-color: @bg-col; + border-radius: 4px; + margin: 0px 8px 0px 0px; +} + +textbox-prompt-colon { + expand: false; + str: ":"; +} + +entry { + padding: 8px; + text-color: @fg-col; + background-color: @bg-col; + placeholder-color: @grey; + placeholder: "Search applications..."; +} + +listview { + border: 0px 0px 0px; + padding: 8px 0px 0px; + margin: 8px 0px 0px; + columns: 1; + lines: 8; + background-color: @bg-col; +} + +element { + padding: 8px; + background-color: @bg-col; + text-color: @fg-col; + border-radius: 4px; +} + +element-icon { + size: 28px; +} + +element selected { + background-color: @selected-col; + text-color: @blue; +} + +mode-switcher { + spacing: 0; +} + +button { + padding: 10px; + background-color: @bg-col-light; + text-color: @grey; + vertical-align: 0.5; + horizontal-align: 0.5; +} + +button selected { + background-color: @bg-col; + text-color: @blue; +} + +message { + background-color: @bg-col-light; + margin: 8px; + padding: 8px; + border-radius: 5px; +} + +textbox { + padding: 6px; + margin: 20px 0px 0px 20px; + text-color: @blue; + background-color: @bg-col-light; +} diff --git a/roles/awesomewm/files/rofi/config.rasi b/roles/awesomewm/files/rofi/config.rasi new file mode 100644 index 00000000..0f397146 --- /dev/null +++ b/roles/awesomewm/files/rofi/config.rasi @@ -0,0 +1,13 @@ +configuration { + modi: "drun,run,window"; + show-icons: true; + icon-theme: "Papirus-Dark"; + font: "BerkeleyMono Nerd Font"; + drun-display-format: "{name}"; + display-drun: " Apps"; + display-run: " Run"; + display-window: " Windows"; + sidebar-mode: false; +} + +@theme "catppuccin-mocha" diff --git a/roles/awesomewm/tasks/Ubuntu.yml b/roles/awesomewm/tasks/Ubuntu.yml index b5993562..f5a77490 100644 --- a/roles/awesomewm/tasks/Ubuntu.yml +++ b/roles/awesomewm/tasks/Ubuntu.yml @@ -13,6 +13,7 @@ - ristretto # Lightweight image viewer (Xfce) - curl # AI usage monitor HTTP client - xclip # General X11 clipboard CLI + - rofi # Layout and cell picker menu - i3lock # Screen locker - playerctl # Media key controls (play/pause/next/prev) - pulseaudio-utils # Provides pactl for volume and mic controls @@ -31,6 +32,7 @@ - policykit-1-gnome # Polkit auth agent for 1Password system auth - network-manager-gnome # nm-applet for WiFi systray widget - jq # JSON parsing for AI usage monitor + - power-profiles-daemon # powerprofilesctl for battery profile switching state: present update_cache: true become: true @@ -163,6 +165,24 @@ mode: "0644" notify: "awesomewm | Ubuntu | Restart AwesomeWM" +- name: "awesomewm | Ubuntu | Ensure rofi config directory exists" + ansible.builtin.file: + path: "{{ ansible_facts['user_dir'] }}/.config/rofi" + state: directory + mode: "0755" + +- name: "awesomewm | Ubuntu | Deploy rofi configuration" + ansible.builtin.copy: + src: "rofi/config.rasi" + dest: "{{ ansible_facts['user_dir'] }}/.config/rofi/config.rasi" + mode: "0644" + +- name: "awesomewm | Ubuntu | Deploy rofi Catppuccin theme" + ansible.builtin.copy: + src: "rofi/catppuccin-mocha.rasi" + dest: "{{ ansible_facts['user_dir'] }}/.config/rofi/catppuccin-mocha.rasi" + mode: "0644" + - name: "awesomewm | Ubuntu | Install GTK theme prerequisites" ansible.builtin.apt: name: diff --git a/roles/awesomewm/tests/test_power_profile_dependencies.sh b/roles/awesomewm/tests/test_power_profile_dependencies.sh new file mode 100644 index 00000000..cece4cea --- /dev/null +++ b/roles/awesomewm/tests/test_power_profile_dependencies.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +tasks_path="$repo_root/roles/awesomewm/tasks/Ubuntu.yml" + +grep -q "power-profiles-daemon" "$tasks_path" diff --git a/roles/awesomewm/tests/test_vicinae_launcher.sh b/roles/awesomewm/tests/test_vicinae_launcher.sh index 6e0d0f21..42593d27 100755 --- a/roles/awesomewm/tests/test_vicinae_launcher.sh +++ b/roles/awesomewm/tests/test_vicinae_launcher.sh @@ -6,6 +6,8 @@ config_path="$repo_root/roles/awesomewm/files/config/rc.lua" layout_manager_path="$repo_root/roles/awesomewm/files/config/cell-management/layout-manager.lua" tasks_path="$repo_root/roles/awesomewm/tasks/Ubuntu.yml" defaults_path="$repo_root/roles/awesomewm/defaults/main.yml" +rofi_config_path="$repo_root/roles/awesomewm/files/rofi/config.rasi" +rofi_theme_path="$repo_root/roles/awesomewm/files/rofi/catppuccin-mocha.rasi" grep -q "techdufus::launcher_root" "$config_path" grep -q "techdufus::launcher_apps" "$config_path" @@ -25,6 +27,7 @@ grep -q "vicinae://launch/clipboard/history?toggle=true" "$config_path" grep -q "vicinae://launch/core/search-emojis?toggle=true" "$config_path" grep -q "vicinae://launch/scripts?fallbackText=settings&toggle=true" "$config_path" grep -q "vicinae server --replace" "$config_path" +grep -q "fabric-awesomewm\" --replace" "$config_path" grep -q "Run dotfiles -t vicinae" "$config_path" grep -q 'class = { "Vicinae", "vicinae" }' "$config_path" grep -q 'instance = { "command", "Vicinae", "vicinae" }' "$config_path" @@ -36,25 +39,39 @@ if grep -q "org.dev_byteatatime_flare.SingleInstance" "$config_path"; then exit 1 fi -if grep -Eq "\\b(rofi|copyq|bemoji|rofimoji)\\b" "$config_path" "$layout_manager_path"; then - echo "Legacy rofi/CopyQ/bemoji runtime references should not remain" >&2 +if grep -Eq "\\b(copyq|bemoji|rofimoji)\\b" "$config_path" "$layout_manager_path"; then + echo "Legacy CopyQ/bemoji runtime references should not remain" >&2 exit 1 fi -if grep -Eq "Install bemoji|Download bemoji|Deploy rofi|Deploy CopyQ|rofi[[:space:]]+#|copyq[[:space:]]+#" "$tasks_path"; then - echo "Legacy rofi/CopyQ/bemoji install or deploy tasks should not remain" >&2 +if grep -Eq "Install bemoji|Download bemoji|Deploy CopyQ|copyq[[:space:]]+#" "$tasks_path"; then + echo "Legacy CopyQ/bemoji install or deploy tasks should not remain" >&2 exit 1 fi grep -q "awesomewm_remove_legacy_launcher_tools: true" "$defaults_path" grep -q "awesomewm_legacy_launcher_packages:" "$defaults_path" -grep -q "rofi" "$defaults_path" grep -q "copyq" "$defaults_path" grep -q "bemoji" "$defaults_path" -grep -q "awful.keygrabber" "$layout_manager_path" +if grep -q "rofi" "$defaults_path"; then + echo "rofi should not be removed as a legacy launcher tool" >&2 + exit 1 +fi + +grep -q "rofi" "$tasks_path" +grep -q "Deploy rofi configuration" "$tasks_path" +grep -q "Deploy rofi Catppuccin theme" "$tasks_path" +grep -q "rofi -dmenu" "$layout_manager_path" +grep -q "escape_rofi_prompt" "$layout_manager_path" +test -f "$rofi_config_path" +test -f "$rofi_theme_path" grep -q "function M.bind_to_cell(cell_index)" "$layout_manager_path" grep -q "move_client_to_cell" "$layout_manager_path" keybindings_path="$repo_root/roles/awesomewm/files/config/cell-management/keybindings.lua" grep -q "techdufus::launcher_emoji" "$keybindings_path" grep -q "techdufus::launcher_settings" "$keybindings_path" + +grep -q "1password --quick-access" "$config_path" +grep -q "{ modkey }, \"space\"" "$config_path" +grep -q "techdufus::launcher_clipboard" "$config_path" diff --git a/roles/fabric/files/config/awesomewm/config.py b/roles/fabric/files/config/awesomewm/config.py index 499b5b24..f253ea2a 100644 --- a/roles/fabric/files/config/awesomewm/config.py +++ b/roles/fabric/files/config/awesomewm/config.py @@ -125,6 +125,8 @@ "firefox": "Firefox", "ghostty": "Ghostty", "google-chrome": "Chrome", + "signal": "Signal", + "signal-desktop": "Signal", "slack": "Slack", "spotify": "Spotify", "steam": "Steam", @@ -141,11 +143,31 @@ "firefox": "firefox", "ghostty": "com.mitchellh.ghostty", "google-chrome": "google-chrome", + "signal": "signal-desktop", + "signal-desktop": "signal-desktop", "slack": "slack", "spotify": "spotify", "steam": "steam", } +NETWORK_ACTION_DEFS = ( + { + "key": "connections", + "label": "Connections", + "command": ["nm-connection-editor"], + }, + { + "key": "wifi", + "label": "Wi-Fi", + "command": ["nm-connection-editor", "--type=802-11-wireless", "--show"], + }, + { + "key": "ethernet", + "label": "Ethernet", + "command": ["nm-connection-editor", "--type=802-3-ethernet", "--show"], + }, +) + Task = dict[str, object] BarVisibilityState = dict[str, object] @@ -419,6 +441,19 @@ def network_label_from_interface(interface: str) -> str: return "NET" +def network_settings_actions() -> list[dict[str, object]]: + return copy.deepcopy(list(NETWORK_ACTION_DEFS)) + + +def open_network_action(action_key: str) -> None: + for action in NETWORK_ACTION_DEFS: + if action["key"] == action_key: + command = action["command"] + if isinstance(command, list): + run_command([str(part) for part in command]) + return + + def volume_text() -> str: return shell_output( "pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null | awk -F'/' 'NR==1 {gsub(/ /,\"\",$2); print $2}'", @@ -638,14 +673,101 @@ def battery_value_from_output(text: str) -> str | None: return value or None +def parse_upower_battery_info(text: str) -> dict[str, str]: + parsed = { + "state": "", + "percentage": "", + "time_to_full": "", + "time_to_empty": "", + "energy_rate": "", + } + key_map = { + "state": "state", + "percentage": "percentage", + "time to full": "time_to_full", + "time to empty": "time_to_empty", + "energy-rate": "energy_rate", + "energy rate": "energy_rate", + } + for line in text.splitlines(): + key, separator, value = line.strip().partition(":") + if not separator: + continue + normalized_key = re.sub(r"\s+", " ", key.strip().lower()) + target = key_map.get(normalized_key) + if target: + parsed[target] = value.strip() + return parsed + + +def battery_summary_from_info(info: dict[str, str]) -> str | None: + percentage = info.get("percentage", "").strip() + if not percentage: + return None + + state = info.get("state", "").strip().lower() + state_label = { + "charging": "CHG", + "discharging": "BAT", + "fully-charged": "AC", + "pending-charge": "AC", + "pending-discharge": "AC", + "empty": "LOW", + }.get(state, "") + return f"{percentage} {state_label}".strip() + + +def battery_detail_rows(info: dict[str, str]) -> list[tuple[str, str]]: + rows = [] + for key, label in ( + ("state", "State"), + ("percentage", "Charge"), + ("time_to_full", "Time to full"), + ("time_to_empty", "Time to empty"), + ("energy_rate", "Energy rate"), + ): + value = info.get(key, "").strip() + if value: + rows.append((label, value)) + return rows + + +def first_battery_path() -> str: + return command_output(["sh", "-c", "upower -e 2>/dev/null | grep '/battery_' | head -n1"], "") + + +def battery_info() -> dict[str, str]: + battery_path = first_battery_path() + if not battery_path: + return parse_upower_battery_info("") + return parse_upower_battery_info(command_output(["upower", "-i", battery_path], "")) + + def battery_value() -> str | None: - return battery_value_from_output( - shell_output( - 'battery="$(upower -e 2>/dev/null | grep BAT | head -n1)"; ' - 'if [ -n "$battery" ]; then upower -i "$battery" | awk \'/percentage:/ {print $2; exit}\'; fi', - "", - ) - ) + return battery_summary_from_info(battery_info()) + + +def parse_power_profiles(text: str) -> list[dict[str, object]]: + profiles = [] + for line in text.splitlines(): + match = re.match(r"^\s*(\*)?\s*([A-Za-z0-9-]+):\s*$", line) + if match: + profiles.append({"name": match.group(2), "active": bool(match.group(1))}) + return profiles + + +def power_profiles() -> list[dict[str, object]]: + return parse_power_profiles(command_output(["powerprofilesctl", "list"], "")) + + +def set_power_profile(profile: str) -> None: + if not re.match(r"^[A-Za-z0-9-]+$", profile): + return + run_command(["powerprofilesctl", "set", profile]) + + +def profile_display_name(profile: str) -> str: + return profile.replace("-", " ").title() def dnd_lua(command: str) -> str: @@ -1223,15 +1345,6 @@ def open_ai_usage_url(provider: str) -> None: run_command(["xdg-open", url]) -def event_has_shift(event: object) -> bool: - try: - from gi.repository import Gdk - - return bool(int(getattr(event, "state", 0)) & int(Gdk.ModifierType.SHIFT_MASK)) - except Exception: - return "shift" in str(getattr(event, "state", "")).lower() - - def codex_usage_from_rate_limits(rate_limits: object) -> str | None: if isinstance(rate_limits, list): for item in reversed(rate_limits): @@ -1561,6 +1674,109 @@ def toggle(self) -> None: self.show_all() +class NetworkSettingsPopout(MonitorWindow): + def __init__(self, monitor: MonitorGeometry): + self.rows = Box(name="network-popout-rows", orientation="v", spacing=4) + super().__init__( + monitor, + title="fabric-network-popout", + name="network-popout", + layer="top", + geometry="top-right", + margin="37px 10px 0px 0px", + type_hint="dialog", + visible=False, + child=Box( + name="popout-panel", + orientation="v", + spacing=8, + children=[ + Label(name="popout-title", label="NETWORK"), + self.rows, + ], + ), + ) + + def refresh(self) -> None: + self.rows.children = [ + Label(name="popout-section", label=f"ACTIVE {network_text()}"), + *self.action_rows(), + ] + + def action_rows(self) -> list[Button]: + rows: list[Button] = [] + for action in network_settings_actions(): + key = str(action["key"]) + label = str(action["label"]) + rows.append( + Button( + name="network-action-row", + child=Label(label=label), + on_clicked=lambda *_args, target=key: open_network_action(target), + ) + ) + return rows + + +class BatteryPowerPopout(MonitorWindow): + def __init__(self, monitor: MonitorGeometry): + self.panel = Box(name="battery-panel", orientation="v", spacing=8) + super().__init__( + monitor, + title="fabric-battery-popout", + name="battery-popout", + layer="top", + geometry="top-right", + margin="37px 10px 0px 0px", + type_hint="dialog", + visible=False, + child=self.panel, + ) + + def refresh(self) -> None: + info = battery_info() + profiles = power_profiles() + children: list[Box | Button | Label] = [Label(name="popout-title", label="BATTERY")] + detail_rows = battery_detail_rows(info) + if detail_rows: + children.extend(self.detail_row(label, value) for label, value in detail_rows) + else: + children.append(Label(name="popout-muted", label="battery unavailable")) + + children.append(Label(name="popout-section", label="POWER PROFILE")) + if profiles: + children.extend(self.profile_row(profile) for profile in profiles) + else: + children.append(Label(name="popout-muted", label="profiles unavailable")) + self.panel.children = children + + def detail_row(self, label: str, value: str) -> Box: + return Box( + name="battery-detail-row", + orientation="h", + spacing=10, + children=[ + Label(name="battery-detail-label", h_expand=True, label=label), + Label(name="battery-detail-value", label=value), + ], + ) + + def profile_row(self, profile: dict[str, object]) -> Button: + name = str(profile.get("name") or "") + active = bool(profile.get("active")) + marker = ">" if active else " " + return Button( + name="battery-profile-row", + style_classes=["active"] if active else [], + child=Label(label=f"{marker} {profile_display_name(name)}"), + on_clicked=lambda *_args, target=name: self.select_profile(target), + ) + + def select_profile(self, profile: str) -> None: + set_power_profile(profile) + self.refresh() + + class AIUsagePopout(MonitorWindow): def __init__(self, monitor: MonitorGeometry, on_provider_changed=None): self.on_provider_changed = on_provider_changed @@ -1849,6 +2065,12 @@ def __init__(self, monitor: MonitorGeometry): self.popup_manager = PopupManager() self.hidden_for_fullscreen = False self.network = StatusPill("NET", "...") + self.network_popout = NetworkSettingsPopout(monitor) + self.network_button = Button( + name="network-button", + child=self.network, + on_clicked=lambda *_: self.popup_manager.toggle("network"), + ) self.volume = StatusPill("VOL", "...") self.audio_popout = AudioDevicePopout(monitor) self.volume_button = EventBox( @@ -1860,13 +2082,13 @@ def __init__(self, monitor: MonitorGeometry): self.volume_button.connect("scroll-event", self.on_volume_scroll) self.ai = StatusPill("AI", "AI") self.ai_popout = AIUsagePopout(monitor, on_provider_changed=self.refresh_ai_usage) - self.ai_button = EventBox( + self.ai_button = Button( name="ai-button", - events=["button-press"], child=self.ai, + on_clicked=lambda *_: self.popup_manager.toggle("ai"), ) - self.ai_button.connect("button-press-event", self.on_ai_button_press) self.calendar_popout = CalendarPopout(monitor) + self.register_popup("network", self.network_popout) self.register_popup("audio", self.audio_popout) self.register_popup("ai", self.ai_popout) self.register_popup("calendar", self.calendar_popout) @@ -1878,14 +2100,26 @@ def __init__(self, monitor: MonitorGeometry): ) battery_initial = battery_value() self.battery = StatusPill("BAT", battery_initial) if battery_initial is not None else None + self.battery_popout = BatteryPowerPopout(monitor) if self.battery is not None else None + self.battery_button = ( + Button( + name="battery-button", + child=self.battery, + on_clicked=lambda *_: self.popup_manager.toggle("battery"), + ) + if self.battery is not None + else None + ) + if self.battery_popout is not None: + self.register_popup("battery", self.battery_popout) end_children = [ - self.network, + self.network_button, self.volume_button, self.ai_button, ] - if self.battery is not None: - end_children.append(self.battery) + if self.battery_button is not None: + end_children.append(self.battery_button) end_children.extend( [ self.dnd_button, @@ -1963,7 +2197,10 @@ def __init__(self, monitor: MonitorGeometry): self.set_fullscreen_visibility(bar_visibility_for_monitor(self.monitor)) def windows(self) -> list[Window]: - return [self, self.audio_popout, self.ai_popout, self.calendar_popout] + windows: list[Window] = [self, self.network_popout, self.audio_popout, self.ai_popout, self.calendar_popout] + if self.battery_popout is not None: + windows.append(self.battery_popout) + return windows def register_popup(self, name: str, popup: Window) -> None: self.popup_manager.register(name, popup) @@ -2024,33 +2261,9 @@ def on_volume_scroll(self, _widget, event) -> bool: change_volume(-5) return True - def on_ai_button_press(self, _widget, event) -> bool: - button = int(getattr(event, "button", 0)) - if button == 1 and event_has_shift(event): - restart_ai_usage_monitor() - elif button == 1: - self.popup_manager.toggle("ai") - elif button == 2: - self.switch_ai_provider() - elif button == 3: - provider = load_ai_provider_preference() - open_ai_usage_url(provider) - return True - def refresh_ai_usage(self) -> None: self.ai.set_value(ai_usage_text()) - def switch_ai_provider(self) -> None: - save_ai_provider_preference(next_ai_provider(load_ai_provider_preference())) - restart_ai_usage_monitor() - self.refresh_after_ai_provider_change() - schedule_ai_provider_refreshes(self.refresh_after_ai_provider_change) - - def refresh_after_ai_provider_change(self) -> None: - self.refresh_ai_usage() - if self.ai_popout.get_visible(): - self.ai_popout.refresh() - def refresh_dnd(self, value: str) -> None: state = normalize_dnd_text(value) self.dnd.set_value(state) diff --git a/roles/fabric/files/config/awesomewm/style.css b/roles/fabric/files/config/awesomewm/style.css index fc1728f8..fdaab3a8 100644 --- a/roles/fabric/files/config/awesomewm/style.css +++ b/roles/fabric/files/config/awesomewm/style.css @@ -53,6 +53,8 @@ #task-button, #status-pill, #dnd-button, +#network-button, +#battery-button, #volume-button, #ai-button, #clock-button { @@ -119,6 +121,8 @@ #launcher-button:hover, #task-overflow:hover, #volume-button:hover, +#network-button:hover, +#battery-button:hover, #ai-button:hover, #clock-button:hover, #dnd-button:hover { @@ -175,10 +179,24 @@ border-bottom: 1px solid alpha(var(--cyan), 0.28); } -#dnd-button { +#dnd-button, +#ai-button, +#battery-button { padding: 0; } +#network-button { + padding: 0; +} + +#network-button #status-pill, +#ai-button #status-pill, +#battery-button #status-pill { + padding: 0 7px; + background-color: transparent; + border: none; +} + #dnd-button #status-pill { padding: 0 7px; background-color: transparent; @@ -338,6 +356,63 @@ background-color: alpha(var(--surface1), 0.92); } +#network-action-row { + min-height: 24px; + padding: 0 8px; + border-radius: 3px; + color: var(--text); + background-color: alpha(var(--surface0), 0.78); + border: 1px solid alpha(var(--surface2), 0.6); +} + +#network-action-row:hover { + border-color: alpha(var(--cyan), 0.86); + background-color: alpha(var(--surface1), 0.92); +} + +#battery-panel { + min-width: 268px; + padding: 10px; + background-color: alpha(var(--base), 0.98); + border: 1px solid alpha(var(--green), 0.55); + border-bottom: 2px solid alpha(var(--green), 0.72); + border-radius: 4px; +} + +#battery-detail-row { + min-height: 20px; + padding: 0 6px; +} + +#battery-detail-label { + color: var(--subtext); +} + +#battery-detail-value { + color: var(--text); + font-weight: 700; +} + +#battery-profile-row { + min-height: 24px; + padding: 0 8px; + border-radius: 3px; + color: var(--text); + background-color: alpha(var(--surface0), 0.78); + border: 1px solid alpha(var(--surface2), 0.6); +} + +#battery-profile-row:hover, +#battery-profile-row.active { + border-color: alpha(var(--green), 0.86); + background-color: alpha(var(--surface1), 0.92); +} + +#battery-profile-row.active { + color: var(--green); + font-weight: 700; +} + tooltip { background-color: var(--base); border: 1px solid var(--cyan); diff --git a/roles/fabric/tests/test_config_helpers.sh b/roles/fabric/tests/test_config_helpers.sh index 05ab94d7..f2c8ff69 100644 --- a/roles/fabric/tests/test_config_helpers.sh +++ b/roles/fabric/tests/test_config_helpers.sh @@ -49,6 +49,7 @@ module = importlib.util.module_from_spec(spec) assert spec.loader is not None sys.modules[spec.name] = module spec.loader.exec_module(module) +config_source = config_path.read_text() module.AI_STATUS_PATH = status_path module.AI_PROVIDER_PREF_PATH = status_path.parent / "provider.txt" @@ -340,6 +341,7 @@ assert grouped == [ ] assert module.overflow_count_for_tasks(tasks, max_icons=2) == 1 assert module.icon_name_for_class("com.mitchellh.ghostty") in {"com.mitchellh.ghostty", "utilities-terminal", "terminal"} +assert module.icon_name_for_class("Signal") == "signal-desktop" assert module.icon_name_for_class("") == "application-x-executable" assert module.initials_for_label("1Password") == "1" assert module.initials_for_label("Chromium") == "C" @@ -350,6 +352,7 @@ assert module.network_label_from_interface("wlp0s20f3") == "WIFI" assert module.network_label_from_interface("tailscale0") == "VPN" assert module.network_label_from_interface("tun0") == "VPN" assert module.network_label_from_interface("") == "OFF" +assert [action["label"] for action in module.network_settings_actions()] == ["Connections", "Wi-Fi", "Ethernet"] assert module.normalize_dnd_text('string "on"') == "on" assert module.normalize_dnd_text('string "off"') == "off" @@ -359,6 +362,44 @@ assert module.normalize_dnd_text("") == "off" assert module.battery_value_from_output("83%") == "83%" assert module.battery_value_from_output("") is None assert module.battery_value_from_output(" ") is None +battery_info = module.parse_upower_battery_info( + """ + state: charging + percentage: 83% + time to full: 1.2 hours + energy-rate: 14.2 W + """ +) +assert battery_info == { + "state": "charging", + "percentage": "83%", + "time_to_full": "1.2 hours", + "time_to_empty": "", + "energy_rate": "14.2 W", +} +assert module.battery_summary_from_info(battery_info) == "83% CHG" +assert module.battery_detail_rows(battery_info) == [ + ("State", "charging"), + ("Charge", "83%"), + ("Time to full", "1.2 hours"), + ("Energy rate", "14.2 W"), +] + +discharging_info = module.parse_upower_battery_info("state: discharging\npercentage: 54%\ntime to empty: 3.5 hours\n") +assert module.battery_summary_from_info(discharging_info) == "54% BAT" + +profile_listing = """ +* balanced: + PlatformDriver: placeholder + + power-saver: + PlatformDriver: placeholder +""" +profiles = module.parse_power_profiles(profile_listing) +assert profiles == [ + {"name": "balanced", "active": True}, + {"name": "power-saver", "active": False}, +] audio_listing = """default_sink\talsa_output.usb.DAC sink\talsa_output.usb.DAC @@ -495,6 +536,19 @@ assert "background-color: alpha(var(--base), 0.97)" in bar_body assert "border-bottom: 2px solid alpha(var(--cyan), 0.55)" in bar_body assert "padding: 3px 10px" in bar_body +for button_id in ("#network-button", "#ai-button", "#battery-button"): + assert button_id in css + +assert "#ai-button #status-pill" in css +assert "#battery-button #status-pill" in css +assert "self.ai_button = Button(" in config_source +assert "self.ai_button = EventBox(" not in config_source +assert 'on_clicked=lambda *_: self.popup_manager.toggle("ai")' in config_source +assert 'self.ai_button.connect("button-press-event"' not in config_source +assert "def on_ai_button_press" not in config_source +assert "def switch_ai_provider" not in config_source +assert "event_has_shift" not in config_source + task_strip = re.search(r"#task-strip\s*\{(?P.*?)\}", css, re.S) assert task_strip is not None assert "min-width" not in task_strip.group("body") From adebeeeed2e4003becef3bf5f93150889543f3f5 Mon Sep 17 00:00:00 2001 From: TechDufus Date: Mon, 18 May 2026 17:51:08 -0500 Subject: [PATCH 11/15] feat(fabric): add dock app action menu Add a right-click action popout for Fabric dock app buttons. The menu can focus a window, close one window, or close all windows for a grouped app while preserving left-click focus behavior. Keep window actions scoped to numeric AwesomeWM window IDs and add regression coverage for grouped task models, action labels, safe Lua command generation, and menu styling. Pulled with rebase before committing and resolved the conflict with the fullscreen bar visibility change. --- roles/fabric/files/config/awesomewm/config.py | 306 ++++++++++++++++-- roles/fabric/files/config/awesomewm/style.css | 32 ++ roles/fabric/tests/test_config_helpers.sh | 93 +++++- 3 files changed, 402 insertions(+), 29 deletions(-) diff --git a/roles/fabric/files/config/awesomewm/config.py b/roles/fabric/files/config/awesomewm/config.py index f253ea2a..97cf9392 100644 --- a/roles/fabric/files/config/awesomewm/config.py +++ b/roles/fabric/files/config/awesomewm/config.py @@ -8,6 +8,7 @@ import subprocess import sys import time +from collections.abc import Callable from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone from pathlib import Path @@ -33,6 +34,8 @@ CODEX_SESSION_TAIL_BYTES = 512 * 1024 MAX_TASK_LABELS = 5 BAR_HEIGHT = 37 +TASK_ACTION_MENU_WIDTH = 286 +TASK_ACTION_MENU_HEIGHT = 260 VOLUME_POLL_MS = 1000 AI_POLL_MS = 5000 BAR_VISIBILITY_POLL_MS = 500 @@ -799,29 +802,70 @@ def toggle_dnd() -> str: ) -def focus_window(window_id: str) -> None: - if not window_id.isdigit(): - return +def valid_window_id(window_id: object) -> str | None: + value = str(window_id or "").strip() + return value if value.isdigit() else None - run_command( - [ - "awesome-client", - ( - "local target = tonumber('%s'); " - "for _, c in ipairs(client.get()) do " - "if c.window == target then " - "c.minimized = false; " - "c:emit_signal('request::activate', 'fabric-taskbar', {raise = true}); " - "return true " - "end " - "end " - "return false" - ) - % window_id, - ] + +def valid_window_ids(window_ids: object) -> list[str]: + values = window_ids if isinstance(window_ids, (list, tuple)) else [window_ids] + ids: list[str] = [] + for item in values: + window_id = valid_window_id(item) + if window_id and window_id not in ids: + ids.append(window_id) + return ids + + +def awesome_focus_window_lua(window_id: object) -> str | None: + target = valid_window_id(window_id) + if target is None: + return None + return ( + "local target = tonumber('%s'); " + "for _, c in ipairs(client.get()) do " + "if c.window == target then " + "c.minimized = false; " + "c:emit_signal('request::activate', 'fabric-taskbar', {raise = true}); " + "return true " + "end " + "end " + "return false" + ) % target + + +def awesome_close_windows_lua(window_ids: object) -> str | None: + ids = valid_window_ids(window_ids) + if not ids: + return None + targets = ", ".join(f"[{window_id}]=true" for window_id in ids) + return ( + f"local targets = {{{targets}}}; " + "for _, c in ipairs(client.get()) do " + "if targets[c.window] then " + "c:kill(); " + "end " + "end " + "return true" ) +def focus_window(window_id: object) -> None: + script = awesome_focus_window_lua(window_id) + if script is not None: + run_command(["awesome-client", script]) + + +def close_windows(window_ids: object) -> None: + script = awesome_close_windows_lua(window_ids) + if script is not None: + run_command(["awesome-client", script]) + + +def close_window(window_id: object) -> None: + close_windows([window_id]) + + def open_client_menu() -> None: run_command( [ @@ -920,6 +964,7 @@ def parse_awesome_clients(stdout: str) -> list[Task]: continue tasks.append( { + "title": title, "label": app_label(title, class_name), "class_name": class_name, "minimized": minimized, @@ -945,6 +990,15 @@ def group_tasks_for_dock(tasks: list[Task], max_icons: int = MAX_TASK_LABELS) -> "label": label, "class_name": class_name, "window_ids": [window_id], + "windows": [ + { + "title": str(task.get("title") or label), + "label": label, + "window_id": window_id, + "focused": bool(task.get("focused")), + "minimized": bool(task.get("minimized")), + } + ], "count": 1, "focused": bool(task.get("focused")), } @@ -952,6 +1006,17 @@ def group_tasks_for_dock(tasks: list[Task], max_icons: int = MAX_TASK_LABELS) -> ordered.append(grouped) else: existing["window_ids"].append(window_id) + windows = existing.get("windows") + if isinstance(windows, list): + windows.append( + { + "title": str(task.get("title") or label), + "label": label, + "window_id": window_id, + "focused": bool(task.get("focused")), + "minimized": bool(task.get("minimized")), + } + ) existing["count"] = int(existing["count"]) + 1 existing["focused"] = bool(existing.get("focused")) or bool(task.get("focused")) @@ -963,6 +1028,105 @@ def overflow_count_for_tasks(tasks: list[Task], max_icons: int = MAX_TASK_LABELS return max(0, unique_count - max_icons) +def short_task_action_text(text: object, limit: int = 44) -> str: + value = re.sub(r"\s+", " ", str(text or "")).strip() or "Window" + if len(value) <= limit: + return value + if limit <= 3: + return value[:limit] + return f"{value[:limit - 3]}..." + + +def task_windows(task: Task) -> list[dict[str, object]]: + raw_windows = task.get("windows") + if isinstance(raw_windows, list): + windows = [window for window in raw_windows if isinstance(window, dict)] + else: + windows = [] + + if not windows: + window_ids = task.get("window_ids") + windows = [ + { + "title": task.get("label") or "Window", + "label": task.get("label") or "App", + "window_id": window_id, + "focused": bool(task.get("focused")), + "minimized": False, + } + for window_id in valid_window_ids(window_ids) + ] + + filtered = [] + for window in windows: + window_id = valid_window_id(window.get("window_id")) + if window_id: + filtered.append( + { + "title": str(window.get("title") or window.get("label") or task.get("label") or "Window"), + "label": str(window.get("label") or task.get("label") or "App"), + "window_id": window_id, + "focused": bool(window.get("focused")), + "minimized": bool(window.get("minimized")), + } + ) + return filtered + + +def task_action_model(task: Task) -> dict[str, object]: + label = str(task.get("label") or "App") + class_name = str(task.get("class_name") or label) + windows = task_windows(task) + rows: list[dict[str, object]] = [] + + if not windows: + rows.append({"kind": "muted", "label": "window unavailable"}) + return {"title": label, "subtitle": class_name, "rows": rows} + + first_window_id = str(windows[0]["window_id"]) + rows.append({"kind": "action", "action": "focus", "label": "Focus", "window_id": first_window_id}) + + if len(windows) == 1: + rows.append({"kind": "action", "action": "close", "label": "Close Window", "window_id": first_window_id}) + return {"title": label, "subtitle": class_name, "rows": rows} + + rows.append({"kind": "section", "label": "WINDOWS"}) + for index, window in enumerate(windows, start=1): + window_id = str(window["window_id"]) + title = short_task_action_text(window.get("title"), limit=38) + rows.append({"kind": "action", "action": "focus", "label": f"Focus {index}: {title}", "window_id": window_id}) + rows.append({"kind": "action", "action": "close", "label": f"Close {index}: {title}", "window_id": window_id}) + + rows.append({"kind": "section", "label": "GROUP"}) + rows.append( + { + "kind": "action", + "action": "close-all", + "label": f"Close All {len(windows)} Windows", + "window_ids": [str(window["window_id"]) for window in windows], + } + ) + + return {"title": label, "subtitle": class_name, "rows": rows} + + +def task_action_margin_for_pointer(monitor: MonitorGeometry, x_root: object, y_root: object) -> str: + try: + x_value = int(float(x_root)) + except (TypeError, ValueError): + x_value = monitor.x + 8 + try: + y_value = int(float(y_root)) + except (TypeError, ValueError): + y_value = monitor.y + BAR_HEIGHT + + max_x = max(8, monitor.width - TASK_ACTION_MENU_WIDTH - 8) + max_y = max(BAR_HEIGHT, monitor.height - TASK_ACTION_MENU_HEIGHT - 8) + x = min(max(8, x_value - monitor.x), max_x) + y = min(max(BAR_HEIGHT, y_value - monitor.y + 8), max_y) + return f"{y}px 0px 0px {x}px" + + def task_button_labels(tasks: list[Task]) -> list[str]: counts = {} labels = [] @@ -1458,7 +1622,8 @@ def set_value(self, value: str) -> None: class TaskStrip(Box): - def __init__(self, **kwargs): + def __init__(self, on_task_secondary_click: Callable[[Task, object], None] | None = None, **kwargs): + self.on_task_secondary_click = on_task_secondary_click self.task_buttons = Box( name="task-buttons", orientation="h", @@ -1504,15 +1669,16 @@ def set_tasks(self, tasks: list[Task]) -> None: grouped_tasks = group_tasks_for_dock(tasks) children = [] for task in grouped_tasks: - window_ids = task.get("window_ids") if isinstance(task.get("window_ids"), list) else [] - window_id = str(window_ids[0]) if window_ids else "" label = str(task.get("label") or "App") class_name = str(task.get("class_name") or label) button = Button( name="task-button", style_classes=["focused"] if bool(task.get("focused")) else [], child=self.task_child(task), - on_clicked=lambda *_args, target=window_id: focus_window(target), + ) + button.connect( + "button-press-event", + lambda widget, event, target=copy.deepcopy(task): self.on_task_button_press(widget, event, target), ) button.set_tooltip_text(class_name) children.append(button) @@ -1529,6 +1695,17 @@ def set_tasks(self, tasks: list[Task]) -> None: self.task_buttons.children = children + def on_task_button_press(self, _widget, event, task: Task) -> bool: + button = int(getattr(event, "button", 0)) + raw_window_ids = task.get("window_ids") if isinstance(task.get("window_ids"), list) else [] + window_ids = valid_window_ids(raw_window_ids) + if button == 1: + if window_ids: + focus_window(window_ids[0]) + elif button == 3 and callable(self.on_task_secondary_click): + self.on_task_secondary_click(task, event) + return True + class PopupManager: def __init__(self, clock=time.monotonic, reopen_suppression_seconds: float = 0.18): @@ -1614,6 +1791,71 @@ def do_get_display_props(self): return display, monitor_rectangle(self.monitor), self.monitor.scale_factor +class TaskActionPopout(MonitorWindow): + def __init__(self, monitor: MonitorGeometry): + self.current_task: Task = {} + self.panel = Box(name="task-action-panel", orientation="v", spacing=6) + super().__init__( + monitor, + title="fabric-task-action-popout", + name="task-action-popout", + layer="top", + geometry="top-left", + margin=f"{BAR_HEIGHT}px 0px 0px 48px", + type_hint="popup-menu", + visible=False, + child=self.panel, + ) + + def set_task(self, task: Task) -> None: + self.current_task = copy.deepcopy(task) + + def set_anchor_from_event(self, event: object) -> None: + self.margin = task_action_margin_for_pointer( + self.monitor, + getattr(event, "x_root", self.monitor.x + 8), + getattr(event, "y_root", self.monitor.y + BAR_HEIGHT), + ) + + def refresh(self) -> None: + model = task_action_model(self.current_task) + children: list[Box | Button | Label] = [ + Label(name="popout-title", label=str(model["title"])), + ] + subtitle = str(model.get("subtitle") or "") + if subtitle and subtitle != str(model["title"]): + children.append(Label(name="popout-muted", label=short_task_action_text(subtitle, limit=42))) + + for row in model["rows"]: + children.append(self.row_widget(row)) + self.panel.children = children + + def row_widget(self, row: dict[str, object]) -> Button | Label: + kind = str(row.get("kind") or "") + if kind == "section": + return Label(name="popout-section", label=str(row.get("label") or "")) + if kind == "muted": + return Label(name="popout-muted", label=str(row.get("label") or "")) + + style_classes = ["danger"] if str(row.get("action") or "").startswith("close") else [] + return Button( + name="task-action-row", + style_classes=style_classes, + child=Label(label=str(row.get("label") or "")), + on_clicked=lambda *_args, action=copy.deepcopy(row): self.activate_action(action), + ) + + def activate_action(self, row: dict[str, object]) -> None: + action = str(row.get("action") or "") + if action == "focus": + focus_window(row.get("window_id")) + elif action == "close": + close_window(row.get("window_id")) + elif action == "close-all": + close_windows(row.get("window_ids")) + self.hide() + + class AudioDevicePopout(MonitorWindow): def __init__(self, monitor: MonitorGeometry): self.rows = Box(name="audio-popout-rows", orientation="v", spacing=4) @@ -2061,9 +2303,10 @@ def __init__(self, monitor: MonitorGeometry): visible=False, ) - self.tasks = TaskStrip() self.popup_manager = PopupManager() self.hidden_for_fullscreen = False + self.task_action_popout = TaskActionPopout(monitor) + self.tasks = TaskStrip(on_task_secondary_click=self.open_task_actions) self.network = StatusPill("NET", "...") self.network_popout = NetworkSettingsPopout(monitor) self.network_button = Button( @@ -2088,6 +2331,7 @@ def __init__(self, monitor: MonitorGeometry): on_clicked=lambda *_: self.popup_manager.toggle("ai"), ) self.calendar_popout = CalendarPopout(monitor) + self.register_popup("task-actions", self.task_action_popout) self.register_popup("network", self.network_popout) self.register_popup("audio", self.audio_popout) self.register_popup("ai", self.ai_popout) @@ -2197,7 +2441,14 @@ def __init__(self, monitor: MonitorGeometry): self.set_fullscreen_visibility(bar_visibility_for_monitor(self.monitor)) def windows(self) -> list[Window]: - windows: list[Window] = [self, self.network_popout, self.audio_popout, self.ai_popout, self.calendar_popout] + windows: list[Window] = [ + self, + self.task_action_popout, + self.network_popout, + self.audio_popout, + self.ai_popout, + self.calendar_popout, + ] if self.battery_popout is not None: windows.append(self.battery_popout) return windows @@ -2243,6 +2494,11 @@ def on_popup_key_press(self, name: str, event) -> bool: return bool(handle_key_press(event)) return False + def open_task_actions(self, task: Task, event: object) -> None: + self.task_action_popout.set_task(task) + self.task_action_popout.set_anchor_from_event(event) + self.popup_manager.open("task-actions") + def on_volume_button_press(self, _widget, event) -> bool: button = int(getattr(event, "button", 0)) if button == 1: diff --git a/roles/fabric/files/config/awesomewm/style.css b/roles/fabric/files/config/awesomewm/style.css index fdaab3a8..88e53f59 100644 --- a/roles/fabric/files/config/awesomewm/style.css +++ b/roles/fabric/files/config/awesomewm/style.css @@ -164,6 +164,38 @@ padding: 0 6px; } +#task-action-panel { + min-width: 286px; + padding: 10px; + background-color: alpha(var(--base), 0.98); + border: 1px solid alpha(var(--cyan), 0.55); + border-bottom: 2px solid alpha(var(--cyan), 0.72); + border-radius: 4px; +} + +#task-action-row { + min-height: 24px; + padding: 0 8px; + border-radius: 3px; + color: var(--text); + background-color: alpha(var(--surface0), 0.78); + border: 1px solid alpha(var(--surface2), 0.6); +} + +#task-action-row:hover { + border-color: alpha(var(--cyan), 0.86); + background-color: alpha(var(--surface1), 0.92); +} + +#task-action-row.danger { + color: var(--red); +} + +#task-action-row.danger:hover { + border-color: alpha(var(--red), 0.86); + background-color: alpha(var(--red), 0.13); +} + #date-time { padding: 0 12px; color: var(--cyan); diff --git a/roles/fabric/tests/test_config_helpers.sh b/roles/fabric/tests/test_config_helpers.sh index f2c8ff69..0e0de217 100644 --- a/roles/fabric/tests/test_config_helpers.sh +++ b/roles/fabric/tests/test_config_helpers.sh @@ -306,10 +306,38 @@ Untitled - Chromium\tChromium-browser\tfalse\t41943044\tfalse /home/techdufus/.config/fabric/awesomewm/config.py\tpython3\tfalse\t41943045\tfalse"''' tasks = module.parse_awesome_clients(awesome_stdout) assert tasks == [ - {"label": "1Password", "class_name": "1Password", "minimized": False, "window_id": "41943041", "focused": False}, - {"label": "Ghostty", "class_name": "com.mitchellh.ghostty", "minimized": False, "window_id": "41943042", "focused": True}, - {"label": "Ghostty", "class_name": "com.mitchellh.ghostty", "minimized": True, "window_id": "41943043", "focused": False}, - {"label": "Chromium", "class_name": "Chromium-browser", "minimized": False, "window_id": "41943044", "focused": False}, + { + "title": "Family - HomeLab - 1Password", + "label": "1Password", + "class_name": "1Password", + "minimized": False, + "window_id": "41943041", + "focused": False, + }, + { + "title": "tmux", + "label": "Ghostty", + "class_name": "com.mitchellh.ghostty", + "minimized": False, + "window_id": "41943042", + "focused": True, + }, + { + "title": "tmux", + "label": "Ghostty", + "class_name": "com.mitchellh.ghostty", + "minimized": True, + "window_id": "41943043", + "focused": False, + }, + { + "title": "Untitled - Chromium", + "label": "Chromium", + "class_name": "Chromium-browser", + "minimized": False, + "window_id": "41943044", + "focused": False, + }, ] assert module.task_button_labels(tasks) == ["1Password", "Ghostty", "Ghostty 2", "Chromium"] assert module.tasks_text(tasks) == "1Password Ghostty Ghostty 2 Chromium" @@ -321,6 +349,15 @@ assert grouped == [ "label": "1Password", "class_name": "1Password", "window_ids": ["41943041"], + "windows": [ + { + "title": "Family - HomeLab - 1Password", + "label": "1Password", + "window_id": "41943041", + "focused": False, + "minimized": False, + } + ], "count": 1, "focused": False, }, @@ -328,6 +365,10 @@ assert grouped == [ "label": "Ghostty", "class_name": "com.mitchellh.ghostty", "window_ids": ["41943042", "41943043"], + "windows": [ + {"title": "tmux", "label": "Ghostty", "window_id": "41943042", "focused": True, "minimized": False}, + {"title": "tmux", "label": "Ghostty", "window_id": "41943043", "focused": False, "minimized": True}, + ], "count": 2, "focused": True, }, @@ -335,11 +376,50 @@ assert grouped == [ "label": "Chromium", "class_name": "Chromium-browser", "window_ids": ["41943044"], + "windows": [ + { + "title": "Untitled - Chromium", + "label": "Chromium", + "window_id": "41943044", + "focused": False, + "minimized": False, + } + ], "count": 1, "focused": False, }, ] assert module.overflow_count_for_tasks(tasks, max_icons=2) == 1 +assert module.valid_window_id("41943041") == "41943041" +assert module.valid_window_id("abc") is None +assert module.valid_window_ids(["41943041", "abc", "41943042", "41943041"]) == ["41943041", "41943042"] +focus_lua = module.awesome_focus_window_lua("41943041") +assert focus_lua is not None +assert "request::activate" in focus_lua +assert "c:kill()" not in focus_lua +close_lua = module.awesome_close_windows_lua(["41943041", "abc", "41943042"]) +assert close_lua is not None +assert "[41943041]=true" in close_lua +assert "[41943042]=true" in close_lua +assert "abc" not in close_lua +assert "c:kill()" in close_lua +assert module.awesome_close_windows_lua(["abc"]) is None +assert module.short_task_action_text("x" * 80, limit=12) == "xxxxxxxxx..." +single_action_model = module.task_action_model(grouped[0]) +assert single_action_model["title"] == "1Password" +assert [row["label"] for row in single_action_model["rows"]] == ["Focus", "Close Window"] +grouped_action_model = module.task_action_model(grouped[1]) +assert grouped_action_model["title"] == "Ghostty" +assert grouped_action_model["rows"][0]["label"] == "Focus" +assert grouped_action_model["rows"][1]["kind"] == "section" +assert grouped_action_model["rows"][2]["label"] == "Focus 1: tmux" +assert grouped_action_model["rows"][3]["label"] == "Close 1: tmux" +assert grouped_action_model["rows"][4]["label"] == "Focus 2: tmux" +assert grouped_action_model["rows"][5]["label"] == "Close 2: tmux" +assert grouped_action_model["rows"][6]["label"] == "GROUP" +assert grouped_action_model["rows"][7]["label"] == "Close All 2 Windows" +assert module.task_action_margin_for_pointer(module.MonitorGeometry(index=0, x=0, y=0, width=1920, height=1080), 100, 9) == "37px 0px 0px 100px" +assert module.task_action_margin_for_pointer(module.MonitorGeometry(index=0, x=0, y=0, width=320, height=240), 900, 900) == "37px 0px 0px 26px" assert module.icon_name_for_class("com.mitchellh.ghostty") in {"com.mitchellh.ghostty", "utilities-terminal", "terminal"} assert module.icon_name_for_class("Signal") == "signal-desktop" assert module.icon_name_for_class("") == "application-x-executable" @@ -548,11 +628,16 @@ assert 'self.ai_button.connect("button-press-event"' not in config_source assert "def on_ai_button_press" not in config_source assert "def switch_ai_provider" not in config_source assert "event_has_shift" not in config_source +assert "class TaskActionPopout" in config_source +assert "def on_task_button_press" in config_source +assert "button == 3" in config_source task_strip = re.search(r"#task-strip\s*\{(?P.*?)\}", css, re.S) assert task_strip is not None assert "min-width" not in task_strip.group("body") for selector in ( + "#task-action-panel", + "#task-action-row", "#ai-panel", "#ai-provider-tabs", "#ai-provider-tab", From 612853a0f6ec3c264609991bfd6a85afcf82f9f5 Mon Sep 17 00:00:00 2001 From: TechDufus Date: Mon, 18 May 2026 17:56:33 -0500 Subject: [PATCH 12/15] chore(desktop): remove stale Flare paths Remove the leftover Flare AppImage install and compatibility signal now that Vicinae is the launcher path for AwesomeWM. Keep the old binary in the legacy cleanup list so future role runs remove it from existing hosts. Refresh docs and regression checks so Signal uses the APT-installed signal-desktop command and Rofi remains documented as a deliberate layout picker dependency. --- roles/awesomewm/README.md | 8 +++----- roles/awesomewm/defaults/main.yml | 1 + roles/awesomewm/files/config/rc.lua | 1 - roles/awesomewm/tasks/Ubuntu.yml | 7 ------- roles/awesomewm/tests/test_vicinae_launcher.sh | 12 +++++++++++- roles/vicinae/README.md | 4 ++-- roles/vicinae/defaults/main.yml | 1 - 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/roles/awesomewm/README.md b/roles/awesomewm/README.md index 38f34d2b..b6d93acb 100644 --- a/roles/awesomewm/README.md +++ b/roles/awesomewm/README.md @@ -120,8 +120,8 @@ graph LR - `flameshot` - Screenshot tool - `thunar` - Lightweight file manager - `ristretto` - Image viewer -- Flare launcher (AppImage, retained temporarily during Vicinae migration) -- Vicinae primary launcher, clipboard, app search, emoji, and settings support when the separate `vicinae` role is installed +- Vicinae launcher hooks for command search, clipboard, app search, emoji, and + settings support when the separate `vicinae` role is installed **Media & System:** - `playerctl` - Media key controls @@ -191,8 +191,6 @@ graph LR ~/.themes/ └── catppuccin-mocha-blue-standard+default/ # GTK theme -~/.local/bin/ -└── flare # Legacy launcher kept during Vicinae migration ``` ### Theming @@ -278,7 +276,7 @@ Hyper + l Signal = { class = "signal", summon = "C", -- CapsLock/F13 + Shift+c - exec = "flatpak run org.signal.Signal", + exec = "signal-desktop", }, ``` diff --git a/roles/awesomewm/defaults/main.yml b/roles/awesomewm/defaults/main.yml index a70105fe..0edf5291 100644 --- a/roles/awesomewm/defaults/main.yml +++ b/roles/awesomewm/defaults/main.yml @@ -9,3 +9,4 @@ awesomewm_legacy_launcher_paths: - "{{ ansible_facts['user_dir'] }}/.config/copyq" - "{{ ansible_facts['user_dir'] }}/.local/bin/bemoji" - "{{ ansible_facts['user_dir'] }}/.local/share/bemoji" + - "{{ ansible_facts['user_dir'] }}/.local/bin/flare" diff --git a/roles/awesomewm/files/config/rc.lua b/roles/awesomewm/files/config/rc.lua index dab6f188..7c887dba 100644 --- a/roles/awesomewm/files/config/rc.lua +++ b/roles/awesomewm/files/config/rc.lua @@ -181,7 +181,6 @@ awesome.connect_signal("techdufus::launcher_apps", launch_vicinae_apps) awesome.connect_signal("techdufus::launcher_clipboard", launch_vicinae_clipboard) awesome.connect_signal("techdufus::launcher_emoji", launch_vicinae_emoji) awesome.connect_signal("techdufus::launcher_settings", launch_vicinae_settings) -awesome.connect_signal("techdufus::launch_flare", launch_vicinae_root) start_vicinae_server() diff --git a/roles/awesomewm/tasks/Ubuntu.yml b/roles/awesomewm/tasks/Ubuntu.yml index f5a77490..6ea54667 100644 --- a/roles/awesomewm/tasks/Ubuntu.yml +++ b/roles/awesomewm/tasks/Ubuntu.yml @@ -75,13 +75,6 @@ state: present become: true -- name: "awesomewm | Ubuntu | Download Flare launcher" - ansible.builtin.get_url: - url: https://github.com/ByteAtATime/flare/releases/download/v0.1.0/flare_0.1.0_amd64.AppImage - dest: "{{ ansible_facts['user_dir'] }}/.local/bin/flare" - mode: "0755" - register: flare_download - - name: "awesomewm | Ubuntu | Ensure ~/.config/awesome exists" ansible.builtin.file: path: "{{ ansible_facts['user_dir'] }}/.config/awesome" diff --git a/roles/awesomewm/tests/test_vicinae_launcher.sh b/roles/awesomewm/tests/test_vicinae_launcher.sh index 42593d27..a593646f 100755 --- a/roles/awesomewm/tests/test_vicinae_launcher.sh +++ b/roles/awesomewm/tests/test_vicinae_launcher.sh @@ -14,7 +14,6 @@ grep -q "techdufus::launcher_apps" "$config_path" grep -q "techdufus::launcher_clipboard" "$config_path" grep -q "techdufus::launcher_emoji" "$config_path" grep -q "techdufus::launcher_settings" "$config_path" -grep -q "techdufus::launch_flare" "$config_path" grep -q "launch_vicinae_root" "$config_path" grep -q "launch_vicinae_apps" "$config_path" grep -q "launch_vicinae_clipboard" "$config_path" @@ -39,6 +38,11 @@ if grep -q "org.dev_byteatatime_flare.SingleInstance" "$config_path"; then exit 1 fi +if grep -Eq "techdufus::launch_flare|Download Flare|flare_0\\.1\\.0|/\\.local/bin/flare" "$config_path" "$tasks_path"; then + echo "Legacy Flare launcher runtime or install path should not remain" >&2 + exit 1 +fi + if grep -Eq "\\b(copyq|bemoji|rofimoji)\\b" "$config_path" "$layout_manager_path"; then echo "Legacy CopyQ/bemoji runtime references should not remain" >&2 exit 1 @@ -53,6 +57,7 @@ grep -q "awesomewm_remove_legacy_launcher_tools: true" "$defaults_path" grep -q "awesomewm_legacy_launcher_packages:" "$defaults_path" grep -q "copyq" "$defaults_path" grep -q "bemoji" "$defaults_path" +grep -q ".local/bin/flare" "$defaults_path" if grep -q "rofi" "$defaults_path"; then echo "rofi should not be removed as a legacy launcher tool" >&2 exit 1 @@ -68,6 +73,11 @@ test -f "$rofi_theme_path" grep -q "function M.bind_to_cell(cell_index)" "$layout_manager_path" grep -q "move_client_to_cell" "$layout_manager_path" +if grep -q "flatpak run org.signal.Signal" "$repo_root/roles/awesomewm/README.md" "$repo_root/roles/awesomewm/files/config/cell-management/apps.lua"; then + echo "Signal should use the APT-installed signal-desktop command" >&2 + exit 1 +fi + keybindings_path="$repo_root/roles/awesomewm/files/config/cell-management/keybindings.lua" grep -q "techdufus::launcher_emoji" "$keybindings_path" grep -q "techdufus::launcher_settings" "$keybindings_path" diff --git a/roles/vicinae/README.md b/roles/vicinae/README.md index 0388df4a..b4bf5131 100644 --- a/roles/vicinae/README.md +++ b/roles/vicinae/README.md @@ -67,5 +67,5 @@ TypeScript extension only after scripts or dmenu prove insufficient. ## Rollout Boundary This role only installs and configures Vicinae. The AwesomeWM role owns desktop -keybindings and removes the retired rofi, CopyQ, and bemoji fallback tools once -the Vicinae launcher, clipboard, app search, emoji, and settings flows validate. +keybindings and removes retired Flare, CopyQ, and bemoji fallback paths. Rofi +is still intentionally installed by AwesomeWM for layout and cell picker menus. diff --git a/roles/vicinae/defaults/main.yml b/roles/vicinae/defaults/main.yml index 4e577db4..206f4cc0 100644 --- a/roles/vicinae/defaults/main.yml +++ b/roles/vicinae/defaults/main.yml @@ -15,7 +15,6 @@ vicinae_install_input_server: true vicinae_install_browser_manifests: false vicinae_enable_user_service: false vicinae_start_from_awesomewm: true -vicinae_remove_flare_after_migration: false vicinae_install_qalc: true vicinae_appimage_asset_regex: "^Vicinae.*x86_64.*AppImage$" From 60c8ebe274ca6c17aeb4a4b1aba4228575a54a11 Mon Sep 17 00:00:00 2001 From: TechDufus Date: Mon, 18 May 2026 17:59:54 -0500 Subject: [PATCH 13/15] docs: fix markdown link check failures Remove the Raycast community link that redirects to a Slack invite blocked by the link checker, and update the systemd journald link to the current stable manpage URL. --- roles/raycast/README.md | 1 - roles/system/README.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/roles/raycast/README.md b/roles/raycast/README.md index 47f76ae4..497944b7 100644 --- a/roles/raycast/README.md +++ b/roles/raycast/README.md @@ -136,7 +136,6 @@ rm -rf ~/Library/Preferences/com.raycast.macos.plist - [Documentation](https://developers.raycast.com/) - [Extension Store](https://www.raycast.com/store) - [GitHub Repository](https://github.com/raycast) -- [Community Forum](https://raycast.com/community) ## License diff --git a/roles/system/README.md b/roles/system/README.md index c5c159cc..46ec5105 100644 --- a/roles/system/README.md +++ b/roles/system/README.md @@ -260,7 +260,7 @@ See `tasks/hosts-management-archive.yml` for archived implementation. - [Ansible Documentation](https://docs.ansible.com/) - [DNF Configuration Reference](https://dnf.readthedocs.io/en/latest/conf_ref.html) -- [systemd Journal Size Management](https://www.freedesktop.org/software/systemd/man/journald.conf.html) +- [systemd Journal Size Management](https://www.freedesktop.org/software/systemd/man/latest/journald.conf.html) - [ZRAM Configuration](https://www.kernel.org/doc/html/latest/admin-guide/blockdev/zram.html) - [win32yank GitHub](https://github.com/equalsraf/win32yank) From 5c5c664c005daca6ca0d6bec0fa6edeffbec060c Mon Sep 17 00:00:00 2001 From: TechDufus Date: Mon, 18 May 2026 18:03:20 -0500 Subject: [PATCH 14/15] docs: avoid flaky journald link Replace the external journald.conf URL with a local manpage reference because CI receives HTTP 418 from the freedesktop host. --- roles/system/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/system/README.md b/roles/system/README.md index 46ec5105..2b5186eb 100644 --- a/roles/system/README.md +++ b/roles/system/README.md @@ -260,7 +260,7 @@ See `tasks/hosts-management-archive.yml` for archived implementation. - [Ansible Documentation](https://docs.ansible.com/) - [DNF Configuration Reference](https://dnf.readthedocs.io/en/latest/conf_ref.html) -- [systemd Journal Size Management](https://www.freedesktop.org/software/systemd/man/latest/journald.conf.html) +- systemd journald configuration: see `man journald.conf` - [ZRAM Configuration](https://www.kernel.org/doc/html/latest/admin-guide/blockdev/zram.html) - [win32yank GitHub](https://github.com/equalsraf/win32yank) From b6635c3a085898b520012fef6281441b38d1184b Mon Sep 17 00:00:00 2001 From: TechDufus Date: Mon, 18 May 2026 18:06:13 -0500 Subject: [PATCH 15/15] docs: avoid flaky slides link Replace the Terminal Trove URL with plain text because the host returns HTTP 403 from the markdown link checker in CI. --- roles/slides/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/slides/README.md b/roles/slides/README.md index e9d51b4c..a2f997f4 100644 --- a/roles/slides/README.md +++ b/roles/slides/README.md @@ -77,7 +77,7 @@ flowchart TD - **Official Repository**: [maaslalani/slides](https://github.com/maaslalani/slides) - **Documentation**: [GitHub README](https://github.com/maaslalani/slides#readme) -- **Terminal Trove**: [slides overview](https://terminaltrove.com/slides/) +- **Terminal Trove**: slides overview ## 📝 Example