├── .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 |
--------------------------------------------------------------------------------