├── .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 | 
3 |
4 |
5 |
6 |
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 | 
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 | Icon indicators… |
59 | …or minimal indicators |
60 |
61 |
62 |
63 |
64 | |
65 |
66 |
67 | |
68 |
69 |
70 |
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 | Open the Hammperspoon console via the menu bar |
141 | Type `hs.ipc.cliInstall()` and hit return |
142 |
143 |
144 |
145 |
146 | |
147 |
148 |
149 | |
150 |
151 |
152 |
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 |
--------------------------------------------------------------------------------