diff --git a/lib/utils.lua b/lib/utils.lua index 551e1b0..5645e01 100644 --- a/lib/utils.lua +++ b/lib/utils.lua @@ -196,6 +196,12 @@ function utils.invoke(instance, name, ...) -- {{{ end end -- }}} +function utils.cb(fn) -- {{{ + return function() + return fn + end +end -- }}} + function utils.extract(list, comp, transform, ...) -- {{{ -- from moses.lua -- extracts value from a list @@ -225,13 +231,33 @@ function utils.setfield(f, v, t) -- {{{ end end -- }}} -function utils.getfield(f, t) -- {{{ +function utils.getfield(f, t, isSafe) -- {{{ -- FROM: https://www.lua.org/pil/14.1.html + -- TODO: add 'tryGet()' — safe get that *doesn't* cause other errors during startup + local v = t or _G -- start with the table of globals + local res = nil for w in string.gmatch(f, "[%w_]+") do - v = v[w] + + if type(v) ~= 'table' then + return v -- if v isn't table, return immediately + end + + v = v[w] -- lookup next val + + if v ~= nil then + res = v -- only update safe result if v not null + end + + log.d('utils.getfield — key "word"',w) + log.d('utils.getfield — "v" is:', v) + log.d('utils.getfield — "res" is:', res) + end + if isSafe then -- return the last non-nil value found + if v ~= nil then return v + else return res end + else return v -- return the last value found regardless end - return v end -- }}} function utils.max(t, transform) -- {{{ @@ -252,6 +278,8 @@ function utils.boolToNum(value) -- {{{ end -- }}} function utils.toBool(val) -- {{{ + if type(val) == 'boolean' then return val end + log.d(val) local TRUE = { ['1'] = true, ['t'] = true, @@ -409,14 +437,6 @@ function utils.zip(a, b) -- {{{ return rv end -- }}} --- function utils.zip(a, b) --- local rv = {} --- for k,v in pairs(a) do --- rv[v] = b[k] --- end --- return rv --- end - function utils.copyShallow(orig) -- {{{ -- FROM: https://github.com/XavierCHN/go/blob/master/game/go/scripts/vscripts/utils/table.lua local orig_type = type(orig) @@ -527,7 +547,7 @@ function utils.levenshteinDistance(str1, str2) -- {{{ (char1[i] == char2[j] and 0 or 1)) end end - return distance[len1][len2] / #str2 -- note + return distance[len1][len2] / #str2 -- note end -- }}} function flattenDict(tbl) -- {{{ diff --git a/stackline/configmanager.lua b/stackline/configmanager.lua index d7ec887..077e166 100644 --- a/stackline/configmanager.lua +++ b/stackline/configmanager.lua @@ -1,53 +1,24 @@ -- https://github.com/erento/lua-schema-validation -local v = require 'stackline.lib.valid' -local o = v.optional local log = hs.logger.new('sline.conf') -log.setLogLevel('debug') +log.setLogLevel('info') log.i("Loading module") +local M = {} + +-- Validators & type lookup +local v = require 'stackline.lib.valid' +local o = v.optional local is_color = v.is_table { -- {{{ white = o(v.is_number()), - red = o(v.is_number()), + red = o(v.is_number()), green = o(v.is_number()), - blue = o(v.is_number()), + blue = o(v.is_number()), alpha = o(v.is_number()), } -- }}} - -local cb = function(fn) -- {{{ - return function() - return fn - end -end -- }}} - -local function unknownType(v) -- {{{ +local function unknownTypeValidator(v) -- {{{ log.i("Not validating: ", schemaType) return true end -- }}} - -local M = {} - -function M:getPathSchema(path) -- {{{ - local _type = u.getfield(path, self.schema) -- lookup type in schema - if not _type then - return false - end - local validator = self.types[_type]() - return _type, validator -end -- }}} - -function M.generateValidator(schemaType) -- {{{ - if type(schemaType) == 'table' then - local children = u.map(schemaType, M.generateValidator) - log.d('validator children:\n', hs.inspect(children)) - return v.is_table(children) - else - log.i('schemaType:', schemaType) - return - M.types[schemaType] and M.types[schemaType]() -- returns a fn to be called with value to validate - or unknownType -- unknown types are assumed-valid - end -end -- }}} - M.types = { -- {{{ ['string'] = { validator = v.is_string, @@ -78,7 +49,6 @@ M.types = { -- {{{ coerce = u.identity, }, } -- }}} - M.schema = { -- {{{ paths = { getStackIdxs = 'string', @@ -111,6 +81,28 @@ M.schema = { -- {{{ }, } -- }}} +function M:getPathSchema(path) -- {{{ + local _type = u.getfield(path, self.schema) -- lookup type in schema + if not _type then + return false + end + local validator = self.types[_type].validator() + return _type, validator +end -- }}} +function M.generateValidator(schemaType) -- {{{ + if type(schemaType) == 'table' then + local children = u.map(schemaType, M.generateValidator) + log.d('validator children:\n', hs.inspect(children)) + return v.is_table(children) + else + log.i('schemaType:', schemaType) + return + M.types[schemaType] and M.types[schemaType].validator() -- returns a fn to be called with value to validate + or unknownTypeValidator -- unknown types are assumed-valid + end +end -- }}} + +-- Config manager function M:init(conf) -- {{{ log.i('Initializing configmanager…') self:validate(conf) @@ -138,9 +130,13 @@ function M:validate(conf) -- {{{ self.autosuggestions = u.keys(u.flatten(self.conf)) else local invalidKeys = table.concat(u.keys(u.flatten(err)), ", ") - hs.notify.show('Invalid stackline config!', - 'invalid keys:' .. invalidKeys, - 'Please refer to the default conf file.') + hs.notify.new(nil, { + title = 'Invalid stackline config!', + subTitle = 'invalid keys:' .. invalidKeys, + informativeText = 'Please refer to the default conf file.', + withdrawAfter = 10 + }):send() + log.e('Invalid stackline config:\n', hs.inspect(err)) end @@ -159,7 +155,16 @@ function M:autosuggest(path) -- {{{ end table.sort(scores, asc) log.d(hs.inspect(scores)) - return scores[1][2], scores[2][2] -- return the best 2 matches + + local result1, result2 = scores[1][2], scores[2][2] -- return the best 2 matches + + hs.notify.new(nil, { + title = 'Did you mean?', + subTitle = string.format('"%s"', result1), + informativeText = string.format('"%s" is not a default stackline config path', path), + withdrawAfter = 10 + }):send() + end -- }}} function M:getOrSet(path, val) -- {{{ @@ -173,10 +178,13 @@ end -- }}} function M:get(path) -- {{{ -- @path is a dot-separated string (e.g., 'appearance.color') -- return full config if no path provided - if path == nil then - return self.conf + if path == nil then return self.conf end + + local ok, val = pcall(u.getfield, path, self.conf) + + if ok then return val + else self:autosuggest(path) end - return u.getfield(path, self.conf) end -- }}} function M:set(path, val) -- {{{ @@ -185,15 +193,18 @@ function M:set(path, val) -- {{{ non-existent path segments will be set to an empty table ]] local _type, validator = self:getPathSchema(path) -- lookup type in schema + val = self.types[_type].coerce(val) -- coerce val to correct type when possible if not _type then - hs.notify.show('Did you mean?', self:autosuggest(path), string.format( - '"%s" is not a default stackline config path', path)) + self:autosuggest(path) else - local isValid, err = validator(val) -- validate val is appropriate type + local isValid, err = validator(val) -- validate val is appropriate type if isValid then log.d('Setting', path, 'to', val) u.setfield(path, val, self.conf) + + local onChange = u.getfield(path, self.events, true).onChange + if type(onChange) == 'function' then onChange() end else log.e(hs.inspect(err)) end diff --git a/stackline/stackline.lua b/stackline/stackline.lua index d43707c..5a7d378 100644 --- a/stackline/stackline.lua +++ b/stackline/stackline.lua @@ -1,9 +1,9 @@ require("hs.ipc") u = require 'stackline.lib.utils' -local wf = hs.window.filter -- just an alias -local u = require 'stackline.lib.utils' -local cb = u.invoke +local wf = hs.window.filter -- just an alias +local u = require 'stackline.lib.utils' +local cb = u.invoke local log = hs.logger.new('stackline') log.setLogLevel('debug') log.i("Loading module") @@ -12,18 +12,33 @@ stackline = {} stackline.config = require 'stackline.stackline.configManager' stackline.window = require 'stackline.stackline.window' -stackline.focusedScreen = nil +function stackline.init(userConfig) -- {{{ + log.i('starting stackline') + + stackline.config:init( -- init config with default conf + user overrides + table.merge(require 'stackline.conf', userConfig) + ) + + stackline.manager = require('stackline.stackline.stackmanager'):init() + + stackline.manager:update() -- always update window state on start + + if stackline.config:get('features.clickToFocus') then + hs.alert.show('clickTracker has started') + log.i('FEAT: ClickTracker starting') + stackline.clickTracker:start() + end +end -- }}} -stackline.wf = wf.new():setOverrideFilter{ -- {{{ +stackline.wf = wf.new():setOverrideFilter{ visible = true, -- (i.e. not hidden and not minimized) fullscreen = false, currentSpace = true, allowRoles = 'AXStandardWindow', -} -- }}} +} local click = hs.eventtap.event.types['leftMouseDown'] -- print hs.eventtap.event.types to see all event types -stackline.clickTracker = hs.eventtap.new({click}, -- {{{ -function(e) +stackline.clickTracker = hs.eventtap.new({click}, function(e) -- {{{ -- Listen for left mouse click events -- if indicator containing the clickAt position can be found, focus that indicator's window local clickAt = hs.geometry.point(e:location().x, e:location().y) @@ -35,34 +50,27 @@ function(e) end) -- }}} stackline.refreshClickTracker = function() -- {{{ + local turnedOn = stackline.config:get('features.clickToFocus') + if stackline.clickTracker:isEnabled() then - stackline.clickTracker:stop() + stackline.clickTracker:stop() -- always stop if running + end + if turnedOn then -- only start if feature is enabled + log.d('features.clickToFocus is enabled!') + hs.alert.show('clickTracker has refreshed') + stackline.clickTracker:start() + else + log.d('features.clickToFocus is DISABLED ❌') + stackline.clickTracker:stop() -- double-stop if disabled + stackline.clickTracker = nil -- erase if disabled end - stackline.clickTracker:start() -end -- }}} - -function stackline.start(userConfig) -- {{{ - log.i('starting stackline') - - -- init config with default conf + user overrides - stackline.config:init( - table.merge( - require 'stackline.conf', - userConfig - ) - ) - - stackline.manager = require('stackline.stackline.stackmanager'):init() - stackline.manager:update() -- always update window state on start - stackline.clickTracker:start() end -- }}} --- 0.30s delay debounces querying via Hammerspoon & yabai --- yabai is only queried if Hammerspoon query results are different than current state -stackline.queryWindowState = hs.timer.delayed.new( - 0.30, - function() stackline.manager:update() end - -- cb(stackline.manager, 'update') → This should work but it doesn't +stackline.queryWindowState = hs.timer.delayed.new(0.30, function() + -- 0.30s delay debounces querying via Hammerspoon & yabai + -- yabai is only queried if Hammerspoon query results are different than current state + stackline.manager:update() +end ) function stackline.redrawWinIndicator(hsWin, _app, _event) -- {{{ @@ -96,29 +104,24 @@ stackline.windowEvents = { -- {{{ wf.windowMinimized, } -- }}} +-- On each win evt above, query window state & check if refersh needed stackline.wf:subscribe(stackline.windowEvents, function() -- {{{ -- callback args: window, app, event stackline.queryWindowState:start() end) -- }}} -stackline.wf:subscribe(wf.windowFocused, stackline.redrawWinIndicator) - -local unfocused = { wf.windowNotVisible, wf.windowUnfocused } -stackline.wf:subscribe(unfocused, stackline.redrawWinIndicator) +-- On each win evt listed, simply *redraw* indicators +-- No need for heavyweight query + refresh +stackline.wf:subscribe({ + wf.windowFocused, + wf.windowNotVisible, + wf.windowUnfocused, +}, stackline.redrawWinIndicator) +-- On space switch, query window state & refresh, plus refresh click tracker hs.spaces.watcher.new(function() -- {{{ - -- Added 2020-08-12 to fill the gap of hs._asm.undocumented.spaces stackline.queryWindowState:start() stackline.refreshClickTracker() end):start() -- }}} --- Delayed start (stackline module needs to be loaded globally before it can reference its own methods) --- TODO: Add instructions to README.md to call stackline:start(userPrefs) from init.lua, and remove this. - --- hs.timer.doUntil(function() -- {{{ --- return stackline.manager --- end, function() --- stackline.start() --- end, 0.1) -- }}} - return stackline diff --git a/stackline/window.lua b/stackline/window.lua index f03f99a..410e879 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -115,7 +115,7 @@ function Window:drawIndicator(overrideOpts) -- {{{ }, self.iconIdx) end - self.indicator:clickActivating(false) + self.indicator:clickActivating(false) -- clicking on a canvas elment should NOT bring Hammerspoon wins to front self.indicator:show(fadeDuration) end -- }}}