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/group_vars/all.yml b/group_vars/all.yml index 0ed8f860..dab025c1 100644 --- a/group_vars/all.yml +++ b/group_vars/all.yml @@ -4,6 +4,7 @@ default_roles: - aldente - asciiquarium - awesomewm + - vicinae - bash - bat - borders @@ -45,6 +46,7 @@ default_roles: - raycast # - ruby # - rust + - signal - slides - spotify - superwhisper 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/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/README.md b/roles/awesomewm/README.md index 765b4b70..b6d93acb 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,16 +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) +- 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 @@ -179,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 @@ -194,16 +188,9 @@ 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 # Application launcher -└── rofimoji # Emoji picker ``` ### Theming @@ -211,7 +198,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:** @@ -290,7 +276,7 @@ Hyper + l Signal = { class = "signal", summon = "C", -- CapsLock/F13 + Shift+c - exec = "flatpak run org.signal.Signal", + exec = "signal-desktop", }, ``` @@ -402,12 +388,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 @@ -491,7 +478,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..0edf5291 100644 --- a/roles/awesomewm/defaults/main.yml +++ b/roles/awesomewm/defaults/main.yml @@ -1,3 +1,12 @@ --- # AwesomeWM role defaults role_name: "awesomewm" + +awesomewm_remove_legacy_launcher_tools: true +awesomewm_legacy_launcher_packages: + - copyq +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/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/awesomewm/files/config/cell-management/keybindings.lua b/roles/awesomewm/files/config/cell-management/keybindings.lua index 296d7a3f..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. @@ -123,11 +144,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 +162,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..eb7dad56 100644 --- a/roles/awesomewm/files/config/cell-management/layout-manager.lua +++ b/roles/awesomewm/files/config/cell-management/layout-manager.lua @@ -15,8 +15,20 @@ 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 tostring(text):gsub('"', '\\"') + 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) @@ -46,6 +58,74 @@ local function reapply_layout_for_screen(target_screen) end end +local function start_rofi_picker(prompt, lines, callback) + if #lines == 0 then + 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) + ) + + awful.spawn.easy_async_with_shell(command, function(stdout, stderr, reason, exit_code) + if exit_code ~= 0 then + return + end + + 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) + 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 +192,27 @@ 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+)%.") + table.insert(picker_lines, string.format("%s%d. %s", marker, i, layout.name)) + end + + start_rofi_picker( + "Layout for " .. screen_label, + picker_lines, + function(selection) + local index = selection:match("^%s*%*?%s*(%d+)%.") if index then - index = tonumber(index) - if index then - M.switch_layout(index, target_screen) - end + M.switch_layout(tonumber(index), target_screen) end - - -- Clean up temp file - os.remove(menu_file) end ) end @@ -169,8 +227,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 +243,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 +257,18 @@ 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_rofi_picker( + string.format("Move %s on %s to cell", c.class or "window", helpers.get_screen_label(target_screen)), + picker_lines, + 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 63cd9da5..7c887dba 100644 --- a/roles/awesomewm/files/config/rc.lua +++ b/roles/awesomewm/files/config/rc.lua @@ -25,49 +25,27 @@ 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") -- 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' -]]) - --- Start clipboard manager daemon (CopyQ) -awful.spawn.once("copyq") +-- 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") @@ -75,7 +53,12 @@ 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") --- Flare launcher starts on-demand (Super+Space) - no auto-start to avoid popup +-- 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" --replace; fi]]) +end + +-- Vicinae launcher server starts after launcher helpers are defined below. -- }}} -- {{{ Error handling @@ -139,6 +122,68 @@ awful.layout.layouts = { } -- }}} +local function notify_vicinae_fallback() + naughty.notify({ + title = "Vicinae unavailable", + text = "Run dotfiles -t vicinae to install it, or use Super+Return for a terminal.", + }) +end + +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() + end + end) +end + +local function launch_vicinae_root() + launch_vicinae("vicinae://toggle") +end + +local function launch_vicinae_open_root() + 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 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 + awful.spawn.once("vicinae server --replace") + end + end) +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_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) + +start_vicinae_server() + -- {{{ Menu -- Create a launcher widget and a main menu myawesomemenu = { @@ -171,6 +216,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 +296,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 +332,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 +417,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 +434,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 @@ -371,19 +475,24 @@ 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" }), + -- Application launcher. + awful.key({ "Mod1" }, "space", function() + awesome.emit_signal("techdufus::launcher_apps") + end, { description = "application launcher", group = "launcher" }), + + -- Primary command launcher. + awful.key({ modkey, "Shift" }, "space", launch_1password_quick_access, + { description = "1Password Quick Access", group = "launcher" }), - -- Flare launcher (Raycast-like: clipboard, calculator, extensions, AI) + -- Clipboard manager. awful.key({ modkey }, "space", function() - awful.spawn("/home/techdufus/.local/bin/flare") - end, { description = "flare launcher", group = "launcher" }), + awesome.emit_signal("techdufus::launcher_clipboard") + end, { description = "clipboard history", group = "launcher" }), - -- Clipboard manager (CopyQ) + -- Primary command launcher. awful.key({ modkey }, "v", function() - awful.spawn("copyq toggle") - end, { description = "clipboard history", group = "launcher" }), + awesome.emit_signal("techdufus::launcher_root") + end, { description = "vicinae launcher", group = "launcher" }), awful.key({ modkey }, "x", function() @@ -543,12 +652,36 @@ awful.rules.rules = { } }, + -- Vicinae launcher should behave like a centered command palette. + { + rule_any = { + 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 + 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 + }, + -- Floating clients. { 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/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/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/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/tasks/Ubuntu.yml b/roles/awesomewm/tasks/Ubuntu.yml index a9ae57d4..6ea54667 100644 --- a/roles/awesomewm/tasks/Ubuntu.yml +++ b/roles/awesomewm/tasks/Ubuntu.yml @@ -11,9 +11,9 @@ - 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 + - 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 @@ -23,7 +23,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 @@ -33,10 +32,36 @@ - 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 +- 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,29 +75,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 - 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" @@ -142,6 +144,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" @@ -241,24 +250,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_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/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_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_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" diff --git a/roles/awesomewm/tests/test_vicinae_launcher.sh b/roles/awesomewm/tests/test_vicinae_launcher.sh new file mode 100755 index 00000000..a593646f --- /dev/null +++ b/roles/awesomewm/tests/test_vicinae_launcher.sh @@ -0,0 +1,87 @@ +#!/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" +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" +grep -q "techdufus::launcher_clipboard" "$config_path" +grep -q "techdufus::launcher_emoji" "$config_path" +grep -q "techdufus::launcher_settings" "$config_path" +grep -q "launch_vicinae_root" "$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 "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" +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 + +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 +fi + +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 "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 +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" + +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" + +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/README.md b/roles/fabric/README.md new file mode 100644 index 00000000..1dd9d1af --- /dev/null +++ b/roles/fabric/README.md @@ -0,0 +1,60 @@ +# 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 +- launcher and settings buttons that emit AwesomeWM signals, allowing AwesomeWM + to route to Vicinae or fallbacks consistently with keyboard shortcuts + +## 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..dd5d5340 --- /dev/null +++ b/roles/fabric/files/bin/fabric-awesomewm @@ -0,0 +1,30 @@ +#!/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" +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 + 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" +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..97cf9392 --- /dev/null +++ b/roles/fabric/files/config/awesomewm/config.py @@ -0,0 +1,2539 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import calendar +import copy +import json +import re +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 + +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 +TASK_ACTION_MENU_WIDTH = 286 +TASK_ACTION_MENU_HEIGHT = 260 +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" +LAUNCHER_SIGNAL = ROOT_LAUNCHER_SIGNAL +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") +''' + +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", + "chromium-browser": "Chromium", + "code": "Code", + "com.mitchellh.ghostty": "Ghostty", + "discord": "Discord", + "firefox": "Firefox", + "ghostty": "Ghostty", + "google-chrome": "Chrome", + "signal": "Signal", + "signal-desktop": "Signal", + "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", + "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] + + +@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 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() + 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 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: + 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_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( + ["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 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}'", + "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 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 + + +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_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: + 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 valid_window_id(window_id: object) -> str | None: + value = str(window_id or "").strip() + return value if value.isdigit() else None + + +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( + [ + "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( + { + "title": title, + "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], + "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")), + } + grouped_by_class[key] = grouped + 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")) + + 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 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 = [] + 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 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") + 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 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, 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", + 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: + 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), + ) + 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) + + 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 + + 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): + 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 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 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) + super().__init__( + monitor, + title="fabric-audio-popout", + 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 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 + 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", + 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(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", + margin="37px 0px 0px 0px", + type_hint="dialog", + visible=False, + child=Box( + name="calendar-panel", + orientation="v", + spacing=8, + children=[ + 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.refresh() + self.show_all() + + +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-left", + type_hint="dock", + type="popup", + focusable=False, + size=bar_size_from_monitor_width(monitor.width), + visible=False, + ) + + 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( + 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( + 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(monitor, on_provider_changed=self.refresh_ai_usage) + self.ai_button = Button( + name="ai-button", + child=self.ai, + 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) + 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 + 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_button, + self.volume_button, + self.ai_button, + ] + if self.battery_button is not None: + end_children.append(self.battery_button) + end_children.extend( + [ + self.dnd_button, + Button( + name="settings-button", + child=Label(label="SET"), + on_clicked=lambda *_: open_settings_launcher(), + ), + ] + ) + + 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 *_: open_launcher(), + ), + 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.set_default_size(monitor.width, BAR_HEIGHT) + 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)), + 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()) + self.set_fullscreen_visibility(bar_visibility_for_monitor(self.monitor)) + + def windows(self) -> list[Window]: + 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 + + 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 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 + + def on_popup_key_press(self, name: str, event) -> bool: + 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 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: + 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 refresh_ai_usage(self) -> None: + self.ai.set_value(ai_usage_text()) + + 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()) + + 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")) + 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 new file mode 100644 index 00000000..88e53f59 --- /dev/null +++ b/roles/fabric/files/config/awesomewm/style.css @@ -0,0 +1,618 @@ +: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, +#network-button, +#battery-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, +#network-button:hover, +#battery-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; +} + +#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); + 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, +#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; + 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 { + min-width: 284px; + 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); +} + +#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; +} + +#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); +} + +#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); + 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..0e0de217 --- /dev/null +++ b/roles/fabric/tests/test_config_helpers.sh @@ -0,0 +1,661 @@ +#!/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 +from datetime import date + +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 +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" +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::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 +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") +] + +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": { + "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 == [ + { + "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" +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"], + "windows": [ + { + "title": "Family - HomeLab - 1Password", + "label": "1Password", + "window_id": "41943041", + "focused": False, + "minimized": False, + } + ], + "count": 1, + "focused": False, + }, + { + "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, + }, + { + "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" +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 [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" +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 +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 +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 +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] + + +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 + +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 +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", + "#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" 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 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/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 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 diff --git a/roles/system/README.md b/roles/system/README.md index c5c159cc..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/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) diff --git a/roles/vicinae/README.md b/roles/vicinae/README.md new file mode 100644 index 00000000..b4bf5131 --- /dev/null +++ b/roles/vicinae/README.md @@ -0,0 +1,71 @@ +# 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 only installs and configures Vicinae. The AwesomeWM role owns desktop +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 new file mode 100644 index 00000000..206f4cc0 --- /dev/null +++ b/roles/vicinae/defaults/main.yml @@ -0,0 +1,31 @@ +--- +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_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