├── .gitignore ├── README.md ├── conf.lua ├── init.lua ├── lib ├── utils.lua └── valid.lua └── stackline ├── configmanager.lua ├── query.lua ├── stack.lua ├── stackline.lua ├── stackmanager.lua └── window.lua /.gitignore: -------------------------------------------------------------------------------- 1 | tmp-notes.txt 2 | stackline/sratch.md 3 | \[nvim-lua\] 4 | 5 | lib/figlet.lua 6 | 7 | luacov.stats.out -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![stackline-logo](https://user-images.githubusercontent.com/1683979/90966915-1f9b1400-e48d-11ea-8cbb-0ceea6fcfc39.png) 3 |

4 | Version 5 | 6 | License: MIT 7 | 8 |

9 | 10 | > Visualize yabai window stacks on macOS. Works with yabai & hammerspoon. 11 | 12 | **Current status** 13 | 14 | Unfortunately, I've haven't been able to work on this project since Q3 2021. Initially, this was due to a scary bout of RSI-esque finger pain that entirely prevented from me from typing (really – I had to use [Talon](https://talonvoice.com/) for basic computer use); The lesson I took away is that my hobbies shouldn't invovlve continuous typing (given I'm already typing all day for work). 15 | 16 | I apologize that I won't be working on this anymore – but that doesn't mean _you_ can't fork & carry the torch ;) 17 | 18 | **June 2021 update** 19 | 20 | 2021-06-06: Fixes & cleanup (`v0.1.61`) 21 | 22 | - Fixed: offset indicators when menubar is not hidden (#80) 23 | - Fixed: Icons don't change when toggling showIcons (#68) 24 | - Fixed: Failure to parse json output from `yabai` that contains `inf` values (might fix #46) 25 | - Removed external dependency on `jq` 26 | - Removed shell script used to call out to `yabai` 27 | - Replaced third-party json library with `hs.json` 28 | - Refactored unnecessary object-orientation out of `stackline.query` 29 | - Cleaned up `stackline.lib.utils` 30 | 31 | See [changelog](https://github.com/AdamWagner/stackline/wiki/Changelog). 32 | 33 | Everything below & more is in the [wiki](https://github.com/AdamWagner/stackline/wiki/Install-dependencies). 34 | 35 | ## What is stackline & why would I want to use it? 36 | 37 | `stackline` adds unobtrusive visual indicators to complement `yabai`'s window stacking functionality. 38 | 39 | A 'stack' enables multiple macOS windows to occupy the same screen space and behave as a single unit. 40 | 41 | Stacks are a recent addition (June 2020) to the (_excellent!_) macOS tiling window manager [koekeishiya/yabai](https://github.com/koekeishiya/yabai). See [yabai #203](https://github.com/koekeishiya/yabai/issues/203) for more info about `yabai`'s stacking feature. Currently, there's no built-in UI for stacks, which makes it easy to forget about stacked windows that aren't visible or get disoriented. 42 | 43 | Enter `stackline`: unobtrusive visual indicators that complement `yabai` window stacks. 44 | 45 | ![stackline-demo](https://user-images.githubusercontent.com/1683979/90967233-08f6bc00-e491-11ea-9b0a-d75f248ce4b1.gif) 46 | 47 | ### Features 48 | 49 | - 🚦 **Window indicators** show the position and window count of stacks 50 | - 🔦 Use **app icons** to show apps inside stacks or slim indicators to save space 51 | - 🧘 **Smart positioning**. Indicators stay on the outside edge of the window nearest the screen edge 52 | - 🕹️ **Flexible control**. Control stackline via shell commands, or access the instance directly via hammerspoon. 53 | - 🖥️ **Multi-monitor support** introduced in `stackline v0.1.55` 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 65 | 68 | 69 | 70 |
Icon indicators……or minimal indicators
63 | 64 | 66 | 67 |
71 | 72 | 73 | ## Quickstart 74 | 75 | ### Prerequisites 76 | 77 | - https://github.com/koekeishiya/yabai ([install guide](https://github.com/koekeishiya/yabai/wiki/Installing-yabai-(latest-release))) 78 | - https://github.com/Hammerspoon/hammerspoon ([getting started guide](https://www.hammerspoon.org/go/)) 79 | 80 | See [wiki](https://github.com/AdamWagner/stackline/wiki/Install-&-configure-dependencies#user-content-configure-yabai-stacks) for example keybindings to create and navigate between stacks. 81 | 82 | ### Installation 83 | 84 | 1. [Clone the repo into ~/.hammerspoon/stackline](https://github.com/AdamWagner/stackline/wiki/Install-stackline#1-clone-the-repo-into-hammerspoonstackline) 85 | 2. [Install the hammerspoon cli tool](https://github.com/AdamWagner/stackline/wiki/Install-stackline#2-install-the-hammerspoon-cli-tool) 86 | 87 | #### 1. Clone the repo into ~/.hammerspoon/stackline 88 | 89 | ```sh 90 | # Get the repo 91 | git clone https://github.com/AdamWagner/stackline.git ~/.hammerspoon/stackline 92 | 93 | # Make stackline run when hammerspoon launches 94 | cd ~/.hammerspoon 95 | echo 'stackline = require "stackline"' >> init.lua 96 | echo 'stackline:init()' >> init.lua 97 | ``` 98 | 99 | Now your `~/.hammerspoon` directory should look like this: 100 | 101 | ``` 102 | ├── init.lua 103 | └── stackline 104 | ├── conf.lua 105 | ├── stackline 106 | │   ├── configmanager.lua 107 | │   ├── query.lua 108 | │   ├── stack.lua 109 | │   ├── stackline.lua 110 | │   ├── stackmanager.lua 111 | │   └── window.lua 112 | └── lib 113 |    └── … 114 | ``` 115 | 116 | 117 | #### 2. Install the hammerspoon cli tool 118 | 119 | This is an optional step. It's required to send configuration commands to `stackline` from scripts, for example: 120 | 121 | ```sh 122 | # Toggle boolean values with the hs cli 123 | hs -c "stackline.config:toggle('appearance.showIcons')" 124 | ``` 125 | 1. Ensure Hammerspoon is running 126 | 2. Open the hammerspoon console via the menu bar 127 | 3. Type `hs.ipc.cliInstall()` and hit return 128 | 129 | If Hammerspoon is installed via Brew on Apple Silicon, `hs.ipc.cliInstall("/opt/homebrew")` [#2930](https://github.com/Hammerspoon/hammerspoon/issues/2930) 130 | 4. Confirm that `hs` is available by entering the following in your terminal (shell): 131 | 132 | ```sh 133 | ❯ which hs 134 | /usr/local/bin/hs 135 | ``` 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 147 | 150 | 151 | 152 |
Open the Hammperspoon console via the menu barType `hs.ipc.cliInstall()` and hit return
145 | 146 | 148 | 149 |
153 | 154 | ### Usage 155 | 156 | - Launch `yabai` (or make sure it's running) (`brew services start yabai`) 157 | - Launch `hammerspoon` (or make sure it's running) (`open -a "Hammerspoon"`) 158 | 159 | **Create a window stack** 160 | 161 | Now, assuming you've been issuing these commands from a terminal and _also_ have a browser window open on the same space, make sure your terminal is positioned immediately to the _left_ of your browser and issue the following command (or use [keybindings](https://github.com/AdamWagner/stackline/wiki/Install-dependencies)) to create a stack: 162 | 163 | ```sh 164 | yabai -m window --stack next 165 | ``` 166 | 167 | Did the terminal window expand to cover the area previously occupied by Safari? Great! At this point, you should notice **two app icons at the top-left corner of your terminal window**, like this: 168 | 169 | 170 | 171 | You can toggle minimalist mode by turning the icons off: 172 | 173 | ```sh 174 | hs -c 'stackline.config:toggle("appearance.showIcons")' 175 | ``` 176 | 177 | 178 | 179 | See the wiki to [for details about how to do this with a key binding](https://github.com/AdamWagner/stackline/wiki/Keybindings). 180 | 181 | 182 | ## Thanks to contributors! 183 | 184 | All are welcome. Feel free to dive in by opening an [issue](https://github.com/AdamWagner/stackline/issues/new) or submitting a PR. 185 | 186 | [@alin23](https://github.com/alin23) initially proposed the [concept for stackline here](https://github.com/koekeishiya/yabai/issues/203#issuecomment-652948362) and encouraged [@AdamWagner](https://github.com/AdamWagner) to share the mostly-broken proof-of-concept publicly. Since then, [@alin23](https://github.com/alin23) dramatically improved upon the initial proof-of-concept with [#13](https://github.com/AdamWagner/stackline/pull/13), has some pretty whiz-bang functionality on deck with [#17](https://github.com/AdamWagner/stackline/pull/17), and has been a great thought partner/reviewer. 187 | 188 | [@zweck](https://github.com/zweck), who, [in the same thread](https://github.com/koekeishiya/yabai/issues/203#issuecomment-656780281), got the gears turning about how [@alin23](https://github.com/alin23)'s idea could be implemented and _also_ urged Adam to share his POC. 189 | 190 | [@johnallen3d](https://github.com/johnallen3d) for being of one the first folks to install stackline, and for identifying several mistakes & gaps in the setup instructions. 191 | 192 | [@pete-may](https://github.com/pete-may) for saving folks from frustration by fixing an out-of-date command in the readme ([#48](https://github.com/AdamWagner/stackline/pull/48)) 193 | 194 | [@AdamWagner](https://github.com/AdamWagner) wrote the initial proof-of-concept (POC) for stackline. 195 | 196 | Give a ⭐️ if you think (a more fully-featured version of) stackline would be useful! 197 | 198 | ### …on the shoulders of giants 199 | 200 | Thanks to [@koekeishiya](https://github.com/koekeishiya) without whom the _wonderful_ [yabai](https://github.com/koekeishiya/yabai) would not exist, and projects like this would have no reason to exist. 201 | 202 | Similarly, thanks to [@dominiklohmann](https://github.com/dominiklohmann), who has helped _so many people_ make chunkwm/yabai "do the thing" they want and provides great feedback on new and proposed yabai features. 203 | 204 | Thanks to [@cmsj](https://github.com/cmsj), [@asmagill](https://github.com/asmagill), and all of the contributors to [hammerspoon](https://github.com/Hammerspoon/hammerspoon) for making macos APIs accessible to the rest of us! 205 | 206 | Thanks to the creators & maintainers of the lua utility libraries [underscore.lua](https://github.com/mirven/underscore.lua), [lume.lua](https://github.com/rxi/lume), and [self.lua](https://github.com/M1que4s/self). 207 | 208 | ## License & attribution 209 | 210 | stackline is licensed under the [↗ MIT License](stackline-license), the same license used by [yabai](https://github.com/koekeishiya/yabai/blob/master/LICENSE.txt) and [hammerspoon](https://github.com/Hammerspoon/hammerspoon/blob/master/LICENSE). 211 | 212 | MIT is a simple permissive license with conditions only requiring the preservation of copyright and license notices. Licensed works, modifications, and larger works may be distributed under different terms and without source code. 213 | 214 | [MIT](LICENSE) © Adam Wagner 215 | -------------------------------------------------------------------------------- /conf.lua: -------------------------------------------------------------------------------- 1 | -- default stackline config 2 | -- TODO: Experiment with setting __index() metatable to leverage autosuggest when keys not found 3 | 4 | c = {} 5 | c.paths = {} 6 | c.appearance = {} 7 | c.features = {} 8 | c.advanced = {} 9 | 10 | -- Paths 11 | c.paths.yabai = '/usr/local/bin/yabai' 12 | 13 | -- Appearance 14 | c.appearance.color = { white = 0.90 } -- Indicator background color, e.g., {red = 0.5, blue = 0 } 15 | c.appearance.alpha = 1 -- Opacity of active indicators 16 | c.appearance.dimmer = 2.5 -- Higher numbers increase contrast b/n focused & unfocused state 17 | c.appearance.iconDimmer = 1.1 -- Higher numbers dim inactive icons *less* than the non-icon indicators 18 | c.appearance.showIcons = true -- Window indicator style ('lozenge'-shaped when false) 19 | c.appearance.size = 32 -- Size of window indicators (height when icons off) 20 | c.appearance.radius = 3 -- Indicator roundness. Higher numbers → *less* roundness… I'm sorry 21 | c.appearance.iconPadding = 4 -- Space between icon & indicator edge. Higher numbers → smaller, more inset icons 22 | c.appearance.pillThinness = 6 -- Aspect ratio of pill-style icons (width = size / pillThinness) 23 | 24 | c.appearance.vertSpacing = 1.2 -- Amount of vertical space between indicators 25 | 26 | c.appearance.offset = {} -- Offset controls position of stack indicators relative to the window 27 | c.appearance.offset.y = 2 -- Distance from top of the window to render indicators 28 | c.appearance.offset.x = 4 -- Distance away from the edge of the window to render indicators 29 | 30 | c.appearance.shouldFade = true -- Enable/disable fade animations 31 | c.appearance.fadeDuration = 0.2 -- Duration of fade animations (seconds) 32 | 33 | -- Features 34 | c.features.clickToFocus = true -- Click indicator to focus window. Mouse clicks are tracked when enabled 35 | c.features.hsBugWorkaround = true -- Workaround for https://github.com/Hammerspoon/hammerspoon/issues/2400 36 | 37 | c.features.fzyFrameDetect = {} -- Round window frame dimensions by fuzzFactor before identifying stacked windows 38 | c.features.fzyFrameDetect.enabled = true -- Enable/disable fuzzy frame detection 39 | c.features.fzyFrameDetect.fuzzFactor = 30 -- Window frame dimensions will be rounded to nearest fuzzFactor 40 | 41 | c.features.winTitles = 'not_implemented' -- Valid options: false, true, 'when_switching', 'not_implemented' 42 | c.features.dynamicLuminosity = 'not_implemented' -- Valid options: false, true, 'not_implemented' 43 | 44 | c.advanced.maxRefreshRate = 0.5 -- How aggressively to refresh Stackline. Higher = slower response time + less battery drain 45 | 46 | return c 47 | -------------------------------------------------------------------------------- /init.lua: -------------------------------------------------------------------------------- 1 | -- Simplify requiring stackline from hammerspoon init.lua 2 | 3 | package.path = hs.configdir ..'/stackline/?.lua;' .. package.path 4 | return require 'stackline.stackline' 5 | -------------------------------------------------------------------------------- /lib/utils.lua: -------------------------------------------------------------------------------- 1 | -- luacheck: ignore 142 112 2 | local log = hs.logger.new('utils', 'info') 3 | log.i('Loading module: utils') 4 | 5 | -- === Extend builtins === 6 | function string:split(p) -- {{{ 7 | -- Splits the string [s] into substrings wherever pattern [p] occurs. 8 | -- Returns: a table of substrings or, a table with the string as the only element 9 | p = p or '%s' -- split on space by default 10 | local temp = {} 11 | local index = 0 12 | local last_index = self:len() 13 | 14 | while true do 15 | local i, e = self:find(p, index) 16 | 17 | if i and e then 18 | local next_index = e + 1 19 | local word_bound = i - 1 20 | table.insert(temp, self:sub(index, word_bound)) 21 | index = next_index 22 | else 23 | if index > 0 and index <= last_index then 24 | table.insert(temp, self:sub(index, last_index)) 25 | elseif index==0 then 26 | temp = {self} 27 | end 28 | break 29 | end 30 | end 31 | 32 | return temp 33 | end -- }}} 34 | 35 | function string:trim() -- {{{ 36 | return self 37 | :gsub('^%s+', '') -- trim leading whitespace 38 | :gsub('%s+$', '') -- trim trailing whitespace 39 | end -- }}} 40 | 41 | function table.slice(t, from, to) -- {{{ 42 | -- Returns a partial table sliced from t, equivalent to t[x:y] in certain languages. 43 | -- Negative indices will be used to access the table from the other end. 44 | local n = #t 45 | to = to or n 46 | from = from or 1 47 | 48 | -- Modulo the negative index, to get it back into range. 49 | if from < 0 then from = (from % n) + 1 end 50 | 51 | 52 | -- Modulo the negative index, to get it back into range.-- Modulo the negative index, to get it back into range.-- Modulo the negative index, to get it back into range. 53 | if to < 0 then to = (to % n) + 1 end 54 | 55 | -- Copy relevant elements into a blank T-Table. 56 | local res, key = {}, 1 57 | 58 | for i = from, to do 59 | res[key] = t[i] 60 | key = key + 1 61 | end 62 | 63 | return res 64 | end -- }}} 65 | 66 | -- === utils module === 67 | local u = {} 68 | 69 | -- Alias hs.fnutils methods 70 | u.map = hs.fnutils.map 71 | u.filter = hs.fnutils.filter 72 | u.reduce = hs.fnutils.reduce 73 | u.partial = hs.fnutils.partial 74 | u.each = hs.fnutils.each 75 | u.contains = hs.fnutils.contains 76 | u.some = hs.fnutils.some 77 | u.any = hs.fnutils.some -- alias 'some()' as 'any()' 78 | u.all = hs.fnutils.every -- alias 'every()' as 'all()' 79 | u.concat = hs.fnutils.concat 80 | u.copy = hs.fnutils.copy 81 | u.sortByKeys = hs.fnutils.sortByKeys 82 | u.sortByValues = hs.fnutils.sortByKeyValues 83 | 84 | function u.length(t) -- {{{ 85 | if type(t)~='table' then return 0 end 86 | local count = 0 87 | for _ in next, t do 88 | count = count + 1 89 | end 90 | return count 91 | end -- }}} 92 | 93 | function u.reverse(tbl) -- {{{ 94 | -- Reverses values in a given array. The passed-in array should not be sparse. 95 | local res = {} 96 | for i = #tbl,1,-1 do 97 | res[#res+1] = tbl[i] 98 | end 99 | return res 100 | end -- }}} 101 | 102 | function u.flip(func) -- {{{ 103 | -- Flips the order of parameters passed to a function 104 | return function(...) 105 | return func(table.unpack(u.reverse({...}))) 106 | end 107 | end -- }}} 108 | 109 | function u.pipe(f, g, ...) -- {{{ 110 | local function simpleCompose(f1, g1) 111 | return function(...) 112 | return f1(g1(...)) 113 | end 114 | end 115 | 116 | if (g==nil) then return f or u.identity end 117 | local nextFn = simpleCompose(g, f) 118 | 119 | return u.pipe(nextFn, ...) 120 | end -- }}} 121 | 122 | function u.isnum(x) -- {{{ 123 | return type(x) == 'number' 124 | end -- }}} 125 | 126 | function u.istable(x) -- {{{ 127 | return type(x) == 'table' 128 | end -- }}} 129 | 130 | function u.isstring(x) -- {{{ 131 | return type(x) == 'string' 132 | end -- }}} 133 | 134 | function u.isbool(x) -- {{{ 135 | return type(x) == 'boolean' 136 | end -- }}} 137 | 138 | function u.isfunc(x) -- {{{ 139 | return type(x) == 'function' 140 | end -- }}} 141 | 142 | function u.isarray(x) -- {{{ 143 | return u.istable(x) and x[1]~=nil and x[u.length(x)]~=nil 144 | end -- }}} 145 | 146 | function u.isjson(x) -- {{{ 147 | return u.isstring(x) and x:find('{') and x:find('}') 148 | end -- }}} 149 | 150 | function u.getiter(x) -- {{{ 151 | if u.isarray(x) then 152 | return ipairs(x) 153 | elseif type(x)=="table" then 154 | return pairs(x) 155 | end 156 | error("expected table", 3) 157 | end -- }}} 158 | 159 | function u.keys(t) -- {{{ 160 | local rtn = {} 161 | for k in u.getiter(t) do 162 | rtn[#rtn + 1] = k 163 | end 164 | return rtn 165 | end -- }}} 166 | 167 | function u.find(t, val) -- {{{ 168 | result = nil 169 | for k, v in u.getiter(t) do 170 | if k==val then 171 | result = v 172 | end 173 | end 174 | return result 175 | end -- }}} 176 | 177 | function u.identity(val) -- {{{ 178 | return val 179 | end -- }}} 180 | 181 | function u.values(t) -- {{{ 182 | local values = {} 183 | for _k, v in pairs(t) do 184 | values[#values + 1] = v 185 | end 186 | return values 187 | end -- }}} 188 | 189 | function u.from_iter(it) -- {{{ 190 | if u.istable(it) then 191 | return it 192 | end 193 | local res = {} 194 | for item in it do 195 | table.insert(res, item) 196 | end 197 | return res 198 | end -- }}} 199 | 200 | function u.uniq(tbl) -- {{{ 201 | local res = {} 202 | for _, v in ipairs(tbl) do 203 | res[v] = true 204 | end 205 | return u.keys(res) 206 | end -- }}} 207 | 208 | function table.merge(t1, t2) -- {{{ 209 | --[[ TEST 210 | y = {blocks = { 'adam', 1, 2,'3', 99, 3 }, 7, 8, 9} 211 | z = {'a', 'b', 'c', name = 'JohnDoe', blocks = { 'test',2,3} } 212 | a = table.merge(y, z) 213 | ]] 214 | 215 | for k,v in pairs(t2) do 216 | local target = t1[k] 217 | local all_tbl = u.all( {v, target}, u.istable) 218 | local all_array = u.all( {v, target}, u.isarray) 219 | 220 | if all_array then 221 | t1[k] = u.concat(t1[k], v) --luacheck: ignore 143 222 | elseif all_tbl then 223 | t1[k] = table.merge(target, v) --luacheck: ignore 143 224 | else 225 | t1[k] = v 226 | end 227 | end 228 | return t1 229 | end -- }}} 230 | 231 | function u.extend(t1, t2) -- {{{ 232 | for k, v in u.getiter(t2) do 233 | t1[k] = v 234 | end 235 | return t1 236 | end -- }}} 237 | 238 | function u.include(tbl, val) -- {{{ 239 | for _k, v in ipairs(tbl) do 240 | if u.equal(v,val) then 241 | return true 242 | end 243 | end 244 | return false 245 | end -- }}} 246 | 247 | function u.cb(fn) -- {{{ 248 | return function() 249 | return fn 250 | end 251 | end -- }}} 252 | 253 | function u.json_cb(fn) -- {{{ 254 | -- wrap fn to decode json arg 255 | return function(...) 256 | return u.pipe(hs.json.decode, fn, ...) 257 | end 258 | end -- }}} 259 | 260 | function u.task_cb(fn) -- {{{ 261 | -- wrap callback given to hs.task 262 | return function(...) 263 | local out = {...} 264 | 265 | local is_hstask = function(x) -- {{{ 266 | return #x==3 267 | and tonumber(x[1]) 268 | and u.isstring(x[2]) 269 | end -- }}} 270 | 271 | if is_hstask(out) then 272 | local stdout = out[2] 273 | 274 | if u.isjson(stdout) then 275 | -- NOTE: hs.json.decode cannot parse "inf" values 276 | -- yabai response may have "inf" values: e.g., frame":{"x":inf,"y":inf,"w":0.0000,"h":0.0000} 277 | -- So, we must replace ":inf," with ":0," 278 | local clean = stdout:gsub(':inf,',':0,') 279 | stdout = hs.json.decode(clean) 280 | end 281 | 282 | return fn(stdout) 283 | end 284 | 285 | -- fallback if 'out' is not from hs.task 286 | return fn(out) 287 | end 288 | end -- }}} 289 | 290 | function u.setfield(path, val, tbl) -- {{{ 291 | log.d(('u.setfield: %s, val: %s'):format(path, val)) 292 | 293 | tbl = tbl or _G -- start with the table of globals 294 | for w, d in path:gmatch('([%w_]+)(.?)') do 295 | if d=='.' then -- not last field? 296 | tbl[w] = tbl[w] or {} -- create table if absent 297 | tbl = tbl[w] -- get the table 298 | else -- last field 299 | tbl[w] = val -- do the assignment 300 | end 301 | end 302 | end -- }}} 303 | 304 | function u.getfield(path, tbl, isSafe) -- {{{ 305 | -- NOTE: isSafe defaults to false 306 | log.d(('u.getfield: %s (isSafe = %s)'):format(path, isSafe)) 307 | 308 | local val = tbl or _G -- start with the table of globals 309 | local res = nil 310 | 311 | for path_seg in path:gmatch('[%w_]+') do 312 | if not u.istable(val) then return val end -- if v isn't table, return immediately 313 | val = val[path_seg] -- lookup next val 314 | if (val~=nil) then res = val end -- only update safe result if v not null 315 | end 316 | 317 | return isSafe and val==nil 318 | and res -- return last non-nil value found 319 | or val -- else return last value found, even if nil 320 | end -- }}} 321 | 322 | function u.toBool(val) -- {{{ 323 | local t = type(val) 324 | 325 | if t=='boolean' then 326 | return val 327 | elseif t=='number' or tonumber(val) then 328 | return tonumber(val)>=1 and true or false 329 | elseif t=='string' then 330 | val = val:trim():lower() 331 | local lookup = { 332 | ['t'] = true, 333 | ['true'] = true, 334 | ['f'] = false, 335 | ['false'] = false, 336 | } 337 | return lookup[val] 338 | end 339 | 340 | print(string.format('toBool(val): Cannot convert %q to boolean. Returning "false"', val)) 341 | return false 342 | end -- }}} 343 | 344 | function u.greaterThan(n) -- {{{ 345 | return function(t) 346 | return #t > n 347 | end 348 | end -- }}} 349 | 350 | function u.roundToNearest(roundTo, numToRound) -- {{{ 351 | return numToRound - numToRound % roundTo 352 | end -- }}} 353 | 354 | function u.p(data, howDeep) -- {{{ 355 | -- local logger = hs.logger.new('inspect', 'debug') 356 | local depth = howDeep or 3 357 | if type(data)=='table' then 358 | print(hs.inspect(data, {depth = depth})) 359 | -- logger.df(hs.inspect(data, {depth = depth})) 360 | else 361 | print(hs.inspect(data, {depth = depth})) 362 | -- logger.df(hs.inspect(data, {depth = depth})) 363 | end 364 | end -- }}} 365 | 366 | function u.pheader(str) -- {{{ 367 | print('\n\n\n') 368 | print("========================================") 369 | print(string.upper(str), '==========') 370 | print("========================================") 371 | end -- }}} 372 | 373 | function u.groupBy(t, f) -- {{{ 374 | -- FROM: https://github.com/pyrodogg/AdventOfCode/blob/1ff5baa57c0a6a86c40f685ba6ab590bd50c2148/2019/lua/util.lua#L149 375 | local res = {} 376 | for _k, v in pairs(t) do 377 | local g 378 | if type(f)=='function' then 379 | g = f(v) 380 | elseif type(f)=='string' and v[f]~=nil then 381 | g = v[f] 382 | else 383 | error('Invalid group parameter [' .. f .. ']') 384 | end 385 | 386 | if res[g]==nil then 387 | res[g] = {} 388 | end 389 | table.insert(res[g], v) 390 | end 391 | return res 392 | end -- }}} 393 | 394 | function u.zip(a, b) -- {{{ 395 | local rv = {} 396 | local idx = 1 397 | local len = math.min(#a, #b) 398 | while idx <= len do 399 | rv[idx] = {a[idx], b[idx]} 400 | idx = idx + 1 401 | end 402 | return rv 403 | end -- }}} 404 | 405 | function u.copyShallow(t) -- {{{ 406 | -- FROM: https://github.com/XavierCHN/go/blob/master/game/go/scripts/vscripts/utils/table.lua 407 | local copy 408 | 409 | if not u.istable(t) then 410 | copy = t 411 | return copy 412 | end 413 | 414 | for k, v in u.getiter(t) do 415 | copy[k] = v 416 | end 417 | return copy 418 | end -- }}} 419 | 420 | function u.deepCopy(obj, seen) -- {{{ 421 | -- from https://gist.githubusercontent.com/tylerneylon/81333721109155b2d244/raw/5d610d32f493939e56efa6bebbcd2018873fb38c/copy.lua 422 | -- The issue here is that the following code will call itself 423 | -- indefinitely and ultimately cause a stack overflow: 424 | -- 425 | -- local my_t = {} 426 | -- my_t.a = my_t 427 | -- local t_copy = copy2(my_t) 428 | -- 429 | -- This happens to both copy1 and copy2, which each try to make 430 | -- a copy of my_t.a, which involves making a copy of my_t.a.a, 431 | -- which involves making a copy of my_t.a.a.a, etc. The 432 | -- recursive table my_t is perfectly legal, and it's possible to 433 | -- make a deep_copy function that can handle this by tracking 434 | -- which tables it has already started to copy. 435 | -- 436 | -- Thanks to @mnemnion for pointing out that we should not call 437 | -- setmetatable() until we're doing copying values; otherwise we 438 | -- may accidentally trigger a custom __index() or __newindex()! 439 | 440 | -- Handle non-tables and previously-seen tables. 441 | if not u.istable(obj) then return obj end 442 | if seen and seen[obj] then return seen[obj] end 443 | 444 | -- New table; mark it as seen and copy recursively. 445 | local s = seen or {} 446 | local res = {} 447 | s[obj] = res 448 | for k, v in u.getiter(obj) do 449 | res[u.deepCopy(k, s)] = u.deepCopy(v, s) 450 | end 451 | return setmetatable(res, getmetatable(obj)) 452 | end -- }}} 453 | 454 | function u.safeSort(tbl, fn) -- {{{ 455 | -- WANRING: Sorting mutates table 456 | fn = fn or function(x,y) return x < y end 457 | if u.isarray(tbl) then 458 | table.sort(tbl,fn) 459 | end 460 | return tbl 461 | end -- }}} 462 | 463 | function u.equal(a, b) -- {{{ 464 | if a==b then 465 | return true 466 | end 467 | 468 | local all_tbls = u.all({a,b}, u.istable) 469 | 470 | if all_tbls and (u.length(a) ~= u.length(b)) 471 | or a==nil 472 | or b==nil 473 | then 474 | return false 475 | end 476 | 477 | u.each({a,b}, u.safeSort) 478 | 479 | for k in u.getiter(a) do 480 | if b[k]~=a[k] then 481 | return false 482 | end 483 | end 484 | 485 | return true 486 | end -- }}} 487 | 488 | function u.levenshteinDistance(str1, str2) -- {{{ 489 | str1, str2 = str1:lower(), str2:lower() 490 | local len1, len2 = #str1, #str2 491 | local c1, c2, dist = {}, {}, {} 492 | 493 | str1:gsub('.', function(c) table.insert(c1, c) end) 494 | str2:gsub('.', function(c) table.insert(c2, c) end) 495 | for i = 0, len1 do dist[i] = {} end 496 | for i = 0, len1 do dist[i][0] = i end 497 | for i = 0, len2 do dist[0][i] = i end 498 | 499 | for i = 1, len1 do 500 | for j = 1, len2 do 501 | dist[i][j] = 502 | math.min(dist[i - 1][j] + 1, 503 | dist[i][j - 1] + 1, dist[i - 1][j - 1] + (c1[i]==c2[j] and 0 or 1)) 504 | end 505 | end 506 | return dist[len1][len2] / #str2 507 | end -- }}} 508 | 509 | function u.flatten(t) -- {{{ 510 | if not u.isarray(t) then 511 | log.i('u.flatten expects array-type tbl, given dict-type tbl') 512 | return t 513 | end 514 | 515 | local ret = {} 516 | for _, v in ipairs(t) do 517 | if u.istable(v) then 518 | for _, fv in ipairs(u.flatten(v)) do 519 | ret[#ret + 1] = fv 520 | end 521 | else 522 | ret[#ret + 1] = v 523 | end 524 | end 525 | return ret 526 | end -- }}} 527 | 528 | function u.flattenPath(tbl) -- {{{ 529 | local function flatten(input, mdepth, depth, prefix, res, circ) -- {{{ 530 | local k, v = next(input) 531 | while k do 532 | local pk = prefix .. k 533 | if not u.istable(v) then 534 | res[pk] = v 535 | else 536 | local ref = tostring(v) 537 | if not circ[ref] then 538 | if mdepth > 0 and depth >= mdepth then 539 | res[pk] = v 540 | else -- set value except circular referenced value 541 | circ[ref] = true 542 | local nextPrefix = pk .. '.' 543 | flatten(v, mdepth, depth + 1, nextPrefix, res, circ) 544 | circ[ref] = nil 545 | end 546 | end 547 | end 548 | k, v = next(input, k) 549 | end 550 | return res 551 | end -- }}} 552 | 553 | local maxdepth = 0 554 | local prefix = '' 555 | local result = {} 556 | local circularRef = {[tostring(tbl)] = true} 557 | 558 | return flatten(tbl, maxdepth, 1, prefix, result, circularRef) 559 | end -- }}} 560 | 561 | return u 562 | -------------------------------------------------------------------------------- /lib/valid.lua: -------------------------------------------------------------------------------- 1 | -- @file validation.lua 2 | -- @author Théo Brigitte 3 | -- @contributor Henrique Silva 4 | -- @date Thu May 28 16:05:15 2015 5 | -- 6 | -- @brief Lua schema validation library. 7 | -- 8 | -- Validation is achieved by matching data against a schema. 9 | -- 10 | -- A schema is a representation of the expected structure of the data. It is 11 | -- a combination of what we call "validators". 12 | -- Validators are clojures which build accurante validation function for each 13 | -- element of the schema. 14 | -- Meta-validators allow to extend the logic of the schema by providing an 15 | -- additional logic layer around validators. 16 | -- e.g. optional() 17 | -- 18 | 19 | -- Import from global environment. 20 | local type = type 21 | local pairs = pairs 22 | local print = print 23 | local format = string.format 24 | local floor = math.floor 25 | local insert = table.insert 26 | local next = next 27 | 28 | -- [AW]: Don't know why this is here. Must be commented out. 29 | -- -- Disable global environment. 30 | -- if _G.setfenv then 31 | -- setfenv(1, {}) 32 | -- else -- Lua 5.2. 33 | -- _ENV = {} 34 | -- end 35 | 36 | local M = { _NAME = 'validation' } 37 | 38 | --- Generate error message for validators. 39 | -- 40 | -- @param data mixed 41 | -- Value that failed validation. 42 | -- @param expected_type string 43 | -- Expected type for data 44 | -- 45 | -- @return 46 | -- String describing the error. 47 | --- 48 | local function error_message(data, expected_type) 49 | if data then 50 | return format('%s is not %s.', tostring(data), expected_type) 51 | end 52 | 53 | return format('is missing and should be %s.', expected_type) 54 | end 55 | 56 | --- Create a readable string output from the validation errors output. 57 | -- 58 | -- @param error_list table 59 | -- Nested table identifying where the error occured. 60 | -- e.g. { price = { rule_value = 'error message' } } 61 | -- @param parents string 62 | -- String of dot separated parents keys 63 | -- 64 | -- @return string 65 | -- Message describing where the error occured. e.g. price.rule_value = "error message" 66 | --- 67 | function M.print_err(error_list, parents) 68 | -- Makes prefix not nil, for posterior concatenation. 69 | local error_output = '' 70 | local parents = parents or '' 71 | if not error_list then return false end 72 | 73 | -- Iterates over the list of messages. 74 | for key, err in pairs(error_list) do -- If it is a node, print it. 75 | if type(err) == 'string' then 76 | error_output = format('%s\n%s%s %s', error_output, parents ,key, err) 77 | else -- If it is a table, recurse it. 78 | error_output = format('%s%s', error_output, M.print_err(err, format('%s%s.', parents, key))) 79 | end 80 | end 81 | 82 | return error_output 83 | end 84 | 85 | --- Validators. 86 | -- 87 | -- A validator is a function in charge of verifying data compliance. 88 | -- 89 | -- Prototype: 90 | -- @key 91 | -- Key of data being validated. 92 | -- @data 93 | -- Current data tree level. Meta-validator might need to verify other keys. e.g. assert() 94 | -- 95 | -- @return 96 | -- true on success, false and message describing the error 97 | --- 98 | 99 | 100 | --- Generates string validator. 101 | -- 102 | -- @return 103 | -- String validator function. 104 | --- 105 | function M.is_string() 106 | return function(value) 107 | if type(value) ~= 'string' then 108 | return false, error_message(value, 'a string') 109 | end 110 | return true 111 | end 112 | end 113 | 114 | --- Generates integer validator. 115 | -- 116 | -- @return 117 | -- Integer validator function. 118 | --- 119 | function M.is_integer() 120 | return function(value) 121 | if type(value) ~= 'number' or value%1 ~= 0 then 122 | return false, error_message(value, 'an integer') 123 | end 124 | return true 125 | end 126 | end 127 | 128 | --- Generates number validator. 129 | -- 130 | -- @return 131 | -- Number validator function. 132 | --- 133 | function M.is_number() 134 | return function(value) 135 | if type(value) ~= 'number' then 136 | return false, error_message(value, 'a number') 137 | end 138 | return true 139 | end 140 | end 141 | 142 | --- Generates boolean validator. 143 | -- 144 | -- @return 145 | -- Boolean validator function. 146 | --- 147 | function M.is_boolean() 148 | return function(value) 149 | if type(value) ~= 'boolean' then 150 | return false, error_message(value, 'a boolean') 151 | end 152 | return true 153 | end 154 | end 155 | 156 | --- Generates an array validator. 157 | -- 158 | -- Validate an array by applying same validator to all elements. 159 | -- 160 | -- @param validator function 161 | -- Function used to validate the values. 162 | -- @param is_object boolean (optional) 163 | -- When evaluted to false (default), it enforce all key to be of type number. 164 | -- 165 | -- @return 166 | -- Array validator function. 167 | -- This validator return value is either true on success or false and 168 | -- a table holding child_validator errors. 169 | --- 170 | function M.is_array(child_validator, is_object) 171 | return function(value, key, data) 172 | local result, err = nil 173 | local err_array = {} 174 | 175 | -- Iterate the array and validate them. 176 | if type(value) == 'table' then 177 | for index in pairs(value) do 178 | if not is_object and type(index) ~= 'number' then 179 | insert(err_array, error_message(value, 'an array') ) 180 | else 181 | result, err = child_validator(value[index], index, value) 182 | if not result then 183 | err_array[index] = err 184 | end 185 | end 186 | end 187 | else 188 | insert(err_array, error_message(value, 'an array') ) 189 | end 190 | 191 | if next(err_array) == nil then 192 | return true 193 | else 194 | return false, err_array 195 | end 196 | end 197 | end 198 | 199 | --- Generates optional validator. 200 | -- 201 | -- When data is present apply the given validator on data. 202 | -- 203 | -- @param validator function 204 | -- Function used to validate value. 205 | -- 206 | -- @return 207 | -- Optional validator function. 208 | -- This validator return true or the result from the given validator. 209 | --- 210 | function M.optional(validator) 211 | return function(value, key, data) 212 | if not value then return true 213 | else 214 | return validator(value, key, data) 215 | end 216 | end 217 | end 218 | 219 | --- Generates or meta validator. 220 | -- 221 | -- Allow data validation using two different validators and applying 222 | -- or condition between results. 223 | -- 224 | -- @param validator_a function 225 | -- Function used to validate value. 226 | -- @param validator_b function 227 | -- Function used to validate value. 228 | -- 229 | -- @return 230 | -- Or validator function. 231 | -- This validator return true or the result from the given validator. 232 | --- 233 | function M.or_op(validator_a, validator_b) 234 | return function(value, key, data) 235 | if not value then return true 236 | else 237 | local valid, err_a = validator_a(value, key, data) 238 | if not valid then 239 | valid, err_b = validator_b(value, key, data) 240 | end 241 | if not valid then 242 | return valid, err_a .. " OR " .. err_b 243 | else 244 | return valid, nil 245 | end 246 | end 247 | end 248 | end 249 | 250 | --- Generates assert validator. 251 | -- 252 | -- This function enforces the existence of key/value with the 253 | -- verification of the key_check. 254 | -- 255 | -- @param key_check mixed 256 | -- Key used to check the optionality of the asserted key. 257 | -- @param match mixed 258 | -- Comparation value. 259 | -- @param validator function 260 | -- Function that validates the type of the data. 261 | -- 262 | -- @return 263 | -- Assert validator function. 264 | -- This validator return true, the result from the given validator or false 265 | -- when the assertion fails. 266 | --- 267 | function M.assert(key_check, match, validator) 268 | return function(value, key, data) 269 | if data[key_check] == match then 270 | return validator(value, key, data) 271 | else 272 | return true 273 | end 274 | end 275 | end 276 | 277 | --- Generates list validator. 278 | -- 279 | -- Ensure the value is contained in the given list. 280 | -- 281 | -- @param list table 282 | -- Set of allowed values. 283 | -- @param value mixed 284 | -- Comparation value. 285 | -- @param validator function 286 | -- Function that validates the type of the data. 287 | -- 288 | -- @return 289 | -- In list validator function. 290 | --- 291 | function M.in_list(list) 292 | return function(value) 293 | local printed_list = "[" 294 | for _, word in pairs(list) do 295 | if word == value then 296 | return true 297 | end 298 | printed_list = printed_list .. " '" .. tostring(word) .. "'" 299 | end 300 | 301 | printed_list = printed_list .. " ]" 302 | return false, { error_message(value, 'in list ' .. printed_list) } 303 | end 304 | end 305 | 306 | --- Generates table validator. 307 | -- 308 | -- Validate table data by using appropriate schema. 309 | -- 310 | -- @param schema table 311 | -- Schema used to validate the table. 312 | -- 313 | -- @return 314 | -- Table validator function. 315 | -- This validator return value is either true on success or false and 316 | -- a nested table holding all errors. 317 | --- 318 | function M.is_table(schema, tolerant) 319 | return function(value) 320 | local result, err = nil 321 | 322 | if type(value) ~= 'table' then 323 | -- Enforce errors of childs value. 324 | _, err = validate_table({}, schema, tolerant) 325 | if not err then err = {} end 326 | result = false 327 | insert(err, error_message(value, 'a table') ) 328 | else 329 | result, err = validate_table(value, schema, tolerant) 330 | end 331 | 332 | return result, err 333 | end 334 | end 335 | 336 | --- Validate function. 337 | -- 338 | -- @param data 339 | -- Table containing the pairs to be validated. 340 | -- @param schema 341 | -- Schema against which the data will be validated. 342 | -- 343 | -- @return 344 | -- String describing the error or true. 345 | --- 346 | function validate_table(data, schema, tolerant) 347 | 348 | -- Array of error messages. 349 | local errs = {} 350 | -- Check if the data is empty. 351 | 352 | -- Check if all data keys are present in the schema. 353 | if not tolerant then 354 | for key in pairs(data) do 355 | if schema[key] == nil then 356 | errs[key] = 'is not allowed.' 357 | end 358 | end 359 | end 360 | 361 | -- Iterates over the keys of the data table. 362 | for key in pairs(schema) do 363 | -- Calls a function in the table and validates it. 364 | local result, err = schema[key](data[key], key, data) 365 | 366 | -- If validation fails, print the result and return it. 367 | if not result then 368 | errs[key] = err 369 | end 370 | end 371 | 372 | -- Lua does not give size of table holding only string as keys. 373 | -- Despite the use of #table we have to manually loop over it. 374 | for _ in pairs(errs) do 375 | return false, errs 376 | end 377 | 378 | return true 379 | end 380 | 381 | return M 382 | -------------------------------------------------------------------------------- /stackline/configmanager.lua: -------------------------------------------------------------------------------- 1 | -- https://github.com/erento/lua-schema-validation 2 | local log = hs.logger.new('configmgr', 'info') 3 | local v = require 'lib.valid' -- Validators & type lookup 4 | local o = v.optional 5 | 6 | local is_color = v.is_table { 7 | white = o(v.is_number()), 8 | red = o(v.is_number()), 9 | green = o(v.is_number()), 10 | blue = o(v.is_number()), 11 | alpha = o(v.is_number()), 12 | } 13 | 14 | local function unknownTypeValidator(val) 15 | log.i('Not validating: ', val) 16 | return true 17 | end 18 | 19 | -- === Config module === 20 | log.i('Loading module: stackline.configmanager') 21 | 22 | local Config = {} 23 | 24 | Config.types = { -- {{{ 25 | -- validator & coerce mthods for each type found in stackline config 26 | ['string'] = { 27 | validator = v.is_string, 28 | coerce = tostring, 29 | }, 30 | ['number'] = { 31 | validator = v.is_number, 32 | coerce = tonumber, 33 | }, 34 | ['table'] = { 35 | validator = v.is_table, 36 | coerce = u.identity, 37 | }, 38 | ['boolean'] = { 39 | validator = v.is_boolean, 40 | coerce = u.toBool, 41 | }, 42 | ['color'] = { 43 | validator = u.cb(is_color), 44 | coerce = u.identity, 45 | }, 46 | ['winTitles'] = { 47 | validator = u.cb(v.in_list { true, false, 'when_switching', 'not_implemented', }), 48 | coerce = u.identity, 49 | }, 50 | ['dynamicLuminosity'] = { 51 | validator = u.cb(v.in_list {true, false, 'not_implemented'}), 52 | coerce = u.identity, 53 | }, 54 | } -- }}} 55 | 56 | local defaultOnChangeEvt = { -- {{{ 57 | __index = function() stackline.queryWindowState:start() end 58 | } -- }}} 59 | 60 | Config.events = setmetatable({ -- {{{ 61 | -- Map stackline actions to config keys 62 | -- If config key changes, perform action 63 | appearance = function() stackline.manager:resetAllIndicators() end, 64 | features = { 65 | clickToFocus = function() return stackline:refreshClickTracker() end, 66 | maxRefreshRate = nil, 67 | hsBugWorkaround = nil, 68 | winTitles = nil, 69 | dynamicLuminosity = nil, 70 | }, 71 | advanced = { 72 | maxRefreshRate = function() print('Needs implemented') end, 73 | }, 74 | }, defaultOnChangeEvt) -- }}} 75 | 76 | Config.schema = { -- {{{ 77 | -- Set type for each stackline config key 78 | paths = { 79 | yabai = 'string' 80 | }, 81 | appearance = { 82 | color = 'color', 83 | alpha = 'number', 84 | dimmer = 'number', 85 | iconDimmer = 'number', 86 | showIcons = 'boolean', 87 | size = 'number', 88 | radius = 'number', 89 | iconPadding = 'number', 90 | pillThinness = 'number', 91 | 92 | vertSpacing = 'number', 93 | offset = {x='number', y='number'}, 94 | shouldFade = 'boolean', 95 | fadeDuration = 'number', 96 | }, 97 | features = { 98 | clickToFocus = 'boolean', 99 | hsBugWorkaround = 'boolean', 100 | winTitles = 'winTitles', 101 | dynamicLuminosity = 'dynamicLuminosity', 102 | fzyFrameDetect = { enabled = 'boolean', fuzzFactor = 'number' }, 103 | }, 104 | advanced = { 105 | maxRefreshRate = 'number', 106 | } 107 | } -- }}} 108 | 109 | function Config:init(conf) -- {{{ 110 | log.i('Initializing configmanager…') 111 | self:validate(conf) 112 | self.__index = self 113 | return self 114 | end -- }}} 115 | 116 | function Config:getPathSchema(path) -- {{{ 117 | local _type = u.getfield(path, self.schema) -- lookup type in schema 118 | if not _type then return false end 119 | local validator = self.types[_type].validator() 120 | 121 | return _type, validator 122 | end -- }}} 123 | 124 | function Config.generateValidator(schemaType) -- {{{ 125 | if u.istable(schemaType) then -- recursively build validator 126 | local children = u.map(schemaType, Config.generateValidator) 127 | log.d('validator children:\n', hs.inspect(children)) 128 | return v.is_table(children) 129 | end 130 | 131 | -- otherwise, return validation fn forgiven type 132 | log.d('schemaType:', schemaType) 133 | return Config.types[schemaType] -- if schemaType is a known config type.. 134 | and Config.types[schemaType].validator() -- then return validation fn 135 | or unknownTypeValidator -- otherwise, unknown types are assumed valid 136 | end -- }}} 137 | 138 | function Config:validate(conf) -- {{{ 139 | local c = conf or self.conf 140 | local validate = self.generateValidator(self.schema) 141 | local isValid, err = validate(c) 142 | 143 | if isValid then 144 | log.i('✓ Conf validated successfully') 145 | self.conf = conf 146 | self.autosuggestions = u.keys(u.flattenPath(self.conf)) 147 | else 148 | local invalidKeys = table.concat(u.keys(u.flattenPath(err)), ', ') 149 | log.e('Invalid stackline config:\n', hs.inspect(err)) 150 | hs.notify.new(nil, { 151 | title = 'Invalid stackline config!', 152 | subTitle = 'invalid keys:' .. invalidKeys, 153 | informativeText = 'Please refer to the default conf file.', 154 | withdrawAfter = 10 155 | }):send() 156 | end 157 | 158 | return isValid, err 159 | end -- }}} 160 | 161 | function Config:getOrSet(path, val) -- {{{ 162 | return (path and val) 163 | and self:set(path, val) 164 | or self:get(path) 165 | end -- }}} 166 | 167 | function Config:get(path) -- {{{ 168 | -- path is a dot-separated string, e.g., 'appearance.color' 169 | -- returns value at path or full config if path not provided 170 | if path==nil then return self.conf end 171 | local val = u.getfield(path, self.conf) 172 | 173 | if val==nil then 174 | return log.w( ('config.get("%s") not found'):format(path) ) 175 | end 176 | 177 | log.d(('get(%s) found: %s'):format(path, val)) 178 | return val 179 | end -- }}} 180 | 181 | function Config:set(path, val) -- {{{ 182 | -- path is a dot-separated string, e.g., 'appearance.color' 183 | -- val is the value to set at path 184 | -- non-existent path segments will be set to an empty table 185 | local _type, validator = self:getPathSchema(path) -- lookup type in schema 186 | if not _type then 187 | self:autosuggest(path) 188 | return self 189 | end 190 | 191 | local typedVal = self.types[_type].coerce(val) 192 | local isValid, err = validator(typedVal) -- validate val is appropriate type 193 | 194 | if not isValid then 195 | log.e('Set', path, 'to invalid value.', hs.inspect(err)) 196 | return self 197 | end 198 | 199 | u.setfield(path, typedVal, self.conf) 200 | 201 | local onChange = u.getfield(path, self.events, true) 202 | if u.isfunc(onChange) then onChange() end 203 | 204 | return self, val 205 | end -- }}} 206 | 207 | function Config:toggle(key) -- {{{ 208 | local val = self:get(key) 209 | if not u.isbool(val) then 210 | log.w(key, 'cannot be toggled because it is not boolean') 211 | return self 212 | end 213 | local toggledVal = not val 214 | log.i('Toggling', key, 'from ', val, 'to ', toggledVal) 215 | self:set(key, toggledVal) 216 | end -- }}} 217 | 218 | function Config:setLogLevel(lvl) -- {{{ 219 | log.setLogLevel(lvl) 220 | log.i( ('Window.log level set to %s'):format(lvl) ) 221 | end -- }}} 222 | 223 | return Config 224 | -------------------------------------------------------------------------------- /stackline/query.lua: -------------------------------------------------------------------------------- 1 | local log = hs.logger.new('query', 'info') 2 | log.i('Loading module: query') 3 | 4 | local function yabai(command, callback) -- {{{ 5 | callback = callback or function(x) return x end 6 | command = '-m ' .. command 7 | 8 | hs.task.new( 9 | stackline.config:get'paths.yabai', 10 | u.task_cb(callback), -- wrap callback in json decoder 11 | command:split(' ') 12 | ):start() 13 | end -- }}} 14 | 15 | local function stackIdMapper(yabaiWindow) -- {{{ 16 | -- u.p(yabaiWindow) 17 | local res = {} 18 | if type(yabaiWindow)~='table' then u.p(yabaiWindow) end 19 | for _,win in pairs(yabaiWindow or {}) do 20 | if win['stack-index']~=0 then 21 | res[tostring(win.id)] = win['stack-index'] 22 | end 23 | end 24 | return res 25 | end -- }}} 26 | 27 | local function getStackedWinIds(byStack) -- {{{ 28 | local stackedWinIds = {} 29 | for _, group in pairs(byStack) do 30 | for _, win in pairs(group) do 31 | stackedWinIds[win.id] = true 32 | end 33 | end 34 | return stackedWinIds 35 | end -- }}} 36 | 37 | local function groupWindows(ws) -- {{{ 38 | -- Given windows from hs.window.filter: 39 | -- 1. Create stackline window objects 40 | -- 2. Group wins by `stackId` prop (aka top-left frame coords) 41 | -- 3. If at least one such group, also group wins by app (to workaround hs bug unfocus event bug) 42 | local byStack 43 | local byApp 44 | 45 | local windows = u.map(ws, function(w) 46 | return stackline.window:new(w) 47 | end) 48 | 49 | -- See 'stackId' def @ /window.lua:233 50 | local groupKey = c.features.fzyFrameDetect.enabled 51 | and 'stackIdFzy' 52 | or 'stackId' 53 | 54 | byStack = u.filter( 55 | u.groupBy(windows, groupKey), 56 | u.greaterThan(1)) -- stacks have >1 window, so ignore 'groups' of 1 57 | 58 | if u.length(byStack) > 0 then 59 | local stackedWinIds = getStackedWinIds(byStack) 60 | local stackedWins = u.filter(windows, function(w) 61 | return stackedWinIds[w.id] --true if win id is in stackedWinIds 62 | end) 63 | 64 | byApp = u.groupBy(stackedWins, 'app') -- app names are keys in group 65 | end 66 | 67 | return byStack, byApp 68 | end -- }}} 69 | 70 | local function removeGroupedWin(win, byStack) -- {{{ 71 | -- remove given window if it's present in byStack windows 72 | return u.map(byStack, function(stack) 73 | return u.filter(stack, function(w) 74 | return w.id ~= win.id 75 | end) 76 | end) 77 | end -- }}} 78 | 79 | local function mergeWinStackIdxs(byStack, winStackIdxs) -- {{{ 80 | -- merge windowID <> stack-index mapping queried from yabai into window objs 81 | 82 | local function assignStackIndex(win) 83 | local stackIdx = winStackIdxs[tostring(win.id)] 84 | if stackIdx == 0 then 85 | -- Remove windows with stackIdx == 0. Such windows overlap exactly with 86 | -- other (potentially stacked) windows, and so are grouped with them, 87 | -- but they are NOT stacked according to yabai. 88 | -- Windows that belong to a *real* stack have stackIdx > 0. 89 | byStack = removeGroupedWin(win, byStack) 90 | end 91 | 92 | -- set the stack idx 93 | win.stackIdx = stackIdx 94 | end 95 | 96 | u.each(byStack, function(stack) 97 | u.each(stack, assignStackIndex) 98 | end) 99 | 100 | return byStack 101 | 102 | end -- }}} 103 | 104 | local function shouldRestack(new) -- {{{ 105 | -- Analyze byStack to determine if a stack refresh is needed 106 | -- • change num stacks (+/-) 107 | -- • changes to existing stack 108 | -- • change position 109 | -- • change num windows (win added / removed) 110 | 111 | local curr = stackline.manager:getSummary() 112 | new = stackline.manager:getSummary(u.values(new)) 113 | 114 | if curr.numStacks ~= new.numStacks then 115 | log.i('Should refresh -> Num stacks changed') 116 | return true 117 | end 118 | 119 | if not u.equal(curr.topLeft, new.topLeft) then 120 | u.p(curr.topLeft) 121 | log.i('Should refresh -> Stack position changed', curr.topLeft, new.topLeft) 122 | return true 123 | end 124 | 125 | if not u.equal(curr.numWindows, new.numWindows) then 126 | log.i('Should refresh -> Windows changed') 127 | return true 128 | end 129 | 130 | log.i('Should not redraw.') 131 | end -- }}} 132 | 133 | local function run(opts) -- {{{ 134 | opts = opts or {} 135 | local byStack, byApp = groupWindows(stackline.wf:getWindows()) -- set byStack & self.appWindows 136 | 137 | -- Check if space has stacks... 138 | local spaceHasStacks = stackline.manager:getSummary().numStacks > 0 139 | 140 | local shouldRefresh = spaceHasStacks and shouldRestack(byStack) -- Don't even check on a space that doesn't have any stacks 141 | 142 | if shouldRefresh or opts.forceRedraw then 143 | log.i('Refreshing stackline') 144 | -- TODO: if there's only 1 space, use 'query --windows --space' to reduce chance that parsing fails 145 | local yabai_cmd = 'query --windows' 146 | 147 | yabai(yabai_cmd, function(yabaiRes) 148 | local winStackIdxs = stackIdMapper(yabaiRes) 149 | stackline.manager:ingest( -- hand over to stackmanager 150 | mergeWinStackIdxs(byStack, winStackIdxs), -- Add the stack indexes from yabai to byStack 151 | byApp, 152 | spaceHasStacks 153 | ) 154 | end) 155 | end 156 | end -- }}} 157 | 158 | return { 159 | run = run, 160 | setLogLevel = log.setLogLevel 161 | } 162 | -------------------------------------------------------------------------------- /stackline/stack.lua: -------------------------------------------------------------------------------- 1 | local Stack = {} 2 | 3 | function Stack:new(stackedWindows) -- {{{ 4 | local stack = { 5 | windows = stackedWindows 6 | } 7 | setmetatable(stack, self) 8 | self.__index = self 9 | return stack 10 | end -- }}} 11 | 12 | function Stack:get() -- {{{ 13 | return self.windows 14 | end -- }}} 15 | 16 | function Stack:getHs() -- {{{ 17 | return u.map(self.windows, function(w) 18 | return w._win 19 | end) 20 | end -- }}} 21 | 22 | function Stack:frame() -- {{{ 23 | -- All stacked windows have the same dimensions, 24 | -- so the 1st Hs window's frame is ~= to the stack's frame 25 | -- TODO: Incorrect when the 1st window has min-size < stack width. See ./query.lua:105 26 | return self.windows[1]._win:frame() 27 | end -- }}} 28 | 29 | function Stack:eachWin(fn) -- {{{ 30 | for _idx, win in pairs(self.windows) do 31 | fn(win) 32 | end 33 | end -- }}} 34 | 35 | function Stack:getOtherAppWindows(win) -- {{{ 36 | -- NOTE: may not need when HS issue #2400 is closed 37 | return u.filter(self:get(), function(w) 38 | return w.app == win.app 39 | end) 40 | end -- }}} 41 | 42 | function Stack:anyFocused() -- {{{ 43 | return u.any(self.windows, function(w) 44 | return w:isFocused() 45 | end) 46 | end -- }}} 47 | 48 | function Stack:resetAllIndicators() -- {{{ 49 | self:eachWin(function(w) 50 | w:setupIndicator():drawIndicator() 51 | end) 52 | end -- }}} 53 | 54 | function Stack:redrawAllIndicators(opts) -- {{{ 55 | self:eachWin(function(win) 56 | if win.id ~= opts.except then 57 | win:redrawIndicator() 58 | end 59 | end) 60 | end -- }}} 61 | 62 | function Stack:deleteAllIndicators() -- {{{ 63 | self:eachWin(function(win) 64 | win:deleteIndicator() 65 | end) 66 | end -- }}} 67 | 68 | function Stack:getWindowByPoint(p) 69 | if p.x < 0 or p.y < 0 then 70 | -- FIX: https://github.com/AdamWagner/stackline/issues/62 71 | -- NOTE: Window indicator frame coordinates are relative to the window's screen. 72 | -- So, if click point has negative X or Y vals, then convert its coordinates 73 | -- to relative to the clicked screen before comparing to window indicator frames. 74 | -- TODO: Clean this up after fix is confirmed 75 | 76 | -- Get the screen with frame that contains point 'p' 77 | local function findClickedScreen(_p) -- {{{ 78 | return table.unpack( 79 | u.filter(hs.screen.allScreens(), function(s) 80 | return _p:inside(s:frame()) 81 | end) 82 | ) 83 | end -- }}} 84 | 85 | local clickedScren = findClickedScreen(p) 86 | p = clickedScren 87 | and clickedScren:absoluteToLocal(p) 88 | or p 89 | end 90 | 91 | return table.unpack( 92 | u.filter(self.windows, function(w) 93 | local indicatorFrame = w.indicator and w.indicator:canvasElements()[1].frame 94 | if not indicatorFrame then return false end 95 | return p:inside(indicatorFrame) -- NOTE: frame *must* be a hs.geometry.rect instance 96 | end) 97 | ) 98 | end 99 | 100 | return Stack 101 | -------------------------------------------------------------------------------- /stackline/stackline.lua: -------------------------------------------------------------------------------- 1 | -- luacheck: globals table.merge 2 | -- luacheck: globals u 3 | -- luacheck: ignore 112 4 | local wf = hs.window.filter 5 | local timer = hs.timer.delayed 6 | local log = hs.logger.new('stackline', 'info') 7 | local click = hs.eventtap.event.types['leftMouseDown'] -- fyi, print hs.eventtap.event.types to see all event types 8 | 9 | log.i'Loading module: stackline' 10 | _G.u = require 'lib.utils' 11 | _G.stackline = {} -- access stackline under global 'stackline' 12 | stackline.config = require'stackline.configmanager' 13 | stackline.window = require'stackline.window' 14 | 15 | function stackline:init(userConfig) -- {{{ 16 | log.i'Initializing stackline' 17 | if stackline.manager then -- re-initializtion guard https://github.com/AdamWagner/stackline/issues/46 18 | return log.i'stackline already initialized' 19 | end 20 | 21 | -- init config with default settings + user overrides 22 | self.config:init(u.extend(require 'stackline.conf', userConfig or {})) 23 | 24 | -- init stackmanager, & run update right away 25 | -- NOTE: Requires self.config to be initialized first 26 | self.manager = require'stackline.stackmanager':init() 27 | self.manager:update({forceRedraw=true}) 28 | 29 | -- Reuseable update fn that runs at most once every maxRefreshRate (default 0.3s) 30 | -- NOTE: yabai is only called if query.shouldRestack() returns true (see ./stackline/query.lua:104) 31 | self.queryWindowState = timer.new( 32 | self.config:get'advanced.maxRefreshRate', 33 | function() 34 | self.manager:update({forceRedraw=self.forceRedraw}) 35 | if(self.forceRedraw) then self.forceRedraw = false end 36 | end, 37 | true -- continue on error 38 | ) 39 | 40 | self:setupListeners() 41 | 42 | self:setupClickTracker() 43 | return self 44 | end -- }}} 45 | 46 | stackline.wf = wf.new():setOverrideFilter{ -- {{{ 47 | -- Default window filter controls what hs.window 'sees' 48 | visible = true, -- i.e., neither hidden nor minimized 49 | fullscreen = false, 50 | currentSpace = true, 51 | allowRoles = 'AXStandardWindow', 52 | } -- }}} 53 | 54 | stackline.events = { -- {{{ 55 | checkOn = { 56 | wf.windowCreated, 57 | wf.windowUnhidden, 58 | 59 | wf.windowMoved, -- NOTE: winMoved includes move AND resize evts 60 | wf.windowUnminimized, 61 | 62 | wf.windowFullscreened, 63 | wf.windowUnfullscreened, 64 | 65 | wf.windowDestroyed, 66 | wf.windowHidden, 67 | wf.windowMinimized, 68 | wf.windowsChanged, -- NOTE: pseudo-event for any change in list of windows. Addresses missing windowCreated events :/ 69 | }, 70 | forceCheckOn = { 71 | wf.windowCreated, 72 | wf.windowsChanged, 73 | wf.windowMoved, 74 | }, 75 | redrawOn = { 76 | wf.windowFocused, 77 | wf.windowNotVisible, 78 | wf.windowUnfocused, 79 | } 80 | } -- }}} 81 | 82 | function stackline:setupListeners() -- {{{ 83 | -- On each win evt above, run update at most once every maxRefreshRate (defaults to 0.3s)) 84 | -- update = query window state & check if redraw needed 85 | self.wf:subscribe(self.events.checkOn, function(_win, _app, evt) 86 | self.forceRedraw = u.contains( -- forceRedraw depending on the type of event 87 | self.events.forceCheckOn, 88 | evt 89 | ) 90 | 91 | log.i('Window event:', evt, 'force:', self.forceRedraw) 92 | self.queryWindowState:start() 93 | end) 94 | 95 | -- On each win evt listed, simply *redraw* indicators 96 | -- No need for heavyweight query + refresh 97 | self.wf:subscribe( 98 | self.events.redrawOn, 99 | self.redrawWinIndicator 100 | ) 101 | end -- }}} 102 | 103 | function stackline:setupClickTracker() -- {{{ 104 | -- Listen for left mouse click events 105 | -- If indicator containing the clickAt position can be found, focus that indicator's window 106 | self.clickTracker = hs.eventtap.new({click}, function(e) 107 | local clickAt = hs.geometry.point(e:location().x, e:location().y) 108 | local clickedWin = self.manager:getClickedWindow(clickAt) 109 | if clickedWin then 110 | log.i('Clicked window at', clickAt) 111 | clickedWin._win:focus() 112 | return true -- stop propogation 113 | end 114 | end) 115 | 116 | -- Activate clickToFocus if feature turned on 117 | if self.config:get'features.clickToFocus' then 118 | log.i'ClickTracker starting' 119 | self.clickTracker:start() 120 | end 121 | end -- }}} 122 | 123 | function stackline:refreshClickTracker() -- {{{ 124 | self.clickTracker:stop() -- always stop if running 125 | if self.config:get'features.clickToFocus' then -- only start if feature is enabled 126 | log.i'features.clickToFocus is enabled — starting clickTracker for current space' 127 | self.clickTracker:start() 128 | end 129 | end -- }}} 130 | 131 | function stackline.redrawWinIndicator(hsWin, _app, _evt) -- {{{ 132 | --[[ Dedicated redraw method to *adjust* the existing canvas element is WAY 133 | faster than deleting the entire indicator & rebuilding it from scratch, 134 | particularly since this skips querying the app icon & building the icon image. 135 | ]] 136 | local stackedWin = stackline.manager:findWindow(hsWin:id()) 137 | if not stackedWin then return end -- if non-existent, the focused win is not stacked 138 | stackedWin:redrawIndicator() 139 | end -- }}} 140 | 141 | function stackline:setLogLevel(lvl) -- {{{ 142 | log.setLogLevel(lvl) 143 | log.i( ('Window.log level set to %s'):format(lvl) ) 144 | end -- }}} 145 | 146 | stackline.spaceWatcher = hs.spaces.watcher.new( -- {{{ 147 | function(spaceIdx) 148 | -- QUESTION: do I need to clean this up? If so, how? 149 | -- Update stackline when switching spaces 150 | -- NOTE: hs.spaces.watcher uses deprecated macos APIs, so this may break in an upcoming macos release 151 | log.i(('hs.spaces.watcher -> changed to space %d'):format(spaceIdx)) 152 | stackline.forceRedraw = true -- force the next update cycle to redraw 153 | stackline.queryWindowState:start() 154 | stackline:refreshClickTracker() 155 | end 156 | ):start() -- }}} 157 | 158 | return stackline 159 | -------------------------------------------------------------------------------- /stackline/stackmanager.lua: -------------------------------------------------------------------------------- 1 | local log = hs.logger.new('stackmanager', 'info') 2 | 3 | local Stackmanager = {} 4 | 5 | Stackmanager.query = require 'stackline.stackline.query' 6 | 7 | function Stackmanager:init() -- {{{ 8 | self.tabStacks = {} 9 | self.showIcons = stackline.config:get('appearance.showIcons') 10 | self.__index = self 11 | return self 12 | end -- }}} 13 | 14 | function Stackmanager:update(opts) -- {{{ 15 | log.i('Running update()') 16 | self.query.run(opts) -- calls Stack:ingest when ready 17 | return self 18 | end -- }}} 19 | 20 | function Stackmanager:ingest(stacks, appWins, shouldClean) -- {{{ 21 | if shouldClean then self:cleanup() end 22 | 23 | for stackId, groupedWindows in pairs(stacks) do 24 | local stack = require 'stackline.stackline.stack':new(groupedWindows) 25 | stack.id = stackId 26 | u.each(stack.windows, function(win) 27 | -- win.otherAppWindows needed to workaround Hammerspoon issue #2400 28 | win.otherAppWindows = u.filter(appWins[win.app], function(w) 29 | -- exclude self and other app windows from other others 30 | return (w.id ~= win.id) and (w.screen == win.screen) 31 | end) 32 | -- TODO: fix error with nil stack field (??): window.lua:32: attempt to index a nil value (field 'stack') 33 | win.stack = stack -- enables calling stack methods from window 34 | end) 35 | table.insert(self.tabStacks, stack) 36 | self:resetAllIndicators() 37 | end 38 | end -- }}} 39 | 40 | function Stackmanager:get() -- {{{ 41 | return self.tabStacks 42 | end -- }}} 43 | 44 | function Stackmanager:eachStack(fn) -- {{{ 45 | for _stackId, stack in pairs(self.tabStacks) do 46 | fn(stack) 47 | end 48 | end -- }}} 49 | 50 | function Stackmanager:cleanup() -- {{{ 51 | self:eachStack(function(stack) 52 | stack:deleteAllIndicators() 53 | end) 54 | self.tabStacks = {} 55 | end -- }}} 56 | 57 | function Stackmanager:getSummary(external) -- {{{ 58 | -- Summarizes all stacks on the current space, making it easy to determine 59 | -- what needs to be updated (if anything) 60 | local stacks = external or self.tabStacks 61 | return { 62 | numStacks = #stacks, 63 | topLeft = u.map(stacks, function(s) 64 | local windows = external and s or s.windows 65 | return windows[1].topLeft 66 | end), 67 | dimensions = u.map(stacks, function(s) 68 | local windows = external and s or s.windows 69 | return windows[1].stackId -- stackId is stringified window frame dims ("1150|93|531|962") 70 | end), 71 | numWindows = u.map(stacks, function(s) 72 | local windows = external and s or s.windows 73 | return #windows 74 | end), 75 | } 76 | end -- }}} 77 | 78 | function Stackmanager:resetAllIndicators() -- {{{ 79 | self:eachStack(function(stack) 80 | stack:resetAllIndicators() 81 | end) 82 | end -- }}} 83 | 84 | function Stackmanager:findWindow(wid) -- {{{ 85 | -- NOTE: A window must be *in* a stack to be found with this method! 86 | for _stackId, stack in pairs(self.tabStacks) do 87 | for _idx, win in pairs(stack.windows) do 88 | if win.id == wid then 89 | return win 90 | end 91 | end 92 | end 93 | end -- }}} 94 | 95 | function Stackmanager:findStackByWindow(win) -- {{{ 96 | -- NOTE: may not need when Hammerspoon #2400 is closed 97 | -- NOTE 2: Currently unused, since reference to "otherAppWindows" is sstored 98 | -- directly on each window. Likely to be useful, tho, so keeping it around. 99 | for _stackId, stack in pairs(self.tabStacks) do 100 | if stack.id == win.stackId then 101 | return stack 102 | end 103 | end 104 | end -- }}} 105 | 106 | function Stackmanager:getShowIconsState() -- {{{ 107 | return self.showIcons 108 | end -- }}} 109 | 110 | function Stackmanager:getClickedWindow(point) -- {{{ 111 | -- given the coordinates of a mouse click, return the first window whose 112 | -- indicator element encompasses the point, or nil if none. 113 | for _stackId, stack in pairs(self.tabStacks) do 114 | local clickedWindow = stack:getWindowByPoint(point) 115 | if clickedWindow then 116 | return clickedWindow 117 | end 118 | end 119 | end -- }}} 120 | 121 | function Stackmanager:setLogLevel(lvl) -- {{{ 122 | log.setLogLevel(lvl) 123 | log.i( ('Window.log level set to %s'):format(lvl) ) 124 | end -- }}} 125 | 126 | return Stackmanager 127 | -------------------------------------------------------------------------------- /stackline/window.lua: -------------------------------------------------------------------------------- 1 | local log = hs.logger.new('window', 'info') 2 | log.i('Loading module: window') 3 | 4 | local Window = {} 5 | 6 | function Window:new(hsWin) -- {{{ 7 | local stackIdResult = self:makeStackId(hsWin) 8 | local ws = { 9 | title = hsWin:title(), -- window title 10 | app = hsWin:application():name(), -- app name (string) 11 | id = hsWin:id(), -- window id (string) NOTE: HS win.id == yabai win.id 12 | frame = hsWin:frame(), -- x,y,w,h of window (table) 13 | stackId = stackIdResult.stackId, -- "{{x}|{y}|{w}|{h}" e.g., "35|63|1185|741" (string) 14 | topLeft = stackIdResult.topLeft, -- "{{x}|{y}" e.g., "35|63" (string) 15 | stackIdFzy = stackIdResult.fzyFrame, -- "{{x}|{y}" e.g., "35|63" (string) 16 | _win = hsWin, -- hs.window object (table) 17 | screen = hsWin:screen():id(), 18 | indicator = nil, -- the canvas element (table) 19 | } 20 | setmetatable(ws, self) 21 | self.__index = self 22 | 23 | log.i( ('Window:new(%s)'):format(ws.id) ) 24 | 25 | return ws 26 | end -- }}} 27 | 28 | function Window:isFocused() -- {{{ 29 | local focusedWin = hs.window.focusedWindow() 30 | if focusedWin == nil then 31 | return false 32 | end 33 | local isFocused = self.id == focusedWin:id() 34 | return isFocused 35 | end -- }}} 36 | 37 | function Window:isStackFocused() -- {{{ 38 | return self.stack:anyFocused() 39 | end -- }}} 40 | 41 | function Window:setupIndicator() -- {{{ 42 | log.d('setupIndicator for', self.id) 43 | self.config = stackline.config:get('appearance') 44 | local c = self.config 45 | self.showIcons = c.showIcons 46 | 47 | self:isStackFocused() 48 | 49 | -- computed from config 50 | self.width = self.showIcons and c.size or (c.size / c.pillThinness) 51 | self.iconRadius = self.width / self.config.radius 52 | 53 | -- Set canvas to fill entire screen 54 | self.screen = self._win:screen() 55 | self.frame = self.screen:absoluteToLocal(hs.geometry(self._win:frame())) 56 | 57 | local xval = self:getIndicatorPosition() 58 | 59 | -- Store canvas elements indexes to reference via :elementAttribute() 60 | -- https://www.hammerspoon.org/docs/hs.canvas.html#elementAttribute 61 | self.rectIdx = 1 62 | self.iconIdx = 2 63 | 64 | -- NOTE: self.stackIdx comes from yabai. Window is stacked if stackIdx > 0 65 | self.indicator_rect = { 66 | x = xval, 67 | y = self.frame.y + c.offset.y + 68 | ((self.stackIdx - 1) * c.size * c.vertSpacing), 69 | w = self.width, 70 | h = c.size, 71 | } 72 | 73 | self.icon_rect = { 74 | x = xval + c.iconPadding, 75 | y = self.indicator_rect.y + c.iconPadding, 76 | w = self.indicator_rect.w - (c.iconPadding * 2), 77 | h = self.indicator_rect.h - (c.iconPadding * 2), 78 | } 79 | return self 80 | end -- }}} 81 | 82 | function Window:drawIndicator(overrideOpts) -- {{{ 83 | log.i('drawIndicator for', self.id) 84 | -- should there be a dedicated "Indicator" class to perform the actual drawing? 85 | local opts = u.extend(self.config, overrideOpts or {}) 86 | local radius = self.showIcons and self.iconRadius or opts.radius 87 | local fadeDuration = opts.shouldFade and opts.fadeDuration or 0 88 | 89 | self.focus = self:isFocused() 90 | self.stackFocus = true 91 | 92 | if self.indicator then 93 | self.indicator:delete() 94 | end 95 | 96 | -- TODO: Should we really create a new canvas for each window? Or should 97 | -- there be one canvas per screen/space into which each window's indicator element is appended? 98 | self.indicator = hs.canvas.new(self.screenFrame) 99 | 100 | self.indicator:insertElement({ 101 | type = "rectangle", 102 | action = "fill", -- options: strokeAndFill, stroke, fill 103 | fillColor = self:getColorAttrs(self.stackFocus, self.focus).bg, 104 | frame = self.indicator_rect, 105 | roundedRectRadii = {xRadius = radius, yRadius = radius}, 106 | withShadow = true, 107 | shadow = self:getShadowAttrs(), 108 | -- trackMouseEnterExit = true, 109 | -- trackMouseByBounds = true, 110 | -- trackMouseDown = true, 111 | }, self.rectIdx) 112 | 113 | if self.showIcons then 114 | -- TODO [low priority]: Figure out how to prevent clipping when adding a subtle shadow 115 | -- to the icon to help distinguish icons with a near-white edge.Note 116 | -- that `padding` attribute, which works for rects, does not work for images. 117 | self.indicator:insertElement({ 118 | type = "image", 119 | image = self:iconFromAppName(), 120 | frame = self.icon_rect, 121 | imageAlpha = self:getColorAttrs(self.stackFocus, self.focus).img, 122 | }, self.iconIdx) 123 | end 124 | 125 | self.indicator:clickActivating(false) -- clicking on a canvas elment should NOT bring Hammerspoon wins to front 126 | self.indicator:show(fadeDuration) 127 | return self 128 | end -- }}} 129 | 130 | function Window:redrawIndicator() -- {{{ 131 | log.i('redrawIndicator for', self.id) 132 | local isWindowFocused = self:isFocused() 133 | local isStackFocused = self:isStackFocused() 134 | 135 | -- has stack, window focus changed? 136 | local stackFocusChange = isStackFocused ~= self.stackFocus 137 | local windowFocusChange = isWindowFocused ~= self.focus 138 | 139 | -- permutations of stack, window change combos 140 | local noChange = not stackFocusChange and not windowFocusChange 141 | local bothChange = stackFocusChange and windowFocusChange 142 | local onlyStackChange = stackFocusChange and not windowFocusChange 143 | local onlyWinChange = not stackFocusChange and windowFocusChange 144 | 145 | -- TODO: Refactor to reduce complexity 146 | -- LOGIC: Redraw according to what changed. 147 | -- Supports indicating the *last-active* window in an unfocused stack. 148 | -- TODO: Fix bug causing stack to continue appearing focused when switching to a non-stacked window from the same app as the focused stack window. Another casualtiy of HS #2400 :< 149 | if noChange then 150 | -- bail early if there's nothing to do 151 | return false 152 | 153 | elseif bothChange then 154 | -- If both change, it means a *focused* window's stack is now unfocused. 155 | self.stackFocus = isStackFocused 156 | self.stack:redrawAllIndicators({except = self.id}) 157 | -- Despite the window being unfocused, do *not* update self.focus 158 | -- (unfocused stack + focused window = last-active window) 159 | 160 | elseif onlyWinChange then 161 | -- changing window focus within a stack 162 | self.focus = isWindowFocused 163 | 164 | local enableTmpFix = stackline.config:get('features.hsBugWorkaround') 165 | if self.focus and enableTmpFix then 166 | self:unfocusOtherAppWindows() 167 | end 168 | 169 | elseif onlyStackChange then 170 | -- aka, already unfocused window's stack is now unfocused, too 171 | -- so update stackFocus 172 | self.stackFocus = isStackFocused 173 | 174 | -- if only stack changed *and* win is focused, it means a previously 175 | -- unfocused stack is now focused, so redraw other window indicators 176 | if isWindowFocused then 177 | self.stack:redrawAllIndicators({except = self.id}) 178 | end 179 | end 180 | 181 | if not self.indicator then 182 | self:setupIndicator() 183 | end 184 | 185 | -- ACTION: Update canvas values 186 | local f = self.focus 187 | local rect = self.indicator[self.rectIdx] 188 | local icon = self.indicator[self.iconIdx] 189 | 190 | local colorAttrs = self:getColorAttrs(self.stackFocus, self.focus) 191 | rect.fillColor = colorAttrs.bg 192 | if self.showIcons then 193 | icon.imageAlpha = colorAttrs.img 194 | end 195 | rect.shadow = self:getShadowAttrs(f) 196 | end -- }}} 197 | 198 | function Window:getScreenSide() -- {{{ 199 | -- Returns the side of the screen that the window is (mostly) on 200 | -- Retval: "left" or "right" 201 | local thresh = 0.75 202 | local screenWidth = self._win:screen():fullFrame().w 203 | 204 | local leftEdge = self.frame.x 205 | local rightEdge = self.frame.x + self.frame.w 206 | local percR = 1 - ((screenWidth - rightEdge) / screenWidth) 207 | local percL = (screenWidth - leftEdge) / screenWidth 208 | 209 | local side = (percR > thresh and percL < thresh) and 'right' or 'left' 210 | return side 211 | 212 | -- TODO [low-priority]: BUG: Right-side window incorrectly reports as a left-side window with {{{ 213 | -- very large padding settings. Will need to consider coordinates from both 214 | -- sides of a window. Impact is minimal with smaller threshold (<= 0.75). }}} 215 | 216 | -- TODO [very-low-priority]: find a way to use hs.window.filter.windowsTo{Dir} {{{ 217 | -- to determine side instead of percLeft/Right 218 | -- https://www.hammerspoon.org/docs/hs.window.filter.html#windowsToWest 219 | -- stackline.wf:windowsToWest(self._win) 220 | -- https://www.hammerspoon.org/docs/hs.window.html#windowsToWest 221 | -- self._win:windowsToSouth() }}} 222 | 223 | end -- }}} 224 | 225 | function Window:getIndicatorPosition() -- {{{ 226 | -- Display indicators on left edge of windows on the left side of the screen, 227 | -- & right edge of windows on the right side of the screen 228 | local xval 229 | local c = self.config 230 | self.screenFrame = self.screen:fullFrame() 231 | self.side = self:getScreenSide() 232 | 233 | -- DONE: Limit stack left/right side to screen boundary to prevent drawing offscreen https://github.com/AdamWagner/stackline/issues/21 234 | if self.side == 'right' then xval = (self.frame.x + self.frame.w) + c.offset.x -- position indicators on right edge 235 | if xval + self.width > self.screenFrame.w then -- don't go beyond the right screen edge 236 | xval = self.screenFrame.w - self.width 237 | end 238 | else -- side is 'left' 239 | xval = self.frame.x - (self.width + c.offset.x) -- position indicators on left edge 240 | xval = math.max(xval, 0) -- don't go beyond left screen edge 241 | end 242 | return xval 243 | end -- }}} 244 | 245 | function Window:getColorAttrs(isStackFocused, isWinFocused) -- {{{ 246 | local opts = self.config 247 | -- Lookup bg color and image alpha based on stack + window focus 248 | -- e.g., fillColor = self:getColorAttrs(self.stackFocus, self.focus).bg 249 | -- iconAlpha = self:getColorAttrs(self.stackFocus, self.focus).img 250 | local colorLookup = { 251 | stack = { 252 | ['true'] = { 253 | window = { 254 | ['true'] = { 255 | bg = u.extend(opts.color, {alpha = opts.alpha}), 256 | img = opts.alpha, 257 | }, 258 | ['false'] = { 259 | bg = u.extend(u.copy(opts.color), 260 | {alpha = opts.alpha / opts.dimmer}), 261 | img = opts.alpha / opts.iconDimmer, 262 | }, 263 | }, 264 | }, 265 | ['false'] = { 266 | window = { 267 | ['true'] = { 268 | bg = u.extend(u.copy(opts.color), { 269 | alpha = opts.alpha / (opts.dimmer / 1.2), 270 | }), 271 | -- last-focused icon stays full alpha when stack unfocused 272 | img = opts.alpha, 273 | }, 274 | ['false'] = { 275 | bg = u.extend(u.copy(opts.color), { 276 | alpha = stackline.manager:getShowIconsState() and 0 or 277 | 0.2, 278 | }), 279 | -- unfocused icon has slightly lower alpha when stack also unfocused 280 | img = opts.alpha / 281 | (opts.iconDimmer + (opts.iconDimmer * 0.70)), 282 | }, 283 | }, 284 | }, 285 | }, 286 | } 287 | -- end 288 | 289 | local isStackFocusedKey = tostring(isStackFocused) 290 | local isWinFocusedKey = tostring(isWinFocused) 291 | return colorLookup.stack[isStackFocusedKey].window[isWinFocusedKey] 292 | end -- }}} 293 | 294 | function Window:getShadowAttrs() -- {{{ 295 | -- less opaque & blurry when iconsDisabled 296 | -- even less opaque & blurry when unfocused 297 | local iconsDisabledDimmer = stackline.manager:getShowIconsState() and 1 or 5 298 | local alphaDimmer = (self.focus and 6 or 7) * iconsDisabledDimmer 299 | local blurDimmer = (self.focus and 15.0 or 7.0) / iconsDisabledDimmer 300 | 301 | -- Shadows should cast outwards toward the screen edges as if due to the glow of onscreen windows… 302 | -- …or, if you prefer, from a light source originating from the center of the screen. 303 | local xDirection = (self.side == 'left') and -1 or 1 304 | local offset = { 305 | h = (self.focus and 3.0 or 2.0) * -1.0, 306 | w = ((self.focus and 7.0 or 6.0) * xDirection) / iconsDisabledDimmer, 307 | } 308 | 309 | -- TODO [just for fun]: Dust off an old Geometry textbook and try get the shadow's angle to rotate around a point at the center of the screen (aka, 'light source') 310 | -- Here's a super crude POC that uses the indicator's stack index such that 311 | -- higher indicators have a negative Y offset and lower indicators have a positive Y offset 312 | -- h = (self.focus and 3.0 or 2.0 - (2 + (self.stackIdx * 5))) * -1.0, 313 | 314 | return { 315 | blurRadius = blurDimmer, 316 | color = {alpha = 1 / alphaDimmer}, -- TODO align all alpha values to be defined like this (1/X) 317 | offset = offset, 318 | } 319 | end -- }}} 320 | 321 | function Window:iconFromAppName() -- {{{ 322 | appBundle = hs.appfinder.appFromName(self.app):bundleID() 323 | return hs.image.imageFromAppBundle(appBundle) 324 | end -- }}} 325 | 326 | function Window:makeStackId(hsWin) -- {{{ 327 | local frame = hsWin:frame():floor() 328 | 329 | local x = frame.x 330 | local y = frame.y 331 | local w = frame.w 332 | local h = frame.h 333 | 334 | local fuzzFactor = stackline.config:get('features.fzyFrameDetect.fuzzFactor') 335 | local roundToFuzzFactor = u.partial(u.roundToNearest, fuzzFactor) 336 | local ff = u.map({x, y, w, h}, roundToFuzzFactor) 337 | 338 | return { 339 | topLeft = table.concat({x, y}, '|'), 340 | stackId = table.concat({x, y, w, h}, '|'), 341 | fzyFrame = table.concat(ff, '|'), 342 | } 343 | end -- }}} 344 | 345 | function Window:deleteIndicator() -- {{{ 346 | log.d('deleteIndicator for', self.id) 347 | if self.indicator then 348 | self.indicator:delete(self.config.fadeDuration) 349 | end 350 | end -- }}} 351 | 352 | function Window:unfocusOtherAppWindows() -- {{{ 353 | log.i('unfocusOtherAppWindows for', self.id) 354 | u.each(self.otherAppWindows, function(w) 355 | w:redrawIndicator() 356 | end) 357 | end -- }}} 358 | 359 | function Window:setLogLevel(lvl) -- {{{ 360 | log.setLogLevel(lvl) 361 | log.i( ('Window.log level set to %s'):format(lvl) ) 362 | end -- }}} 363 | 364 | return Window 365 | --------------------------------------------------------------------------------