diff --git a/config/sketchybar/.luarc.json b/config/sketchybar/.luarc.json new file mode 100644 index 0000000..0d4f72d --- /dev/null +++ b/config/sketchybar/.luarc.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", + "runtime.version": "Lua 5.4", + "diagnostics.global": [ + "sbar" + ] +} diff --git a/config/sketchybar/items/aerospace.lua b/config/sketchybar/items/aerospace.lua index 585157e..cc14139 100644 --- a/config/sketchybar/items/aerospace.lua +++ b/config/sketchybar/items/aerospace.lua @@ -1,25 +1,28 @@ -local Promise = require 'promise' local colors = require 'colors' local utils = require 'utils' local settings = require 'settings' local app_icons = require 'app_icons' +local async = utils.async +local await = utils.await +local awaitAll = utils.awaitAll +local exec = utils.exec + local aerospace_cmd = '/opt/homebrew/bin/aerospace' +-- Aerospace commands local function getAllWorkspaces() - return utils.sbarExecP( - aerospace_cmd .. " list-workspaces --all --format '%{workspace}%{monitor-appkit-nsscreen-screens-id}%{monitor-id}%{monitor-name}' --json" - ) + return exec(aerospace_cmd .. " list-workspaces --all --format '%{workspace}%{monitor-appkit-nsscreen-screens-id}%{monitor-id}%{monitor-name}' --json") end local function getVisibleWorkspaces() - return utils.sbarExecP( + return exec( aerospace_cmd .. " list-workspaces --visible --monitor all --format '%{workspace}%{monitor-appkit-nsscreen-screens-id}%{monitor-id}%{monitor-name}' --json" ) end local function getAllWindows() - return utils.sbarExecP( + return exec( aerospace_cmd .. " list-windows --all --format '%{app-name}%{window-title}%{workspace}%{monitor-id}%{monitor-appkit-nsscreen-screens-id}%{monitor-name}' --json" ) @@ -37,6 +40,7 @@ local function getMonitorId(obj) return tonumber(obj['monitor-id']) or 1 end +-- State local spaces = {} local space_paddings = {} local state = { @@ -44,133 +48,135 @@ local state = { updating = false, } -local function getState() - local newstate = { workspaces = {} } - - -- Pre-initialize all workspaces from the spaces table - for workspaceid, _ in pairs(spaces) do - newstate.workspaces[workspaceid] = { - id = workspaceid, - monitor = 1, - active = false, - empty = true, - apps = {}, - appicons = '', - } +-- Build app icons string from app list +local function buildAppIcons(apps) + local appkeys = {} + for app in pairs(apps) do + table.insert(appkeys, app) end + table.sort(appkeys) - return Promise.all({ getAllWorkspaces(), getVisibleWorkspaces(), getAllWindows() }):thenCall(function(values) - local all, visible, apps = values[1], values[2], values[3] + if #appkeys == 0 then return '' end - for _, workspace in ipairs(all) do - local workspaceid = workspace['workspace'] - if newstate.workspaces[workspaceid] then newstate.workspaces[workspaceid]['monitor'] = getMonitorId(workspace) end + local icons = '' + for _, app in ipairs(appkeys) do + local lookup = app_icons[app] + local icon = lookup or app_icons['Default'] + icons = icons .. ' ' .. icon + end + return icons +end + +-- Fetch and build workspace state +local fetchState = async(function() + if state.updating then return end + state.updating = true + + local ok, err = pcall(function() + local all, visible, windows = table.unpack(awaitAll { + getAllWorkspaces(), + getVisibleWorkspaces(), + getAllWindows(), + }) + + local newstate = { workspaces = {} } + + -- Initialize all workspaces + for workspaceid, _ in pairs(spaces) do + newstate.workspaces[workspaceid] = { + id = workspaceid, + monitor = 1, + active = false, + empty = true, + apps = {}, + } end - for _, workspace in ipairs(visible) do - local workspaceid = workspace['workspace'] - if newstate.workspaces[workspaceid] then newstate.workspaces[workspaceid]['active'] = true end + -- Set monitor IDs + for _, ws in ipairs(all) do + local id = ws['workspace'] + if newstate.workspaces[id] then newstate.workspaces[id].monitor = getMonitorId(ws) end end - for _, window in ipairs(apps) do - local workspaceid = window['workspace'] - local appname = window['app-name'] - if newstate.workspaces[workspaceid] then - newstate.workspaces[workspaceid]['apps'][appname] = true - newstate.workspaces[workspaceid]['empty'] = false + -- Mark visible workspaces as active + for _, ws in ipairs(visible) do + local id = ws['workspace'] + if newstate.workspaces[id] then newstate.workspaces[id].active = true end + end + + -- Collect apps per workspace + for _, win in ipairs(windows) do + local id = win['workspace'] + local app = win['app-name'] + if newstate.workspaces[id] then + newstate.workspaces[id].apps[app] = true + newstate.workspaces[id].empty = false end end - for workspaceid, workspacestate in pairs(newstate.workspaces) do - local appkeys = {} - for app in pairs(workspacestate['apps']) do - table.insert(appkeys, app) - end - table.sort(appkeys) - if #appkeys > 0 then - for _, app in ipairs(appkeys) do - local lookup = app_icons[app] - local icon = ((lookup == nil) and app_icons['Default'] or lookup) - workspacestate['appicons'] = workspacestate['appicons'] .. ' ' .. icon - end - else - workspacestate['appicons'] = '' - end + -- Build app icons + for _, ws in pairs(newstate.workspaces) do + ws.appicons = buildAppIcons(ws.apps) end - return newstate + state.workspaces = newstate.workspaces end) -end -local function updateState() - if not state.updating then - state.updating = true - return getState() - :thenCall(function(newstate) - state.workspaces = newstate.workspaces - state.updating = false - end) - :catch(function(err) - state.updating = false - print('Error updating state: ' .. tostring(err)) - end) - end - return Promise.resolve() -end + state.updating = false + if not ok then print('Error fetching state: ' .. tostring(err)) end +end) +-- Sync UI with state local function syncState() sbar.animate('tanh', 10, function() - for workspaceid, workspacestate in pairs(state.workspaces) do - if not workspacestate['empty'] or workspacestate['active'] then - spaces[workspaceid]:set { - drawing = true, - display = workspacestate['monitor'], - label = { - string = workspacestate['appicons'], - highlight = workspacestate['active'], - }, - icon = { - highlight = workspacestate['active'], - }, - } - space_paddings[workspaceid]:set { drawing = true } - else - spaces[workspaceid]:set { - drawing = false, - display = workspacestate['monitor'], - label = workspacestate['appicons'], - } - space_paddings[workspaceid]:set { drawing = false } - end + for id, ws in pairs(state.workspaces) do + local shouldShow = not ws.empty or ws.active + + spaces[id]:set { + drawing = shouldShow, + display = ws.monitor, + label = { + string = ws.appicons, + highlight = ws.active, + }, + icon = { + highlight = ws.active, + }, + } + space_paddings[id]:set { drawing = shouldShow } end end) end -local function updateStateAndSync() return updateState():thenCall(syncState) end +local function updateStateAndSync() + fetchState() + -- Use a small delay to let fetchState complete before syncing + sbar.exec('sleep 0.1', syncState) +end +-- Handle workspace change event local function onActiveSpaceChange(env) - local focused_workspace = env.FOCUSED_WORKSPACE - local prev_workspace = env.PREV_WORKSPACE + local focused = env.FOCUSED_WORKSPACE + local prev = env.PREV_WORKSPACE - -- Immediately show the focused workspace with animation - if spaces[focused_workspace] then + -- Immediately update UI for responsiveness + if spaces[focused] then sbar.animate('tanh', 10, function() - spaces[focused_workspace]:set { + spaces[focused]:set { drawing = true, icon = { highlight = true }, label = { highlight = true }, } - space_paddings[focused_workspace]:set { drawing = true } + space_paddings[focused]:set { drawing = true } - if spaces[prev_workspace] then - spaces[prev_workspace]:set { + if spaces[prev] then + spaces[prev]:set { icon = { highlight = false }, label = { highlight = false }, } - -- Hide previous workspace if it's empty - if state.workspaces[prev_workspace] and state.workspaces[prev_workspace]['empty'] then - spaces[prev_workspace]:set { drawing = false } - space_paddings[prev_workspace]:set { drawing = false } + if state.workspaces[prev] and state.workspaces[prev].empty then + spaces[prev]:set { drawing = false } + space_paddings[prev]:set { drawing = false } end end end) @@ -179,60 +185,62 @@ local function onActiveSpaceChange(env) updateStateAndSync() end -local function setup() - getAllWorkspaces() - :thenCall(function(workspaces) - for _, workspace in ipairs(workspaces) do - local workspaceid = workspace['workspace'] - local display = getMonitorId(workspace) +-- Setup +local setup = async(function() + local workspaces = await(getAllWorkspaces()) - local space = sbar.add('item', 'space.' .. workspaceid, { - drawing = false, - updates = 'when_shown', - display = display, - icon = { - string = workspaceid, - color = colors.fg, - highlight_color = colors.red, - }, - label = { - padding_right = 12, - color = colors.fg, - highlight_color = colors.blue, - font = 'sketchybar-app-font:Regular:14.0', - y_offset = -1, - }, - padding_left = 1, - padding_right = 1, - click_script = aerospace_cmd .. ' workspace ' .. workspaceid, - }) + -- Create space items + for _, ws in ipairs(workspaces) do + local id = ws['workspace'] + local display = getMonitorId(ws) - spaces[workspaceid] = space + local space = sbar.add('item', 'space.' .. id, { + drawing = false, + updates = 'when_shown', + display = display, + icon = { + string = id, + color = colors.fg, + highlight_color = colors.red, + }, + label = { + padding_right = 12, + color = colors.fg, + highlight_color = colors.blue, + font = 'sketchybar-app-font:Regular:14.0', + y_offset = -1, + }, + padding_left = 1, + padding_right = 1, + click_script = aerospace_cmd .. ' workspace ' .. id, + }) + spaces[id] = space - local padding = sbar.add('space', 'space.padding.' .. space.name, { - drawing = false, - updates = 'when_shown', - display = display, - script = '', - width = settings.space_paddings, - }) - space_paddings[workspaceid] = padding - end - end) - :thenCall(function() - sbar.add('event', 'aerospace_workspace_change') + local padding = sbar.add('space', 'space.padding.' .. space.name, { + drawing = false, + updates = 'when_shown', + display = display, + script = '', + width = settings.space_paddings, + }) + space_paddings[id] = padding + end - local space_window_observer = sbar.add('item', { - drawing = false, - updates = true, - }) + -- Register events + sbar.add('event', 'aerospace_workspace_change') - space_window_observer:subscribe('aerospace_workspace_change', onActiveSpaceChange) - space_window_observer:subscribe('space_windows_change', updateStateAndSync) - space_window_observer:subscribe('system_woke', updateStateAndSync) - space_window_observer:subscribe('front_app_switched', updateStateAndSync) - end) - :thenCall(updateStateAndSync) -end + local observer = sbar.add('item', { + drawing = false, + updates = true, + }) + + observer:subscribe('aerospace_workspace_change', onActiveSpaceChange) + observer:subscribe('space_windows_change', updateStateAndSync) + observer:subscribe('system_woke', updateStateAndSync) + observer:subscribe('front_app_switched', updateStateAndSync) + + -- Initial state sync + updateStateAndSync() +end) setup() diff --git a/config/sketchybar/utils.lua b/config/sketchybar/utils.lua index f9e292d..ac0b7ec 100644 --- a/config/sketchybar/utils.lua +++ b/config/sketchybar/utils.lua @@ -1,5 +1,3 @@ -local Promise = require 'promise' - local M = {} function M.dump(o) @@ -15,19 +13,68 @@ function M.dump(o) end end -local function onErrorP(reason) print('Error found: ' .. (reason and M.dump(reason) or 'unknown')) end +-- Coroutine-based async/await helpers +function M.await(callback_fn) + local co = coroutine.running() + if not co then error 'await must be called inside async' end --- https://github.com/Tnixc/nix-config/blob/main/home/programs/aerospace-sketchybar/sbar-config-libs/items/aerospaces.lua -function M.sbarExecP(cmd) - return Promise.new(function(resolve, failfunc) + local result, err + local done = false + + callback_fn(function(res, e) + result = res + err = e + done = true + if coroutine.status(co) == 'suspended' then coroutine.resume(co) end + end) + + if not done then coroutine.yield() end + if err then error(err) end + return result +end + +function M.awaitAll(callback_fns) + local co = coroutine.running() + if not co then error 'awaitAll must be called inside async' end + + local results = {} + local pending = #callback_fns + local err = nil + + for i, fn in ipairs(callback_fns) do + fn(function(res, e) + if e then err = e end + results[i] = res + pending = pending - 1 + if pending == 0 and coroutine.status(co) == 'suspended' then coroutine.resume(co) end + end) + end + + if pending > 0 then coroutine.yield() end + if err then error(err) end + return results +end + +function M.async(fn) + return function(...) + local args = { ... } + local co = coroutine.create(function() fn(table.unpack(args)) end) + local ok, err = coroutine.resume(co) + if not ok then print('Async error: ' .. tostring(err)) end + end +end + +-- Execute a shell command, returns a callback function for use with await +function M.exec(cmd) + return function(resolve) sbar.exec(cmd, function(result, exit_code) if exit_code ~= 0 then - if failfunc ~= nil then failfunc(string.format('Exit Code: %s Message: %s', tostring(exit_code), M.dump(result))) end + resolve(nil, string.format('Exit Code: %s Message: %s', tostring(exit_code), M.dump(result))) else - if resolve ~= nil then resolve(result) end + resolve(result, nil) end end) - end):catch(onErrorP) + end end return M diff --git a/darwin/sketchybar/default.nix b/darwin/sketchybar/default.nix index 0d3c082..1c1ef52 100644 --- a/darwin/sketchybar/default.nix +++ b/darwin/sketchybar/default.nix @@ -45,6 +45,7 @@ in hm.home.packages = with pkgs; [ sketchybar-app-font + custom.sk-utils ]; hm.xdg.configFile = {