├── .envrc ├── ROADMAP.md ├── currently_issues.md ├── dev.sh ├── flake.lock ├── flake.nix ├── icons ├── battery-powersave.svg ├── battery-symbolic.svg ├── cpu-symbolic.svg ├── fan-symbolic.svg ├── github-symbolic.svg ├── gpu-symbolic.svg ├── memory-symbolic.svg ├── network-download-symbolic.svg ├── network-symbolic.svg ├── network-upload-symbolic.svg ├── storage-symbolic.svg ├── system-symbolic.svg ├── temperature-symbolic.svg └── voltage-symbolic.svg ├── init.lua ├── lua ├── lib │ ├── common.lua │ ├── dbus.lua │ ├── debug.lua │ ├── display.lua │ ├── dock-config.lua │ ├── github.lua │ ├── niri.lua │ ├── profile.lua │ ├── profiler.lua │ ├── state.lua │ ├── sysinfo.lua │ ├── theme.lua │ ├── utils.lua │ └── vitals.lua ├── widgets │ ├── ActiveClient.lua │ ├── Notification.lua │ ├── Vitals.lua │ └── Workspaces.lua └── windows │ ├── AudioControl.lua │ ├── Bar.lua │ ├── Battery.lua │ ├── Desktop.lua │ ├── DisplayControl.lua │ ├── Dock.lua │ ├── Github.lua │ ├── MediaControl.lua │ ├── Network.lua │ ├── NotificationPopups.lua │ ├── OSD.lua │ └── SysInfo.lua ├── scss ├── abstracts │ ├── _colors.scss │ ├── _functions.scss │ ├── _index.scss │ └── _mixins.scss ├── base │ ├── _reset.scss │ └── _typography.scss ├── components │ ├── _button.scss │ └── _tooltip.scss ├── style.scss ├── widgets │ ├── active-client.scss │ ├── vitals.scss │ └── workspaces.scss └── windows │ ├── OSD.scss │ ├── audio-control.scss │ ├── bar.scss │ ├── battery.scss │ ├── desktop.scss │ ├── display-control.scss │ ├── dock.scss │ ├── github.scss │ ├── media-control.scss │ ├── network.scss │ ├── notifications.scss │ └── sysinfo.scss ├── todo.md └── user-variables.lua /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | 2 | # Implementation Roadmap for astal-bar Optimization 3 | 4 | Here's a prioritized task list to systematically implement the recommended improvements: 5 | 6 | ## Phase 1: Foundation and Utilities 7 | 8 | 1. **Create Utility Module** 9 | - Create `lib/utils.lua` containing essential utilities 10 | - Implement a robust debounce function 11 | - Add throttle, memoize, and safe cleanup functions 12 | - Write thorough documentation for each utility 13 | 14 | 2. **Implement Basic State Management** 15 | - Create `lib/state.lua` with a simple event bus implementation 16 | - Define core state objects and their default values 17 | - Implement subscribe/publish pattern for state updates 18 | - Add state persistence functionality where appropriate 19 | 20 | 3. **Add Profiling Tools** 21 | - Set up luagraph integration 22 | - Create a simple profiling wrapper to toggle profiling 23 | - Add helper functions to output profiling data 24 | - Document usage procedures for developers 25 | 26 | ## Phase 2: Core Components Enhancement 27 | 28 | 4. **Refactor Dock Component** (highest priority) 29 | - Replace polling with event-driven updates 30 | - Implement state management for application list 31 | - Apply debouncing to user interactions 32 | - Fix memory leaks and cleanup procedures 33 | 34 | 5. **Optimize Resource Management** 35 | - Create centralized cleanup mechanisms for common resources 36 | - Standardize widget lifecycle management 37 | - Implement lazy initialization for heavy components 38 | - Ensure proper cleanup on component destruction 39 | 40 | 6. **Enhance System Integration** 41 | - Replace polling with system event listeners where possible 42 | - Create better D-Bus integration for system events 43 | - Implement efficient window tracking mechanisms 44 | - Add proper signal handling for system changes 45 | 46 | ## Phase 3: Component-Specific Optimizations 47 | 48 | 7. **Refactor Bar Component** 49 | - Update to use the state management system 50 | - Implement proper window reference management 51 | - Replace direct polling with event subscriptions 52 | - Apply performance optimizations 53 | 54 | 8. **Optimize Media and Notification Components** 55 | - Centralize media player state management 56 | - Improve notification efficiency and lifecycle 57 | - Reduce redundant updates in UI components 58 | - Apply proper cleanup for transient components 59 | 60 | 9. **Enhance Workspace and Window Management** 61 | - Create a reactive workspace management system 62 | - Optimize window tracking and updates 63 | - Implement efficient workspace switching 64 | - Reduce memory usage for window representations 65 | 66 | ## Phase 4: Refinement and Documentation 67 | 68 | 10. **Standardize Component Patterns** 69 | - Create consistent component creation templates 70 | - Standardize error handling and logging 71 | - Establish common patterns for state subscription 72 | - Document best practices for custom components 73 | 74 | 11. **Optimize Rendering and Updates** 75 | - Implement visibility-based update suspension 76 | - Add render optimization for off-screen components 77 | - Reduce unnecessary redraws and layout calculations 78 | - Prioritize updates based on user interaction 79 | 80 | 12. **Complete Documentation** 81 | - Create comprehensive API documentation 82 | - Add performance best practices guide 83 | - Document state management patterns 84 | - Provide examples for common customization scenarios 85 | 86 | ## Phase 5: Community and Extension 87 | 88 | 13. **Create Extension Points** 89 | - Define clean APIs for community extensions 90 | - Implement plugin architecture if appropriate 91 | - Create example extensions and plugins 92 | - Document extension development process 93 | 94 | 14. **Performance Testing Framework** 95 | - Implement automated performance benchmarks 96 | - Create memory usage monitoring tools 97 | - Add performance regression tests 98 | - Document performance testing procedures 99 | 100 | 15. **Final Optimization Pass** 101 | - Conduct thorough profiling of entire application 102 | - Address any remaining memory leaks 103 | - Optimize startup and shutdown procedures 104 | - Fine-tune event handling and dispatch 105 | -------------------------------------------------------------------------------- /currently_issues.md: -------------------------------------------------------------------------------- 1 | # 🐛 Astal Bar Known Issues 2 | 3 | ## System Module 4 | 5 | ### Suspension Recovery Error 6 | 7 | **Priority:** High **Status:** Open **Component:** System Suspension Handler 8 | 9 | The system produces a variable error when resuming from suspension mode, 10 | specifically displaying an "emit signal" error. 11 | 12 | **Steps to Reproduce:** 13 | 14 | 1. Put laptop into suspension mode 15 | 2. Resume from suspension 16 | 3. Error appears referencing "Variable" with an "emit signal" error 17 | 18 | **Expected Behavior:** 19 | 20 | - System should resume from suspension without errors 21 | - All signals should reconnect properly after suspension 22 | -------------------------------------------------------------------------------- /dev.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | WORKDIR=$(pwd) 4 | 5 | _lua() { 6 | PID=$(pgrep -f 'lua init.lua') 7 | 8 | if [[ -n "$PID" ]]; then 9 | echo "killing lua process" 10 | kill "$PID" 11 | fi 12 | 13 | lua init.lua & 14 | } 15 | 16 | _scss() { 17 | sass "$WORKDIR"/scss/style.scss /tmp/style.css 18 | _lua 19 | } 20 | 21 | _lua 22 | 23 | inotifywait --quiet --monitor --event create,modify,delete --recursive $WORKDIR | while read DIRECTORY EVENT FILE; do 24 | file_extension=${FILE##*.} 25 | case $file_extension in 26 | lua) 27 | echo "reload lua..." 28 | _lua 29 | ;; 30 | scss) 31 | echo "reload scss..." 32 | _scss 33 | ;; 34 | esac 35 | done 36 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "astal": { 4 | "inputs": { 5 | "nixpkgs": [ 6 | "nixpkgs" 7 | ] 8 | }, 9 | "locked": { 10 | "lastModified": 1741396597, 11 | "narHash": "sha256-RQYpdggQLWTynaT1ISqbACo8plSTBNunG8rv8+A82g0=", 12 | "owner": "aylur", 13 | "repo": "astal", 14 | "rev": "f38433594051ee75957720d1c36de00896a67eb6", 15 | "type": "github" 16 | }, 17 | "original": { 18 | "owner": "aylur", 19 | "repo": "astal", 20 | "type": "github" 21 | } 22 | }, 23 | "nixpkgs": { 24 | "locked": { 25 | "lastModified": 1741513245, 26 | "narHash": "sha256-7rTAMNTY1xoBwz0h7ZMtEcd8LELk9R5TzBPoHuhNSCk=", 27 | "owner": "nixos", 28 | "repo": "nixpkgs", 29 | "rev": "e3e32b642a31e6714ec1b712de8c91a3352ce7e1", 30 | "type": "github" 31 | }, 32 | "original": { 33 | "owner": "nixos", 34 | "ref": "nixos-unstable", 35 | "repo": "nixpkgs", 36 | "type": "github" 37 | } 38 | }, 39 | "root": { 40 | "inputs": { 41 | "astal": "astal", 42 | "nixpkgs": "nixpkgs" 43 | } 44 | } 45 | }, 46 | "root": "root", 47 | "version": 7 48 | } 49 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 4 | astal = { 5 | url = "github:aylur/astal"; 6 | inputs.nixpkgs.follows = "nixpkgs"; 7 | }; 8 | }; 9 | 10 | outputs = { 11 | self, 12 | nixpkgs, 13 | astal, 14 | }: let 15 | system = "x86_64-linux"; 16 | pkgs = nixpkgs.legacyPackages.${system}; 17 | in { 18 | packages.${system}.default = astal.lib.mkLuaPackage { 19 | inherit pkgs; 20 | name = "kaneru"; 21 | src = ./.; 22 | 23 | extraPackages = with astal.packages.${system}; 24 | [ 25 | battery 26 | astal3 27 | io 28 | apps 29 | bluetooth 30 | mpris 31 | network 32 | notifd 33 | powerprofiles 34 | tray 35 | wireplumber 36 | ] 37 | ++ (with pkgs; [ 38 | dart-sass 39 | inotify-tools 40 | brightnessctl 41 | gammastep 42 | wget 43 | curl 44 | fastfetch 45 | ]) 46 | ++ (with pkgs.lua52Packages; [ 47 | cjson 48 | luautf8 49 | ]); 50 | }; 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /icons/battery-powersave.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/battery-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/cpu-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icons/fan-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/github-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icons/gpu-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/memory-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icons/network-download-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/network-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/network-upload-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/storage-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/system-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/temperature-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/voltage-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /init.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local App = require("astal.gtk3.app") 3 | local Debug = require("lua.lib.debug") 4 | 5 | Debug.info("App", "Starting astal-bar") 6 | Debug.info("App", "Modules loaded successfully") 7 | 8 | local Desktop = require("lua.windows.Desktop") 9 | local Bar = require("lua.windows.Bar") 10 | local Dock = require("lua.windows.Dock") 11 | local NotificationPopups = require("lua.windows.NotificationPopups") 12 | local OSD = require("lua.windows.OSD") 13 | local src = require("lua.lib.common").src 14 | 15 | Debug.info("App", "Components loaded successfully") 16 | 17 | Debug.set_config({ 18 | log_to_file = true, 19 | log_to_console = false, 20 | max_file_size = 1024 * 1024, 21 | log_level = Debug.LEVELS.DEBUG, 22 | }) 23 | 24 | local scss = src("scss/style.scss") 25 | local css = "/tmp/style.css" 26 | 27 | astal.exec("sass " .. scss .. " " .. css) 28 | 29 | local user_vars = loadfile(src("user-variables.lua"))() 30 | local monitor_config = user_vars.monitor or { 31 | mode = "primary", 32 | specific_monitor = 1, 33 | } 34 | 35 | App:start({ 36 | instance_name = "kaneru", 37 | css = css, 38 | on_second_instance = function() 39 | Debug.warn("App", "Another instance attempted to start") 40 | end, 41 | request_handler = function(msg, res) 42 | Debug.debug("App", "Request received: %s", msg) 43 | res("ok") 44 | end, 45 | main = function() 46 | if #App.monitors == 0 then 47 | Debug.error("App", "No monitors detected") 48 | return 49 | end 50 | 51 | local function get_target_monitor() 52 | if monitor_config.mode == "specific" then 53 | local monitor = App.monitors[monitor_config.specific_monitor] 54 | if not monitor then 55 | Debug.warn("App", "Specified monitor not found, falling back to primary") 56 | return App.monitors[1] 57 | end 58 | return monitor 59 | else 60 | return App.monitors[1] 61 | end 62 | end 63 | 64 | local function create_windows(monitor) 65 | if not monitor then 66 | Debug.error("App", "Invalid monitor provided") 67 | return false 68 | end 69 | 70 | local windows = { 71 | -- desktop = Desktop(monitor), 72 | bar = Bar(monitor), 73 | dock = Dock(monitor), 74 | notifications = NotificationPopups(monitor), 75 | osd = OSD(monitor), 76 | } 77 | 78 | for name, window in pairs(windows) do 79 | if not window then 80 | Debug.error("App", "Failed to create " .. name) 81 | return false 82 | end 83 | window.gdkmonitor = monitor 84 | end 85 | 86 | return true 87 | end 88 | 89 | if monitor_config.mode == "all" then 90 | for _, monitor in ipairs(App.monitors) do 91 | if not create_windows(monitor) then 92 | Debug.error("App", "Failed to create windows for monitor") 93 | return 94 | end 95 | end 96 | else 97 | local target_monitor = get_target_monitor() 98 | if not create_windows(target_monitor) then 99 | Debug.error("App", "Failed to create windows for target monitor") 100 | return 101 | end 102 | end 103 | end, 104 | }) 105 | -------------------------------------------------------------------------------- /lua/lib/common.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Variable = require("astal").Variable 3 | local Gtk = require("astal.gtk3").Gtk 4 | local GLib = astal.require("GLib") 5 | local Debug = require("lua.lib.debug") 6 | 7 | local M = {} 8 | 9 | function M.src(path) 10 | if not path then 11 | Debug.error("Common", "No path provided for src") 12 | return nil 13 | end 14 | local str = debug.getinfo(2, "S").source:sub(2) 15 | local src = str:match("(.*/)") or str:match("(.*\\)") or "./" 16 | return src .. path 17 | end 18 | 19 | function M.map(array, func) 20 | if not array then 21 | Debug.error("Common", "Nil array passed to map") 22 | return {} 23 | end 24 | local new_arr = {} 25 | for i, v in ipairs(array) do 26 | new_arr[i] = func(v, i) 27 | end 28 | return new_arr 29 | end 30 | 31 | function M.file_exists(path) 32 | if not path then 33 | Debug.error("Common", "No path provided for file_exists") 34 | return false 35 | end 36 | return GLib.file_test(path, "EXISTS") 37 | end 38 | 39 | function M.varmap(initial) 40 | if not initial then 41 | Debug.error("Common", "No initial value provided for varmap") 42 | initial = {} 43 | end 44 | 45 | local map = initial 46 | local var = Variable({}) 47 | 48 | local function notify() 49 | local arr = {} 50 | for _, value in pairs(map) do 51 | table.insert(arr, value) 52 | end 53 | var:set(arr) 54 | end 55 | 56 | local function delete(key) 57 | if not key then 58 | Debug.error("Common", "No key provided for varmap delete") 59 | return 60 | end 61 | 62 | if Gtk.Widget:is_type_of(map[key]) then 63 | map[key]:destroy() 64 | end 65 | 66 | map[key] = nil 67 | end 68 | 69 | notify() 70 | 71 | return setmetatable({ 72 | set = function(key, value) 73 | if not key then 74 | Debug.error("Common", "No key provided for varmap set") 75 | return 76 | end 77 | delete(key) 78 | map[key] = value 79 | notify() 80 | end, 81 | delete = function(key) 82 | delete(key) 83 | notify() 84 | end, 85 | get = function() 86 | return var:get() 87 | end, 88 | subscribe = function(callback) 89 | if not callback then 90 | Debug.error("Common", "No callback provided for varmap subscribe") 91 | return nil 92 | end 93 | return var:subscribe(callback) 94 | end, 95 | drop = function() 96 | var:drop() 97 | end, 98 | }, { 99 | __call = function() 100 | return var() 101 | end, 102 | }) 103 | end 104 | 105 | function M.time(time, format) 106 | if not time then 107 | Debug.error("Common", "No time provided for formatting") 108 | return "" 109 | end 110 | format = format or "%H:%M" 111 | local success, datetime = pcall(function() 112 | return GLib.DateTime.new_from_unix_local(time):format(format) 113 | end) 114 | if not success then 115 | Debug.error("Common", "Failed to format time") 116 | return "" 117 | end 118 | return datetime 119 | end 120 | 121 | return M 122 | -------------------------------------------------------------------------------- /lua/lib/dbus.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Debug = require("lua.lib.debug") 3 | local GLib = astal.require("GLib") 4 | local Gio = astal.require("Gio") 5 | 6 | local DBus = {} 7 | 8 | local subscriptions = {} 9 | local connections = { 10 | session = nil, 11 | system = nil, 12 | } 13 | 14 | function DBus.initialize() 15 | if connections.session then 16 | return 17 | end 18 | 19 | connections.session = Gio.bus_get_sync(Gio.BusType.SESSION, nil) 20 | connections.system = Gio.bus_get_sync(Gio.BusType.SYSTEM, nil) 21 | 22 | if not connections.session or not connections.system then 23 | Debug.error("DBus", "Failed to initialize D-Bus connections") 24 | return false 25 | end 26 | 27 | return true 28 | end 29 | 30 | function DBus.cleanup() 31 | for _, sub in ipairs(subscriptions) do 32 | if sub.connection and sub.id then 33 | sub.connection:signal_unsubscribe(sub.id) 34 | end 35 | end 36 | 37 | subscriptions = {} 38 | connections.session = nil 39 | connections.system = nil 40 | end 41 | 42 | function DBus.subscribe(bus_type, sender, object_path, interface, signal, callback) 43 | if not connections.session then 44 | if not DBus.initialize() then 45 | return nil 46 | end 47 | end 48 | 49 | local connection = (bus_type == "system") and connections.system or connections.session 50 | if not connection then 51 | Debug.error("DBus", "No D-Bus connection available") 52 | return nil 53 | end 54 | 55 | local subscription_id = connection:signal_subscribe( 56 | sender, 57 | interface, 58 | signal, 59 | object_path, 60 | nil, 61 | Gio.DBusSignalFlags.NONE, 62 | function(_, _, _, _, _, parameters) 63 | local args = {} 64 | if parameters then 65 | for i = 0, parameters:get_n_children() - 1 do 66 | table.insert(args, parameters:get_child_value(i)) 67 | end 68 | end 69 | callback(table.unpack(args)) 70 | end 71 | ) 72 | 73 | if subscription_id > 0 then 74 | local sub = { 75 | connection = connection, 76 | id = subscription_id, 77 | bus_type = bus_type, 78 | sender = sender, 79 | path = object_path, 80 | interface = interface, 81 | signal = signal, 82 | } 83 | table.insert(subscriptions, sub) 84 | return sub 85 | end 86 | 87 | Debug.error("DBus", "Failed to subscribe to signal: %s, %s, %s", sender or "", interface or "", signal or "") 88 | return nil 89 | end 90 | 91 | function DBus.call(bus_type, destination, object_path, interface, method, parameters, callback) 92 | if not connections.session then 93 | if not DBus.initialize() then 94 | return false 95 | end 96 | end 97 | 98 | local connection = (bus_type == "system") and connections.system or connections.session 99 | if not connection then 100 | Debug.error("DBus", "No D-Bus connection available") 101 | return false 102 | end 103 | 104 | local params = nil 105 | if parameters then 106 | params = GLib.Variant.new_tuple(parameters, #parameters) 107 | end 108 | 109 | if callback then 110 | connection:call( 111 | destination, 112 | object_path, 113 | interface, 114 | method, 115 | params, 116 | nil, 117 | Gio.DBusCallFlags.NONE, 118 | -1, 119 | nil, 120 | function(conn, result) 121 | local success, ret = pcall(function() 122 | return conn:call_finish(result) 123 | end) 124 | 125 | if success and ret then 126 | callback(true, ret) 127 | else 128 | callback(false, ret) 129 | end 130 | end 131 | ) 132 | return true 133 | else 134 | local success, ret = pcall(function() 135 | return connection:call_sync( 136 | destination, 137 | object_path, 138 | interface, 139 | method, 140 | params, 141 | nil, 142 | Gio.DBusCallFlags.NONE, 143 | -1, 144 | nil 145 | ) 146 | end) 147 | 148 | if success and ret then 149 | return true, ret 150 | else 151 | Debug.error( 152 | "DBus", 153 | "Call failed: %s, %s, %s.%s: %s", 154 | destination or "", 155 | object_path or "", 156 | interface or "", 157 | method or "", 158 | tostring(ret) 159 | ) 160 | return false, ret 161 | end 162 | end 163 | end 164 | 165 | function DBus.watch_name(bus_type, name, callback) 166 | if not connections.session then 167 | if not DBus.initialize() then 168 | return nil 169 | end 170 | end 171 | 172 | local connection = (bus_type == "system") and connections.system or connections.session 173 | if not connection then 174 | Debug.error("DBus", "No D-Bus connection available") 175 | return nil 176 | end 177 | 178 | local watcher_id = Gio.bus_watch_name_on_connection(connection, name, Gio.BusNameWatcherFlags.NONE, function() 179 | callback(true) 180 | end, function() 181 | callback(false) 182 | end) 183 | 184 | if watcher_id > 0 then 185 | return { 186 | unwatch = function() 187 | Gio.bus_unwatch_name(watcher_id) 188 | end, 189 | } 190 | end 191 | 192 | return nil 193 | end 194 | 195 | return DBus 196 | -------------------------------------------------------------------------------- /lua/lib/debug.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local GLib = astal.require("GLib") 3 | 4 | local Debug = {} 5 | 6 | local LOG_DIR = GLib.get_user_data_dir() .. "/astal" 7 | local LOG_FILE = LOG_DIR .. "/debug.log" 8 | 9 | local COLORS = { 10 | RED = "\27[31m", 11 | YELLOW = "\27[33m", 12 | BLUE = "\27[34m", 13 | GRAY = "\27[90m", 14 | RESET = "\27[0m", 15 | } 16 | 17 | Debug.LEVELS = { 18 | ERROR = "ERROR", 19 | WARN = "WARN", 20 | INFO = "INFO", 21 | DEBUG = "DEBUG", 22 | } 23 | 24 | local LEVEL_COLORS = { 25 | ERROR = COLORS.RED, 26 | WARN = COLORS.YELLOW, 27 | INFO = COLORS.BLUE, 28 | DEBUG = COLORS.GRAY, 29 | } 30 | 31 | local config = { 32 | log_to_file = true, 33 | log_to_console = false, 34 | max_file_size = 1024 * 1024, 35 | log_level = Debug.LEVELS.DEBUG, 36 | } 37 | 38 | if not GLib.file_test(LOG_DIR, "EXISTS") then 39 | astal.exec({ "mkdir", "-p", LOG_DIR }) 40 | end 41 | 42 | local function get_timestamp() 43 | local now = GLib.DateTime.new_now_local() 44 | return now:format("%Y-%m-%d %H:%M:%S") 45 | end 46 | 47 | local function format_message(level, module, message, ...) 48 | local formatted_msg = string.format(message, ...) 49 | local color = LEVEL_COLORS[level] or "" 50 | return string.format("%s[%s] [%s] [%s] %s%s\n", color, get_timestamp(), level, module, formatted_msg, COLORS.RESET) 51 | end 52 | 53 | local function write_to_file(msg) 54 | if not config.log_to_file then 55 | return 56 | end 57 | 58 | local file = io.open(LOG_FILE, "a") 59 | if file then 60 | file:write(msg) 61 | file:close() 62 | else 63 | io.stderr:write("Failed to open log file for writing\n") 64 | end 65 | end 66 | 67 | local function log(level, module, message, ...) 68 | local msg = format_message(level, module, message, ...) 69 | 70 | if config.log_to_console then 71 | io.stdout:write(msg) 72 | io.stdout:flush() 73 | end 74 | 75 | write_to_file(msg) 76 | end 77 | 78 | function Debug.error(module, message, ...) 79 | log(Debug.LEVELS.ERROR, module, message, ...) 80 | end 81 | 82 | function Debug.warn(module, message, ...) 83 | log(Debug.LEVELS.WARN, module, message, ...) 84 | end 85 | 86 | function Debug.info(module, message, ...) 87 | log(Debug.LEVELS.INFO, module, message, ...) 88 | end 89 | 90 | function Debug.debug(module, message, ...) 91 | log(Debug.LEVELS.DEBUG, module, message, ...) 92 | end 93 | 94 | function Debug.set_config(new_config) 95 | for k, v in pairs(new_config) do 96 | config[k] = v 97 | end 98 | end 99 | 100 | function Debug.get_config() 101 | return config 102 | end 103 | 104 | return Debug 105 | -------------------------------------------------------------------------------- /lua/lib/display.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Variable = astal.Variable 3 | local exec = astal.exec 4 | local GLib = astal.require("GLib") 5 | local Debug = require("lua.lib.debug") 6 | 7 | local Display = {} 8 | 9 | local STATE_FILE = GLib.get_user_cache_dir() .. "/astal/display.json" 10 | local DEFAULT_STATE = { 11 | enabled = false, 12 | temp = 3500, 13 | } 14 | 15 | local function load_state() 16 | local file = io.open(STATE_FILE, "r") 17 | if not file then 18 | return DEFAULT_STATE 19 | end 20 | 21 | local content = file:read("*all") 22 | file:close() 23 | 24 | local success, data = pcall(astal.json_decode, content) 25 | return success and data or DEFAULT_STATE 26 | end 27 | 28 | local function save_state(enabled, temp) 29 | os.execute("mkdir -p " .. GLib.get_user_cache_dir() .. "/astal") 30 | local file = io.open(STATE_FILE, "w") 31 | if not file then 32 | Debug.error("Display", "Failed to open state file for writing") 33 | return false 34 | end 35 | 36 | local data = { 37 | enabled = enabled and true or false, 38 | temp = tonumber(temp) or 3500, 39 | } 40 | 41 | local success, result = pcall(function() 42 | return string.format('{"enabled":%s,"temp":%d}', data.enabled and "true" or "false", data.temp) 43 | end) 44 | 45 | if not success then 46 | Debug.error("Display", "Failed to format state data: " .. tostring(result)) 47 | file:close() 48 | return false 49 | end 50 | 51 | file:write(result) 52 | file:close() 53 | 54 | return true 55 | end 56 | 57 | function Display:init_night_light_state() 58 | local function check_gammastep_status() 59 | local success, ps_out = pcall(exec, "pgrep gammastep") 60 | if success and ps_out and ps_out ~= "" then 61 | local _, cmdline = pcall(exec, "ps -o args= -p " .. ps_out:gsub("%s+", "")) 62 | local temp = cmdline and cmdline:match("-O%s*(%d+)") 63 | 64 | if temp then 65 | temp = tonumber(temp) 66 | self.actual_temp = temp 67 | local normalized = (temp - 2500) / 4000 68 | self.night_light_temp:set(normalized) 69 | self.night_light_enabled:set(true) 70 | return true 71 | end 72 | end 73 | self.night_light_enabled:set(false) 74 | return false 75 | end 76 | 77 | pcall(check_gammastep_status) 78 | end 79 | 80 | function Display:New() 81 | local stored_state = load_state() 82 | local initial_temp = stored_state.temp or 3500 83 | local normalized_temp = (initial_temp - 2500) / 4000 84 | 85 | local instance = { 86 | brightness = Variable.new(tonumber(exec("brightnessctl get")) / 255 or 0.75), 87 | night_light_enabled = Variable.new(stored_state.enabled or false), 88 | night_light_temp = Variable.new(normalized_temp), 89 | actual_temp = initial_temp, 90 | update_timeout = nil, 91 | initialized = false, 92 | } 93 | setmetatable(instance, self) 94 | self.__index = self 95 | 96 | instance.night_light_enabled:subscribe(function(enabled) 97 | save_state(enabled == true, math.floor(instance.actual_temp)) 98 | end) 99 | 100 | instance.night_light_temp:subscribe(function() 101 | save_state(instance.night_light_enabled:get() == true, math.floor(instance.actual_temp)) 102 | end) 103 | 104 | instance:init_night_light_state() 105 | instance.initialized = true 106 | 107 | if stored_state.enabled then 108 | instance:apply_night_light() 109 | end 110 | 111 | return instance 112 | end 113 | 114 | function Display:set_brightness(value) 115 | if value < 0 or value > 1 then 116 | Debug.error("Display", "Invalid brightness value: %f", value) 117 | return false 118 | end 119 | 120 | local percentage = math.floor(value * 100) 121 | local _, err = exec(string.format("brightnessctl set %d%%", percentage)) 122 | if err then 123 | Debug.error("Display", "Failed to set brightness: %s", err) 124 | return false 125 | end 126 | 127 | self.brightness:set(value) 128 | return true 129 | end 130 | 131 | function Display:apply_night_light() 132 | if not self.initialized then 133 | return 134 | end 135 | 136 | local proc_success, ps_out = pcall(exec, "pgrep gammastep") 137 | if proc_success and ps_out and ps_out ~= "" then 138 | local _, cmdline = pcall(exec, "ps -o args= -p " .. ps_out:gsub("%s+", "")) 139 | local current_temp = cmdline and cmdline:match("-O%s*(%d+)") 140 | 141 | if current_temp and tonumber(current_temp) == self.actual_temp then 142 | self.night_light_enabled:set(true) 143 | return 144 | end 145 | end 146 | 147 | self.actual_temp = math.floor(2500 + (self.night_light_temp:get() * 4000)) 148 | 149 | if proc_success and ps_out and ps_out ~= "" then 150 | local kill_success, _ = pcall(exec, "pkill gammastep") 151 | if not kill_success then 152 | Debug.error("Display", "Failed to kill existing gammastep process") 153 | end 154 | GLib.usleep(100000) 155 | end 156 | 157 | local spawn_success, _, _, stderr = pcall(function() 158 | return GLib.spawn_command_line_async(string.format("gammastep -O %d", self.actual_temp)) 159 | end) 160 | 161 | if not spawn_success or stderr then 162 | Debug.error("Display", "Failed to start gammastep: %s", stderr or "unknown error") 163 | self.night_light_enabled:set(false) 164 | else 165 | self.night_light_enabled:set(true) 166 | save_state(true, self.actual_temp) 167 | end 168 | end 169 | 170 | function Display:toggle_night_light() 171 | if not self.initialized then 172 | Debug.error("Display", "Display not properly initialized") 173 | return 174 | end 175 | 176 | local new_state = not self.night_light_enabled:get() 177 | 178 | if new_state then 179 | self:apply_night_light() 180 | else 181 | local kill_success, _ = pcall(exec, "pkill gammastep") 182 | if not kill_success then 183 | Debug.error("Display", "Failed to kill gammastep process") 184 | end 185 | 186 | GLib.usleep(100000) 187 | 188 | local reset_success, _ = pcall(function() 189 | return GLib.spawn_command_line_async("gammastep -x") 190 | end) 191 | 192 | if not reset_success then 193 | Debug.error("Display", "Failed to reset gammastep") 194 | end 195 | 196 | self.night_light_enabled:set(false) 197 | save_state(false, self.actual_temp) 198 | end 199 | end 200 | 201 | function Display:set_night_light_temp(value) 202 | if value < 0 or value > 1 then 203 | Debug.error("Display", "Invalid temperature value: %f", value) 204 | return false 205 | end 206 | 207 | self.night_light_temp:set(value) 208 | 209 | if self.night_light_enabled:get() then 210 | if self.update_timeout then 211 | GLib.source_remove(self.update_timeout) 212 | self.update_timeout = nil 213 | end 214 | 215 | self.update_timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 150, function() 216 | self:apply_night_light() 217 | self.update_timeout = nil 218 | return GLib.SOURCE_REMOVE 219 | end) 220 | end 221 | 222 | return true 223 | end 224 | 225 | function Display:cleanup() 226 | if self.update_timeout then 227 | GLib.source_remove(self.update_timeout) 228 | self.update_timeout = nil 229 | end 230 | 231 | if self.night_light_enabled then 232 | save_state(self.night_light_enabled:get() == true, math.floor(self.actual_temp)) 233 | end 234 | 235 | if self.brightness then 236 | self.brightness:drop() 237 | self.brightness = nil 238 | end 239 | if self.night_light_enabled then 240 | self.night_light_enabled:drop() 241 | self.night_light_enabled = nil 242 | end 243 | if self.night_light_temp then 244 | self.night_light_temp:drop() 245 | self.night_light_temp = nil 246 | end 247 | 248 | self.initialized = false 249 | end 250 | 251 | local instance = nil 252 | function Display.get_default() 253 | if not instance then 254 | instance = Display:New() 255 | end 256 | return instance 257 | end 258 | 259 | function Display.cleanup_singleton() 260 | if instance then 261 | instance:cleanup() 262 | instance = nil 263 | end 264 | end 265 | 266 | return Display 267 | -------------------------------------------------------------------------------- /lua/lib/dock-config.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Apps = astal.require("AstalApps") 3 | local Debug = require("lua.lib.debug") 4 | local State = require("lua.lib.state") 5 | local Niri = require("lua.lib.niri") 6 | 7 | local M = {} 8 | local apps = Apps.Apps.new() 9 | if not apps then 10 | Debug.error("DockConfig", "Failed to initialize Apps service") 11 | end 12 | 13 | M.pinned_apps = {} 14 | M.running_apps = {} 15 | local window_callback = nil 16 | local is_initialized = false 17 | 18 | local config_path = debug.getinfo(1).source:match("@?(.*/)") .. "../../user-variables.lua" 19 | local user_vars = {} 20 | local success, loaded_vars = pcall(loadfile, config_path) 21 | if success and loaded_vars then 22 | user_vars = loaded_vars() or {} 23 | end 24 | 25 | local default_pinned_apps = { "firefox", "kitty" } 26 | local configured_pinned_apps = (user_vars.dock and user_vars.dock.pinned_apps) or default_pinned_apps 27 | 28 | local function find_desktop_entry(name) 29 | if not apps then 30 | Debug.error("DockConfig", "Apps service not available") 31 | return nil 32 | end 33 | 34 | local app_list = apps:get_list() 35 | if not app_list then 36 | Debug.error("DockConfig", "Failed to get application list") 37 | return nil 38 | end 39 | 40 | for _, app in ipairs(app_list) do 41 | if 42 | app 43 | and app.entry 44 | and (app.name and app.name:lower():match(name:lower()) or app.entry:lower():match(name:lower())) 45 | then 46 | return app.entry 47 | end 48 | end 49 | return nil 50 | end 51 | 52 | local function safe_set_state(name, value) 53 | if State.get(name) then 54 | State.set(name, value) 55 | end 56 | end 57 | 58 | local function update_running_apps(window_state) 59 | local running = {} 60 | local app_list = apps:get_list() 61 | 62 | if not app_list then 63 | return 64 | end 65 | 66 | for _, app in ipairs(app_list) do 67 | if app and app.entry then 68 | for app_id in pairs(window_state) do 69 | if 70 | app.entry:lower():match(app_id:lower()) 71 | or (app.wm_class and app.wm_class:lower():match(app_id:lower())) 72 | then 73 | running[app.entry] = true 74 | break 75 | end 76 | end 77 | end 78 | end 79 | 80 | M.running_apps = running 81 | safe_set_state("dock_running_apps", running) 82 | end 83 | 84 | function M.initialize_pinned_apps(pinned_apps) 85 | M.pinned_apps = {} 86 | local apps_to_check = pinned_apps or configured_pinned_apps 87 | 88 | if not apps_to_check then 89 | Debug.error("DockConfig", "No pinned apps configuration found") 90 | return 91 | end 92 | 93 | for _, name in ipairs(apps_to_check) do 94 | local desktop_entry = find_desktop_entry(name) 95 | if desktop_entry then 96 | table.insert(M.pinned_apps, desktop_entry) 97 | end 98 | end 99 | 100 | safe_set_state("dock_pinned_apps", M.pinned_apps) 101 | end 102 | 103 | function M.is_running(desktop_entry) 104 | return M.running_apps[desktop_entry] or false 105 | end 106 | 107 | function M.is_pinned(desktop_entry) 108 | for _, entry in ipairs(M.pinned_apps) do 109 | if entry == desktop_entry then 110 | return true 111 | end 112 | end 113 | return false 114 | end 115 | 116 | function M.setup_listeners() 117 | if window_callback then 118 | return 119 | end 120 | window_callback = Niri.register_window_state_callback(update_running_apps) 121 | end 122 | 123 | function M.init() 124 | if is_initialized then 125 | return 126 | end 127 | 128 | M.initialize_pinned_apps() 129 | M.setup_listeners() 130 | 131 | local initial_windows = Niri.get_all_windows() 132 | local initial_state = {} 133 | for _, window in ipairs(initial_windows) do 134 | if window.app_id then 135 | initial_state[window.app_id] = true 136 | end 137 | end 138 | update_running_apps(initial_state) 139 | 140 | is_initialized = true 141 | end 142 | 143 | function M.cleanup() 144 | if window_callback then 145 | window_callback.unregister() 146 | window_callback = nil 147 | end 148 | 149 | M.running_apps = {} 150 | M.pinned_apps = {} 151 | is_initialized = false 152 | end 153 | 154 | astal.monitor_file(config_path, function() 155 | local new_config = loadfile(config_path)() 156 | if new_config and new_config.dock and new_config.dock.pinned_apps then 157 | M.initialize_pinned_apps(new_config.dock.pinned_apps) 158 | end 159 | end) 160 | 161 | return M 162 | -------------------------------------------------------------------------------- /lua/lib/github.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local cjson = require("cjson") 3 | local GLib = astal.require("GLib") 4 | local Debug = require("lua.lib.debug") 5 | 6 | local CACHE_DIR = GLib.get_user_cache_dir() .. "/astal" 7 | local CACHE_FILE = CACHE_DIR .. "/github-events.json" 8 | local CACHE_MAX_SIZE = 1024 * 1024 9 | local MAX_RETRIES = 3 10 | local RETRY_DELAY = 10 11 | local RATE_CHECK_INTERVAL = 900 12 | local RATE_LIMIT_COOLDOWN = 3600 13 | 14 | local state = { 15 | rate = { 16 | last_check = 0, 17 | status = true, 18 | remaining = 60, 19 | error_time = 0, 20 | }, 21 | cache = { 22 | last_cleanup = 0, 23 | last_viewed = 0, 24 | loaded = false, 25 | data = nil, 26 | timestamp = 0, 27 | }, 28 | } 29 | 30 | local Github = {} 31 | 32 | local config_path = debug.getinfo(1).source:match("@?(.*/)") .. "../../user-variables.lua" 33 | local user_vars = loadfile(config_path)() 34 | 35 | local function ensure_cache_dir() 36 | if not GLib.file_test(CACHE_DIR, "EXISTS") then 37 | GLib.mkdir_with_parents(CACHE_DIR, 0755) 38 | end 39 | end 40 | 41 | local function cleanup_cache() 42 | local current_time = os.time() 43 | if current_time - state.cache.last_cleanup < 3600 then 44 | return 45 | end 46 | 47 | if GLib.file_test(CACHE_FILE, "EXISTS") then 48 | local size = GLib.file_get_contents(CACHE_FILE) 49 | if size and #size > CACHE_MAX_SIZE then 50 | os.remove(CACHE_FILE) 51 | end 52 | end 53 | 54 | state.cache.last_cleanup = current_time 55 | end 56 | 57 | local function execute_curl(cmd) 58 | local handle = io.popen(cmd) 59 | if not handle then 60 | return nil 61 | end 62 | local result = handle:read("*a") 63 | handle:close() 64 | return result 65 | end 66 | 67 | local function load_cache() 68 | if state.cache.loaded then 69 | return state.cache.data, state.cache.timestamp 70 | end 71 | 72 | Debug.debug("GitHub", "Loading cached events") 73 | if not GLib.file_test(CACHE_FILE, "EXISTS") then 74 | state.cache.loaded = true 75 | return nil, 0 76 | end 77 | 78 | local content = astal.read_file(CACHE_FILE) 79 | if not content then 80 | state.cache.loaded = true 81 | return nil, 0 82 | end 83 | 84 | local decoded_ok, cache = pcall(cjson.decode, content) 85 | if not decoded_ok or type(cache) ~= "table" then 86 | state.cache.loaded = true 87 | return nil, 0 88 | end 89 | 90 | state.cache.data = cache.events 91 | state.cache.timestamp = cache.last_update or 0 92 | state.cache.loaded = true 93 | 94 | return state.cache.data, state.cache.timestamp 95 | end 96 | 97 | local function save_cache(events) 98 | if not events or type(events) ~= "table" then 99 | return false 100 | end 101 | ensure_cache_dir() 102 | cleanup_cache() 103 | 104 | local cache = { 105 | last_update = os.time(), 106 | events = events, 107 | } 108 | 109 | local encoded_ok, encoded = pcall(cjson.encode, cache) 110 | if not encoded_ok then 111 | return false 112 | end 113 | 114 | local temp_file = CACHE_FILE .. ".tmp" 115 | if not pcall(function() 116 | astal.write_file(temp_file, encoded) 117 | end) then 118 | pcall(os.remove, temp_file) 119 | return false 120 | end 121 | 122 | if not pcall(function() 123 | os.rename(temp_file, CACHE_FILE) 124 | end) then 125 | pcall(os.remove, temp_file) 126 | return false 127 | end 128 | 129 | state.cache.data = events 130 | state.cache.timestamp = cache.last_update 131 | 132 | return true 133 | end 134 | 135 | local function check_rate_limit() 136 | local current_time = os.time() 137 | 138 | if current_time - state.rate.error_time < RATE_LIMIT_COOLDOWN then 139 | Debug.debug("GitHub", "In rate limit cooldown period") 140 | return false 141 | end 142 | 143 | if current_time - state.rate.last_check < RATE_CHECK_INTERVAL then 144 | Debug.debug("GitHub", "Using cached rate limit status (remaining: %d)", state.rate.remaining) 145 | return state.rate.status 146 | end 147 | 148 | local rate_limit_check = execute_curl(table.concat({ 149 | "curl -s --connect-timeout 3 --max-time 5", 150 | "https://api.github.com/rate_limit", 151 | "-H 'Accept: application/vnd.github+json'", 152 | }, " ")) 153 | 154 | if not rate_limit_check then 155 | Debug.warn("GitHub", "Rate limit check failed") 156 | state.rate.error_time = current_time 157 | return false 158 | end 159 | 160 | local rate_ok, rate_data = pcall(cjson.decode, rate_limit_check) 161 | if not rate_ok or not rate_data or not rate_data.resources or not rate_data.resources.core then 162 | Debug.warn("GitHub", "Invalid rate limit response") 163 | state.rate.error_time = current_time 164 | return false 165 | end 166 | 167 | local remaining = rate_data.resources.core.remaining 168 | state.rate.remaining = remaining 169 | state.rate.last_check = current_time 170 | 171 | if remaining < 10 then 172 | Debug.warn("GitHub", "Rate limit low") 173 | state.rate.error_time = current_time 174 | state.rate.status = false 175 | return false 176 | end 177 | 178 | state.rate.status = true 179 | return true 180 | end 181 | 182 | local function fetch_github_events(username, attempt) 183 | attempt = attempt or 1 184 | if attempt > MAX_RETRIES then 185 | Debug.warn("GitHub", "Max retries reached") 186 | return nil, "max_retries" 187 | end 188 | 189 | if not check_rate_limit() then 190 | Debug.warn("GitHub", "Rate limit exceeded") 191 | return nil, "rate_limit" 192 | end 193 | 194 | local url = string.format("https://api.github.com/users/%s/received_events", username) 195 | local output = execute_curl(table.concat({ 196 | "curl -s -S --connect-timeout 3 --max-time 5 --compressed", 197 | "-H 'Accept: application/json'", 198 | "-H 'Accept-Encoding: gzip, deflate, br'", 199 | "-H 'User-Agent: astal-bar'", 200 | "-H 'Connection: keep-alive'", 201 | "'" .. url .. "'", 202 | }, " ")) 203 | 204 | if not output then 205 | if attempt < MAX_RETRIES then 206 | astal.sleep(RETRY_DELAY * (2 ^ (attempt - 1))) 207 | return fetch_github_events(username, attempt + 1) 208 | end 209 | return nil, "network_error" 210 | end 211 | 212 | local parse_ok, events = pcall(cjson.decode, output) 213 | if not parse_ok or type(events) ~= "table" then 214 | Debug.error("GitHub", "Parse error: %s", parse_ok and "invalid format" or events) 215 | return nil, "parse_error" 216 | end 217 | 218 | return events 219 | end 220 | 221 | function Github.get_events() 222 | return load_cache() 223 | end 224 | 225 | function Github.update_events_async(callback) 226 | GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, function() 227 | local events, timestamp = Github.update_events() 228 | if callback then 229 | callback(events, timestamp) 230 | end 231 | return false 232 | end) 233 | end 234 | 235 | function Github.update_events() 236 | Debug.debug("GitHub", "Fetching new GitHub events") 237 | 238 | if os.time() - state.rate.error_time < RATE_LIMIT_COOLDOWN then 239 | Debug.warn("GitHub", "In rate limit cooldown") 240 | return load_cache() 241 | end 242 | 243 | local username = user_vars.github and user_vars.github.username or "linuxmobile" 244 | local events = fetch_github_events(username) 245 | local current_time = os.time() 246 | 247 | if events and #events > 0 then 248 | save_cache(events) 249 | return events, current_time 250 | end 251 | 252 | return load_cache() 253 | end 254 | 255 | function Github.get_last_update_time() 256 | if state.cache.loaded then 257 | return state.cache.timestamp 258 | end 259 | local _, timestamp = load_cache() 260 | return timestamp 261 | end 262 | 263 | function Github.mark_viewed() 264 | state.cache.last_viewed = os.time() 265 | end 266 | 267 | function Github.format_time(iso_time) 268 | if not iso_time then 269 | return "unknown time" 270 | end 271 | 272 | local timestamp = GLib.DateTime.new_from_iso8601(iso_time, nil) 273 | if not timestamp then 274 | return "invalid time" 275 | end 276 | 277 | local diff = GLib.DateTime.new_now_local():difference(timestamp) / 1000000 278 | 279 | if diff < 60 then 280 | return "just now" 281 | elseif diff < 3600 then 282 | return string.format("%d minutes ago", math.floor(diff / 60)) 283 | elseif diff < 86400 then 284 | return string.format("%d hours ago", math.floor(diff / 3600)) 285 | else 286 | return string.format("%d days ago", math.floor(diff / 86400)) 287 | end 288 | end 289 | 290 | function Github.format_last_update(timestamp) 291 | if not timestamp or timestamp == 0 then 292 | return "Never updated" 293 | end 294 | 295 | local diff = os.time() - timestamp 296 | 297 | if diff < 60 then 298 | return "Updated just now" 299 | elseif diff < 3600 then 300 | return string.format("Updated %d minutes ago", math.floor(diff / 60)) 301 | elseif diff < 86400 then 302 | return string.format("Updated %d hours ago", math.floor(diff / 3600)) 303 | else 304 | return string.format("Updated %d days ago", math.floor(diff / 86400)) 305 | end 306 | end 307 | 308 | load_cache() 309 | 310 | return Github 311 | -------------------------------------------------------------------------------- /lua/lib/profile.lua: -------------------------------------------------------------------------------- 1 | local clock = os.clock 2 | 3 | --- The "profile" module controls when to start or stop collecting data and can be used to generate reports. 4 | -- @module profile 5 | -- @alias profile 6 | local profile = {} 7 | 8 | -- function labels 9 | local _labeled = {} 10 | -- function definitions 11 | local _defined = {} 12 | -- time of last call 13 | local _tcalled = {} 14 | -- total execution time 15 | local _telapsed = {} 16 | -- number of calls 17 | local _ncalls = {} 18 | -- list of internal profiler functions 19 | local _internal = {} 20 | 21 | --- This is an internal function. 22 | -- @tparam string event Event type 23 | -- @tparam number line Line number 24 | -- @tparam[opt] table info Debug info table 25 | function profile.hooker(event, line, info) 26 | info = info or debug.getinfo(2, "fnS") 27 | local f = info.func 28 | -- ignore the profiler itself 29 | if _internal[f] or info.what ~= "Lua" then 30 | return 31 | end 32 | -- get the function name if available 33 | if info.name then 34 | _labeled[f] = info.name 35 | end 36 | -- find the line definition 37 | if not _defined[f] then 38 | _defined[f] = info.short_src .. ":" .. info.linedefined 39 | _ncalls[f] = 0 40 | _telapsed[f] = 0 41 | end 42 | if _tcalled[f] then 43 | local dt = clock() - _tcalled[f] 44 | _telapsed[f] = _telapsed[f] + dt 45 | _tcalled[f] = nil 46 | end 47 | if event == "tail call" then 48 | local prev = debug.getinfo(3, "fnS") 49 | profile.hooker("return", line, prev) 50 | profile.hooker("call", line, info) 51 | elseif event == "call" then 52 | _tcalled[f] = clock() 53 | else 54 | _ncalls[f] = _ncalls[f] + 1 55 | end 56 | end 57 | 58 | --- Sets a clock function to be used by the profiler. 59 | -- @tparam function func Clock function that returns a number 60 | function profile.setclock(f) 61 | assert(type(f) == "function", "clock must be a function") 62 | clock = f 63 | end 64 | 65 | --- Starts collecting data. 66 | function profile.start() 67 | if rawget(_G, "jit") then 68 | jit.off() 69 | jit.flush() 70 | end 71 | debug.sethook(profile.hooker, "cr") 72 | end 73 | 74 | --- Stops collecting data. 75 | function profile.stop() 76 | debug.sethook() 77 | for f in pairs(_tcalled) do 78 | local dt = clock() - _tcalled[f] 79 | _telapsed[f] = _telapsed[f] + dt 80 | _tcalled[f] = nil 81 | end 82 | -- merge closures 83 | local lookup = {} 84 | for f, d in pairs(_defined) do 85 | local id = (_labeled[f] or "?") .. d 86 | local f2 = lookup[id] 87 | if f2 then 88 | _ncalls[f2] = _ncalls[f2] + (_ncalls[f] or 0) 89 | _telapsed[f2] = _telapsed[f2] + (_telapsed[f] or 0) 90 | _defined[f], _labeled[f] = nil, nil 91 | _ncalls[f], _telapsed[f] = nil, nil 92 | else 93 | lookup[id] = f 94 | end 95 | end 96 | collectgarbage("collect") 97 | end 98 | 99 | --- Resets all collected data. 100 | function profile.reset() 101 | for f in pairs(_ncalls) do 102 | _ncalls[f] = 0 103 | end 104 | for f in pairs(_telapsed) do 105 | _telapsed[f] = 0 106 | end 107 | for f in pairs(_tcalled) do 108 | _tcalled[f] = nil 109 | end 110 | collectgarbage("collect") 111 | end 112 | 113 | --- This is an internal function. 114 | -- @tparam function a First function 115 | -- @tparam function b Second function 116 | -- @treturn boolean True if "a" should rank higher than "b" 117 | function profile.comp(a, b) 118 | local dt = _telapsed[b] - _telapsed[a] 119 | if dt == 0 then 120 | return _ncalls[b] < _ncalls[a] 121 | end 122 | return dt < 0 123 | end 124 | 125 | --- Generates a report of functions that have been called since the profile was started. 126 | -- Returns the report as a numeric table of rows containing the rank, function label, number of calls, total execution time and source code line number. 127 | -- @tparam[opt] number limit Maximum number of rows 128 | -- @treturn table Table of rows 129 | function profile.query(limit) 130 | local t = {} 131 | for f, n in pairs(_ncalls) do 132 | if n > 0 then 133 | t[#t + 1] = f 134 | end 135 | end 136 | table.sort(t, profile.comp) 137 | if limit then 138 | while #t > limit do 139 | table.remove(t) 140 | end 141 | end 142 | for i, f in ipairs(t) do 143 | local dt = 0 144 | if _tcalled[f] then 145 | dt = clock() - _tcalled[f] 146 | end 147 | t[i] = { i, _labeled[f] or "?", _ncalls[f], _telapsed[f] + dt, _defined[f] } 148 | end 149 | return t 150 | end 151 | 152 | local cols = { 3, 29, 11, 24, 32 } 153 | 154 | --- Generates a text report of functions that have been called since the profile was started. 155 | -- Returns the report as a string that can be printed to the console. 156 | -- @tparam[opt] number limit Maximum number of rows 157 | -- @treturn string Text-based profiling report 158 | function profile.report(n) 159 | local out = {} 160 | local report = profile.query(n) 161 | for i, row in ipairs(report) do 162 | for j = 1, 5 do 163 | local s = row[j] 164 | local l2 = cols[j] 165 | s = tostring(s) 166 | local l1 = s:len() 167 | if l1 < l2 then 168 | s = s .. (" "):rep(l2 - l1) 169 | elseif l1 > l2 then 170 | s = s:sub(l1 - l2 + 1, l1) 171 | end 172 | row[j] = s 173 | end 174 | out[i] = table.concat(row, " | ") 175 | end 176 | 177 | local row = 178 | " +-----+-------------------------------+-------------+--------------------------+----------------------------------+ \n" 179 | local col = 180 | " | # | Function | Calls | Time | Code | \n" 181 | local sz = row .. col .. row 182 | if #out > 0 then 183 | sz = sz .. " | " .. table.concat(out, " | \n | ") .. " | \n" 184 | end 185 | return "\n" .. sz .. row 186 | end 187 | 188 | -- store all internal profiler functions 189 | for _, v in pairs(profile) do 190 | if type(v) == "function" then 191 | _internal[v] = true 192 | end 193 | end 194 | 195 | return profile 196 | -------------------------------------------------------------------------------- /lua/lib/profiler.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local profile = require("lua.lib.profile") 3 | local Debug = require("lua.lib.debug") 4 | local GLib = astal.require("GLib") 5 | 6 | local Profiler = {} 7 | local is_profiling = false 8 | local auto_save_timer 9 | local components_data = {} 10 | 11 | local HOME = os.getenv("HOME") 12 | local PROFILE_DIR = HOME .. "/.local/share/astal/profiler" 13 | 14 | if not astal.file_exists(PROFILE_DIR) then 15 | os.execute("mkdir -p " .. PROFILE_DIR) 16 | end 17 | 18 | local config = { 19 | enabled = false, 20 | auto_save = true, 21 | save_interval = 300, 22 | report_limit = 30, 23 | reset_after_save = true, 24 | component_reports = true, 25 | output_dir = PROFILE_DIR, 26 | } 27 | 28 | function Profiler.configure(options) 29 | if not options then 30 | return 31 | end 32 | 33 | for k, v in pairs(options) do 34 | if config[k] ~= nil then 35 | config[k] = v 36 | end 37 | end 38 | 39 | if not astal.file_exists(config.output_dir) then 40 | os.execute("mkdir -p " .. config.output_dir) 41 | end 42 | end 43 | 44 | function Profiler.load_config() 45 | local user_vars = require("user-variables") 46 | if user_vars.profiling then 47 | Profiler.configure(user_vars.profiling) 48 | end 49 | return config.enabled 50 | end 51 | 52 | function Profiler.start() 53 | if is_profiling then 54 | return 55 | end 56 | 57 | is_profiling = true 58 | profile.reset() 59 | profile.start() 60 | Debug.info("Profiler", "Profiling started") 61 | 62 | if config.auto_save and not auto_save_timer then 63 | auto_save_timer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, config.save_interval, function() 64 | Profiler.save_report() 65 | if config.reset_after_save then 66 | profile.reset() 67 | end 68 | return true 69 | end) 70 | end 71 | 72 | return true 73 | end 74 | 75 | function Profiler.stop() 76 | if not is_profiling then 77 | return 78 | end 79 | 80 | is_profiling = false 81 | profile.stop() 82 | Debug.info("Profiler", "Profiling stopped") 83 | 84 | if auto_save_timer then 85 | GLib.source_remove(auto_save_timer) 86 | auto_save_timer = nil 87 | end 88 | 89 | return true 90 | end 91 | 92 | function Profiler.reset() 93 | profile.reset() 94 | Debug.info("Profiler", "Profiler data reset") 95 | return true 96 | end 97 | 98 | function Profiler.save_report(filename, limit) 99 | if not is_profiling then 100 | return nil 101 | end 102 | 103 | limit = limit or config.report_limit 104 | local timestamp = os.date("%Y%m%d-%H%M%S") 105 | 106 | if not filename then 107 | filename = config.output_dir .. "/profile-" .. timestamp .. ".txt" 108 | end 109 | 110 | local report = profile.report(limit) 111 | if not report then 112 | Debug.error("Profiler", "No profiling data to save") 113 | return nil 114 | end 115 | 116 | local file = io.open(filename, "w") 117 | if not file then 118 | Debug.error("Profiler", "Failed to create profiling report file: " .. filename) 119 | return nil 120 | end 121 | 122 | file:write(report) 123 | file:close() 124 | 125 | Debug.info("Profiler", "Profiling report saved to " .. filename) 126 | return filename 127 | end 128 | 129 | function Profiler.component_start(component_name) 130 | if not is_profiling or not config.component_reports then 131 | return 132 | end 133 | 134 | components_data[component_name] = components_data[component_name] or {} 135 | components_data[component_name].start_time = os.clock() 136 | return true 137 | end 138 | 139 | function Profiler.component_stop(component_name) 140 | if not is_profiling or not config.component_reports then 141 | return 142 | end 143 | 144 | local comp_data = components_data[component_name] 145 | if not comp_data or not comp_data.start_time then 146 | return 147 | end 148 | 149 | local elapsed = os.clock() - comp_data.start_time 150 | comp_data.executions = (comp_data.executions or 0) + 1 151 | comp_data.total_time = (comp_data.total_time or 0) + elapsed 152 | comp_data.avg_time = comp_data.total_time / comp_data.executions 153 | 154 | Debug.debug("Profiler", "Component '" .. component_name .. "': " .. string.format("%0.6f", elapsed) .. " sec") 155 | return elapsed 156 | end 157 | 158 | function Profiler.wrap(func, name) 159 | if not func or type(func) ~= "function" then 160 | return func 161 | end 162 | 163 | name = name or debug.getinfo(func, "n").name or "anonymous" 164 | 165 | return function(...) 166 | if not is_profiling then 167 | return func(...) 168 | end 169 | 170 | local args = { ... } 171 | Profiler.component_start(name) 172 | 173 | local results = { xpcall(function() 174 | return func(table.unpack(args)) 175 | end, debug.traceback) } 176 | 177 | Profiler.component_stop(name) 178 | 179 | local success = table.remove(results, 1) 180 | if not success then 181 | Debug.error("Profiler", "Function '" .. name .. "' error: " .. results[1]) 182 | return nil, results[1] 183 | end 184 | 185 | return table.unpack(results) 186 | end 187 | end 188 | 189 | function Profiler.get_component_data() 190 | local result = {} 191 | for name, data in pairs(components_data) do 192 | if type(data) == "table" and data.executions and data.executions > 0 then 193 | result[name] = { 194 | executions = data.executions, 195 | total_time = data.total_time, 196 | avg_time = data.avg_time, 197 | } 198 | end 199 | end 200 | return result 201 | end 202 | 203 | function Profiler.save_component_report(filename) 204 | if not is_profiling then 205 | return nil 206 | end 207 | 208 | local timestamp = os.date("%Y%m%d-%H%M%S") 209 | if not filename then 210 | filename = config.output_dir .. "/components-" .. timestamp .. ".txt" 211 | end 212 | 213 | local data = Profiler.get_component_data() 214 | if not next(data) then 215 | Debug.warn("Profiler", "No component profiling data to save") 216 | return nil 217 | end 218 | 219 | local components = {} 220 | for name, stats in pairs(data) do 221 | table.insert(components, { 222 | name = name, 223 | executions = stats.executions, 224 | total_time = stats.total_time, 225 | avg_time = stats.avg_time, 226 | }) 227 | end 228 | 229 | table.sort(components, function(a, b) 230 | return a.total_time > b.total_time 231 | end) 232 | 233 | local lines = { 234 | " +-----+----------------------------------+----------+----------------+----------------+", 235 | " | # | Component | Calls | Total Time (s) | Avg Time (s) |", 236 | " +-----+----------------------------------+----------+----------------+----------------+", 237 | } 238 | 239 | for i, comp in ipairs(components) do 240 | local row = string.format( 241 | " | %-3d | %-32s | %-8d | %-14.6f | %-14.6f |", 242 | i, 243 | comp.name, 244 | comp.executions, 245 | comp.total_time, 246 | comp.avg_time 247 | ) 248 | table.insert(lines, row) 249 | end 250 | 251 | table.insert(lines, " +-----+----------------------------------+----------+----------------+----------------+") 252 | 253 | local report = table.concat(lines, "\n") 254 | local file = io.open(filename, "w") 255 | if not file then 256 | Debug.error("Profiler", "Failed to create component report file: " .. filename) 257 | return nil 258 | end 259 | 260 | file:write(report) 261 | file:close() 262 | 263 | Debug.info("Profiler", "Component report saved to " .. filename) 264 | return filename 265 | end 266 | 267 | function Profiler.window_load(window, name) 268 | if not is_profiling or not window then 269 | return 270 | end 271 | name = name or window.class_name or "unknown_window" 272 | 273 | Profiler.component_start("window_load:" .. name) 274 | window:hook(window, "map", function() 275 | Profiler.component_stop("window_load:" .. name) 276 | end) 277 | end 278 | 279 | function Profiler.trace_memory() 280 | if not is_profiling then 281 | return 282 | end 283 | 284 | local before = collectgarbage("count") 285 | collectgarbage("collect") 286 | local after = collectgarbage("count") 287 | 288 | Debug.info( 289 | "Profiler", 290 | "Memory usage: " 291 | .. string.format("%0.2f", after) 292 | .. " KB (freed: " 293 | .. string.format("%0.2f", before - after) 294 | .. " KB)" 295 | ) 296 | 297 | return after, before - after 298 | end 299 | 300 | function Profiler.init_app_profiling() 301 | if not Profiler.load_config() then 302 | return false 303 | end 304 | 305 | Debug.info("Profiler", "Initializing application profiling") 306 | Profiler.start() 307 | 308 | GLib.timeout_add_seconds(GLib.PRIORITY_LOW, 120, function() 309 | if is_profiling then 310 | Profiler.trace_memory() 311 | return true 312 | end 313 | return false 314 | end) 315 | 316 | return true 317 | end 318 | 319 | function Profiler.cleanup() 320 | Profiler.stop() 321 | if auto_save_timer then 322 | GLib.source_remove(auto_save_timer) 323 | auto_save_timer = nil 324 | end 325 | components_data = {} 326 | collectgarbage("collect") 327 | end 328 | 329 | function Profiler.is_enabled() 330 | return is_profiling 331 | end 332 | 333 | return Profiler 334 | -------------------------------------------------------------------------------- /lua/lib/state.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Variable = astal.Variable 3 | local Debug = require("lua.lib.debug") 4 | 5 | local State = {} 6 | local subscribers = {} 7 | local state_objects = {} 8 | local STATE_DIR = os.getenv("HOME") .. "/.local/share/astal/state" 9 | 10 | local function ensure_state_dir() 11 | os.execute("mkdir -p " .. STATE_DIR) 12 | end 13 | 14 | function State.create(name, initial_value) 15 | if state_objects[name] then 16 | Debug.warn("State", "State object '%s' already exists, returning existing instance", name) 17 | return state_objects[name] 18 | end 19 | 20 | local state = Variable(initial_value) 21 | state_objects[name] = state 22 | subscribers[name] = {} 23 | 24 | return state 25 | end 26 | 27 | function State.get(name) 28 | if not state_objects[name] then 29 | Debug.warn("State", "State object '%s' does not exist", name) 30 | return nil 31 | end 32 | 33 | return state_objects[name] 34 | end 35 | 36 | function State.set(name, value) 37 | if not state_objects[name] then 38 | Debug.error("State", "Cannot set state: object '%s' does not exist", name) 39 | return false 40 | end 41 | 42 | state_objects[name]:set(value) 43 | return true 44 | end 45 | 46 | function State.update(name, updater_fn) 47 | if not state_objects[name] then 48 | Debug.error("State", "Cannot update state: object '%s' does not exist", name) 49 | return false 50 | end 51 | 52 | local current = state_objects[name]:get() 53 | local new_value = updater_fn(current) 54 | state_objects[name]:set(new_value) 55 | return true 56 | end 57 | 58 | function State.subscribe(name, callback) 59 | if not state_objects[name] then 60 | Debug.error("State", "Cannot subscribe: state object '%s' does not exist", name) 61 | return nil 62 | end 63 | 64 | local subscription = state_objects[name]:subscribe(callback) 65 | table.insert(subscribers[name], subscription) 66 | 67 | return { 68 | unsubscribe = function() 69 | subscription:unsubscribe() 70 | for i, sub in ipairs(subscribers[name]) do 71 | if sub == subscription then 72 | table.remove(subscribers[name], i) 73 | break 74 | end 75 | end 76 | end, 77 | } 78 | end 79 | 80 | function State.cleanup(name) 81 | if not state_objects[name] then 82 | return 83 | end 84 | 85 | for _, subscription in ipairs(subscribers[name] or {}) do 86 | pcall(function() 87 | subscription:unsubscribe() 88 | end) 89 | end 90 | 91 | subscribers[name] = {} 92 | 93 | pcall(function() 94 | state_objects[name]:drop() 95 | end) 96 | state_objects[name] = nil 97 | end 98 | 99 | function State.cleanup_all() 100 | for name, _ in pairs(state_objects) do 101 | State.cleanup(name) 102 | end 103 | 104 | subscribers = {} 105 | state_objects = {} 106 | end 107 | 108 | function State.derive(name, dependencies, transform_fn) 109 | local dep_vars = {} 110 | for _, dep_name in ipairs(dependencies) do 111 | local state = State.get(dep_name) 112 | if not state then 113 | Debug.error("State", "Cannot derive: dependency '%s' does not exist", dep_name) 114 | return nil 115 | end 116 | table.insert(dep_vars, state) 117 | end 118 | 119 | local derived = Variable.derive(dep_vars, transform_fn) 120 | state_objects[name] = derived 121 | subscribers[name] = {} 122 | 123 | return derived 124 | end 125 | 126 | function State.persist(name, storage_key) 127 | if not state_objects[name] then 128 | Debug.error("State", "Cannot persist: state object '%s' does not exist", name) 129 | return false 130 | end 131 | 132 | local key = storage_key or name 133 | local value = state_objects[name]:get() 134 | 135 | local success, err = pcall(function() 136 | local serialized 137 | if type(value) == "table" then 138 | serialized = astal.json_encode(value) 139 | else 140 | serialized = tostring(value) 141 | end 142 | 143 | ensure_state_dir() 144 | 145 | local file = io.open(STATE_DIR .. "/" .. key .. ".json", "w") 146 | if file then 147 | file:write(serialized) 148 | file:close() 149 | return true 150 | end 151 | return false 152 | end) 153 | 154 | if not success then 155 | Debug.error("State", "Failed to persist state '%s': %s", name, err) 156 | return false 157 | end 158 | 159 | return true 160 | end 161 | 162 | function State.load(name, storage_key) 163 | local key = storage_key or name 164 | local path = STATE_DIR .. "/" .. key .. ".json" 165 | 166 | local success, content = pcall(function() 167 | local file = io.open(path, "r") 168 | if not file then 169 | return nil 170 | end 171 | 172 | local content = file:read("*all") 173 | file:close() 174 | return content 175 | end) 176 | 177 | if not success or not content then 178 | Debug.warn("State", "Failed to load state '%s' from storage", name) 179 | return nil 180 | end 181 | 182 | local parsed 183 | success, parsed = pcall(function() 184 | return astal.json_decode(content) 185 | end) 186 | 187 | if not success then 188 | Debug.warn("State", "Failed to parse state '%s' from storage, trying as raw value", name) 189 | return content 190 | end 191 | 192 | return parsed 193 | end 194 | 195 | function State.create_persisted(name, initial_value, storage_key) 196 | local stored_value = State.load(name, storage_key) 197 | local state = State.create(name, stored_value or initial_value) 198 | 199 | State.subscribe(name, function() 200 | State.persist(name, storage_key) 201 | end) 202 | 203 | return state 204 | end 205 | 206 | function State.poll(name, interval, getter_fn) 207 | if state_objects[name] then 208 | Debug.warn("State", "State '%s' already exists, will update with poll", name) 209 | else 210 | State.create(name, getter_fn()) 211 | end 212 | 213 | local timer_id = astal.timeout_interval(interval, function() 214 | local value = getter_fn() 215 | State.set(name, value) 216 | return true 217 | end) 218 | 219 | return { 220 | stop = function() 221 | astal.clear_timeout(timer_id) 222 | end, 223 | } 224 | end 225 | 226 | return State 227 | -------------------------------------------------------------------------------- /lua/lib/sysinfo.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local cjson = require("cjson") 3 | local Debug = require("lua.lib.debug") 4 | local GLib = astal.require("GLib") 5 | 6 | local M = {} 7 | 8 | local cache = { 9 | data = nil, 10 | timestamp = 0, 11 | lifetime = 30, 12 | } 13 | 14 | function M.get_cache_info() 15 | local size = 0 16 | if cache.data then 17 | for k, v in pairs(cache.data) do 18 | size = size + 1 19 | if type(v) == "table" then 20 | for _ in pairs(v) do 21 | size = size + 1 22 | end 23 | end 24 | end 25 | end 26 | 27 | return { 28 | has_data = cache.data ~= nil, 29 | items = size, 30 | age = GLib.get_monotonic_time() / 1000000 - cache.timestamp, 31 | } 32 | end 33 | 34 | local function get_os_logo(os_id) 35 | if not os_id then 36 | return nil 37 | end 38 | 39 | os_id = os_id:lower() 40 | 41 | local logo_map = { 42 | ["nixos"] = "distributor-logo-nixos", 43 | ["arch"] = "distributor-logo-archlinux", 44 | ["ubuntu"] = "distributor-logo-ubuntu", 45 | ["fedora"] = "distributor-logo-fedora", 46 | ["debian"] = "distributor-logo-debian", 47 | ["kali"] = "distributor-logo-kali", 48 | ["manjaro"] = "distributor-logo-manjaro", 49 | ["opensuse"] = "distributor-logo-opensuse", 50 | ["gentoo"] = "distributor-logo-gentoo", 51 | ["centos"] = "distributor-logo-centos", 52 | ["redhat"] = "distributor-logo-redhat", 53 | ["rhel"] = "distributor-logo-redhat", 54 | ["mint"] = "distributor-logo-linuxmint", 55 | ["elementary"] = "distributor-logo-elementary", 56 | ["pop"] = "distributor-logo-pop-os", 57 | ["zorin"] = "distributor-logo-zorin", 58 | ["void"] = "distributor-logo-void", 59 | ["slackware"] = "distributor-logo-slackware", 60 | ["artix"] = "distributor-logo-artix", 61 | ["endeavour"] = "distributor-logo-endeavouros", 62 | ["garuda"] = "distributor-logo-garuda", 63 | ["solus"] = "distributor-logo-solus", 64 | } 65 | 66 | return logo_map[os_id] or "distributor-logo" 67 | end 68 | 69 | local function parse_fastfetch_data(raw_data) 70 | if not raw_data or type(raw_data) ~= "table" then 71 | Debug.error("SysInfo", "Invalid raw data") 72 | return {} 73 | end 74 | 75 | local niri_version = "unknown" 76 | local niri_handle, niri_err = io.popen("niri -V") 77 | if niri_handle then 78 | local version = niri_handle:read("*l") 79 | niri_handle:close() 80 | if version then 81 | niri_version = version:match("niri%s+(.+)") or "unknown" 82 | end 83 | end 84 | 85 | local parsed = {} 86 | 87 | local found_wm_name = nil 88 | 89 | for _, item in ipairs(raw_data) do 90 | if item.type == "WM" and item.result and item.result.prettyName then 91 | found_wm_name = item.result.prettyName 92 | break 93 | end 94 | end 95 | 96 | for _, item in ipairs(raw_data) do 97 | if item.type and item.result then 98 | if item.type == "Title" then 99 | parsed.title = { 100 | name = item.result.userName, 101 | separator = item.result.hostName, 102 | } 103 | elseif item.type == "OS" then 104 | parsed.os = { 105 | name = item.result.name, 106 | version = item.result.version, 107 | codename = item.result.codename, 108 | type = item.result.id, 109 | } 110 | 111 | parsed.os.icon_name = get_os_logo(item.result.id) 112 | elseif item.type == "Kernel" then 113 | parsed.kernel = { 114 | name = item.result.name, 115 | version = item.result.release, 116 | } 117 | elseif item.type == "Uptime" then 118 | local uptime = nil 119 | 120 | if type(item.result) == "table" and type(item.result.uptime) == "number" then 121 | uptime = math.floor(item.result.uptime / 1000) 122 | end 123 | 124 | if not uptime or uptime <= 0 or uptime > 31536000 then 125 | local handle = io.popen("cat /proc/uptime") 126 | if handle then 127 | local proc_uptime = handle:read("*l") 128 | handle:close() 129 | if proc_uptime then 130 | uptime = tonumber(proc_uptime:match("^%S+")) 131 | end 132 | end 133 | end 134 | 135 | if uptime and uptime > 0 then 136 | local days = math.floor(uptime / 86400) 137 | local hours = math.floor((uptime % 86400) / 3600) 138 | local minutes = math.floor((uptime % 3600) / 60) 139 | 140 | local parts = {} 141 | if days > 0 then 142 | table.insert(parts, days .. " day" .. (days ~= 1 and "s" or "")) 143 | end 144 | if hours > 0 or #parts > 0 then 145 | table.insert(parts, hours .. " hour" .. (hours ~= 1 and "s" or "")) 146 | end 147 | if minutes > 0 or #parts == 0 then 148 | table.insert(parts, minutes .. " minute" .. (minutes ~= 1 and "s" or "")) 149 | end 150 | 151 | parsed.uptime = { 152 | seconds = uptime, 153 | formatted = table.concat(parts, ", "), 154 | } 155 | else 156 | parsed.uptime = { 157 | seconds = 0, 158 | formatted = "Unknown", 159 | } 160 | end 161 | elseif item.type == "Packages" then 162 | parsed.packages = { 163 | total = item.result.all, 164 | formatted = tostring(item.result.all), 165 | } 166 | elseif item.type == "Terminal" then 167 | parsed.terminal = { 168 | name = item.result.prettyName, 169 | version = item.result.version, 170 | } 171 | elseif item.type == "Display" then 172 | if item.result and #item.result > 0 then 173 | local displays = {} 174 | for i, display in ipairs(item.result) do 175 | table.insert( 176 | displays, 177 | string.format("%s (%dx%d)", display.name, display.output.width, display.output.height) 178 | ) 179 | end 180 | 181 | parsed.display = { 182 | server = "Wayland", 183 | compositor = table.concat(displays, ", "), 184 | } 185 | end 186 | elseif item.type == "DE" then 187 | if item.result and item.result.name and item.result.name ~= "Unknown" then 188 | parsed.de = { 189 | name = item.result.name, 190 | version = item.result.version or "", 191 | } 192 | else 193 | parsed.de = { 194 | name = found_wm_name or "Wayland Compositor", 195 | version = niri_version, 196 | } 197 | end 198 | elseif item.type == "WM" then 199 | parsed.wm = { 200 | name = item.result.prettyName, 201 | version = niri_version, 202 | } 203 | elseif item.type == "CPU" then 204 | parsed.cpu = { 205 | name = item.result.cpu, 206 | cores = item.result.cores.physical, 207 | threads = item.result.cores.logical, 208 | frequency = string.format("%.1f GHz", item.result.frequency.max / 1000), 209 | } 210 | elseif item.type == "GPU" then 211 | if item.result[1] then 212 | local gpu = item.result[1] 213 | parsed.gpu = { 214 | name = string.format("%s %s (%s)", gpu.vendor, gpu.name, gpu.type), 215 | driver = gpu.driver, 216 | type = gpu.type, 217 | } 218 | end 219 | elseif item.type == "Memory" then 220 | parsed.memory = { 221 | total = string.format("%.1f GB", item.result.total / (1024 * 1024 * 1024)), 222 | used = string.format("%.1f GB", item.result.used / (1024 * 1024 * 1024)), 223 | percentage = math.floor((item.result.used / item.result.total) * 100), 224 | } 225 | elseif item.type == "Disk" then 226 | if item.result[1] then 227 | parsed.disk = { 228 | total = string.format("%.1f GB", item.result[1].bytes.total / (1024 * 1024 * 1024)), 229 | used = string.format("%.1f GB", item.result[1].bytes.used / (1024 * 1024 * 1024)), 230 | percentage = math.floor((item.result[1].bytes.used / item.result[1].bytes.total) * 100), 231 | } 232 | end 233 | end 234 | end 235 | end 236 | 237 | if not parsed.de then 238 | parsed.de = { 239 | name = found_wm_name or "Wayland Compositor", 240 | version = niri_version, 241 | } 242 | end 243 | 244 | return parsed 245 | end 246 | 247 | local function execute_fastfetch() 248 | local handle, err = io.popen( 249 | [[fastfetch --structure Title:Break:OS:Host:Kernel:Uptime:Packages:Shell:Display:DE:WM:Terminal:CPU:GPU:Memory:Disk:Battery:PowerAdapter:Locale:Break --format json]] 250 | ) 251 | if not handle then 252 | Debug.error("SysInfo", "Failed to execute fastfetch: " .. (err or "unknown error")) 253 | return nil 254 | end 255 | 256 | local output 257 | local success, read_err = pcall(function() 258 | output = handle:read("*a") 259 | end) 260 | 261 | handle:close() 262 | 263 | if not success then 264 | Debug.error("SysInfo", "Failed to read fastfetch output: " .. (read_err or "unknown error")) 265 | return nil 266 | end 267 | 268 | if not output or output == "" then 269 | Debug.error("SysInfo", "Empty fastfetch output") 270 | return nil 271 | end 272 | 273 | local decode_success, data = pcall(cjson.decode, output) 274 | if not decode_success or type(data) ~= "table" then 275 | Debug.error("SysInfo", "Failed to parse fastfetch JSON") 276 | return nil 277 | end 278 | 279 | return data 280 | end 281 | 282 | function M.get_info() 283 | local current_time = GLib.get_monotonic_time() / 1000000 284 | 285 | if cache.data and (current_time - cache.timestamp) < cache.lifetime then 286 | return cache.data 287 | end 288 | 289 | local raw_data = execute_fastfetch() 290 | if not raw_data then 291 | return cache.data or {} 292 | end 293 | 294 | cache.data = parse_fastfetch_data(raw_data) 295 | cache.timestamp = current_time 296 | 297 | return cache.data 298 | end 299 | 300 | function M.refresh() 301 | cache.data = nil 302 | cache.timestamp = 0 303 | return M.get_info() 304 | end 305 | 306 | function M.cleanup() 307 | if cache.data then 308 | for k in pairs(cache.data) do 309 | if type(cache.data[k]) == "table" then 310 | cache.data[k] = nil 311 | end 312 | end 313 | cache.data = nil 314 | end 315 | cache.timestamp = 0 316 | collectgarbage("collect") 317 | end 318 | 319 | return M 320 | -------------------------------------------------------------------------------- /lua/lib/theme.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Variable = require("astal.variable") 3 | local exec = astal.exec 4 | local Debug = require("lua.lib.debug") 5 | local GLib = astal.require("GLib") 6 | 7 | local Theme = {} 8 | 9 | function Theme:New() 10 | local instance = { 11 | is_dark = Variable.new(false), 12 | } 13 | setmetatable(instance, self) 14 | self.__index = self 15 | 16 | instance:update_theme_state() 17 | 18 | instance.is_dark:watch({ "bash", "-c", "dconf watch /org/gnome/desktop/interface/color-scheme" }, function(out) 19 | GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, function() 20 | instance:update_theme_state() 21 | return GLib.SOURCE_REMOVE 22 | end) 23 | return out:match("prefer%-dark") ~= nil 24 | end) 25 | 26 | return instance 27 | end 28 | 29 | function Theme:update_theme_state() 30 | local current_theme = self:get_current_theme_mode() 31 | self.is_dark:set(current_theme == "dark") 32 | end 33 | 34 | function Theme:get_current_theme_mode() 35 | local out, err = exec("dconf read /org/gnome/desktop/interface/color-scheme") 36 | if err then 37 | Debug.error("Theme", "Failed to read dconf theme setting: %s", err) 38 | return "light" 39 | end 40 | return out:match("prefer%-dark") and "dark" or "light" 41 | end 42 | 43 | function Theme:toggle_theme() 44 | local current_state = self.is_dark:get() 45 | local new_state = not current_state 46 | local scheme = new_state and "prefer-dark" or "prefer-light" 47 | 48 | pcall(function() 49 | exec("niri msg action do-screen-transition") 50 | end) 51 | 52 | local _, err = exec(string.format("dconf write /org/gnome/desktop/interface/color-scheme \"'%s'\"", scheme)) 53 | if err then 54 | Debug.error("Theme", "Failed to set theme: %s", err) 55 | return 56 | end 57 | 58 | GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, function() 59 | self:update_theme_state() 60 | return GLib.SOURCE_REMOVE 61 | end) 62 | end 63 | 64 | function Theme:cleanup() 65 | if self.is_dark then 66 | self.is_dark:drop() 67 | end 68 | end 69 | 70 | local instance = nil 71 | function Theme.get_default() 72 | if not instance then 73 | instance = Theme:New() 74 | end 75 | return instance 76 | end 77 | 78 | function Theme.cleanup_singleton() 79 | if instance then 80 | instance:cleanup() 81 | instance = nil 82 | end 83 | end 84 | 85 | return Theme 86 | -------------------------------------------------------------------------------- /lua/lib/utils.lua: -------------------------------------------------------------------------------- 1 | local GLib = require("astal").require("GLib") 2 | 3 | local utils = {} 4 | 5 | function utils.debounce(func, wait, immediate) 6 | local timeout_id = nil 7 | local function debounced(...) 8 | local args = { ... } 9 | 10 | if timeout_id then 11 | GLib.source_remove(timeout_id) 12 | timeout_id = nil 13 | end 14 | 15 | if immediate and not timeout_id then 16 | func(table.unpack(args)) 17 | end 18 | 19 | timeout_id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, wait, function() 20 | if not immediate then 21 | func(table.unpack(args)) 22 | end 23 | timeout_id = nil 24 | return GLib.SOURCE_REMOVE 25 | end) 26 | 27 | return function() 28 | if timeout_id then 29 | GLib.source_remove(timeout_id) 30 | timeout_id = nil 31 | end 32 | end 33 | end 34 | 35 | return debounced 36 | end 37 | 38 | function utils.throttle(func, limit) 39 | local last = 0 40 | local timeout_id = nil 41 | 42 | return function(...) 43 | local now = GLib.get_monotonic_time() / 1000 44 | local args = { ... } 45 | 46 | if (now - last) > limit then 47 | last = now 48 | return func(table.unpack(args)) 49 | else 50 | if timeout_id then 51 | GLib.source_remove(timeout_id) 52 | end 53 | 54 | timeout_id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, limit - (now - last), function() 55 | last = GLib.get_monotonic_time() / 1000 56 | func(table.unpack(args)) 57 | timeout_id = nil 58 | return GLib.SOURCE_REMOVE 59 | end) 60 | end 61 | end 62 | end 63 | 64 | function utils.memoize(func) 65 | local cache = {} 66 | 67 | return function(...) 68 | local args = { ... } 69 | local key = table.concat(args, "|") 70 | 71 | if cache[key] == nil then 72 | cache[key] = func(table.unpack(args)) 73 | end 74 | 75 | return cache[key] 76 | end 77 | end 78 | 79 | function utils.safe_cleanup(callback) 80 | return function(...) 81 | local success, err = pcall(callback, ...) 82 | if not success then 83 | local Debug = require("lua.lib.debug") 84 | Debug.error("SafeCleanup", "Error during cleanup: %s", err) 85 | end 86 | end 87 | end 88 | 89 | function utils.delay(ms, callback) 90 | return GLib.timeout_add(GLib.PRIORITY_DEFAULT, ms, function() 91 | callback() 92 | return GLib.SOURCE_REMOVE 93 | end) 94 | end 95 | 96 | function utils.create_rate_limiter(limit_ms) 97 | local last_call_time = 0 98 | local pending_call = nil 99 | 100 | local function execute_call(func, args) 101 | if pending_call then 102 | GLib.source_remove(pending_call) 103 | pending_call = nil 104 | end 105 | 106 | last_call_time = GLib.get_monotonic_time() / 1000 107 | return func(table.unpack(args)) 108 | end 109 | 110 | return function(func) 111 | return function(...) 112 | local args = { ... } 113 | local current_time = GLib.get_monotonic_time() / 1000 114 | local time_since_last = current_time - last_call_time 115 | 116 | if time_since_last >= limit_ms then 117 | return execute_call(func, args) 118 | else 119 | if pending_call then 120 | GLib.source_remove(pending_call) 121 | end 122 | 123 | pending_call = GLib.timeout_add(GLib.PRIORITY_DEFAULT, limit_ms - time_since_last, function() 124 | execute_call(func, args) 125 | pending_call = nil 126 | return GLib.SOURCE_REMOVE 127 | end) 128 | end 129 | end 130 | end 131 | end 132 | 133 | function utils.once(func) 134 | local called = false 135 | local result 136 | 137 | return function(...) 138 | if not called then 139 | called = true 140 | result = func(...) 141 | end 142 | return result 143 | end 144 | end 145 | 146 | return utils 147 | -------------------------------------------------------------------------------- /lua/lib/vitals.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Variable = require("astal.variable") 3 | local Debug = require("lua.lib.debug") 4 | local GLib = require("lgi").GLib 5 | 6 | local Vitals = {} 7 | 8 | local cache = { 9 | cpu = { value = 0, timestamp = 0 }, 10 | ram = { value = 0, timestamp = 0 }, 11 | prev_cpu_values = nil, 12 | cache_lifetime = 500, 13 | } 14 | 15 | local function parse_cpu_stats(content) 16 | if not content then 17 | return nil 18 | end 19 | local cpu_line = content:match("^cpu%s+(%d+%s+%d+%s+%d+%s+%d+%s+%d+%s+%d+%s+%d+%s+%d+%s+%d+%s+%d+)") 20 | if not cpu_line then 21 | return nil 22 | end 23 | 24 | local values = {} 25 | for v in cpu_line:gmatch("%d+") do 26 | values[#values + 1] = tonumber(v) or 0 27 | end 28 | return values 29 | end 30 | 31 | local function parse_mem_stats(content) 32 | if not content then 33 | return nil 34 | end 35 | 36 | local stats = { 37 | total = tonumber(content:match("MemTotal:%s+(%d+)")) or 0, 38 | free = tonumber(content:match("MemFree:%s+(%d+)")) or 0, 39 | buffers = tonumber(content:match("Buffers:%s+(%d+)")) or 0, 40 | cached = tonumber(content:match("Cached:%s+(%d+)")) or 0, 41 | } 42 | 43 | if stats.total == 0 then 44 | return nil 45 | end 46 | return stats 47 | end 48 | 49 | function Vitals:calculate_cpu_usage() 50 | local current_time = GLib.get_monotonic_time() / 1000 51 | if current_time - (cache.cpu.timestamp or 0) < cache.cache_lifetime then 52 | return cache.cpu.value or 0 53 | end 54 | 55 | local content = astal.read_file("/proc/stat") 56 | if not content then 57 | Debug.error("Vitals", "Failed to read /proc/stat") 58 | return cache.cpu.value or 0 59 | end 60 | 61 | local values = parse_cpu_stats(content) 62 | if not values or not values[1] or not values[2] or not values[3] or not values[4] or not values[5] then 63 | Debug.error("Vitals", "Failed to parse CPU stats") 64 | return cache.cpu.value or 0 65 | end 66 | 67 | if not cache.prev_cpu_values then 68 | cache.prev_cpu_values = values 69 | cache.cpu.value = 0 70 | cache.cpu.timestamp = current_time 71 | return 0 72 | end 73 | 74 | local prev = cache.prev_cpu_values 75 | if not prev or not prev[1] or not prev[2] or not prev[3] or not prev[4] or not prev[5] then 76 | cache.prev_cpu_values = values 77 | cache.cpu.value = 0 78 | cache.cpu.timestamp = current_time 79 | return 0 80 | end 81 | 82 | local curr_total = (values[1] or 0) + (values[2] or 0) + (values[3] or 0) + (values[4] or 0) + (values[5] or 0) 83 | local prev_total = (prev[1] or 0) + (prev[2] or 0) + (prev[3] or 0) + (prev[4] or 0) + (prev[5] or 0) 84 | local total_delta = curr_total - prev_total 85 | 86 | if total_delta <= 0 then 87 | cache.prev_cpu_values = values 88 | return cache.cpu.value or 0 89 | end 90 | 91 | local curr_idle = (values[4] or 0) + (values[5] or 0) 92 | local prev_idle = (prev[4] or 0) + (prev[5] or 0) 93 | local idle_delta = curr_idle - prev_idle 94 | 95 | cache.prev_cpu_values = values 96 | cache.cpu.value = math.floor(((total_delta - idle_delta) / total_delta) * 100 + 0.5) 97 | cache.cpu.timestamp = current_time 98 | 99 | return cache.cpu.value or 0 100 | end 101 | 102 | function Vitals:calculate_ram_usage() 103 | local current_time = GLib.get_monotonic_time() / 1000 104 | if current_time - (cache.ram.timestamp or 0) < cache.cache_lifetime then 105 | return cache.ram.value or 0 106 | end 107 | 108 | local content = astal.read_file("/proc/meminfo") 109 | if not content then 110 | Debug.error("Vitals", "Failed to read /proc/meminfo") 111 | return cache.ram.value or 0 112 | end 113 | 114 | local stats = parse_mem_stats(content) 115 | if not stats or not stats.total or not stats.free or not stats.buffers or not stats.cached then 116 | Debug.error("Vitals", "Failed to parse memory stats") 117 | return cache.ram.value or 0 118 | end 119 | 120 | if stats.total == 0 then 121 | return cache.ram.value or 0 122 | end 123 | 124 | local used = (stats.total or 0) - (stats.free or 0) - (stats.buffers or 0) - (stats.cached or 0) 125 | cache.ram.value = math.floor((used / stats.total) * 100 + 0.5) 126 | cache.ram.timestamp = current_time 127 | 128 | return cache.ram.value or 0 129 | end 130 | 131 | function Vitals:New() 132 | local instance = { 133 | cpu_usage = Variable(0):poll(1000, function() 134 | return self:calculate_cpu_usage() 135 | end), 136 | memory_usage = Variable(0):poll(1000, function() 137 | return self:calculate_ram_usage() 138 | end), 139 | disk_usage = Variable(0), 140 | temperature = Variable(0), 141 | } 142 | 143 | astal.monitor_file("/proc/stat", function() 144 | if instance and instance.cpu_usage then 145 | instance.cpu_usage:set(self:calculate_cpu_usage()) 146 | end 147 | end) 148 | 149 | astal.monitor_file("/proc/meminfo", function() 150 | if instance and instance.memory_usage then 151 | instance.memory_usage:set(self:calculate_ram_usage()) 152 | end 153 | end) 154 | 155 | setmetatable(instance, self) 156 | self.__index = self 157 | return instance 158 | end 159 | 160 | function Vitals:start_monitoring() 161 | if self.cpu_usage then 162 | self.cpu_usage:start_poll() 163 | end 164 | if self.memory_usage then 165 | self.memory_usage:start_poll() 166 | end 167 | end 168 | 169 | function Vitals:stop_monitoring() 170 | if self.cpu_usage then 171 | self.cpu_usage:stop_poll() 172 | end 173 | if self.memory_usage then 174 | self.memory_usage:stop_poll() 175 | end 176 | end 177 | 178 | local instance 179 | 180 | function Vitals.get_default() 181 | if not instance then 182 | instance = Vitals:New() 183 | instance:start_monitoring() 184 | end 185 | return instance 186 | end 187 | 188 | return Vitals 189 | -------------------------------------------------------------------------------- /lua/widgets/ActiveClient.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Widget = require("astal.gtk3").Widget 3 | local Variable = astal.Variable 4 | local bind = astal.bind 5 | local utf8 = require("lua-utf8") 6 | local niri = require("lua.lib.niri") 7 | local Debug = require("lua.lib.debug") 8 | 9 | local SANITIZE_PATTERN = "[%z\1-\31\127]" 10 | 11 | local function sanitize_utf8(text) 12 | if not text then 13 | return "" 14 | end 15 | 16 | if type(text) ~= "string" then 17 | text = tostring(text) 18 | end 19 | 20 | local gsub_ok, result = pcall(utf8.gsub, text, SANITIZE_PATTERN, "") 21 | if not gsub_ok then 22 | Debug.warn("ActiveClient", "Failed to sanitize UTF-8 text: %s", result) 23 | return tostring(text) 24 | end 25 | 26 | return result 27 | end 28 | 29 | local function truncate_text(text, length) 30 | if not text then 31 | return "" 32 | end 33 | 34 | local sanitized = sanitize_utf8(text) 35 | 36 | local len_ok, len = pcall(utf8.len, sanitized) 37 | if not len_ok then 38 | Debug.warn("ActiveClient", "Failed to get UTF-8 length: %s", len) 39 | return sanitized 40 | end 41 | 42 | if len > length then 43 | local sub_ok, result = pcall(utf8.sub, sanitized, 1, length) 44 | if not sub_ok then 45 | Debug.warn("ActiveClient", "Failed to truncate UTF-8 text: %s", result) 46 | return sanitized 47 | end 48 | return result .. "..." 49 | end 50 | 51 | return sanitized 52 | end 53 | 54 | return function() 55 | local config = { 56 | window_poll_interval = 450, 57 | window_debounce_threshold = 100000, 58 | } 59 | 60 | local window_var, _, cleanup_fn = niri.create_window_variable(config) 61 | 62 | local app_id_var = Variable.derive({ bind(window_var) }, function(window) 63 | if not window or type(window) ~= "table" then 64 | return "Desktop" 65 | end 66 | return sanitize_utf8(window.app_id or "Desktop") 67 | end) 68 | 69 | local title_var = Variable.derive({ bind(window_var) }, function(window) 70 | if not window or type(window) ~= "table" then 71 | return "niri" 72 | end 73 | return truncate_text(window.title or "niri", 40) 74 | end) 75 | 76 | return Widget.Box({ 77 | class_name = "ActiveClient", 78 | setup = function(self) 79 | self:hook(self, "destroy", function() 80 | if cleanup_fn then 81 | cleanup_fn() 82 | end 83 | app_id_var:drop() 84 | title_var:drop() 85 | end) 86 | end, 87 | Widget.Box({ 88 | orientation = "VERTICAL", 89 | Widget.Label({ 90 | class_name = "app-id", 91 | label = bind(app_id_var), 92 | halign = "START", 93 | }), 94 | Widget.Label({ 95 | class_name = "window-title", 96 | label = bind(title_var), 97 | halign = "START", 98 | ellipsize = "END", 99 | }), 100 | }), 101 | }) 102 | end 103 | -------------------------------------------------------------------------------- /lua/widgets/Notification.lua: -------------------------------------------------------------------------------- 1 | local Widget = require("astal.gtk3").Widget 2 | local Gtk = require("astal.gtk3").Gtk 3 | local Astal = require("astal.gtk3").Astal 4 | local map = require("lua.lib.common").map 5 | local time = require("lua.lib.common").time 6 | local file_exists = require("lua.lib.common").file_exists 7 | local Debug = require("lua.lib.debug") 8 | 9 | local function is_icon(icon) 10 | if not icon then 11 | return false 12 | end 13 | if icon:match("^file://") then 14 | return false 15 | end 16 | return Astal.Icon.lookup_icon(icon) ~= nil 17 | end 18 | 19 | return function(props) 20 | if not props.notification then 21 | Debug.error("Notification", "No notification data provided") 22 | return nil 23 | end 24 | 25 | local n = props.notification 26 | 27 | local image_path = nil 28 | local app_icon = n:get_app_icon() 29 | 30 | if app_icon and app_icon:match("^file://") then 31 | image_path = app_icon:gsub("^file://", "") 32 | if image_path and not file_exists(image_path) then 33 | Debug.error("Notification", "Image file not found: %s", image_path) 34 | end 35 | app_icon = nil 36 | end 37 | 38 | local header = Widget.Box({ 39 | class_name = "header", 40 | (app_icon or n:get_desktop_entry()) and Widget.Icon({ 41 | class_name = "app-icon", 42 | icon = app_icon or n:get_desktop_entry(), 43 | }), 44 | Widget.Label({ 45 | class_name = "app-name", 46 | halign = "START", 47 | ellipsize = "END", 48 | label = n:get_app_name() or "Unknown", 49 | }), 50 | Widget.Label({ 51 | class_name = "time", 52 | hexpand = true, 53 | halign = "END", 54 | label = time(n:get_time()), 55 | }), 56 | Widget.Button({ 57 | on_clicked = function() 58 | local success, err = pcall(function() 59 | n:dismiss() 60 | end) 61 | if not success then 62 | Debug.error("Notification", "Failed to dismiss notification: %s", err) 63 | end 64 | end, 65 | Widget.Icon({ icon = "window-close-symbolic" }), 66 | }), 67 | }) 68 | 69 | local content = Widget.Box({ 70 | class_name = "content", 71 | (image_path and file_exists(image_path)) and Widget.Box({ 72 | valign = "START", 73 | class_name = "image", 74 | css = string.format("background-image: url('%s')", image_path), 75 | }), 76 | image_path and is_icon(image_path) and Widget.Box({ 77 | valign = "START", 78 | class_name = "icon-image", 79 | Widget.Icon({ 80 | icon = image_path, 81 | hexpand = true, 82 | vexpand = true, 83 | halign = "CENTER", 84 | valign = "CENTER", 85 | }), 86 | }), 87 | Widget.Box({ 88 | vertical = true, 89 | Widget.Label({ 90 | class_name = "summary", 91 | halign = "START", 92 | xalign = 0, 93 | ellipsize = "END", 94 | label = n:get_summary(), 95 | }), 96 | Widget.Label({ 97 | class_name = "body", 98 | wrap = true, 99 | use_markup = true, 100 | halign = "START", 101 | xalign = 0, 102 | justify = "FILL", 103 | label = n:get_body(), 104 | }), 105 | }), 106 | }) 107 | 108 | local actions_box = #n:get_actions() > 0 109 | and Widget.Box({ 110 | class_name = "actions", 111 | map(n:get_actions(), function(action) 112 | local label, id = action.label, action.id 113 | return Widget.Button({ 114 | hexpand = true, 115 | on_clicked = function() 116 | local success, err = pcall(function() 117 | return n:invoke(id) 118 | end) 119 | if not success then 120 | Debug.error("Notification", "Failed to invoke action: %s", err) 121 | end 122 | end, 123 | Widget.Label({ 124 | label = label, 125 | halign = "CENTER", 126 | hexpand = true, 127 | }), 128 | }) 129 | end), 130 | }) 131 | 132 | return Widget.EventBox({ 133 | class_name = string.format("Notification %s", string.lower(n:get_urgency())), 134 | setup = props.setup, 135 | on_hover_lost = props.on_hover_lost, 136 | Widget.Box({ 137 | vertical = true, 138 | header, 139 | Gtk.Separator({ visible = true }), 140 | content, 141 | actions_box, 142 | }), 143 | }) 144 | end 145 | -------------------------------------------------------------------------------- /lua/widgets/Vitals.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Widget = require("astal.gtk3.widget") 3 | local bind = astal.bind 4 | local Vitals = require("lua.lib.vitals") 5 | local Debug = require("lua.lib.debug") 6 | 7 | local icons_path = os.getenv("PWD") .. "/icons/" 8 | 9 | local function create_metric_widget(class_name, icon_name, value_binding) 10 | local vitals = Vitals.get_default() 11 | if not vitals then 12 | Debug.error("VitalsWidget", string.format("Failed to initialize vitals service for %s widget", class_name)) 13 | return Widget.Box({}) 14 | end 15 | 16 | return Widget.Box({ 17 | class_name = class_name, 18 | Widget.Icon({ 19 | icon = icons_path .. icon_name, 20 | css = "padding-right: 5pt;", 21 | }), 22 | Widget.Label({ 23 | label = bind(value_binding):as(function(usage) 24 | return string.format("%d%%", usage) 25 | end), 26 | }), 27 | }) 28 | end 29 | 30 | local function VitalsWidget() 31 | local vitals = Vitals.get_default() 32 | if not vitals then 33 | return Widget.Box({}) 34 | end 35 | 36 | local cleanup_refs = {} 37 | local is_destroyed = false 38 | 39 | cleanup_refs.memory = create_metric_widget("memory", "memory-symbolic.svg", vitals.memory_usage) 40 | cleanup_refs.cpu = create_metric_widget("cpu", "cpu-symbolic.svg", vitals.cpu_usage) 41 | 42 | return Widget.Box({ 43 | css = "padding: 0 5pt;", 44 | class_name = "Vitals", 45 | spacing = 5, 46 | cleanup_refs.memory, 47 | cleanup_refs.cpu, 48 | setup = function(self) 49 | self:hook(self, "destroy", function() 50 | if is_destroyed then 51 | return 52 | end 53 | is_destroyed = true 54 | 55 | if vitals.memory_usage then 56 | vitals.memory_usage:drop() 57 | end 58 | if vitals.cpu_usage then 59 | vitals.cpu_usage:drop() 60 | end 61 | 62 | cleanup_refs = nil 63 | collectgarbage("collect") 64 | end) 65 | end, 66 | }) 67 | end 68 | 69 | return function() 70 | return VitalsWidget() 71 | end 72 | -------------------------------------------------------------------------------- /lua/widgets/Workspaces.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Widget = require("astal.gtk3.widget") 3 | local bind = astal.bind 4 | local niri = require("lua.lib.niri") 5 | local Debug = require("lua.lib.debug") 6 | 7 | local function WorkspaceButton(props) 8 | return Widget.Button({ 9 | class_name = "workspace-button" .. (props.is_active and " active" or ""), 10 | on_clicked = function() 11 | if not niri.switch_to_workspace(props.id, props.monitor_name) then 12 | Debug.error("Workspaces", "Failed to switch to workspace %d on %s", props.id, props.monitor_name) 13 | end 14 | end, 15 | }) 16 | end 17 | 18 | local function MonitorWorkspaces(props) 19 | local monitor_number = props.name == "HDMI-A-1" and 2 or 1 20 | local buttons = {} 21 | local workspaces = props.workspaces 22 | 23 | for i = 1, #workspaces do 24 | local ws = workspaces[i] 25 | buttons[i] = WorkspaceButton({ 26 | id = ws.id, 27 | monitor = monitor_number, 28 | monitor_name = props.name, 29 | is_active = ws.is_active, 30 | workspace_id = ws.workspace_id, 31 | }) 32 | end 33 | 34 | return Widget.Box({ 35 | class_name = string.format("monitor-workspaces monitor-%d", monitor_number), 36 | orientation = "HORIZONTAL", 37 | spacing = 3, 38 | Widget.Box({ 39 | orientation = "HORIZONTAL", 40 | spacing = 3, 41 | table.unpack(buttons), 42 | }), 43 | }) 44 | end 45 | 46 | return function() 47 | local config = { 48 | monitor_order = { "eDP-1", "HDMI-A-1" }, 49 | monitor_poll_interval = 5000, 50 | workspace_poll_interval = 250, 51 | } 52 | 53 | local vars = niri.create_workspace_variables(config) 54 | 55 | return Widget.Box({ 56 | class_name = "Workspaces", 57 | orientation = "HORIZONTAL", 58 | spacing = 2, 59 | bind(vars.workspaces):as(function(ws) 60 | local new_widgets = {} 61 | 62 | for i = 1, #ws do 63 | local monitor = ws[i] 64 | new_widgets[i] = MonitorWorkspaces({ 65 | monitor = monitor.monitor, 66 | name = monitor.name, 67 | workspaces = monitor.workspaces, 68 | }) 69 | end 70 | 71 | return new_widgets 72 | end), 73 | setup = function(self) 74 | self:hook(self, "destroy", function() 75 | if vars and vars.cleanup then 76 | vars.cleanup() 77 | vars = nil 78 | end 79 | end) 80 | end, 81 | }) 82 | end 83 | -------------------------------------------------------------------------------- /lua/windows/AudioControl.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Widget = require("astal.gtk3.widget") 3 | local bind = astal.bind 4 | local Gtk = astal.require("Gtk") 5 | local Wp = astal.require("AstalWp") 6 | local Variable = astal.Variable 7 | local Debug = require("lua.lib.debug") 8 | local Process = astal.require("AstalIO").Process 9 | 10 | local function create_volume_control(type, cleanup_refs, is_destroyed) 11 | local audio = Wp.get_default().audio 12 | if not audio then 13 | Debug.error("AudioControl", "Failed to get audio service") 14 | return Widget.Box({}) 15 | end 16 | 17 | local device = audio["default_" .. type] 18 | if not device then 19 | Debug.error("AudioControl", "No default " .. type .. " device found") 20 | return Widget.Box({}) 21 | end 22 | 23 | local current_volume = math.floor((device.volume or 0) * 100) 24 | local device_volume = Variable(current_volume) 25 | local device_mute = Variable(device.mute or false) 26 | cleanup_refs["device_volume_" .. type] = device_volume 27 | cleanup_refs["device_mute_" .. type] = device_mute 28 | 29 | local icon_name = Variable.derive({ device_volume, device_mute }, function(vol, muted) 30 | if muted then 31 | return type == "speaker" and "audio-volume-muted-symbolic" or "microphone-disabled-symbolic" 32 | else 33 | if type == "speaker" then 34 | if vol <= 0 then 35 | return "audio-volume-muted-symbolic" 36 | elseif vol <= 33 then 37 | return "audio-volume-low-symbolic" 38 | elseif vol <= 66 then 39 | return "audio-volume-medium-symbolic" 40 | else 41 | return "audio-volume-high-symbolic" 42 | end 43 | else 44 | if vol <= 0 then 45 | return "microphone-sensitivity-muted-symbolic" 46 | elseif vol <= 33 then 47 | return "microphone-sensitivity-low-symbolic" 48 | elseif vol <= 66 then 49 | return "microphone-sensitivity-medium-symbolic" 50 | else 51 | return "microphone-sensitivity-high-symbolic" 52 | end 53 | end 54 | end 55 | end) 56 | cleanup_refs["icon_name_" .. type] = icon_name 57 | 58 | local volume_scale = Widget.Slider({ 59 | class_name = "volume-slider " .. type .. "-slider", 60 | draw_value = false, 61 | hexpand = true, 62 | width_request = 200, 63 | orientation = Gtk.Orientation.HORIZONTAL, 64 | value = current_volume, 65 | adjustment = Gtk.Adjustment({ 66 | lower = 0, 67 | upper = 100, 68 | step_increment = 5, 69 | page_increment = 5, 70 | }), 71 | on_value_changed = function(self) 72 | if not device or is_destroyed then 73 | return 74 | end 75 | local new_value = math.floor(self:get_value() / 5) * 5 / 100 76 | if new_value >= 0 and new_value <= 1 then 77 | device.volume = new_value 78 | device_volume:set(self:get_value()) 79 | end 80 | end, 81 | }) 82 | 83 | local volume_box = Widget.Box({ 84 | class_name = type .. "-control", 85 | orientation = "VERTICAL", 86 | spacing = 8, 87 | hexpand = true, 88 | setup = function(self) 89 | self:hook(device, "notify::volume", function() 90 | if not is_destroyed then 91 | local raw_value = device.volume * 100 92 | local new_value = math.floor(raw_value / 5) * 5 93 | device_volume:set(new_value) 94 | volume_scale:set_value(new_value) 95 | end 96 | end) 97 | 98 | self:hook(device, "notify::mute", function() 99 | if not is_destroyed then 100 | device_mute:set(device.mute) 101 | end 102 | end) 103 | end, 104 | Widget.Box({ 105 | orientation = "HORIZONTAL", 106 | spacing = 10, 107 | hexpand = true, 108 | Widget.Box({ 109 | orientation = "HORIZONTAL", 110 | Widget.Icon({ 111 | class_name = type .. "-icon", 112 | icon = icon_name(), 113 | }), 114 | }), 115 | Widget.Label({ 116 | label = type == "speaker" and "Speaker" or "Microphone", 117 | xalign = 0, 118 | hexpand = true, 119 | }), 120 | Widget.Button({ 121 | class_name = "mute-button", 122 | on_clicked = function() 123 | if device then 124 | device.mute = not device.mute 125 | end 126 | end, 127 | child = Widget.Icon({ 128 | icon = bind(device_mute):as(function(muted) 129 | return muted 130 | and (type == "speaker" and "audio-volume-muted-symbolic" or "microphone-disabled-symbolic") 131 | or ( 132 | type == "speaker" and "audio-volume-high-symbolic" 133 | or "microphone-sensitivity-high-symbolic" 134 | ) 135 | end), 136 | }), 137 | }), 138 | }), 139 | Widget.Box({ 140 | orientation = "HORIZONTAL", 141 | spacing = 10, 142 | hexpand = true, 143 | volume_scale, 144 | Widget.Label({ 145 | class_name = "volume-percentage", 146 | label = bind(device_volume):as(function(vol) 147 | return string.format("%d%%", math.floor(vol or 0)) 148 | end), 149 | width_chars = 4, 150 | xalign = 1, 151 | }), 152 | }), 153 | }) 154 | 155 | if device.volume then 156 | volume_scale:set_value(current_volume) 157 | end 158 | 159 | return volume_box 160 | end 161 | 162 | local function create_device_list(devices, icon_name) 163 | local buttons = {} 164 | for _, device in ipairs(devices or {}) do 165 | table.insert( 166 | buttons, 167 | Widget.Button({ 168 | class_name = "device-item", 169 | hexpand = true, 170 | child = Widget.Box({ 171 | orientation = "HORIZONTAL", 172 | spacing = 10, 173 | hexpand = true, 174 | Widget.Icon({ icon = icon_name }), 175 | Widget.Label({ 176 | label = device.description or "Unknown Device", 177 | hexpand = true, 178 | xalign = 0, 179 | }), 180 | }), 181 | on_clicked = function() 182 | device:set_is_default(true) 183 | end, 184 | }) 185 | ) 186 | end 187 | return buttons 188 | end 189 | 190 | local AudioControlWindow = {} 191 | 192 | function AudioControlWindow.new(gdkmonitor) 193 | if not gdkmonitor then 194 | Debug.error("AudioControl", "No monitor available") 195 | return nil 196 | end 197 | 198 | local Anchor = astal.require("Astal").WindowAnchor 199 | local window 200 | local is_destroyed = false 201 | local cleanup_refs = {} 202 | 203 | local function close_window() 204 | if window and not is_destroyed then 205 | window:hide() 206 | end 207 | end 208 | 209 | cleanup_refs.show_output_devices = Variable(false) 210 | cleanup_refs.show_input_devices = Variable(false) 211 | 212 | local expanded_output_class = Variable.derive({ cleanup_refs.show_output_devices }, function(shown) 213 | return shown and "expanded" or "" 214 | end) 215 | cleanup_refs.expanded_output_class = expanded_output_class 216 | 217 | local expanded_input_class = Variable.derive({ cleanup_refs.show_input_devices }, function(shown) 218 | return shown and "expanded" or "" 219 | end) 220 | cleanup_refs.expanded_input_class = expanded_input_class 221 | 222 | local audio = Wp.get_default().audio 223 | if not audio then 224 | Debug.error("AudioControl", "Failed to get audio service for devices") 225 | return nil 226 | end 227 | 228 | window = Widget.Window({ 229 | class_name = "AudioControlWindow", 230 | gdkmonitor = gdkmonitor, 231 | anchor = Anchor.TOP + Anchor.RIGHT, 232 | width_request = 350, 233 | setup = function(self) 234 | self:hook(self, "destroy", function() 235 | if is_destroyed then 236 | return 237 | end 238 | is_destroyed = true 239 | for _, ref in pairs(cleanup_refs) do 240 | if type(ref) == "table" and ref.drop then 241 | ref:drop() 242 | end 243 | end 244 | cleanup_refs = nil 245 | collectgarbage("collect") 246 | end) 247 | end, 248 | child = Widget.Box({ 249 | orientation = "VERTICAL", 250 | spacing = 15, 251 | css = "padding: 15px;", 252 | hexpand = true, 253 | Widget.Box({ 254 | class_name = "volume-controls-container", 255 | orientation = "VERTICAL", 256 | spacing = 10, 257 | hexpand = true, 258 | create_volume_control("speaker", cleanup_refs, is_destroyed), 259 | create_volume_control("microphone", cleanup_refs, is_destroyed), 260 | }), 261 | Widget.Box({ 262 | class_name = "device-controls", 263 | orientation = "VERTICAL", 264 | spacing = 15, 265 | hexpand = true, 266 | Widget.Box({ 267 | class_name = "section-header", 268 | orientation = "HORIZONTAL", 269 | hexpand = true, 270 | Widget.Label({ 271 | label = "Devices", 272 | xalign = 0, 273 | hexpand = true, 274 | }), 275 | }), 276 | Widget.Box({ 277 | class_name = "devices-container", 278 | orientation = "VERTICAL", 279 | spacing = 10, 280 | hexpand = true, 281 | Widget.Button({ 282 | class_name = "device-selector", 283 | hexpand = true, 284 | on_clicked = function() 285 | if cleanup_refs.show_input_devices:get() then 286 | cleanup_refs.show_input_devices:set(false) 287 | end 288 | cleanup_refs.show_output_devices:set(not cleanup_refs.show_output_devices:get()) 289 | end, 290 | child = Widget.Box({ 291 | orientation = "HORIZONTAL", 292 | spacing = 10, 293 | hexpand = true, 294 | Widget.Icon({ icon = "audio-speakers-symbolic" }), 295 | Widget.Box({ 296 | hexpand = true, 297 | Widget.Label({ 298 | label = "Audio Output", 299 | xalign = 0, 300 | hexpand = true, 301 | }), 302 | }), 303 | Widget.Icon({ 304 | icon = "pan-down-symbolic", 305 | class_name = expanded_output_class(), 306 | }), 307 | }), 308 | }), 309 | Widget.Revealer({ 310 | transition_duration = 200, 311 | transition_type = "SLIDE_DOWN", 312 | reveal_child = bind(cleanup_refs.show_output_devices), 313 | hexpand = true, 314 | child = Widget.Box({ 315 | orientation = "VERTICAL", 316 | spacing = 5, 317 | class_name = "device-list outputs-list", 318 | hexpand = true, 319 | bind(audio, "speakers"):as(function(speakers) 320 | return create_device_list(speakers, "audio-speakers-symbolic") 321 | end), 322 | }), 323 | }), 324 | Widget.Button({ 325 | class_name = "device-selector", 326 | hexpand = true, 327 | on_clicked = function() 328 | if cleanup_refs.show_output_devices:get() then 329 | cleanup_refs.show_output_devices:set(false) 330 | end 331 | cleanup_refs.show_input_devices:set(not cleanup_refs.show_input_devices:get()) 332 | end, 333 | child = Widget.Box({ 334 | orientation = "HORIZONTAL", 335 | spacing = 10, 336 | hexpand = true, 337 | Widget.Icon({ icon = "audio-input-microphone-symbolic" }), 338 | Widget.Box({ 339 | hexpand = true, 340 | Widget.Label({ 341 | label = "Audio Input", 342 | xalign = 0, 343 | hexpand = true, 344 | }), 345 | }), 346 | Widget.Icon({ 347 | icon = "pan-down-symbolic", 348 | class_name = expanded_input_class(), 349 | }), 350 | }), 351 | }), 352 | Widget.Revealer({ 353 | transition_duration = 200, 354 | transition_type = "SLIDE_DOWN", 355 | reveal_child = bind(cleanup_refs.show_input_devices), 356 | hexpand = true, 357 | child = Widget.Box({ 358 | orientation = "VERTICAL", 359 | spacing = 5, 360 | class_name = "device-list inputs-list", 361 | hexpand = true, 362 | bind(audio, "microphones"):as(function(microphones) 363 | return create_device_list(microphones, "audio-input-microphone-symbolic") 364 | end), 365 | }), 366 | }), 367 | }), 368 | }), 369 | Widget.Box({ 370 | class_name = "settings", 371 | hexpand = true, 372 | Widget.Button({ 373 | label = "Sound Settings", 374 | hexpand = true, 375 | on_clicked = function() 376 | close_window() 377 | Process.exec_async("env XDG_CURRENT_DESKTOP=GNOME gnome-control-center sound") 378 | end, 379 | }), 380 | }), 381 | }), 382 | }) 383 | 384 | return window 385 | end 386 | 387 | return AudioControlWindow 388 | -------------------------------------------------------------------------------- /lua/windows/Desktop.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Widget = require("astal.gtk3.widget") 3 | local Debug = require("lua.lib.debug") 4 | 5 | return function(gdkmonitor) 6 | if not gdkmonitor then 7 | Debug.error("Desktop", "Failed to initialize: gdkmonitor is nil") 8 | return nil 9 | end 10 | 11 | local Anchor = astal.require("Astal").WindowAnchor 12 | 13 | local desktop = Widget.Window({ 14 | class_name = "DesktopFrame", 15 | gdkmonitor = gdkmonitor, 16 | anchor = Anchor.TOP + Anchor.LEFT + Anchor.RIGHT + Anchor.BOTTOM, 17 | exclusivity = "EXCLUSIVE", 18 | layer = "BACKGROUND", 19 | click_through = true, 20 | child = Widget.Box({ 21 | class_name = "desktop-frame", 22 | }), 23 | }) 24 | 25 | return desktop 26 | end 27 | -------------------------------------------------------------------------------- /lua/windows/DisplayControl.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Widget = require("astal.gtk3.widget") 3 | local bind = astal.bind 4 | local Variable = astal.Variable 5 | local GLib = astal.require("GLib") 6 | local Debug = require("lua.lib.debug") 7 | local Display = require("lua.lib.display") 8 | local Theme = require("lua.lib.theme") 9 | local Anchor = astal.require("Astal").WindowAnchor 10 | local Process = astal.require("AstalIO").Process 11 | 12 | local function BrightnessControl() 13 | local display = Display.get_default() 14 | 15 | return Widget.Box({ 16 | class_name = "brightness-card", 17 | orientation = "VERTICAL", 18 | spacing = 12, 19 | hexpand = true, 20 | Widget.Box({ 21 | orientation = "HORIZONTAL", 22 | spacing = 10, 23 | hexpand = true, 24 | Widget.Icon({ 25 | icon = "display-brightness-symbolic", 26 | class_name = "setting-icon", 27 | }), 28 | Widget.Label({ 29 | label = "Brightness", 30 | xalign = 0, 31 | hexpand = true, 32 | class_name = "setting-title", 33 | }), 34 | Widget.Label({ 35 | label = bind(display.brightness):as(function(val) 36 | return string.format("%.0f%%", val * 100) 37 | end), 38 | xalign = 1, 39 | class_name = "setting-value", 40 | }), 41 | }), 42 | Widget.Box({ 43 | orientation = "HORIZONTAL", 44 | spacing = 14, 45 | hexpand = true, 46 | Widget.Icon({ 47 | icon = "display-brightness-low-symbolic", 48 | class_name = "slider-icon", 49 | }), 50 | Widget.Slider({ 51 | class_name = "brightness-slider", 52 | hexpand = true, 53 | draw_value = false, 54 | value = display.brightness:get(), 55 | on_value_changed = function(self) 56 | local value = self:get_value() 57 | if display and display.set_brightness then 58 | display:set_brightness(value) 59 | end 60 | end, 61 | }), 62 | Widget.Icon({ 63 | icon = "display-brightness-high-symbolic", 64 | class_name = "slider-icon", 65 | }), 66 | }), 67 | }) 68 | end 69 | 70 | local function QuickToggles() 71 | local display = Display.get_default() 72 | local theme = Theme.get_default() 73 | 74 | local vars = { 75 | night_light_class = Variable.derive({ display.night_light_enabled }, function(enabled) 76 | return enabled and "quick-toggle night-light active" or "quick-toggle night-light" 77 | end), 78 | dark_mode_class = Variable.derive({ theme.is_dark }, function(enabled) 79 | return enabled and "quick-toggle dark-mode active" or "quick-toggle dark-mode" 80 | end), 81 | temp_label = Variable.derive({ display.night_light_temp }, function(val) 82 | local temp = 2500 + (val * 4000) 83 | return string.format("%.0fK", temp) 84 | end), 85 | } 86 | 87 | return Widget.Box({ 88 | class_name = "quick-toggles-card", 89 | orientation = "VERTICAL", 90 | spacing = 12, 91 | hexpand = true, 92 | setup = function(self) 93 | self:hook(self, "destroy", function() 94 | for _, var in pairs(vars) do 95 | if var then 96 | var:drop() 97 | end 98 | end 99 | end) 100 | end, 101 | Widget.Box({ 102 | class_name = "toggles-row", 103 | orientation = "HORIZONTAL", 104 | spacing = 10, 105 | hexpand = true, 106 | Widget.Button({ 107 | class_name = bind(vars.night_light_class), 108 | hexpand = true, 109 | on_clicked = function() 110 | display:toggle_night_light() 111 | end, 112 | child = Widget.Box({ 113 | orientation = "VERTICAL", 114 | spacing = 5, 115 | hexpand = true, 116 | Widget.Icon({ 117 | icon = "night-light-symbolic", 118 | class_name = "toggle-icon", 119 | }), 120 | Widget.Label({ 121 | label = "Night Light", 122 | xalign = 0.5, 123 | class_name = "toggle-label", 124 | }), 125 | }), 126 | }), 127 | Widget.Button({ 128 | class_name = bind(vars.dark_mode_class), 129 | hexpand = true, 130 | on_clicked = function() 131 | theme:toggle_theme() 132 | end, 133 | child = Widget.Box({ 134 | orientation = "VERTICAL", 135 | spacing = 5, 136 | hexpand = true, 137 | Widget.Icon({ 138 | icon = "dark-mode-symbolic", 139 | class_name = "toggle-icon", 140 | }), 141 | Widget.Label({ 142 | label = "Dark Mode", 143 | xalign = 0.5, 144 | class_name = "toggle-label", 145 | }), 146 | }), 147 | }), 148 | }), 149 | Widget.Revealer({ 150 | transition_duration = 200, 151 | transition_type = "SLIDE_DOWN", 152 | reveal_child = bind(display.night_light_enabled), 153 | hexpand = true, 154 | child = Widget.Box({ 155 | orientation = "VERTICAL", 156 | spacing = 12, 157 | hexpand = true, 158 | class_name = "color-temperature-controls", 159 | Widget.Box({ 160 | orientation = "HORIZONTAL", 161 | spacing = 10, 162 | hexpand = true, 163 | Widget.Label({ 164 | label = "Color Temperature", 165 | xalign = 0, 166 | hexpand = true, 167 | class_name = "subsetting-title", 168 | }), 169 | Widget.Label({ 170 | label = bind(vars.temp_label), 171 | xalign = 1, 172 | class_name = "setting-value", 173 | }), 174 | }), 175 | Widget.Box({ 176 | orientation = "HORIZONTAL", 177 | spacing = 14, 178 | hexpand = true, 179 | Widget.Icon({ 180 | icon = "temperature-cold", 181 | class_name = "slider-icon", 182 | }), 183 | Widget.Slider({ 184 | class_name = "gamma-slider", 185 | hexpand = true, 186 | draw_value = false, 187 | value = display.night_light_temp:get(), 188 | on_value_changed = function(self) 189 | local value = self:get_value() 190 | display:set_night_light_temp(value) 191 | end, 192 | }), 193 | Widget.Icon({ 194 | icon = "temperature-warm", 195 | class_name = "slider-icon", 196 | }), 197 | }), 198 | Widget.Box({ 199 | orientation = "HORIZONTAL", 200 | spacing = 5, 201 | hexpand = true, 202 | Widget.Label({ 203 | label = "Cool", 204 | xalign = 0, 205 | class_name = "slider-label", 206 | }), 207 | Widget.Box({ hexpand = true }), 208 | Widget.Label({ 209 | label = "Warm", 210 | xalign = 1, 211 | class_name = "slider-label", 212 | }), 213 | }), 214 | }), 215 | }), 216 | }) 217 | end 218 | 219 | local function Settings(close_window) 220 | return Widget.Box({ 221 | class_name = "settings", 222 | hexpand = true, 223 | Widget.Button({ 224 | label = "Display Settings", 225 | hexpand = true, 226 | class_name = "settings-button", 227 | on_clicked = function() 228 | if close_window then 229 | close_window() 230 | end 231 | Process.exec_async("env XDG_CURRENT_DESKTOP=GNOME gnome-control-center display") 232 | end, 233 | }), 234 | }) 235 | end 236 | 237 | local DisplayControlWindow = {} 238 | 239 | function DisplayControlWindow.new(gdkmonitor) 240 | if not gdkmonitor then 241 | Debug.error("DisplayControl", "Failed to initialize: gdkmonitor is nil") 242 | return nil 243 | end 244 | 245 | local display = Display.get_default() 246 | if not display or not display.initialized then 247 | Debug.error("DisplayControl", "Display system not properly initialized") 248 | return nil 249 | end 250 | 251 | local window 252 | local is_closing = false 253 | 254 | local function close_window() 255 | if window and not is_closing then 256 | is_closing = true 257 | window:hide() 258 | is_closing = false 259 | end 260 | end 261 | 262 | local function monitor_handler() 263 | if display and display.initialized and display.night_light_enabled:get() then 264 | local proc_success, ps_out = pcall(Process.exec, "pgrep gammastep") 265 | if not (proc_success and ps_out and ps_out ~= "") then 266 | display:apply_night_light() 267 | end 268 | end 269 | end 270 | 271 | window = Widget.Window({ 272 | class_name = "DisplayControlWindow", 273 | gdkmonitor = gdkmonitor, 274 | anchor = Anchor.TOP + Anchor.RIGHT, 275 | setup = function(self) 276 | self:hook(self, "map", monitor_handler) 277 | self:hook(self, "destroy", function() 278 | if display then 279 | display:cleanup() 280 | end 281 | Display.cleanup_singleton() 282 | end) 283 | end, 284 | child = Widget.Box({ 285 | orientation = "VERTICAL", 286 | spacing = 16, 287 | css = "padding: 20px;", 288 | hexpand = true, 289 | Widget.Box({ 290 | class_name = "section-container", 291 | orientation = "VERTICAL", 292 | spacing = 12, 293 | hexpand = true, 294 | BrightnessControl(), 295 | }), 296 | Widget.Box({ 297 | class_name = "section-container", 298 | orientation = "VERTICAL", 299 | spacing = 12, 300 | hexpand = true, 301 | QuickToggles(), 302 | }), 303 | Widget.Box({ vexpand = true }), 304 | Settings(close_window), 305 | }), 306 | }) 307 | 308 | return window 309 | end 310 | 311 | return DisplayControlWindow 312 | -------------------------------------------------------------------------------- /lua/windows/Dock.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Widget = require("astal.gtk3").Widget 3 | local Apps = astal.require("AstalApps") 4 | local dock_config = require("lua.lib.dock-config") 5 | local GLib = astal.require("GLib") 6 | local Debug = require("lua.lib.debug") 7 | local State = require("lua.lib.state") 8 | local utils = require("lua.lib.utils") 9 | 10 | local apps_singleton 11 | local function get_apps_service() 12 | if not apps_singleton then 13 | apps_singleton = Apps.Apps.new() 14 | if apps_singleton and apps_singleton.reload then 15 | apps_singleton:reload() 16 | else 17 | Debug.error("Dock", "Failed to initialize Apps service") 18 | end 19 | end 20 | return apps_singleton 21 | end 22 | 23 | local function DockIcon(props) 24 | if not props.icon then 25 | Debug.warn("Dock", "Creating dock icon without icon for entry: %s", props.desktop_entry or "unknown") 26 | end 27 | 28 | return Widget.Button({ 29 | class_name = "dock-icon", 30 | on_clicked = props.on_clicked, 31 | Widget.Box({ 32 | orientation = "VERTICAL", 33 | spacing = 2, 34 | Widget.Icon({ 35 | icon = props.icon or "application-x-executable", 36 | pixel_size = 48, 37 | }), 38 | Widget.Box({ 39 | class_name = "indicator", 40 | visible = props.is_running or false, 41 | hexpand = false, 42 | halign = "CENTER", 43 | width_request = 3, 44 | height_request = 3, 45 | }), 46 | }), 47 | }) 48 | end 49 | 50 | local function DockContainer() 51 | local apps = get_apps_service() 52 | if not apps then 53 | Debug.error("Dock", "Failed to initialize Apps service") 54 | return Widget.Box({}) 55 | end 56 | 57 | local container_active = true 58 | local subscription_apps, subscription_running, subscription_pinned 59 | local visibility_subscription 60 | local is_visible = false 61 | 62 | local initial_apps = {} 63 | local app_list = apps:get_list() 64 | if app_list then 65 | for _, app in ipairs(app_list) do 66 | if app and app.entry then 67 | initial_apps[app.entry] = app 68 | end 69 | end 70 | end 71 | 72 | State.create("dock_available_apps", initial_apps) 73 | State.create("dock_running_apps", {}) 74 | State.create("dock_pinned_apps", {}) 75 | 76 | dock_config.init() 77 | 78 | local function cleanup_container() 79 | container_active = false 80 | 81 | if subscription_apps then 82 | subscription_apps:unsubscribe() 83 | subscription_apps = nil 84 | end 85 | 86 | if subscription_running then 87 | subscription_running:unsubscribe() 88 | subscription_running = nil 89 | end 90 | 91 | if subscription_pinned then 92 | subscription_pinned:unsubscribe() 93 | subscription_pinned = nil 94 | end 95 | 96 | if visibility_subscription then 97 | visibility_subscription:unsubscribe() 98 | visibility_subscription = nil 99 | end 100 | 101 | State.cleanup("dock_available_apps") 102 | State.cleanup("dock_running_apps") 103 | State.cleanup("dock_pinned_apps") 104 | end 105 | 106 | local update_apps_state = utils.debounce(function() 107 | if not container_active or not is_visible then 108 | return 109 | end 110 | 111 | local app_list = apps:get_list() 112 | if not app_list then 113 | return 114 | end 115 | 116 | local available = {} 117 | for _, app in ipairs(app_list) do 118 | if app and app.entry then 119 | available[app.entry] = app 120 | end 121 | end 122 | 123 | State.set("dock_available_apps", available) 124 | end, 1000) 125 | 126 | local render_icons = utils.throttle(function(self) 127 | if not self or not container_active then 128 | return 129 | end 130 | 131 | local available_apps = State.get("dock_available_apps"):get() or {} 132 | local pinned_apps = State.get("dock_pinned_apps"):get() or {} 133 | local running_apps = State.get("dock_running_apps"):get() or {} 134 | 135 | local children = self:get_children() 136 | if children then 137 | for _, child in ipairs(children) do 138 | self:remove(child) 139 | child:destroy() 140 | end 141 | end 142 | 143 | local icon_box = Widget.Box({ 144 | spacing = 8, 145 | homogeneous = false, 146 | halign = "CENTER", 147 | hexpand = true, 148 | }) 149 | 150 | for _, desktop_entry in ipairs(pinned_apps) do 151 | local app = available_apps[desktop_entry] 152 | if app then 153 | icon_box:add(DockIcon({ 154 | icon = app.icon_name, 155 | is_running = running_apps[desktop_entry] or false, 156 | desktop_entry = desktop_entry, 157 | on_clicked = function() 158 | if app.launch then 159 | app:launch() 160 | end 161 | end, 162 | })) 163 | end 164 | end 165 | 166 | for entry, app in pairs(available_apps) do 167 | if running_apps[entry] and not dock_config.is_pinned(entry) then 168 | icon_box:add(DockIcon({ 169 | icon = app.icon_name, 170 | is_running = true, 171 | desktop_entry = entry, 172 | on_clicked = function() 173 | if app.launch then 174 | app:launch() 175 | end 176 | end, 177 | })) 178 | end 179 | end 180 | 181 | self:add(icon_box) 182 | end, 250) 183 | 184 | return Widget.Box({ 185 | class_name = "dock-container", 186 | spacing = 8, 187 | homogeneous = false, 188 | halign = "CENTER", 189 | hexpand = true, 190 | width_request = 50, 191 | setup = function(self) 192 | visibility_subscription = State.subscribe("dock_visible", function(value) 193 | is_visible = value 194 | if value then 195 | update_apps_state() 196 | end 197 | end) 198 | 199 | subscription_apps = State.subscribe("dock_available_apps", function() 200 | render_icons(self) 201 | end) 202 | 203 | subscription_running = State.subscribe("dock_running_apps", function() 204 | render_icons(self) 205 | end) 206 | 207 | subscription_pinned = State.subscribe("dock_pinned_apps", function() 208 | render_icons(self) 209 | end) 210 | 211 | self:hook(self, "destroy", cleanup_container) 212 | 213 | render_icons(self) 214 | end, 215 | }) 216 | end 217 | 218 | local function create_revealer(content) 219 | if not content then 220 | Debug.error("Dock", "Attempting to create revealer with nil content") 221 | return nil 222 | end 223 | 224 | return Widget.Revealer({ 225 | transition_type = "SLIDE_UP", 226 | transition_duration = 200, 227 | reveal_child = true, 228 | content, 229 | }) 230 | end 231 | 232 | return function(gdkmonitor) 233 | if not gdkmonitor then 234 | Debug.error("Dock", "Failed to initialize: gdkmonitor is nil") 235 | return nil 236 | end 237 | 238 | local Anchor = astal.require("Astal").WindowAnchor 239 | State.create("dock_enabled", true) 240 | State.create("dock_visible", true) 241 | 242 | local hide_timeout 243 | local revealer 244 | local dock_window 245 | local detector_window 246 | local subscription 247 | local is_cleaned_up = false 248 | 249 | local cleanup = utils.safe_cleanup(function() 250 | if is_cleaned_up then 251 | return 252 | end 253 | is_cleaned_up = true 254 | 255 | if hide_timeout and tonumber(hide_timeout) > 0 then 256 | GLib.source_remove(hide_timeout) 257 | hide_timeout = nil 258 | end 259 | 260 | if subscription then 261 | subscription:unsubscribe() 262 | subscription = nil 263 | end 264 | 265 | State.cleanup("dock_visible") 266 | dock_config.cleanup() 267 | 268 | if detector_window then 269 | detector_window:destroy() 270 | detector_window = nil 271 | end 272 | 273 | if revealer then 274 | revealer = nil 275 | end 276 | end) 277 | 278 | local show_dock = utils.throttle(function() 279 | if is_cleaned_up then 280 | return 281 | end 282 | 283 | if hide_timeout and tonumber(hide_timeout) > 0 then 284 | GLib.source_remove(hide_timeout) 285 | hide_timeout = nil 286 | end 287 | 288 | State.set("dock_visible", true) 289 | end, 250) 290 | 291 | local hide_dock = utils.throttle(function() 292 | if is_cleaned_up then 293 | return 294 | end 295 | State.set("dock_visible", false) 296 | end, 250) 297 | 298 | local schedule_hide = function(delay) 299 | if is_cleaned_up then 300 | return 301 | end 302 | 303 | if hide_timeout and tonumber(hide_timeout) > 0 then 304 | GLib.source_remove(hide_timeout) 305 | hide_timeout = nil 306 | end 307 | 308 | hide_timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay or 500, function() 309 | hide_dock() 310 | hide_timeout = nil 311 | return GLib.SOURCE_REMOVE 312 | end) 313 | end 314 | 315 | local dock_container = DockContainer() 316 | 317 | dock_window = Widget.Window({ 318 | class_name = "Dock", 319 | gdkmonitor = gdkmonitor, 320 | anchor = Anchor.BOTTOM + Anchor.LEFT + Anchor.RIGHT, 321 | setup = function(self) 322 | subscription = State.subscribe("dock_visible", function(value) 323 | if revealer then 324 | revealer.reveal_child = value 325 | end 326 | 327 | if self and not is_cleaned_up then 328 | if value then 329 | self:get_style_context():add_class("revealed") 330 | else 331 | self:get_style_context():remove_class("revealed") 332 | end 333 | end 334 | end) 335 | 336 | self:hook(self, "destroy", cleanup) 337 | end, 338 | Widget.EventBox({ 339 | on_hover_lost = function() 340 | schedule_hide(500) 341 | end, 342 | on_hover = show_dock, 343 | Widget.Box({ 344 | class_name = "dock-wrapper", 345 | halign = "CENTER", 346 | hexpand = false, 347 | setup = function(self) 348 | if dock_container then 349 | revealer = create_revealer(dock_container) 350 | if revealer then 351 | self:add(revealer) 352 | end 353 | end 354 | end, 355 | }), 356 | }), 357 | }) 358 | 359 | detector_window = Widget.Window({ 360 | class_name = "DockDetector", 361 | gdkmonitor = gdkmonitor, 362 | anchor = Anchor.BOTTOM + Anchor.LEFT + Anchor.RIGHT, 363 | Widget.EventBox({ 364 | height_request = 1, 365 | on_hover = show_dock, 366 | }), 367 | }) 368 | 369 | detector_window:show_all() 370 | schedule_hide(1500) 371 | 372 | return dock_window 373 | end 374 | -------------------------------------------------------------------------------- /lua/windows/Github.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Widget = require("astal.gtk3.widget") 3 | local bind = astal.bind 4 | local Variable = astal.Variable 5 | local GLib = astal.require("GLib") 6 | local map = require("lua.lib.common").map 7 | local Github = require("lua.lib.github") 8 | local Debug = require("lua.lib.debug") 9 | 10 | local function format_event_type(type) 11 | return type:gsub("Event", ""):lower() 12 | end 13 | 14 | local function format_repo_name(repo) 15 | return repo.name or "" 16 | end 17 | 18 | local function AvatarImage(url) 19 | return Widget.Box({ 20 | class_name = "avatar-image", 21 | css = string.format( 22 | [[ 23 | background-image: url('%s'); 24 | background-size: cover; 25 | border-radius: 8px; 26 | ]], 27 | url 28 | ), 29 | width_request = 40, 30 | height_request = 40, 31 | }) 32 | end 33 | 34 | local function LoadingIndicator() 35 | return Widget.Box({ 36 | class_name = "loading-indicator", 37 | valign = "CENTER", 38 | halign = "CENTER", 39 | vexpand = true, 40 | Widget.Label({ label = "Loading GitHub events..." }), 41 | }) 42 | end 43 | 44 | local function ErrorIndicator() 45 | return Widget.Box({ 46 | class_name = "error-indicator", 47 | valign = "CENTER", 48 | halign = "CENTER", 49 | vexpand = true, 50 | Widget.Label({ label = "Failed to fetch events" }), 51 | }) 52 | end 53 | 54 | local function EventItem(props, close_window) 55 | return Widget.Button({ 56 | class_name = "github-event-item", 57 | on_clicked = function() 58 | close_window() 59 | GLib.spawn_command_line_async("xdg-open " .. props.url) 60 | end, 61 | child = Widget.Box({ 62 | orientation = "HORIZONTAL", 63 | spacing = 10, 64 | AvatarImage(props.avatar_url), 65 | Widget.Box({ 66 | orientation = "VERTICAL", 67 | spacing = 4, 68 | hexpand = true, 69 | Widget.Box({ 70 | orientation = "HORIZONTAL", 71 | spacing = 5, 72 | Widget.Label({ 73 | class_name = "actor-name", 74 | label = props.actor, 75 | xalign = 0, 76 | }), 77 | Widget.Label({ 78 | class_name = "event-type", 79 | label = props.type, 80 | xalign = 0, 81 | }), 82 | Widget.Label({ 83 | class_name = "repo-name", 84 | label = props.repo, 85 | xalign = 0, 86 | hexpand = true, 87 | }), 88 | }), 89 | Widget.Label({ 90 | class_name = "event-time", 91 | label = props.time, 92 | xalign = 0, 93 | }), 94 | }), 95 | }), 96 | }) 97 | end 98 | 99 | local function process_events(github_events) 100 | if not github_events then 101 | Debug.warn("GitHub", "Failed to process events: empty data") 102 | return { error = true } 103 | end 104 | if #github_events == 0 then 105 | Debug.debug("GitHub", "No events received from API") 106 | return { empty = true } 107 | end 108 | 109 | local result = {} 110 | for i, event in ipairs(github_events) do 111 | if event.actor and event.repo then 112 | result[i] = { 113 | type = format_event_type(event.type), 114 | actor = event.actor.login, 115 | repo = format_repo_name(event.repo), 116 | time = Github.format_time(event.created_at), 117 | avatar_url = event.actor.avatar_url, 118 | url = "https://github.com/" .. event.repo.name, 119 | } 120 | end 121 | end 122 | 123 | if #result == 0 then 124 | return { empty = true } 125 | end 126 | return result 127 | end 128 | 129 | local GithubWindow = {} 130 | 131 | function GithubWindow.new(gdkmonitor) 132 | local Anchor = astal.require("Astal").WindowAnchor 133 | local cleanup_refs = {} 134 | local is_destroyed = false 135 | local window 136 | local first_map = true 137 | 138 | local cached_events, cached_timestamp = Github.get_events() 139 | local has_valid_cache = cached_events and #cached_events > 0 140 | 141 | local function close_window() 142 | if window and not is_destroyed then 143 | window:hide() 144 | end 145 | end 146 | 147 | local processed_data 148 | if has_valid_cache then 149 | processed_data = process_events(cached_events) 150 | else 151 | processed_data = { loading = true } 152 | end 153 | 154 | cleanup_refs.events_var = Variable.new(processed_data) 155 | cleanup_refs.last_update_var = 156 | Variable.new(cached_timestamp > 0 and Github.format_last_update(cached_timestamp) or "") 157 | cleanup_refs.is_loading_var = Variable.new(false) 158 | cleanup_refs.update_label_visible = Variable.new(cached_timestamp > 0) 159 | 160 | local function start_update_timer() 161 | if cleanup_refs.update_timer_id then 162 | GLib.source_remove(cleanup_refs.update_timer_id) 163 | end 164 | 165 | cleanup_refs.update_timer_id = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 60, function() 166 | if is_destroyed then 167 | return false 168 | end 169 | local timestamp = Github.get_last_update_time() 170 | if timestamp and timestamp > 0 and cleanup_refs.update_label_visible:get() then 171 | cleanup_refs.last_update_var:set(Github.format_last_update(timestamp)) 172 | end 173 | return true 174 | end) 175 | end 176 | 177 | local function refresh_data_async() 178 | if is_destroyed or cleanup_refs.is_loading_var:get() then 179 | return 180 | end 181 | 182 | cleanup_refs.is_loading_var:set(true) 183 | cleanup_refs.last_update_var:set("Updating...") 184 | 185 | GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, function() 186 | if is_destroyed then 187 | return false 188 | end 189 | 190 | local fresh_events, update_timestamp = Github.update_events() 191 | local success, new_processed_data = pcall(process_events, fresh_events) 192 | 193 | if success and new_processed_data and not new_processed_data.empty and not new_processed_data.error then 194 | cleanup_refs.events_var:set(new_processed_data) 195 | end 196 | 197 | cleanup_refs.last_update_var:set(Github.format_last_update(update_timestamp)) 198 | cleanup_refs.update_label_visible:set(true) 199 | cleanup_refs.is_loading_var:set(false) 200 | return false 201 | end) 202 | end 203 | 204 | if has_valid_cache then 205 | start_update_timer() 206 | end 207 | 208 | window = Widget.Window({ 209 | class_name = "GithubWindow", 210 | gdkmonitor = gdkmonitor, 211 | anchor = Anchor.TOP + Anchor.RIGHT, 212 | width_request = 420, 213 | height_request = 400, 214 | child = Widget.Box({ 215 | orientation = "VERTICAL", 216 | spacing = 8, 217 | Widget.Box({ 218 | class_name = "header", 219 | orientation = "HORIZONTAL", 220 | spacing = 10, 221 | Widget.Icon({ 222 | icon = os.getenv("PWD") .. "/icons/github-symbolic.svg", 223 | }), 224 | Widget.Label({ 225 | label = "GitHub Activity", 226 | xalign = 0, 227 | hexpand = true, 228 | }), 229 | }), 230 | Widget.Box({ 231 | class_name = "update-bar", 232 | orientation = "HORIZONTAL", 233 | spacing = 8, 234 | visible = bind(cleanup_refs.update_label_visible), 235 | Widget.Label({ 236 | label = bind(cleanup_refs.last_update_var), 237 | xalign = 0, 238 | hexpand = true, 239 | }), 240 | Widget.Button({ 241 | class_name = "refresh-button", 242 | child = Widget.Icon({ 243 | icon = "view-refresh-symbolic", 244 | }), 245 | on_clicked = function() 246 | if not cleanup_refs.is_loading_var:get() then 247 | refresh_data_async() 248 | end 249 | end, 250 | }), 251 | }), 252 | Widget.Box({ 253 | vexpand = true, 254 | hexpand = true, 255 | class_name = "github-feed-container", 256 | child = bind(cleanup_refs.events_var):as(function(evt) 257 | if evt.error then 258 | return ErrorIndicator() 259 | end 260 | if evt.empty or #evt == 0 or evt.loading then 261 | return LoadingIndicator() 262 | end 263 | return Widget.Scrollable({ 264 | vscrollbar_policy = "AUTOMATIC", 265 | hscrollbar_policy = "NEVER", 266 | class_name = "github-feed", 267 | child = Widget.Box({ 268 | orientation = "VERTICAL", 269 | spacing = 8, 270 | map(evt, function(event) 271 | return EventItem(event, close_window) 272 | end), 273 | }), 274 | }) 275 | end), 276 | }), 277 | }), 278 | setup = function(self) 279 | self:hook(self, "destroy", function() 280 | if is_destroyed then 281 | return 282 | end 283 | is_destroyed = true 284 | 285 | if cleanup_refs.update_timer_id then 286 | GLib.source_remove(cleanup_refs.update_timer_id) 287 | end 288 | 289 | for _, ref in pairs(cleanup_refs) do 290 | if type(ref) == "table" and ref.drop then 291 | ref:drop() 292 | end 293 | end 294 | 295 | cleanup_refs = nil 296 | collectgarbage("collect") 297 | end) 298 | 299 | self:hook(self, "map", function() 300 | if first_map then 301 | first_map = false 302 | if not has_valid_cache then 303 | refresh_data_async() 304 | else 305 | GLib.idle_add(GLib.PRIORITY_LOW, function() 306 | if not is_destroyed and not cleanup_refs.is_loading_var:get() then 307 | refresh_data_async() 308 | end 309 | return false 310 | end) 311 | end 312 | Github.mark_viewed() 313 | end 314 | end) 315 | end, 316 | }) 317 | 318 | return window 319 | end 320 | 321 | return GithubWindow 322 | -------------------------------------------------------------------------------- /lua/windows/MediaControl.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Widget = require("astal.gtk3.widget") 3 | local bind = astal.bind 4 | local Mpris = astal.require("AstalMpris") 5 | local Gtk = astal.require("Gtk") 6 | local GLib = astal.require("GLib") 7 | local Variable = astal.Variable 8 | local Debug = require("lua.lib.debug") 9 | 10 | local function format_time(seconds) 11 | if not seconds or type(seconds) ~= "number" then 12 | return "0:00" 13 | end 14 | local minutes = math.floor(seconds / 60) 15 | local secs = math.floor(seconds % 60) 16 | return string.format("%d:%02d", minutes, secs) 17 | end 18 | 19 | local function AlbumImage(player) 20 | return Widget.Box({ 21 | class_name = "album-image", 22 | width_request = 150, 23 | height_request = 150, 24 | hexpand = true, 25 | halign = "CENTER", 26 | css = bind(player, "cover-art"):as(function(cover) 27 | return cover and string.format("background-image: url('%s'); background-size: cover;", cover) or "" 28 | end), 29 | }) 30 | end 31 | 32 | local function MediaInfo(player) 33 | return Widget.Box({ 34 | class_name = "media-info", 35 | orientation = "VERTICAL", 36 | spacing = 5, 37 | hexpand = true, 38 | Widget.Label({ 39 | class_name = "media-title", 40 | label = bind(player, "title"):as(function(title) 41 | return title or "No Title" 42 | end), 43 | xalign = 0, 44 | ellipsize = "END", 45 | }), 46 | Widget.Label({ 47 | class_name = "media-artist", 48 | label = bind(player, "artist"):as(function(artist) 49 | return artist or "Unknown Artist" 50 | end), 51 | xalign = 0, 52 | ellipsize = "END", 53 | }), 54 | Widget.Label({ 55 | class_name = "media-album", 56 | visible = bind(player, "album"):as(function(album) 57 | return album and album ~= "" 58 | end), 59 | label = bind(player, "album"), 60 | xalign = 0, 61 | ellipsize = "END", 62 | }), 63 | }) 64 | end 65 | 66 | local function ProgressTracker(player) 67 | local position_var = Variable(0) 68 | local timer_id 69 | local is_seeking = false 70 | 71 | return Widget.Box({ 72 | class_name = "progress-tracker", 73 | orientation = "HORIZONTAL", 74 | spacing = 10, 75 | hexpand = true, 76 | setup = function(self) 77 | timer_id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000, function() 78 | if not is_seeking and player and player.available then 79 | if player["playback-status"] == "PLAYING" then 80 | position_var:set(tonumber(player.position) or 0) 81 | end 82 | end 83 | return true 84 | end) 85 | 86 | self:hook(self, "destroy", function() 87 | if timer_id then 88 | GLib.source_remove(timer_id) 89 | timer_id = nil 90 | end 91 | position_var:drop() 92 | end) 93 | end, 94 | Widget.Label({ 95 | label = bind(position_var):as(format_time), 96 | width_chars = 5, 97 | }), 98 | Widget.Box({ 99 | hexpand = true, 100 | Widget.Slider({ 101 | class_name = "progress-slider", 102 | hexpand = true, 103 | draw_value = false, 104 | adjustment = Gtk.Adjustment({ 105 | lower = 0, 106 | upper = 100, 107 | step_increment = 1, 108 | }), 109 | value = bind(position_var):as(function(pos) 110 | if is_seeking then 111 | return nil 112 | end 113 | local length = tonumber(player.length) or 1 114 | return ((tonumber(pos) or 0) / length) * 100 115 | end), 116 | on_button_press_event = function() 117 | is_seeking = true 118 | return false 119 | end, 120 | on_button_release_event = function(self) 121 | if player and player.available then 122 | local length = tonumber(player.length) or 1 123 | local new_position = (self:get_value() / 100) * length 124 | player.position = new_position 125 | position_var:set(new_position) 126 | end 127 | is_seeking = false 128 | return false 129 | end, 130 | }), 131 | }), 132 | Widget.Label({ 133 | label = bind(player, "length"):as(format_time), 134 | width_chars = 5, 135 | }), 136 | }) 137 | end 138 | 139 | local function ProgressBar(player) 140 | return ProgressTracker(player) 141 | end 142 | 143 | local function PlaybackControls(player) 144 | local is_busy = false 145 | 146 | local function perform_action(action) 147 | if is_busy or not player or not player.available then 148 | return 149 | end 150 | 151 | is_busy = true 152 | action() 153 | 154 | GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, function() 155 | is_busy = false 156 | return false 157 | end) 158 | end 159 | 160 | return Widget.Box({ 161 | class_name = "playback-controls", 162 | orientation = "HORIZONTAL", 163 | spacing = 20, 164 | halign = "CENTER", 165 | Widget.Button({ 166 | sensitive = bind(player, "can-go-previous"), 167 | on_clicked = function() 168 | perform_action(function() 169 | player:previous() 170 | end) 171 | end, 172 | child = Widget.Icon({ 173 | icon = "media-skip-backward-symbolic", 174 | pixel_size = 16, 175 | }), 176 | }), 177 | Widget.Button({ 178 | on_clicked = function() 179 | perform_action(function() 180 | if player["playback-status"] == "PLAYING" then 181 | player:pause() 182 | else 183 | player:play() 184 | end 185 | end) 186 | end, 187 | child = Widget.Icon({ 188 | icon = bind(player, "playback-status"):as(function(status) 189 | return status == "PLAYING" and "media-playback-pause-symbolic" or "media-playback-start-symbolic" 190 | end), 191 | pixel_size = 24, 192 | }), 193 | }), 194 | Widget.Button({ 195 | sensitive = bind(player, "can-go-next"), 196 | on_clicked = function() 197 | perform_action(function() 198 | player:next() 199 | end) 200 | end, 201 | child = Widget.Icon({ 202 | icon = "media-skip-forward-symbolic", 203 | pixel_size = 16, 204 | }), 205 | }), 206 | }) 207 | end 208 | 209 | local MediaControlWindow = {} 210 | 211 | function MediaControlWindow.new(gdkmonitor) 212 | if not gdkmonitor then 213 | Debug.error("MediaControl", "No monitor available") 214 | return nil 215 | end 216 | 217 | local Anchor = astal.require("Astal").WindowAnchor 218 | local user_vars = require("user-variables") 219 | local mpris = Mpris.get_default() 220 | local cleanup_refs = {} 221 | local is_destroyed = false 222 | 223 | local function get_active_player() 224 | if not mpris then 225 | return nil 226 | end 227 | 228 | local success, players = pcall(function() 229 | return mpris:get_players() 230 | end) 231 | 232 | if not success or not players or #players == 0 then 233 | return nil 234 | end 235 | 236 | local preferred_players = user_vars.media and user_vars.media.preferred_players or {} 237 | for _, preferred in ipairs(preferred_players) do 238 | for _, player in ipairs(players) do 239 | if player.bus_name:match(preferred) and player.available then 240 | return player 241 | end 242 | end 243 | end 244 | 245 | for _, player in ipairs(players) do 246 | if player.available then 247 | return player 248 | end 249 | end 250 | return nil 251 | end 252 | 253 | local initial_player = get_active_player() 254 | if not initial_player or not initial_player.available then 255 | return nil 256 | end 257 | 258 | cleanup_refs.window_state = Variable({ 259 | player = initial_player, 260 | available = true, 261 | position = 0, 262 | length = initial_player.length or 0, 263 | }) 264 | 265 | cleanup_refs.poll_id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000, function() 266 | if is_destroyed then 267 | return false 268 | end 269 | 270 | local current_player = get_active_player() 271 | if current_player and current_player.available then 272 | if current_player.bus_name ~= cleanup_refs.window_state:get().player.bus_name then 273 | cleanup_refs.window_state:set({ 274 | player = current_player, 275 | available = true, 276 | length = current_player.length or 0, 277 | }) 278 | end 279 | else 280 | cleanup_refs.window_state:set({ 281 | player = nil, 282 | available = false, 283 | length = 0, 284 | }) 285 | end 286 | return true 287 | end) 288 | 289 | local window = Widget.Window({ 290 | class_name = "MediaControlWindow", 291 | gdkmonitor = gdkmonitor, 292 | anchor = Anchor.TOP, 293 | setup = function(self) 294 | self:hook(self, "destroy", function() 295 | if is_destroyed then 296 | return 297 | end 298 | is_destroyed = true 299 | 300 | if cleanup_refs.poll_id then 301 | GLib.source_remove(cleanup_refs.poll_id) 302 | cleanup_refs.poll_id = nil 303 | end 304 | if cleanup_refs.window_state then 305 | cleanup_refs.window_state:drop() 306 | cleanup_refs.window_state = nil 307 | end 308 | cleanup_refs = nil 309 | collectgarbage("collect") 310 | end) 311 | end, 312 | child = Widget.Box({ 313 | orientation = "VERTICAL", 314 | spacing = 15, 315 | css = "padding: 20px;", 316 | bind(cleanup_refs.window_state):as(function(state) 317 | if not state.available or not state.player then 318 | return Widget.Box() 319 | end 320 | 321 | return Widget.Box({ 322 | orientation = "HORIZONTAL", 323 | spacing = 24, 324 | AlbumImage(state.player), 325 | Widget.Box({ 326 | orientation = "VERTICAL", 327 | spacing = 16, 328 | hexpand = true, 329 | MediaInfo(state.player), 330 | Widget.Box({ 331 | orientation = "VERTICAL", 332 | spacing = 12, 333 | valign = "END", 334 | vexpand = true, 335 | ProgressBar(state.player), 336 | PlaybackControls(state.player), 337 | }), 338 | }), 339 | }) 340 | end), 341 | }), 342 | }) 343 | 344 | return window 345 | end 346 | 347 | return MediaControlWindow 348 | -------------------------------------------------------------------------------- /lua/windows/NotificationPopups.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Widget = require("astal.gtk3").Widget 3 | local Debug = require("lua.lib.debug") 4 | local Notifd = astal.require("AstalNotifd") 5 | local Notification = require("lua.widgets.Notification") 6 | local timeout = astal.timeout 7 | local Variable = astal.Variable 8 | local bind = astal.bind 9 | 10 | local TIMEOUT_DELAY = 5000 11 | 12 | local notifd = Notifd.get_default() 13 | if not notifd then 14 | Debug.error("NotificationPopups", "Failed to get notification daemon") 15 | end 16 | 17 | local function NotificationMap(parent) 18 | local notifications = Variable({}) 19 | local notif_map = {} 20 | local timer_vars = {} 21 | local subscriptions = {} 22 | local is_destroyed = false 23 | 24 | local function update_notifications() 25 | if is_destroyed then 26 | return 27 | end 28 | local arr = {} 29 | for _, widget in pairs(notif_map) do 30 | table.insert(arr, widget) 31 | end 32 | notifications:set(arr) 33 | end 34 | 35 | local function remove_notification(id) 36 | if is_destroyed then 37 | return 38 | end 39 | 40 | if timer_vars[id] then 41 | pcall(function() 42 | timer_vars[id]:drop() 43 | end) 44 | timer_vars[id] = nil 45 | end 46 | 47 | if subscriptions[id] then 48 | pcall(function() 49 | subscriptions[id]:unsubscribe() 50 | end) 51 | subscriptions[id] = nil 52 | end 53 | 54 | if notif_map[id] then 55 | if notif_map[id].destroy then 56 | notif_map[id]:destroy() 57 | end 58 | notif_map[id] = nil 59 | update_notifications() 60 | end 61 | end 62 | 63 | parent:hook(notifd, "notified", function(_, id) 64 | if is_destroyed then 65 | return 66 | end 67 | 68 | local notification = notifd:get_notification(id) 69 | if not notification then 70 | Debug.error("NotificationPopups", "Failed to get notification with id: %d", id) 71 | return 72 | end 73 | 74 | local timer = Variable(0) 75 | timer_vars[id] = timer 76 | 77 | notif_map[id] = Notification({ 78 | notification = notification, 79 | on_hover_lost = function() 80 | if not is_destroyed then 81 | remove_notification(id) 82 | end 83 | end, 84 | setup = function(self) 85 | if is_destroyed then 86 | return 87 | end 88 | 89 | subscriptions[id] = timer:subscribe(function() 90 | if not is_destroyed then 91 | remove_notification(id) 92 | end 93 | end) 94 | 95 | self:hook(self, "destroy", function() 96 | if subscriptions[id] then 97 | pcall(function() 98 | subscriptions[id]:unsubscribe() 99 | end) 100 | subscriptions[id] = nil 101 | end 102 | end) 103 | 104 | timeout(TIMEOUT_DELAY, function() 105 | if not is_destroyed and timer_vars[id] then 106 | timer:set(1) 107 | end 108 | end) 109 | end, 110 | }) 111 | 112 | update_notifications() 113 | end) 114 | 115 | parent:hook(notifd, "resolved", function(_, id) 116 | if not is_destroyed then 117 | remove_notification(id) 118 | end 119 | end) 120 | 121 | parent:hook(parent, "destroy", function() 122 | is_destroyed = true 123 | 124 | for id, sub in pairs(subscriptions) do 125 | pcall(function() 126 | sub:unsubscribe() 127 | end) 128 | end 129 | subscriptions = {} 130 | 131 | for id, timer in pairs(timer_vars) do 132 | pcall(function() 133 | timer:drop() 134 | end) 135 | end 136 | timer_vars = {} 137 | 138 | for id, notif in pairs(notif_map) do 139 | if notif.destroy then 140 | notif:destroy() 141 | end 142 | end 143 | notif_map = {} 144 | 145 | pcall(function() 146 | notifications:drop() 147 | end) 148 | 149 | collectgarbage("collect") 150 | end) 151 | 152 | return notifications 153 | end 154 | 155 | return function(gdkmonitor) 156 | if not gdkmonitor then 157 | Debug.error("NotificationPopups", "Failed to initialize: gdkmonitor is nil") 158 | return nil 159 | end 160 | 161 | local Anchor = astal.require("Astal").WindowAnchor 162 | 163 | return Widget.Window({ 164 | class_name = "NotificationPopups", 165 | gdkmonitor = gdkmonitor, 166 | anchor = Anchor.TOP + Anchor.RIGHT, 167 | setup = function(self) 168 | local notifs = NotificationMap(self) 169 | self:add(Widget.Box({ 170 | vertical = true, 171 | bind(notifs), 172 | })) 173 | end, 174 | }) 175 | end 176 | -------------------------------------------------------------------------------- /lua/windows/OSD.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Widget = require("astal.gtk3").Widget 3 | local Wp = astal.require("AstalWp") 4 | local bind = astal.bind 5 | local Debug = require("lua.lib.debug") 6 | local GLib = astal.require("GLib") 7 | local Variable = require("astal.variable") 8 | 9 | local SHOW_TIMEOUT = 1500 10 | 11 | local function create_volume_indicator(device, class_name) 12 | return Widget.Box({ 13 | class_name = class_name, 14 | visible = false, 15 | Widget.Box({ 16 | class_name = "indicator", 17 | Widget.Icon({ 18 | icon = bind(device, "volume-icon"), 19 | }), 20 | Widget.Label({ 21 | label = bind(device, "volume"):as(function(vol) 22 | return string.format("%d%%", math.floor((vol or 0) * 100)) 23 | end), 24 | }), 25 | }), 26 | Widget.Box({ 27 | class_name = "slider-container", 28 | Widget.Slider({ 29 | class_name = "volume-slider", 30 | hexpand = true, 31 | width_request = 150, 32 | value = bind(device, "volume"), 33 | on_dragged = function(slider) 34 | device.volume = slider.value 35 | end, 36 | }), 37 | }), 38 | }) 39 | end 40 | 41 | local function create_mute_indicator(device, class_name) 42 | return Widget.Box({ 43 | class_name = class_name .. "-mute", 44 | visible = false, 45 | Widget.Icon({ 46 | icon = bind(device, "volume-icon"), 47 | }), 48 | Widget.Label({ 49 | label = "Muted", 50 | }), 51 | }) 52 | end 53 | 54 | local function create_osd_widget(cleanup_refs, window_ref) 55 | local speaker = Wp.get_default().audio.default_speaker 56 | local mic = Wp.get_default().audio.default_microphone 57 | 58 | if not speaker or not mic then 59 | Debug.error( 60 | "OSD", 61 | "Failed to get audio devices - Speaker: %s, Mic: %s", 62 | speaker and "OK" or "NULL", 63 | mic and "OK" or "NULL" 64 | ) 65 | end 66 | 67 | return Widget.Box({ 68 | class_name = "OSD", 69 | vertical = true, 70 | css = "min-width: 300px; min-height: 50px;", 71 | setup = function(self) 72 | local is_destroyed = false 73 | local speaker_vol = create_volume_indicator(speaker, "volume-indicator") 74 | local speaker_mute = create_mute_indicator(speaker, "volume-indicator") 75 | local mic_vol = create_volume_indicator(mic, "mic-indicator") 76 | local mic_mute = create_mute_indicator(mic, "mic-indicator") 77 | 78 | self:add(speaker_vol) 79 | self:add(speaker_mute) 80 | self:add(mic_vol) 81 | self:add(mic_mute) 82 | 83 | local current_visible_widget = nil 84 | 85 | local function hide_all() 86 | if is_destroyed then 87 | return 88 | end 89 | speaker_vol.visible = false 90 | mic_vol.visible = false 91 | speaker_mute.visible = false 92 | mic_mute.visible = false 93 | window_ref.visible = false 94 | current_visible_widget = nil 95 | end 96 | 97 | hide_all() 98 | 99 | local function update_visible_widget(widget) 100 | if is_destroyed or current_visible_widget == widget then 101 | return 102 | end 103 | 104 | speaker_vol.visible = false 105 | mic_vol.visible = false 106 | speaker_mute.visible = false 107 | mic_mute.visible = false 108 | 109 | widget.visible = true 110 | current_visible_widget = widget 111 | end 112 | 113 | local function show_osd(widget) 114 | if is_destroyed or _G.AUDIO_CONTROL_UPDATING then 115 | return 116 | end 117 | 118 | if not window_ref.visible then 119 | window_ref.visible = true 120 | update_visible_widget(widget) 121 | else 122 | update_visible_widget(widget) 123 | end 124 | 125 | if cleanup_refs.timer_id then 126 | GLib.source_remove(cleanup_refs.timer_id) 127 | cleanup_refs.timer_id = nil 128 | end 129 | 130 | cleanup_refs.timer_id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, SHOW_TIMEOUT, function() 131 | if not is_destroyed then 132 | hide_all() 133 | end 134 | cleanup_refs.timer_id = nil 135 | return GLib.SOURCE_REMOVE 136 | end) 137 | end 138 | 139 | cleanup_refs.speaker_volume = Variable.derive({ bind(speaker, "volume") }, function(vol) 140 | return vol 141 | end) 142 | 143 | cleanup_refs.speaker_mute = Variable.derive({ bind(speaker, "mute") }, function(muted) 144 | return muted 145 | end) 146 | 147 | cleanup_refs.mic_volume = Variable.derive({ bind(mic, "volume") }, function(vol) 148 | return vol 149 | end) 150 | 151 | cleanup_refs.mic_mute = Variable.derive({ bind(mic, "mute") }, function(muted) 152 | return muted 153 | end) 154 | 155 | cleanup_refs.speaker_volume:subscribe(function() 156 | show_osd(speaker_vol) 157 | end) 158 | 159 | cleanup_refs.speaker_mute:subscribe(function(muted) 160 | show_osd(muted and speaker_mute or speaker_vol) 161 | end) 162 | 163 | cleanup_refs.mic_volume:subscribe(function() 164 | show_osd(mic_vol) 165 | end) 166 | 167 | cleanup_refs.mic_mute:subscribe(function(muted) 168 | show_osd(muted and mic_mute or mic_vol) 169 | end) 170 | 171 | self:hook(self, "destroy", function() 172 | is_destroyed = true 173 | 174 | if cleanup_refs.speaker_volume then 175 | cleanup_refs.speaker_volume:drop() 176 | end 177 | if cleanup_refs.speaker_mute then 178 | cleanup_refs.speaker_mute:drop() 179 | end 180 | if cleanup_refs.mic_volume then 181 | cleanup_refs.mic_volume:drop() 182 | end 183 | if cleanup_refs.mic_mute then 184 | cleanup_refs.mic_mute:drop() 185 | end 186 | end) 187 | end, 188 | }) 189 | end 190 | 191 | return function(gdkmonitor) 192 | if not gdkmonitor then 193 | Debug.error("OSD", "Failed to initialize OSD: gdkmonitor is nil") 194 | return nil 195 | end 196 | 197 | local cleanup_refs = {} 198 | local is_destroyed = false 199 | local Anchor = astal.require("Astal").WindowAnchor 200 | 201 | local window = Widget.Window({ 202 | class_name = "OSDWindow", 203 | gdkmonitor = gdkmonitor, 204 | anchor = Anchor.BOTTOM, 205 | visible = false, 206 | setup = function(self) 207 | self:add(create_osd_widget(cleanup_refs, self)) 208 | end, 209 | on_destroy = function() 210 | if is_destroyed then 211 | return 212 | end 213 | is_destroyed = true 214 | 215 | if cleanup_refs.timer_id then 216 | GLib.source_remove(cleanup_refs.timer_id) 217 | end 218 | 219 | for key, ref in pairs(cleanup_refs) do 220 | if type(ref) == "table" and ref.drop then 221 | ref:drop() 222 | elseif type(ref) == "number" then 223 | GLib.source_remove(ref) 224 | end 225 | cleanup_refs[key] = nil 226 | end 227 | 228 | cleanup_refs = nil 229 | collectgarbage("collect") 230 | end, 231 | }) 232 | 233 | return window 234 | end 235 | -------------------------------------------------------------------------------- /lua/windows/SysInfo.lua: -------------------------------------------------------------------------------- 1 | local astal = require("astal") 2 | local Widget = require("astal.gtk3.widget") 3 | local Debug = require("lua.lib.debug") 4 | local SysInfo = require("lua.lib.sysinfo") 5 | 6 | local function create_info_row(label, value) 7 | if not label then 8 | return nil 9 | end 10 | return Widget.Box({ 11 | orientation = "HORIZONTAL", 12 | spacing = 10, 13 | hexpand = true, 14 | Widget.Label({ 15 | label = label .. ":", 16 | class_name = "info-label", 17 | xalign = 0, 18 | }), 19 | Widget.Label({ 20 | label = value or "Unknown", 21 | class_name = "info-value", 22 | xalign = 0, 23 | hexpand = true, 24 | }), 25 | }) 26 | end 27 | 28 | local SysInfoWindow = {} 29 | 30 | function SysInfoWindow.new(gdkmonitor) 31 | if not gdkmonitor then 32 | Debug.error("SysInfo", "Failed to initialize: gdkmonitor is nil") 33 | return nil 34 | end 35 | 36 | local info = SysInfo.get_info() 37 | if not info then 38 | Debug.error("SysInfo", "Failed to get system information") 39 | return nil 40 | end 41 | 42 | local Anchor = astal.require("Astal").WindowAnchor 43 | 44 | local distro_name = info.os and info.os.name or "Unknown" 45 | local distro_version = info.os and info.os.version or "Unknown" 46 | local distro_codename = info.os and info.os.codename or "Unknown" 47 | local wm_name = info.wm and info.wm.name or "Unknown" 48 | local wm_version = info.wm and info.wm.version or "Unknown" 49 | local username = info.title and info.title.name or "unknown" 50 | local hostname = info.title and info.title.separator or "unknown" 51 | local terminal_name = info.terminal and info.terminal.name or "Unknown" 52 | local cpu_name = info.cpu and info.cpu.name or "Unknown" 53 | local gpu_name = info.gpu and info.gpu.name or "Unknown" 54 | local memory_used = info.memory and info.memory.used or "Unknown" 55 | local memory_total = info.memory and info.memory.total or "Unknown" 56 | local de_name = info.de and info.de.name or "Unknown" 57 | local uptime = info.uptime and info.uptime.formatted or "Unknown" 58 | local display_compositor = info.display and info.display.compositor or "Unknown" 59 | 60 | local distro_icon = (info.os and info.os.icon_name) or "computer-symbolic" 61 | 62 | return Widget.Window({ 63 | class_name = "SysInfoWindow", 64 | gdkmonitor = gdkmonitor, 65 | anchor = Anchor.TOP + Anchor.RIGHT, 66 | child = Widget.Box({ 67 | orientation = "VERTICAL", 68 | spacing = 20, 69 | css = "padding: 20px;", 70 | Widget.Box({ 71 | orientation = "HORIZONTAL", 72 | spacing = 20, 73 | Widget.Box({ 74 | orientation = "VERTICAL", 75 | spacing = 10, 76 | Widget.Box({ 77 | orientation = "HORIZONTAL", 78 | spacing = 10, 79 | Widget.Icon({ 80 | class_name = "distro-logo", 81 | icon = distro_icon, 82 | pixel_size = 64, 83 | }), 84 | Widget.Box({ 85 | orientation = "VERTICAL", 86 | spacing = 5, 87 | Widget.Label({ 88 | label = distro_name, 89 | class_name = "distro-name", 90 | xalign = 0, 91 | }), 92 | Widget.Label({ 93 | label = distro_version, 94 | class_name = "distro-version", 95 | xalign = 0, 96 | }), 97 | Widget.Label({ 98 | label = distro_codename, 99 | class_name = "distro-codename", 100 | xalign = 0, 101 | }), 102 | }), 103 | }), 104 | }), 105 | Widget.Box({ 106 | orientation = "VERTICAL", 107 | spacing = 10, 108 | create_info_row("WM", wm_name), 109 | create_info_row("Version", wm_version), 110 | }), 111 | }), 112 | Widget.Box({ 113 | orientation = "VERTICAL", 114 | spacing = 10, 115 | create_info_row("User", string.format("%s@%s", username, hostname)), 116 | create_info_row("Terminal", terminal_name), 117 | create_info_row("CPU", cpu_name), 118 | create_info_row("GPU", gpu_name), 119 | create_info_row("Memory", string.format("%s / %s", memory_used, memory_total)), 120 | create_info_row("WM", de_name), 121 | create_info_row("Uptime", uptime), 122 | create_info_row("Display", string.format("%s", display_compositor)), 123 | }), 124 | }), 125 | }) 126 | end 127 | 128 | function SysInfoWindow.refresh(window) 129 | if not window then 130 | return nil 131 | end 132 | 133 | SysInfo.refresh() 134 | local new_window = SysInfoWindow.new(window.gdkmonitor) 135 | 136 | if new_window then 137 | local old_position = window:get_position() 138 | new_window:move(old_position.x, old_position.y) 139 | window:destroy() 140 | return new_window 141 | end 142 | 143 | return window 144 | end 145 | 146 | function SysInfoWindow.destroy(window) 147 | if window then 148 | window:destroy() 149 | window = nil 150 | end 151 | 152 | SysInfo.cleanup() 153 | collectgarbage("collect") 154 | end 155 | 156 | return SysInfoWindow 157 | -------------------------------------------------------------------------------- /scss/abstracts/_colors.scss: -------------------------------------------------------------------------------- 1 | $rosewater: #d5d5ca; 2 | $flamingo: #a3a39a; 3 | $pink: #d16458; 4 | $mauve: #a47de9; 5 | $red: #e55a4f; 6 | $maroon: #e16458; 7 | $peach: #e5a55c; 8 | $yellow: #dba336; 9 | $green: #8dba64; 10 | $teal: #56d9ad; 11 | $sky: #4bbedf; 12 | $sapphire: #5c9cda; 13 | $blue: #6a8cef; 14 | $lavender: #c08adf; 15 | 16 | $text: #d5d5ca; 17 | $subtext1: #bdc3a8; 18 | $subtext0: #a3a39a; 19 | $overlay2: #8a9e6b; 20 | $overlay1: #474747; 21 | $overlay0: #2a2e2a; 22 | 23 | $surface2: #1d2621; 24 | $surface1: #141914; 25 | $surface0: #0f120f; 26 | 27 | $base: #0a0f0c; 28 | $mantle: #080c09; 29 | $crust: #050705; 30 | 31 | $fg: $text; 32 | $bg: $crust; 33 | $bg1: rgba(29, 38, 33, 0.6); 34 | $border: #1d2621; 35 | $shadow: $crust; 36 | 37 | $surfaceVariant: $base; 38 | $onSurfaceVariant: $subtext1; 39 | 40 | $surface: $surface0; 41 | $onSurface: $subtext1; 42 | 43 | $primary: $blue; 44 | $onPrimary: $lavender; 45 | -------------------------------------------------------------------------------- /scss/abstracts/_functions.scss: -------------------------------------------------------------------------------- 1 | @use "sass:math"; 2 | @use "sass:string"; 3 | 4 | @function to-rem($px) { 5 | @return math.div($px, 16px) * 1rem; 6 | } 7 | 8 | @function gtkalpha($c, $a) { 9 | @return string.unquote("alpha(#{$c},#{$a})"); 10 | } 11 | -------------------------------------------------------------------------------- /scss/abstracts/_index.scss: -------------------------------------------------------------------------------- 1 | @forward "colors"; 2 | @forward "functions"; 3 | @forward "mixins"; 4 | -------------------------------------------------------------------------------- /scss/abstracts/_mixins.scss: -------------------------------------------------------------------------------- 1 | @use "./colors" as *; 2 | 3 | @mixin window { 4 | background-color: $crust; 5 | padding: 1rem; 6 | color: $text; 7 | box-shadow: 0 0 4px 2px $shadow; 8 | } 9 | 10 | @mixin unset($rec: false) { 11 | background: none; 12 | border: none; 13 | padding: 0; 14 | margin: 0; 15 | 16 | @if $rec { 17 | * { 18 | background: none; 19 | border: none; 20 | padding: 0; 21 | margin: 0; 22 | } 23 | } 24 | } 25 | 26 | @mixin hovered-button { 27 | &:hover { 28 | background-color: $primary; 29 | color: $base; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /scss/base/_reset.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | 3 | window { 4 | background-color: transparent; 5 | } 6 | -------------------------------------------------------------------------------- /scss/base/_typography.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | 3 | * { 4 | font-family: "Departure Mono", monospace; 5 | font-size: to-rem(16px); 6 | font-weight: 500; 7 | } 8 | -------------------------------------------------------------------------------- /scss/components/_button.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | 3 | @mixin button { 4 | padding: to-rem(4px) to-rem(16px); 5 | border-radius: to-rem(50px); 6 | background-color: gtkalpha($surface1, 0.6); 7 | color: $text; 8 | transition: all 200ms ease; 9 | border: none; 10 | margin: 0 to-rem(4px); 11 | 12 | &:hover { 13 | background-color: gtkalpha($surface2, 0.8); 14 | } 15 | 16 | icon { 17 | font-size: to-rem(16px); 18 | color: inherit; 19 | } 20 | 21 | label { 22 | color: inherit; 23 | font-size: to-rem(14px); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /scss/components/_tooltip.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | 3 | tooltip { 4 | background-color: $bg; 5 | border-radius: 1rem; 6 | font-size: to-rem(19px); 7 | } 8 | -------------------------------------------------------------------------------- /scss/style.scss: -------------------------------------------------------------------------------- 1 | // Base 2 | @use "base/reset"; 3 | @use "base/typography"; 4 | 5 | // Components 6 | @use "components/tooltip"; 7 | @use "components/button"; 8 | 9 | // Windows 10 | @use "windows/desktop.scss"; 11 | @use "windows/bar"; 12 | @use "windows/dock.scss"; 13 | @use "windows/notifications"; 14 | @use "windows/OSD"; 15 | @use "windows/github.scss"; 16 | @use "windows/audio-control.scss"; 17 | @use "windows/display-control.scss"; 18 | @use "windows/network.scss"; 19 | @use "windows/battery.scss"; 20 | @use "windows/sysinfo.scss"; 21 | @use "windows/media-control.scss"; 22 | 23 | // Widgets 24 | @use "widgets/workspaces.scss"; 25 | @use "widgets/active-client"; 26 | @use "widgets/vitals"; 27 | -------------------------------------------------------------------------------- /scss/widgets/active-client.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | 3 | @mixin ActiveClient { 4 | .ActiveClient { 5 | .app-id { 6 | font-size: to-rem(13px); 7 | color: $overlay2; 8 | } 9 | .window-title { 10 | font-size: to-rem(15px); 11 | font-weight: 600; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /scss/widgets/vitals.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | @use "../components/button" as *; 3 | 4 | .Vitals { 5 | @include button; 6 | 7 | box { 8 | margin: 4px; 9 | } 10 | 11 | .cpu { 12 | icon { 13 | color: $overlay2; 14 | } 15 | } 16 | 17 | .memory { 18 | icon { 19 | color: $green; 20 | } 21 | } 22 | 23 | .disk { 24 | icon { 25 | color: $yellow; 26 | } 27 | } 28 | 29 | .temperature { 30 | icon { 31 | color: $red; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /scss/widgets/workspaces.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | 3 | .Workspaces { 4 | .monitor-workspaces { 5 | background-color: gtkalpha($surface1, 0.6); 6 | border-radius: to-rem(50px); 7 | padding: to-rem(8px) to-rem(8px); 8 | margin: 0 to-rem(4px) 0 0; 9 | 10 | &.monitor-1 .workspace-button { 11 | background-color: gtkalpha($blue, 0.2); 12 | 13 | &.active { 14 | background-color: $blue; 15 | box-shadow: 0 0 to-rem(4px) gtkalpha($blue, 0.4); 16 | } 17 | } 18 | 19 | &.monitor-2 .workspace-button { 20 | background-color: gtkalpha($green, 0.2); 21 | 22 | &.active { 23 | background-color: $green; 24 | box-shadow: 0 0 to-rem(4px) gtkalpha($green, 0.4); 25 | } 26 | } 27 | 28 | &.monitor-3 .workspace-button { 29 | background-color: gtkalpha($pink, 0.2); 30 | 31 | &.active { 32 | background-color: $pink; 33 | box-shadow: 0 0 to-rem(4px) gtkalpha($pink, 0.4); 34 | } 35 | } 36 | 37 | &.monitor-4 .workspace-button { 38 | background-color: gtkalpha($yellow, 0.2); 39 | 40 | &.active { 41 | background-color: $yellow; 42 | box-shadow: 0 0 to-rem(4px) gtkalpha($yellow, 0.4); 43 | } 44 | } 45 | 46 | &.monitor-5 .workspace-button { 47 | background-color: gtkalpha($teal, 0.2); 48 | 49 | &.active { 50 | background-color: $teal; 51 | box-shadow: 0 0 to-rem(4px) gtkalpha($teal, 0.4); 52 | } 53 | } 54 | 55 | &.monitor-6 .workspace-button { 56 | background-color: gtkalpha($sapphire, 0.2); 57 | 58 | &.active { 59 | background-color: $sapphire; 60 | box-shadow: 0 0 to-rem(4px) gtkalpha($sapphire, 0.4); 61 | } 62 | } 63 | 64 | .workspace-button { 65 | min-width: to-rem(10px); 66 | min-height: to-rem(10px); 67 | padding: 0; 68 | margin: 0 to-rem(2px); 69 | border-radius: to-rem(50px); 70 | transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); 71 | 72 | &:hover { 73 | -gtk-icon-transform: scale(1.2); 74 | } 75 | 76 | &.active { 77 | -gtk-icon-transform: scale(1.3); 78 | } 79 | } 80 | 81 | &:hover { 82 | background-color: gtkalpha($surface2, 0.7); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /scss/windows/OSD.scss: -------------------------------------------------------------------------------- 1 | @use "sass:string"; 2 | @use "sass:color"; 3 | @use "../abstracts" as *; 4 | 5 | window.OSDWindow { 6 | @include unset; 7 | background: transparent; 8 | box-shadow: none; 9 | } 10 | 11 | .OSD { 12 | background-color: gtkalpha($surface0, 0.95); 13 | border-radius: to-rem(24px); 14 | border: to-rem(1px) solid gtkalpha($border, 0.15); 15 | margin: to-rem(32px) 0; 16 | box-shadow: 0 to-rem(4px) to-rem(20px) gtkalpha(black, 0.2); 17 | padding: to-rem(5px); 18 | 19 | scale { 20 | trough { 21 | background-color: gtkalpha($surface2, 0.5); 22 | min-height: to-rem(10px); 23 | border-radius: to-rem(12px); 24 | 25 | highlight { 26 | background-color: $overlay2; 27 | background-image: none; 28 | border-radius: to-rem(12px); 29 | min-height: to-rem(10px); 30 | } 31 | } 32 | 33 | slider { 34 | opacity: 0; 35 | min-height: 0; 36 | min-width: 0; 37 | border: none; 38 | background: transparent; 39 | box-shadow: none; 40 | } 41 | } 42 | 43 | .volume-indicator-mute, 44 | .mic-indicator-mute { 45 | padding-right: to-rem(8px); 46 | icon { 47 | padding-right: to-rem(8px); 48 | } 49 | } 50 | 51 | .volume-indicator, 52 | .volume-indicator-mute, 53 | .mic-indicator, 54 | .mic-indicator-mute { 55 | padding: to-rem(14px) to-rem(18px); 56 | margin: to-rem(4px); 57 | 58 | .indicator { 59 | icon { 60 | margin-right: to-rem(12px); 61 | font-size: to-rem(24px); 62 | } 63 | 64 | label { 65 | color: $text; 66 | font-size: to-rem(14px); 67 | font-weight: 500; 68 | padding-right: to-rem(8px); 69 | } 70 | } 71 | } 72 | 73 | .volume-indicator { 74 | .indicator icon { 75 | color: $overlay2; 76 | } 77 | 78 | scale trough highlight { 79 | background-color: $overlay2; 80 | background-image: none; 81 | } 82 | } 83 | 84 | .mic-indicator { 85 | .indicator icon { 86 | color: $red; 87 | } 88 | 89 | scale trough highlight { 90 | background-color: $red; 91 | background-image: none; 92 | } 93 | } 94 | 95 | .volume-indicator-mute, 96 | .mic-indicator-mute { 97 | .indicator { 98 | icon { 99 | color: $red; 100 | } 101 | 102 | label { 103 | color: $subtext1; 104 | } 105 | } 106 | 107 | scale trough { 108 | background-color: gtkalpha($surface2, 0.3); 109 | 110 | highlight { 111 | background-color: $overlay2; 112 | background-image: none; 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /scss/windows/audio-control.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | @use "../components/button" as *; 3 | @use "sass:color"; 4 | 5 | window.AudioControlWindow { 6 | background: transparent; 7 | 8 | > box { 9 | background-color: $surface0; 10 | border-radius: to-rem(24px); 11 | border: to-rem(1px) solid gtkalpha($border, 0.15); 12 | min-width: to-rem(350px); 13 | margin: to-rem(12px) to-rem(90px); 14 | 15 | .volume-controls-container { 16 | background-color: gtkalpha($surface1, 0.5); 17 | border-radius: to-rem(20px); 18 | padding: to-rem(15px); 19 | margin-bottom: to-rem(16px); 20 | 21 | .speaker-control, 22 | .microphone-control { 23 | margin: to-rem(5px) 0; 24 | 25 | .speaker-icon, 26 | .microphone-icon { 27 | font-size: to-rem(20px); 28 | color: $overlay2; 29 | margin-right: to-rem(10px); 30 | } 31 | 32 | label { 33 | font-weight: 500; 34 | font-size: to-rem(14px); 35 | } 36 | 37 | .mute-button { 38 | padding: to-rem(6px); 39 | background-color: gtkalpha($surface1, 0.6); 40 | border-radius: to-rem(18px); 41 | 42 | &:hover { 43 | background-color: gtkalpha($surface2, 0.5); 44 | } 45 | 46 | icon { 47 | font-size: to-rem(18px); 48 | } 49 | } 50 | 51 | scale { 52 | margin: to-rem(12px) 0 to-rem(8px) 0; 53 | padding: to-rem(4px) 0; 54 | 55 | trough { 56 | background-color: gtkalpha($surface2, 0.5); 57 | border-radius: to-rem(12px); 58 | min-height: to-rem(10px); 59 | transition: all 0.2s ease; 60 | 61 | highlight { 62 | border-radius: to-rem(12px); 63 | min-height: to-rem(10px); 64 | background-color: $overlay2; 65 | background-image: none; 66 | transition: all 0.2s ease; 67 | } 68 | } 69 | 70 | &:hover trough, 71 | &:active trough { 72 | background-color: gtkalpha($surface2, 0.7); 73 | min-height: to-rem(14px); 74 | 75 | highlight { 76 | min-height: to-rem(14px); 77 | background-color: color.scale($overlay2, $lightness: 15%); 78 | box-shadow: 0 0 to-rem(6px) gtkalpha($overlay2, 0.4); 79 | } 80 | } 81 | 82 | slider { 83 | opacity: 0; 84 | min-height: 0; 85 | min-width: 0; 86 | border: none; 87 | background: transparent; 88 | box-shadow: none; 89 | } 90 | } 91 | 92 | .volume-percentage { 93 | color: $subtext0; 94 | font-size: to-rem(14px); 95 | font-weight: 500; 96 | margin-left: to-rem(5px); 97 | } 98 | } 99 | } 100 | 101 | .section-header { 102 | margin: to-rem(8px) 0; 103 | 104 | label { 105 | font-size: to-rem(16px); 106 | font-weight: 600; 107 | color: $text; 108 | } 109 | } 110 | 111 | .device-controls { 112 | margin-bottom: to-rem(16px); 113 | 114 | .devices-container { 115 | background-color: gtkalpha($surface1, 0.5); 116 | border-radius: to-rem(20px); 117 | padding: to-rem(10px); 118 | 119 | .device-selector { 120 | padding: to-rem(10px); 121 | border-radius: to-rem(16px); 122 | background-color: transparent; 123 | transition: all 200ms ease; 124 | margin-bottom: to-rem(4px); 125 | border: to-rem(1px) solid transparent; 126 | 127 | &:hover { 128 | background-color: gtkalpha($surface2, 0.5); 129 | } 130 | 131 | box { 132 | icon:first-child { 133 | font-size: to-rem(18px); 134 | color: $overlay2; 135 | margin-right: to-rem(12px); 136 | } 137 | 138 | label { 139 | font-weight: 500; 140 | font-size: to-rem(14px); 141 | } 142 | } 143 | 144 | icon:last-child { 145 | transition: all 250ms cubic-bezier(0.4, 0, 0.2, 1); 146 | -gtk-icon-transform: rotate(-90deg); 147 | opacity: 0.7; 148 | 149 | &.expanded { 150 | -gtk-icon-transform: rotate(0deg); 151 | opacity: 1; 152 | } 153 | } 154 | } 155 | 156 | .device-list { 157 | padding: to-rem(5px); 158 | background-color: gtkalpha($surface1, 0.3); 159 | border-radius: to-rem(12px); 160 | margin: to-rem(5px); 161 | 162 | button.device-item { 163 | padding: to-rem(12px) to-rem(10px); 164 | border-radius: to-rem(12px); 165 | background-color: transparent; 166 | transition: all 200ms ease; 167 | margin: to-rem(2px) 0; 168 | border: to-rem(1px) solid transparent; 169 | 170 | &:hover { 171 | background-color: gtkalpha($surface2, 0.5); 172 | } 173 | 174 | &.active { 175 | background-color: gtkalpha($overlay2, 0.15); 176 | border: to-rem(1px) solid gtkalpha($overlay2, 0.3); 177 | 178 | icon { 179 | color: $overlay2; 180 | } 181 | } 182 | 183 | box { 184 | icon { 185 | font-size: to-rem(18px); 186 | color: $overlay2; 187 | opacity: 0.8; 188 | } 189 | 190 | label { 191 | color: $text; 192 | font-weight: 500; 193 | font-size: to-rem(14px); 194 | margin-left: to-rem(8px); 195 | } 196 | } 197 | } 198 | } 199 | } 200 | } 201 | 202 | .settings { 203 | margin-top: to-rem(10px); 204 | 205 | button { 206 | @include button; 207 | padding: to-rem(12px); 208 | background-color: $overlay2; 209 | border-radius: to-rem(20px); 210 | font-weight: 500; 211 | font-size: to-rem(14px); 212 | color: $base; 213 | border: none; 214 | transition: all 200ms ease; 215 | 216 | &:hover { 217 | background-color: gtkalpha($overlay2, 0.8); 218 | box-shadow: 0 0 to-rem(2px) to-rem(4px) gtkalpha(black, 0.2); 219 | } 220 | } 221 | } 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /scss/windows/bar.scss: -------------------------------------------------------------------------------- 1 | @use "sass:color"; 2 | @use "../abstracts" as *; 3 | @use "../widgets/active-client" as *; 4 | @use "../components/button" as *; 5 | 6 | .Bar { 7 | @include unset($rec: true); 8 | 9 | > centerbox { 10 | background-color: $surface0; 11 | padding: to-rem(4px) 0; 12 | } 13 | 14 | .left-box { 15 | background-color: $surface0; 16 | border-radius: to-rem(20px); 17 | padding: to-rem(8px) to-rem(16px); 18 | /* margin: to-rem(20px) 0 0 to-rem(20px); */ 19 | } 20 | 21 | .center-box { 22 | background-color: $surface0; 23 | border-radius: to-rem(20px); 24 | padding: to-rem(8px) to-rem(16px); 25 | /* margin: to-rem(20px) 0 0 to-rem(20px); */ 26 | } 27 | 28 | .right-box { 29 | background-color: $surface0; 30 | border-radius: to-rem(20px); 31 | padding: to-rem(8px) to-rem(16px); 32 | /* margin: to-rem(20px) to-rem(20px) 0 0; */ 33 | } 34 | 35 | @include ActiveClient; 36 | 37 | .media-container { 38 | .media-clickable { 39 | border-radius: to-rem(12px); 40 | background-color: gtkalpha($surface1, 0.6); 41 | transition: all 200ms ease; 42 | 43 | &:hover { 44 | background-color: gtkalpha($surface2, 0.8); 45 | } 46 | 47 | box { 48 | .Cover { 49 | border-radius: 100%; 50 | background-size: cover; 51 | background-position: center; 52 | min-width: 24px; 53 | min-height: 24px; 54 | background-color: gtkalpha($surface2, 0.6); 55 | margin-left: to-rem(6px); 56 | } 57 | 58 | label { 59 | color: $text; 60 | font-size: to-rem(13px); 61 | font-weight: 500; 62 | } 63 | 64 | revealer { 65 | transition: all 200ms cubic-bezier(0.1, 1, 0.3, 1); 66 | padding-right: to-rem(6px); 67 | } 68 | } 69 | } 70 | } 71 | 72 | .github-button { 73 | @include button; 74 | } 75 | 76 | .systray- { 77 | button { 78 | @include button; 79 | } 80 | } 81 | 82 | .audio-button { 83 | @include button; 84 | } 85 | 86 | .display-button { 87 | @include button; 88 | } 89 | 90 | .wifi-button { 91 | @include button; 92 | } 93 | 94 | .battery-button { 95 | @include button; 96 | } 97 | 98 | .clock-button { 99 | @include button; 100 | } 101 | 102 | .sysinfo-button { 103 | border-radius: 100%; 104 | padding: to-rem(6px); 105 | background-color: gtkalpha($surface1, 0.6); 106 | transition: all 200ms ease; 107 | border: none; 108 | margin: 0 to-rem(4px); 109 | 110 | &:hover { 111 | background-color: gtkalpha($surface2, 0.8); 112 | } 113 | 114 | .profile-image { 115 | border-radius: 100%; 116 | background-size: cover; 117 | background-position: center; 118 | min-width: 24px; 119 | min-height: 24px; 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /scss/windows/battery.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | 3 | window.BatteryWindow { 4 | background: transparent; 5 | 6 | > box { 7 | background-color: $surface0; 8 | border-radius: to-rem(24px); 9 | border: to-rem(1px) solid gtkalpha($border, 0.2); 10 | min-width: to-rem(350px); 11 | margin: to-rem(12px) to-rem(90px); 12 | 13 | .battery-main-info { 14 | margin-bottom: to-rem(16px); 15 | 16 | icon { 17 | font-size: to-rem(48px); 18 | color: $overlay2; 19 | margin-right: to-rem(16px); 20 | } 21 | 22 | box label { 23 | &:first-child { 24 | font-size: to-rem(18px); 25 | font-weight: 600; 26 | color: $text; 27 | margin-bottom: to-rem(2px); 28 | } 29 | 30 | &:last-child { 31 | font-size: to-rem(14px); 32 | color: $subtext1; 33 | } 34 | } 35 | } 36 | 37 | .battery-info-container { 38 | background-color: gtkalpha($surface1, 0.5); 39 | border-radius: to-rem(20px); 40 | padding: to-rem(16px); 41 | margin-bottom: to-rem(16px); 42 | 43 | .battery-details { 44 | box { 45 | margin: to-rem(6px) 0; 46 | 47 | label { 48 | &:nth-child(1) { 49 | color: $subtext1; 50 | font-size: to-rem(14px); 51 | } 52 | 53 | &:nth-child(2) { 54 | color: $text; 55 | font-size: to-rem(14px); 56 | font-weight: 500; 57 | } 58 | } 59 | } 60 | } 61 | } 62 | 63 | .power-profiles-section { 64 | margin-bottom: to-rem(16px); 65 | 66 | > label { 67 | font-size: to-rem(16px); 68 | font-weight: 600; 69 | color: $text; 70 | margin-bottom: to-rem(8px); 71 | } 72 | 73 | .power-mode-buttons { 74 | .power-mode-button { 75 | background-color: gtkalpha($surface1, 0.5); 76 | border-radius: to-rem(16px); 77 | padding: to-rem(12px) to-rem(8px); 78 | transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); 79 | border: to-rem(1px) solid transparent; 80 | 81 | &:hover { 82 | background-color: gtkalpha($surface2, 0.6); 83 | border-color: gtkalpha($border, 0.15); 84 | } 85 | 86 | &.active { 87 | background-color: gtkalpha($overlay2, 0.15); 88 | border-color: gtkalpha($overlay2, 0.3); 89 | 90 | label { 91 | color: $overlay2; 92 | font-weight: 600; 93 | } 94 | } 95 | 96 | label { 97 | font-size: to-rem(13px); 98 | font-weight: 500; 99 | color: $text; 100 | } 101 | } 102 | } 103 | } 104 | 105 | .conservation-mode-section { 106 | margin-bottom: to-rem(16px); 107 | 108 | > label { 109 | font-size: to-rem(16px); 110 | font-weight: 600; 111 | color: $text; 112 | margin-bottom: to-rem(8px); 113 | } 114 | 115 | .conservation-mode-button { 116 | background-color: gtkalpha($surface1, 0.5); 117 | border-radius: to-rem(18px); 118 | padding: to-rem(14px) to-rem(16px); 119 | transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); 120 | border: to-rem(1px) solid transparent; 121 | 122 | &:hover { 123 | background-color: gtkalpha($surface2, 0.6); 124 | border-color: gtkalpha($border, 0.15); 125 | } 126 | 127 | &.active { 128 | background-color: gtkalpha($overlay2, 0.15); 129 | border-color: gtkalpha($overlay2, 0.3); 130 | 131 | icon:first-child { 132 | color: $overlay2; 133 | } 134 | 135 | box label:first-child { 136 | color: $overlay2; 137 | } 138 | 139 | icon:last-child { 140 | color: $overlay2; 141 | } 142 | } 143 | 144 | icon:first-child { 145 | font-size: to-rem(22px); 146 | color: $text; 147 | margin-right: to-rem(10px); 148 | } 149 | 150 | box { 151 | label:first-child { 152 | font-size: to-rem(14px); 153 | font-weight: 500; 154 | color: $text; 155 | margin-bottom: to-rem(2px); 156 | } 157 | 158 | label:last-child { 159 | font-size: to-rem(12px); 160 | color: $subtext1; 161 | } 162 | } 163 | 164 | icon:last-child { 165 | font-size: to-rem(16px); 166 | color: $subtext0; 167 | } 168 | } 169 | } 170 | 171 | .settings-section { 172 | .settings-button { 173 | background-color: $overlay2; 174 | border-radius: to-rem(20px); 175 | padding: to-rem(14px); 176 | transition: all 200ms ease; 177 | border: none; 178 | 179 | &:hover { 180 | background-color: gtkalpha($overlay2, 0.8); 181 | box-shadow: 0 to-rem(2px) to-rem(4px) gtkalpha(black, 0.2); 182 | } 183 | 184 | label { 185 | font-size: to-rem(14px); 186 | font-weight: 500; 187 | color: $base; 188 | } 189 | } 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /scss/windows/desktop.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | 3 | window.DesktopFrame { 4 | background: transparent; 5 | 6 | .desktop-frame { 7 | background-color: transparent; 8 | box-shadow: 0 0 0 to-rem(40px) $surface0; 9 | margin: to-rem(10px); 10 | border-radius: to-rem(30px); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /scss/windows/display-control.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | @use "../components/button" as *; 3 | @use "sass:color"; 4 | 5 | window.DisplayControlWindow { 6 | background: transparent; 7 | 8 | > box { 9 | background-color: $surface0; 10 | border-radius: to-rem(24px); 11 | border: to-rem(1px) solid gtkalpha($border, 0.2); 12 | min-width: to-rem(350px); 13 | margin: to-rem(12px) to-rem(60px); 14 | 15 | .section-container { 16 | background-color: gtkalpha($surface1, 0.5); 17 | border-radius: to-rem(20px); 18 | padding: to-rem(16px); 19 | margin-bottom: to-rem(16px); 20 | } 21 | 22 | .brightness-card { 23 | > box:first-child { 24 | margin-bottom: to-rem(12px); 25 | 26 | .setting-icon { 27 | font-size: to-rem(20px); 28 | color: $yellow; 29 | } 30 | 31 | .setting-title { 32 | font-size: to-rem(15px); 33 | font-weight: 500; 34 | color: $text; 35 | } 36 | 37 | .setting-value { 38 | font-size: to-rem(13px); 39 | font-weight: 500; 40 | color: $overlay2; 41 | min-width: to-rem(40px); 42 | } 43 | } 44 | 45 | > box:nth-child(2) { 46 | .slider-icon { 47 | color: $subtext1; 48 | font-size: to-rem(16px); 49 | min-width: to-rem(20px); 50 | } 51 | 52 | scale { 53 | margin: to-rem(4px) 0; 54 | padding: to-rem(4px) 0; 55 | 56 | trough { 57 | background-color: gtkalpha($surface2, 0.5); 58 | border-radius: to-rem(12px); 59 | min-height: to-rem(10px); 60 | transition: all 0.2s ease; 61 | 62 | highlight { 63 | border-radius: to-rem(12px); 64 | min-height: to-rem(10px); 65 | background-color: $overlay2; 66 | background-image: none; 67 | transition: all 0.2s ease; 68 | } 69 | } 70 | 71 | &:hover trough, 72 | &:active trough { 73 | background-color: gtkalpha($surface2, 0.7); 74 | min-height: to-rem(14px); 75 | 76 | highlight { 77 | min-height: to-rem(14px); 78 | background-color: color.scale($yellow, $lightness: 15%); 79 | background-image: none; 80 | box-shadow: 0 0 to-rem(6px) gtkalpha($yellow, 0.4); 81 | } 82 | } 83 | 84 | slider { 85 | opacity: 0; 86 | min-height: 0; 87 | min-width: 0; 88 | border: none; 89 | background: transparent; 90 | box-shadow: none; 91 | } 92 | } 93 | } 94 | } 95 | 96 | .quick-toggles-card { 97 | .toggles-row { 98 | margin-bottom: to-rem(8px); 99 | } 100 | 101 | .quick-toggle { 102 | border-radius: to-rem(16px); 103 | background-color: gtkalpha($surface2, 0.3); 104 | padding: to-rem(8px) to-rem(5px); 105 | margin-right: to-rem(8px); 106 | border: to-rem(1px) solid transparent; 107 | transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); 108 | 109 | &:hover { 110 | background-color: gtkalpha($surface2, 0.5); 111 | border-color: gtkalpha($border, 0.15); 112 | } 113 | 114 | &.active { 115 | border-color: gtkalpha($overlay2, 0.3); 116 | } 117 | 118 | &.night-light { 119 | &.active { 120 | background-color: gtkalpha($peach, 0.12); 121 | border-color: gtkalpha($peach, 0.3); 122 | 123 | icon, 124 | label { 125 | color: $peach; 126 | } 127 | } 128 | } 129 | 130 | &.dark-mode { 131 | &.active { 132 | background-color: gtkalpha($lavender, 0.12); 133 | border-color: gtkalpha($lavender, 0.3); 134 | 135 | icon, 136 | label { 137 | color: $lavender; 138 | } 139 | } 140 | } 141 | 142 | .toggle-icon { 143 | font-size: to-rem(20px); 144 | margin: to-rem(3px) 0; 145 | color: $text; 146 | } 147 | 148 | .toggle-label { 149 | font-size: to-rem(12px); 150 | font-weight: 500; 151 | color: $text; 152 | } 153 | } 154 | 155 | .color-temperature-controls { 156 | background-color: gtkalpha($surface2, 0.3); 157 | border-radius: to-rem(16px); 158 | padding: to-rem(12px); 159 | margin-top: to-rem(4px); 160 | 161 | .subsetting-title { 162 | font-size: to-rem(14px); 163 | font-weight: 500; 164 | color: $overlay2; 165 | margin-bottom: to-rem(8px); 166 | } 167 | 168 | .slider-icon { 169 | font-size: to-rem(16px); 170 | color: $subtext1; 171 | } 172 | 173 | .gamma-slider { 174 | margin: to-rem(8px) 0; 175 | 176 | &, 177 | &:hover, 178 | &:active, 179 | &:focus, 180 | &:disabled { 181 | background-color: transparent; 182 | } 183 | 184 | trough { 185 | background-image: linear-gradient(to right, $peach 0%, $blue 100%); 186 | border-radius: to-rem(8px); 187 | min-height: to-rem(4px); 188 | 189 | highlight { 190 | background-color: transparent; 191 | background-image: none; 192 | border: none; 193 | } 194 | } 195 | 196 | slider { 197 | min-width: to-rem(18px); 198 | min-height: to-rem(18px); 199 | background-color: $text; 200 | border-radius: 50%; 201 | box-shadow: 0 to-rem(1px) to-rem(3px) gtkalpha(black, 0.2); 202 | margin: to-rem(-7px); 203 | } 204 | } 205 | 206 | .slider-label { 207 | font-size: to-rem(12px); 208 | color: $subtext1; 209 | margin-top: to-rem(4px); 210 | } 211 | } 212 | } 213 | 214 | .settings { 215 | margin-top: to-rem(8px); 216 | 217 | .settings-button { 218 | @include button; 219 | padding: to-rem(12px); 220 | background-color: $overlay2; 221 | border-radius: to-rem(20px); 222 | font-weight: 500; 223 | font-size: to-rem(14px); 224 | color: $base; 225 | border: none; 226 | transition: all 200ms ease; 227 | 228 | &:hover { 229 | background-color: gtkalpha($overlay2, 0.9); 230 | box-shadow: 0 to-rem(2px) to-rem(4px) gtkalpha(black, 0.2); 231 | } 232 | } 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /scss/windows/dock.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | 3 | window.Dock { 4 | background: transparent; 5 | transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); 6 | 7 | .dock-wrapper { 8 | background-color: gtkalpha($surface0, 0.95); 9 | border: to-rem(1px) solid gtkalpha($border, 0.2); 10 | border-radius: to-rem(16px); 11 | padding: to-rem(4px); 12 | margin: to-rem(10px) to-rem(24px); 13 | box-shadow: 0 to-rem(2px) to-rem(12px) gtkalpha($shadow, 0.2); 14 | transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); 15 | opacity: 1; 16 | } 17 | 18 | &:not(.revealed) .dock-wrapper { 19 | opacity: 0.5; 20 | margin-bottom: to-rem(-60px); 21 | background-color: gtkalpha($surface0, 0.3); 22 | padding: to-rem(2px); 23 | min-height: to-rem(4px); 24 | border-radius: to-rem(2px); 25 | 26 | .dock-container { 27 | opacity: 0; 28 | } 29 | } 30 | 31 | .dock-container { 32 | min-height: to-rem(58px); 33 | padding: to-rem(2px); 34 | transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1); 35 | } 36 | 37 | .dock-icon { 38 | padding: to-rem(4px) to-rem(6px); 39 | border-radius: to-rem(8px); 40 | transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); 41 | background-color: transparent; 42 | margin: 0 to-rem(1px); 43 | 44 | &:hover { 45 | background-color: gtkalpha($surface1, 0.6); 46 | -gtk-icon-transform: scale(1.1); 47 | } 48 | 49 | icon { 50 | font-size: to-rem(48px); 51 | color: $text; 52 | -gtk-icon-shadow: 0 to-rem(1px) to-rem(2px) gtkalpha($shadow, 0.2); 53 | } 54 | 55 | .indicator { 56 | background-color: $overlay2; 57 | border-radius: 50%; 58 | min-width: to-rem(5px); 59 | min-height: to-rem(5px); 60 | margin: 0; 61 | opacity: 0.8; 62 | } 63 | } 64 | } 65 | 66 | window.DockDetector { 67 | background: transparent; 68 | 69 | eventbox { 70 | background: transparent; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /scss/windows/github.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | 3 | window.GithubWindow { 4 | background: transparent; 5 | 6 | > box { 7 | background-color: $surface0; 8 | border-radius: to-rem(24px); 9 | border: to-rem(1px) solid gtkalpha($border, 0.2); 10 | min-width: to-rem(360px); 11 | margin: to-rem(12px) to-rem(198px); 12 | padding: to-rem(20px); 13 | 14 | .header { 15 | padding: to-rem(4px) 0; 16 | margin-bottom: to-rem(16px); 17 | 18 | icon { 19 | font-size: to-rem(24px); 20 | color: $overlay2; 21 | } 22 | 23 | label { 24 | color: $text; 25 | font-size: to-rem(18px); 26 | font-weight: 600; 27 | margin-left: to-rem(4px); 28 | } 29 | } 30 | 31 | .update-bar { 32 | background-color: gtkalpha($surface1, 0.5); 33 | border-radius: to-rem(20px); 34 | padding: to-rem(12px) to-rem(16px); 35 | margin-bottom: to-rem(16px); 36 | border: to-rem(1px) solid transparent; 37 | 38 | label { 39 | color: $overlay2; 40 | font-size: to-rem(13px); 41 | font-weight: 500; 42 | } 43 | 44 | .refresh-button { 45 | background-color: transparent; 46 | padding: to-rem(6px); 47 | border-radius: to-rem(14px); 48 | min-height: to-rem(28px); 49 | min-width: to-rem(28px); 50 | transition: all 200ms ease; 51 | } 52 | 53 | .refresh-button:hover { 54 | background-color: gtkalpha($overlay2, 0.1); 55 | } 56 | 57 | .refresh-button icon { 58 | color: $overlay2; 59 | font-size: to-rem(16px); 60 | } 61 | } 62 | 63 | .github-feed-container { 64 | background-color: gtkalpha($surface1, 0.5); 65 | border-radius: to-rem(20px); 66 | padding: to-rem(16px); 67 | margin-bottom: to-rem(16px); 68 | 69 | .loading-indicator, 70 | .error-indicator { 71 | padding: to-rem(40px) 0; 72 | 73 | label { 74 | color: $overlay2; 75 | font-size: to-rem(14px); 76 | } 77 | } 78 | 79 | .github-feed { 80 | min-height: to-rem(300px); 81 | 82 | scrolledwindow { 83 | border: none; 84 | outline: none; 85 | box-shadow: none; 86 | } 87 | 88 | viewport { 89 | border: none; 90 | outline: none; 91 | } 92 | 93 | .github-event-item { 94 | padding: to-rem(14px) to-rem(16px); 95 | border-radius: to-rem(16px); 96 | background-color: gtkalpha($surface2, 0.3); 97 | transition: all 200ms ease; 98 | margin: to-rem(8px) 0; 99 | border: to-rem(1px) solid transparent; 100 | } 101 | 102 | .github-event-item:hover { 103 | background-color: gtkalpha($surface2, 0.5); 104 | border-color: gtkalpha($border, 0.15); 105 | } 106 | 107 | .avatar-image { 108 | border-radius: to-rem(12px); 109 | min-width: to-rem(44px); 110 | min-height: to-rem(44px); 111 | margin-right: to-rem(12px); 112 | border: to-rem(1px) solid gtkalpha($border, 0.2); 113 | } 114 | 115 | .actor-name { 116 | color: $overlay2; 117 | font-weight: 600; 118 | font-size: to-rem(14px); 119 | } 120 | 121 | .event-type { 122 | color: $subtext1; 123 | font-size: to-rem(14px); 124 | font-weight: 400; 125 | } 126 | 127 | .repo-name { 128 | color: $text; 129 | font-size: to-rem(14px); 130 | font-weight: 500; 131 | margin: to-rem(4px) 0; 132 | } 133 | 134 | .event-time { 135 | color: $subtext1; 136 | font-size: to-rem(12px); 137 | margin-top: to-rem(4px); 138 | } 139 | } 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /scss/windows/media-control.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | 3 | window.MediaControlWindow { 4 | background: transparent; 5 | 6 | > box { 7 | background-color: $surface0; 8 | border-radius: to-rem(20px); 9 | margin: to-rem(12px) 0; 10 | 11 | .album-image { 12 | border-radius: to-rem(20px); 13 | background-size: cover; 14 | background-position: center; 15 | min-width: to-rem(150px); 16 | min-height: to-rem(150px); 17 | } 18 | 19 | .media-info { 20 | min-width: to-rem(400px); 21 | 22 | .media-title { 23 | font-size: to-rem(24px); 24 | font-weight: 600; 25 | color: $overlay2; 26 | } 27 | 28 | .media-artist { 29 | font-size: to-rem(16px); 30 | font-weight: 500; 31 | color: $text; 32 | } 33 | 34 | .media-album { 35 | font-size: to-rem(14px); 36 | color: $subtext1; 37 | } 38 | } 39 | 40 | .progress-tracker { 41 | label { 42 | font-size: to-rem(12px); 43 | font-weight: 500; 44 | color: $subtext1; 45 | min-width: to-rem(45px); 46 | } 47 | 48 | .progress-slider { 49 | trough { 50 | background-color: gtkalpha($surface2, 0.5); 51 | border-radius: to-rem(12px); 52 | min-height: to-rem(4px); 53 | transition: all 200ms ease; 54 | 55 | highlight { 56 | background-color: $overlay2; 57 | border-radius: to-rem(12px); 58 | min-height: to-rem(4px); 59 | transition: all 200ms ease; 60 | } 61 | } 62 | 63 | &:hover trough { 64 | min-height: to-rem(6px); 65 | 66 | highlight { 67 | min-height: to-rem(6px); 68 | background-color: gtkalpha($overlay2, 0.8); 69 | } 70 | } 71 | 72 | slider { 73 | opacity: 0; 74 | min-height: 0; 75 | min-width: 0; 76 | border: none; 77 | background: transparent; 78 | box-shadow: none; 79 | } 80 | } 81 | } 82 | 83 | .playback-controls { 84 | button { 85 | background-color: gtkalpha($surface1, 0.6); 86 | border-radius: to-rem(16px); 87 | transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); 88 | border: to-rem(1px) solid transparent; 89 | 90 | &:hover { 91 | background-color: gtkalpha($surface2, 0.6); 92 | border-color: gtkalpha($border, 0.15); 93 | } 94 | 95 | &:active { 96 | background-color: gtkalpha($overlay2, 0.15); 97 | border-color: gtkalpha($overlay2, 0.3); 98 | } 99 | 100 | &:disabled { 101 | opacity: 0.5; 102 | } 103 | 104 | icon { 105 | color: $text; 106 | 107 | &.media-playback-pause-symbolic, 108 | &.media-playback-start-symbolic { 109 | font-size: to-rem(24px); 110 | } 111 | } 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /scss/windows/network.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | @use "../components/button" as *; 3 | 4 | window.NetworkWindow { 5 | background: transparent; 6 | 7 | > box { 8 | background-color: $surface0; 9 | border-radius: to-rem(24px); 10 | border: to-rem(1px) solid gtkalpha($border, 0.2); 11 | min-width: to-rem(350px); 12 | margin: to-rem(12px) to-rem(90px); 13 | 14 | .quick-settings-row { 15 | margin: to-rem(5px) 0 to-rem(15px); 16 | 17 | .quick-toggle { 18 | border-radius: to-rem(16px); 19 | background-color: gtkalpha($surface1, 0.5); 20 | padding: to-rem(8px) to-rem(5px); 21 | margin-right: to-rem(8px); 22 | border: to-rem(1px) solid transparent; 23 | min-width: to-rem(80px); 24 | transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); 25 | 26 | &:hover { 27 | background-color: gtkalpha($surface2, 0.5); 28 | border-color: gtkalpha($border, 0.15); 29 | } 30 | 31 | &.active { 32 | background-color: gtkalpha($overlay2, 0.15); 33 | border-color: gtkalpha($overlay2, 0.3); 34 | 35 | icon { 36 | color: $overlay2; 37 | } 38 | 39 | label { 40 | color: $overlay2; 41 | } 42 | } 43 | 44 | box { 45 | min-width: to-rem(80px); 46 | 47 | icon { 48 | font-size: to-rem(20px); 49 | margin: to-rem(3px) 0; 50 | color: $text; 51 | } 52 | 53 | label { 54 | font-size: to-rem(12px); 55 | font-weight: 500; 56 | color: $text; 57 | } 58 | } 59 | } 60 | } 61 | 62 | .current-network { 63 | background-color: gtkalpha($surface1, 0.5); 64 | border-radius: to-rem(20px); 65 | padding: to-rem(16px); 66 | margin: to-rem(8px) 0 to-rem(16px); 67 | 68 | > box:first-child { 69 | margin-bottom: to-rem(10px); 70 | 71 | icon { 72 | font-size: to-rem(24px); 73 | color: $overlay2; 74 | } 75 | 76 | label { 77 | font-size: 1.1em; 78 | font-weight: 600; 79 | color: $text; 80 | } 81 | } 82 | 83 | .network-details { 84 | box { 85 | margin: to-rem(6px) 0; 86 | 87 | label { 88 | &:nth-child(1) { 89 | color: $subtext1; 90 | font-size: to-rem(13px); 91 | } 92 | 93 | &:nth-child(2) { 94 | color: $text; 95 | font-size: to-rem(13px); 96 | font-weight: 500; 97 | } 98 | } 99 | } 100 | } 101 | } 102 | 103 | .section-header { 104 | margin: to-rem(4px) 0; 105 | 106 | label { 107 | font-size: to-rem(16px); 108 | font-weight: 600; 109 | color: $text; 110 | } 111 | } 112 | 113 | .networks-section { 114 | .networks-container { 115 | background-color: gtkalpha($surface1, 0.5); 116 | border-radius: to-rem(20px); 117 | padding: to-rem(10px); 118 | 119 | .network-selector { 120 | padding: to-rem(10px); 121 | border-radius: to-rem(16px); 122 | background-color: transparent; 123 | transition: all 200ms ease; 124 | margin-bottom: to-rem(4px); 125 | border: to-rem(1px) solid transparent; 126 | 127 | &:hover { 128 | background-color: gtkalpha($surface2, 0.5); 129 | } 130 | 131 | box { 132 | icon:first-child { 133 | font-size: to-rem(18px); 134 | color: $overlay2; 135 | margin-right: to-rem(12px); 136 | } 137 | 138 | label { 139 | font-weight: 500; 140 | font-size: to-rem(14px); 141 | } 142 | } 143 | 144 | icon:last-child { 145 | transition: all 250ms cubic-bezier(0.4, 0, 0.2, 1); 146 | -gtk-icon-transform: rotate(-90deg); 147 | opacity: 0.7; 148 | 149 | &.expanded { 150 | -gtk-icon-transform: rotate(0deg); 151 | opacity: 1; 152 | } 153 | } 154 | } 155 | 156 | .networks-list-container { 157 | padding: to-rem(5px); 158 | 159 | .network-list { 160 | min-height: to-rem(200px); 161 | 162 | &, 163 | scrolledwindow { 164 | border: none; 165 | outline: none; 166 | box-shadow: none; 167 | } 168 | 169 | viewport { 170 | border: none; 171 | outline: none; 172 | } 173 | 174 | button { 175 | padding: to-rem(12px) to-rem(10px); 176 | border-radius: to-rem(12px); 177 | background-color: transparent; 178 | transition: all 200ms ease; 179 | margin: to-rem(2px) 0; 180 | border: to-rem(1px) solid transparent; 181 | 182 | &:hover { 183 | background-color: gtkalpha($surface2, 0.5); 184 | } 185 | 186 | &.active { 187 | background-color: gtkalpha($overlay2, 0.15); 188 | border: to-rem(1px) solid gtkalpha($overlay2, 0.3); 189 | 190 | icon { 191 | color: $overlay2; 192 | } 193 | } 194 | 195 | box { 196 | icon { 197 | font-size: to-rem(18px); 198 | color: $overlay2; 199 | opacity: 0.8; 200 | } 201 | 202 | label:nth-child(2) { 203 | color: $text; 204 | font-weight: 500; 205 | margin-left: to-rem(8px); 206 | } 207 | 208 | label:nth-child(3) { 209 | color: $overlay2; 210 | font-size: to-rem(13px); 211 | font-weight: 500; 212 | } 213 | } 214 | } 215 | } 216 | } 217 | } 218 | } 219 | 220 | .settings { 221 | margin-top: to-rem(12px); 222 | 223 | button { 224 | @include button; 225 | padding: to-rem(12px); 226 | background-color: $overlay2; 227 | border-radius: to-rem(20px); 228 | font-weight: 500; 229 | font-size: to-rem(14px); 230 | color: $base; 231 | border: none; 232 | transition: all 200ms ease; 233 | 234 | &:hover { 235 | background-color: gtkalpha($overlay2, 0.8); 236 | box-shadow: 0 to-rem(2px) to-rem(4px) gtkalpha(black, 0.2); 237 | } 238 | } 239 | } 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /scss/windows/notifications.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | 3 | eventbox.Notification { 4 | &:first-child > box { 5 | margin-top: to-rem(16px); 6 | } 7 | 8 | &:last-child > box { 9 | margin-bottom: to-rem(16px); 10 | } 11 | 12 | > box { 13 | min-width: to-rem(500px); 14 | border-radius: to-rem(20px); 15 | background-color: $surface0; 16 | margin: to-rem(0.5px) to-rem(20px); 17 | box-shadow: to-rem(2px) to-rem(3px) to-rem(8px) 0 gtkalpha($shadow, 0.4); 18 | } 19 | 20 | &.critical > box { 21 | border: to-rem(1px) solid gtkalpha($red, 0.4); 22 | 23 | .header { 24 | .app-name { 25 | color: $red; 26 | } 27 | 28 | .app-icon { 29 | color: gtkalpha($red, 0.8); 30 | } 31 | } 32 | } 33 | 34 | .header { 35 | padding: to-rem(8px); 36 | color: $subtext1; 37 | border-radius: to-rem(20px) to-rem(20px) 0 0; 38 | background-color: gtkalpha($surface1, 0.6); 39 | 40 | .app-icon { 41 | margin: 0 to-rem(6px); 42 | color: $lavender; 43 | } 44 | 45 | .app-name { 46 | margin-right: to-rem(5px); 47 | font-weight: bold; 48 | color: $overlay2; 49 | 50 | &:first-child { 51 | margin-left: to-rem(6px); 52 | } 53 | } 54 | 55 | .time { 56 | margin: 0 to-rem(6px); 57 | color: $subtext0; 58 | } 59 | 60 | button { 61 | padding: to-rem(3px); 62 | min-width: 0; 63 | min-height: 0; 64 | color: $overlay2; 65 | transition: all 200ms ease; 66 | 67 | &:hover { 68 | color: $red; 69 | } 70 | } 71 | } 72 | 73 | .content { 74 | margin: to-rem(16px); 75 | margin-top: to-rem(8px); 76 | padding: to-rem(20px) 0; 77 | 78 | .summary { 79 | font-size: to-rem(18px); 80 | color: $text; 81 | } 82 | 83 | .body { 84 | color: $subtext1; 85 | padding: to-rem(6px) 0; 86 | } 87 | 88 | .image { 89 | border: to-rem(1px) solid $border; 90 | margin-right: to-rem(8px); 91 | border-radius: to-rem(9px); 92 | min-width: to-rem(100px); 93 | min-height: to-rem(100px); 94 | background-size: cover; 95 | background-position: center; 96 | } 97 | } 98 | 99 | .actions { 100 | margin: to-rem(16px); 101 | margin-top: 0; 102 | padding: to-rem(3px); 103 | 104 | button { 105 | margin: 0 to-rem(5px); 106 | padding: to-rem(8px) to-rem(16px); 107 | border-radius: to-rem(6px); 108 | background-color: gtkalpha($surface1, 0.6); 109 | color: $text; 110 | transition: all 200ms ease; 111 | 112 | &:hover { 113 | background-color: $overlay2; 114 | color: $base; 115 | } 116 | 117 | &:first-child { 118 | margin-left: 0; 119 | } 120 | 121 | &:last-child { 122 | margin-right: 0; 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /scss/windows/sysinfo.scss: -------------------------------------------------------------------------------- 1 | @use "../abstracts" as *; 2 | @use "sass:color"; 3 | 4 | window.SysInfoWindow { 5 | background: transparent; 6 | 7 | > box { 8 | background-color: $surface0; 9 | border-radius: to-rem(24px); 10 | border: to-rem(1px) solid gtkalpha($border, 0.2); 11 | min-width: to-rem(400px); 12 | margin: to-rem(12px) to-rem(10px); 13 | 14 | > box:first-child { 15 | background-color: gtkalpha($surface1, 0.5); 16 | border-radius: to-rem(20px); 17 | margin-bottom: to-rem(16px); 18 | 19 | > box:first-child { 20 | padding: to-rem(16px); 21 | 22 | > box { 23 | .distro-logo { 24 | font-size: to-rem(64px); 25 | color: $overlay2; 26 | margin-right: to-rem(16px); 27 | } 28 | 29 | > box { 30 | .distro-name { 31 | font-size: to-rem(24px); 32 | font-weight: 600; 33 | color: $text; 34 | } 35 | 36 | .distro-version { 37 | font-size: to-rem(14px); 38 | font-weight: 500; 39 | color: $overlay2; 40 | } 41 | 42 | .distro-codename { 43 | font-size: to-rem(13px); 44 | color: $subtext1; 45 | } 46 | } 47 | } 48 | } 49 | 50 | > box:last-child { 51 | padding: to-rem(16px); 52 | border-left: to-rem(1px) solid gtkalpha($border, 0.1); 53 | 54 | .info-label { 55 | color: $overlay2; 56 | font-size: to-rem(14px); 57 | } 58 | 59 | .info-value { 60 | color: $text; 61 | font-size: to-rem(14px); 62 | font-weight: 500; 63 | } 64 | } 65 | } 66 | 67 | > box:last-child { 68 | background-color: gtkalpha($surface1, 0.5); 69 | border-radius: to-rem(20px); 70 | padding: to-rem(16px); 71 | margin-bottom: to-rem(16px); 72 | 73 | box { 74 | padding: to-rem(8px) 0; 75 | border-bottom: to-rem(1px) solid gtkalpha($border, 0.1); 76 | 77 | &:last-child { 78 | border-bottom: none; 79 | } 80 | 81 | .info-label { 82 | color: $overlay2; 83 | font-size: to-rem(14px); 84 | min-width: to-rem(80px); 85 | } 86 | 87 | .info-value { 88 | color: $text; 89 | font-size: to-rem(14px); 90 | font-weight: 500; 91 | } 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | # 🚀 Astal Bar Development Tasks 2 | 3 | ## Core Modules To-Do List 4 | 5 | ### Bluetooth Module 6 | 7 | - [ ] Create a Bluetooth module with additional information 8 | - Device battery levels 9 | - Connection status 10 | - Other device details 11 | 12 | ### Display Controls Module 13 | 14 | - [x] Create a comprehensive display control module including: 15 | - Brightness control (with multi-monitor support like KDE Plasma) 16 | - Night light slider (or toggle switch as fallback) 17 | - Dark mode toggle 18 | 19 | ### Keyboard Visualization 20 | 21 | - [ ] Develop a module that replicates `keycastr` functionality 22 | - Real-time key press display 23 | - Visual feedback 24 | 25 | ### Dock Improvements 26 | 27 | - [ ] Enhance dock functionality 28 | - Window focusing when clicking open applications 29 | - Investigate Niri compatibility 30 | 31 | ### Clock & Calendar Module 32 | 33 | - [ ] Create clock module (similar to GNOME 46) 34 | - Calendar integration 35 | - Notification center 36 | - Event management 37 | - Timer/pomodoro functionality with configurable work and rest periods 38 | 39 | ### GitHub Integration 40 | 41 | - [-] Develop GitHub events module 42 | - Utilize GitHub's free API 43 | - Display personal event feed 44 | - Activity tracking 45 | - Add tabs for different views: 46 | - Current activity view 47 | - Pull request status tracker 48 | - Subscribed notifications panel 49 | 50 | ### System Vitals Module 51 | 52 | - [ ] Complete the Vitals module to display: 53 | - RAM usage 54 | - CPU utilization 55 | - System temperature 56 | - Additional metrics similar to GNOME Vitals extension 57 | 58 | ### Configuration Module 59 | 60 | - [ ] Create a module for user configurations: 61 | - User-variable settings 62 | - Pinned icon management 63 | - GitHub account setup 64 | - Additional customization options 65 | 66 | ### System Info Panel 67 | 68 | - [ ] Add a dock icon for system settings that includes: 69 | - Configuration options 70 | - Custom Lua-based system info display (similar to neofetch) 71 | -------------------------------------------------------------------------------- /user-variables.lua: -------------------------------------------------------------------------------- 1 | return { 2 | dock = { 3 | pinned_apps = { 4 | "nautilus", 5 | "ghostty", 6 | "zen", 7 | "telegram", 8 | "obs", 9 | "zed", 10 | "resources", 11 | }, 12 | }, 13 | github = { 14 | username = "linuxmobile", 15 | }, 16 | monitor = { 17 | mode = "specific", -- Can be "primary", "all", or "specific" 18 | specific_monitor = 1, 19 | }, 20 | profile = { 21 | picture = os.getenv("HOME") .. "/Downloads/fastfetch/greenish/fastfech.png", 22 | }, 23 | media = { 24 | preferred_players = { 25 | "zen", 26 | "firefox", 27 | }, 28 | }, 29 | display = { 30 | night_light_temp_initial = 3500, 31 | }, 32 | } 33 | --------------------------------------------------------------------------------