diff --git a/README.md b/README.md index 0b93b8d..e418ad8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![stackline-logo](assets/stackline-github-banner@2x.png)

- Version + Version License: MIT @@ -8,36 +8,31 @@ > Visualize yabai window stacks on macOS. Works with yabai & hammerspoon. -## ⚠️ WARNING: THIS IS A PROOF-OF-CONCEPT +## ⚠️ ~~WARNING: THIS IS A PROOF-OF-CONCEPT~~ (it's more like an 'alpha' now!) -Currently, [stackline](https://github.com/AdamWagner/stackline) is a proof-of-concept for visualizing the total number and status of stacked windows. Feel free to try it out and open [issues](https://github.com/AdamWagner/stackline/issues) / PRs, but using stackline full-time is not recommended (yet). +My humble thanks to all who have been suffering through error-ridden setup instructions, spinning fans, flickering UI, and crashes. I'm happy to say that I _think_ this branch fixes enough of these issues that it _should_ be reasonable for actual use ;-) -There is much crucial fuctionality that is either missing or broken. For example, stack indicators do not refresh when: +As before, if you notice something that's off, or could be better, please open an issue (or a PR!). -1. the tree is rotated or mirrored -2. updating padding or gap values -3. a stacked window is warped out of the stack -4. app icons are toggled on/off +--- -## What is stackline & why do I need it? + +## What is stackline & why would I want to use it? Consider a browser window with many tabs. A tabbed user interface consists of a collection of windows that occupy the same screen space. Only _one_ tabbed window may be visible at any given time, and it's the user's job to specify the 'active' window. -To enable this task, tabbed interfaces provide visual indicators for each tab, each occupying much less space than the tab it references, which enables all indicators to be visible at all times. Each indicator _identifies the contents of a window_ & _communicates its position relative to the active window_. - -A 'stack' provides a generalized subset of the functionality provided by tabbed user interfaces: it enables multiple to windows to occupy the same screen space, and provides mechanisms to navigate its member windows. It also provides mechanisms to add & remove windows from the stack. +Tabbed interfaces provide visual indicators for each tab. The indicators are relatively small, so they can be visible at all times. Each indicator _identifies the contents of a window_ & _communicates its position relative to the active window_. -Critically for stackline, a 'stack' does not provide the visual indicators necessary to identify how many windows belong to a stack or understand the relative position of the active window within the stack. +A 'stack' provides a generalized subset of a tabbed UI: it enables multiple to windows to occupy the same screen space, and provides mechanisms to navigate its member windows. It also provides mechanisms to add & remove windows from the stack. Stacks are a recent addition (June 2020) to the (_excellent!_) macOS tiling window manager [koekeishiya/yabai,](https://github.com/koekeishiya/yabai,) and visualization UI is not yet in-the-box. -Enter stackline, which adds non-obtrusive visual indicators to yabai's stacking functionality. +Enter stackline, which adds non-obtrusive visual indicators to yabai'e 's stacking functionality. ![stackline-demo](assets/stackline-demo.gif) - ## Getting started with stackline **Prerequisites** @@ -47,11 +42,9 @@ Enter stackline, which adds non-obtrusive visual indicators to yabai's stacking 3. https://github.com/stedolan/jq (`brew install jq`) -You're free to bind yabai commands using your favorite key remapper tool -(skhd, karabiner elements, and even hammerspoon are all viable options). +You're free to bind yabai commands using your favorite key remapper tool (skhd, karabiner elements, and even hammerspoon are all viable options). -That said, you're _probably_ using https://github.com/koekeishiya/skhd. If so, -now is a good time to map keys for navigating and manipulating yabai stacks. +That said, you're _probably_ using https://github.com/koekeishiya/skhd. If so, now is a good time to map keys for navigating and manipulating yabai stacks. ```sh # Focus window up/down in stack @@ -70,7 +63,6 @@ cmd + ctrl - right : yabai -m window east --stack $(yabai -m query --windows --w 1. Clone the repo into ~/.hammerspoon/stackline 2. Install the hammerspoon cli tool -3. Add signals to ~/.yabairc #### 1. Clone the repo into ~/.hammerspoon/stackline @@ -80,7 +72,7 @@ git clone https://github.com/AdamWagner/stackline.git ~/.hammerspoon/stackline # Make stackline run when hammerspoon launches cd ~/.hammerspoon -echo 'require "stackline.stackline.core"' >> init.lua +echo 'local stackline = require "stackline.stackline.stackline"' >> init.lua ``` Now your `~/.hammerspoon` directory should look like this: @@ -114,39 +106,6 @@ Confirm that `hs` is now available: /usr/local/bin/hs ``` -#### 3. Add signals to ~/.yabairc - -Add signals to `~/.yabairc`: - -```sh -STACKLINE_EVENTS="\ - application_activated \ - application_front_switched \ - application_hidden \ - application_launched \ - application_terminated \ - application_visible \ - window_created \ - window_deminimized \ - window_focused \ - window_minimized \ - window_resized" - -yabai -m signal --add \ - event="window_destroyed" \ - label="stackline_window_destroyed" \ - action="echo ':window_destroyed' | /usr/local/bin/hs -m stackline-events" - -for event in $STACKLINE_EVENTS -do - yabai -m signal --add \ - event="$event" \ - label="stackline_$event" \ - app!="Hammerspoon" \ - action="echo ':$event' | /usr/local/bin/hs -m stackline-events" -done -``` - ### RETRO? GO! FIDO? GO! GUIDANCE… We're almost there! @@ -170,6 +129,7 @@ Did the terminal window expand to cover the area previously occupied by Safari? ![stackline setup 01](assets/stackline-setup-01@2x.png) The default stack indicator style is a "pill" as seen ↑ + To toggle icons: ```sh @@ -180,33 +140,59 @@ To toggle icons: Image (and feature!) courtesy of [@alin23](https://github.com/alin23). + +#### Keybindings + +If you use `shkd`, you can bind a key combo to toggle icons `~/.skhdrc` file using the hammerspoon cli we installed earlier. + +```sh +# if this doesn't work, try using the absolute path to the hammerspoon cli: /usr/local/bin/hs +shift + alt - b : echo ":toggle_icons:1" | hs -m stackline-config +``` + +Alternatively, you can control stackline by accessing the instance directly via Hammerspoon. + +For example, to bind a key combo to toggle icons, you could add the following to your `~/.hammerspoon/init.lua` file, _after_ requiring the stackline module & assigning a local variable `stackline`: + +```lua +local stackline = require "stackline.stackline.stackline" -- you should already have this line ;-) + +-- bind alt+ctrl+t to toggle stackline icons +hs.hotkey.bind({'alt', 'ctrl'}, 't', function() + stackline.manager:toggleIcons() +end) +``` + ## Help us get to v1.0.0! -Give a ⭐️ if you think (a fully functional version of) stackline would be useful! +Give a ⭐️ if you think (a more fully-featured version of) stackline would be useful! ## Thanks to contributors! All are welcome (actually, _please_ help us, 🤣️)! Feel free to dive in by opening an [issue](https://github.com/AdamWagner/stackline/issues/new) or submitting a PR. -[@AdamWagner](https://github.com/AdamWagner) wrote the initial proof-of-concept (POC) for stackline. -[@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 this mostly-broken POC publicly. +[@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 https://github.com/AdamWagner/stackline/pull/13, has some pretty whiz-bang functionality on deck with https://github.com/AdamWagner/stackline/pull/17, and has been a great thought partner/reviewer. -- After [@alin23](https://github.com/alin23)'s https://github.com/AdamWagner/stackline/pull/13, stackline sucks a lot less. +[@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](gh-alin23)'s idea could be implemented and _also_ urged Adam to share his POC. -Thanks to [@johnallen3d](https://github.com/johnallen3d) for being one the first folks to install stackline, and for identifying several mistakes & gaps in the setup instructions. +[@johnallen3d](https://github.com/johnallen3d) for being one the first folks to install stackline, and for identifying several mistakes & gaps in the setup instructions. -[@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](gh-alin23)'s idea could be implemented and _also_ urged Adam to share his POC. +[@AdamWagner](https://github.com/AdamWagner) wrote the initial proof-of-concept (POC) for stackline. ### …on the shoulders of giants + Thanks to [@koekeishiya](gh-koekeishiya) without whom the _wonderful_ [yabai](https://github.com/koekeishiya/yabai) would not exist, and projects like this would have no reason to exist. 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. -Finally, 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 opening up macOS APIs to all of us! +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! + +Thanks to the creators & maintainers of the lua utility libaries [underscore.lua](https://github.com/mirven/underscore.lua), [lume.lua](https://github.com/rxi/lume), and [self.lua](https://github.com/M1que4s/self). ## License & attribution + 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). MIT is a simple permissive license with conditions only requiring preservation of copyright and license notices. Licensed works, modifications, and larger works may be distributed under different terms and without source code. diff --git a/bin/yabai-get-stack-idx b/bin/yabai-get-stack-idx new file mode 100755 index 0000000..f10a477 --- /dev/null +++ b/bin/yabai-get-stack-idx @@ -0,0 +1,5 @@ +#!/bin/dash + +/usr/local/bin/yabai -m query --windows --space \ + | /usr/local/bin/jq --raw-output --compact-output --monochrome-output 'map({"\(.id)": .["stack-index"]}) | reduce .[] as $item ({}; . + $item)' + diff --git a/bin/yabai-get-stacks b/bin/yabai-get-stacks deleted file mode 100755 index 7e50c2e..0000000 --- a/bin/yabai-get-stacks +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/dash - -# ↑ dash for fast startup - -# HELP {{{ -usage() { -cat <<-EOF -USAGE - Called by hammerspoon module 'stackline' on the following events: - windowCreated, windowUnhidden, windowUnminimized, - windowDestroyed, windowHidden, windowMinimized, - windowMoved, windowFocused, windowUnfocused, - windowFullscreened, windowUnfullscreened - See ../stackline/core.lua - -RETURNS window stack data - as a json array of "stacks" - where each stack is an array of windows: - [ - [ - { "id": "123abc", … }, - {…}, - {…}, - {…}, - ], - […] - ] - -DEPENDS on 'yabai' & 'jq' -EOF -} -if [ "$1" = "-h" ] || [ "$1" = "--help" ] ; then - usage - exit 1 -fi -# }}} - -# Set path per /etc/paths -# Ensures that non-standard binaries `yabai` & `jq` are available -# Alternatively, you may specify absolute paths -eval $(/usr/libexec/path_helper) - -# The main course -yabai -m query --windows --space $YABAI_SPACE \ - | jq --raw-output --compact-output --monochrome-output ' - map(with_entries(select( - .key == ("id", "app", "subrole", "frame", "focused", "stack-index", "visible") - ))) # select only the fields that we need - | map(select( - .subrole == "AXStandardWindow" and - .visible == 1)) # minimized == 0 may be preferrable? - | map(.frameFlat = "\(.frame.x)|\(.frame.y)") # frame x,y to string to group wins → stacks - | sort_by(.["stack-index"]) - | group_by(.frameFlat) # … the aforementioned grouping - | map(select(length > 1)) # we only care about *stacks*, which contain > 1 window - ' - diff --git a/notes.md b/dev-notes/general-dev-notes.md similarity index 50% rename from notes.md rename to dev-notes/general-dev-notes.md index e9c5f0b..f7fe49d 100644 --- a/notes.md +++ b/dev-notes/general-dev-notes.md @@ -1,8 +1,17 @@ # stackline development notes ## Inspiration +[hhtwm/init.lua](https//github.com/szymonkaliski/hhtwm/blob/master/hhtwm/init.lua) + +Probably the best source of inspo, esp. the module.cache = {…} implementation. + +[megalithic/window.lua](https://github.com/megalithic/dotfiles/blob/master/hammerspoon/hammerspoon.symlink/ext/window.lua) + + - [/utils/wm/window-handlers.lua](https://github.com/megalithic/dotfiles/blob/master/hammerspoon/hammerspoon.symlink/utils/wm/window-handlers.lua) + - [/utils/wm/init.lua](https://github.com/megalithic/dotfiles/blob/master/hammerspoon/hammerspoon.symlink/utils/wm/init.lua) [window_set.lua](https://github.com/macrael/panes/blob/master/Panes.spoon/window_set.lua) + Seems similar to what I'm doing, but it didn't run w/ SpoonInstall so I haven't used it yet See how they manage window indicators: @@ -25,6 +34,68 @@ See how they manage window indicators: - [colorboard.lua](https://github.com/CommandPost/CommandPost/blob/develop/src/plugins/finalcutpro/touchbar/widgets/colorboard.lua): CommandPost Colorboard The opposite of above, this is *complicated*! Lots of state management, but not sure how applicable it is for me. - [statuslets.lua](https://github.com/cmsj/hammerspoon-config/blob/master/statuslets.lua): statuslets + +## Debugging + +Debugging utils for lua + +- https://github.com/renatomaia/loop-debugging + +## Caching & queing + +[pyericz/LuaWorkQueue](https://github.com/pyericz/LuaWorkQueue/tree/master/src) +A work queue implementation written in Lua. + +[darkwark/queue-lua](https://github.com/darkwark/queue-lua) +Queue implementation for Lua and PICO-8 +Newer (2020) + +[hewenning/Lua-Container](https://github.com/hewenning/Lua-Container/blob/master/Container.lua) +🌏 Implement various containers, stack, queue, priority queue, heap, A* algorithms, etc. through Lua. + +[KurtLoeffler/Lua_CachePool](https://github.com/KurtLoeffler/Lua_CachePool) +A lua library for creating pools of cached objects. + + +## Working with async in Hammerspoon + +`hs.task` is async. There are 2 ways to deal with this: + +1. `hs.timer.waitUntil(pollingCallback, func)` +2. `hs.task.new(…):start():waitUntilUNex()` + +The 1st polls a callback to check if the expected result of the async task has +materialized. +The 2nd makes `hs.task` synchronous. + +The docs strongly discourage use of the 2nd approach, but as long as there isn't +background work that could be done while waiting (there isn't in the use case +I'm thinking of), then it should be slightly _faster_ than polling since the +callback will fire immediately when the task completes. It also saves the cycles +needed to poll in the first place. + +```lua +-- Wait until the win.stackIdx is set from async shell script + hs.task.new("/usr/local/bin/dash", function(_code, stdout, stderr) + callback(stdout) + end, {cmd, args}):start():waitUntilExit() + + -- NOTE: timer.waitUntil is like 'await' in javascript + hs.timer.waitUntil(winIdxIsSet, function() return data end) + +-- Checker func to confirm that win.stackIdx is set +-- For hs.timer.waitUntil +-- NOTE: Temporarily using hs.task:waitUntilExit() to accomplish the +-- same thing +function winIdxIsSet() + if win.stackIdx ~= nil then + return true + end +end +``` + + + ## Using hs.canvas Yet another project has a similar take: diff --git a/dev-notes/luarocks-reqs.txt b/dev-notes/luarocks-reqs.txt new file mode 100644 index 0000000..29bdac2 --- /dev/null +++ b/dev-notes/luarocks-reqs.txt @@ -0,0 +1 @@ +luarocks install moses \ No newline at end of file diff --git a/dev-notes/refactor-notes-2020-08-09.md b/dev-notes/refactor-notes-2020-08-09.md new file mode 100644 index 0000000..a64d421 --- /dev/null +++ b/dev-notes/refactor-notes-2020-08-09.md @@ -0,0 +1,163 @@ +# Refactor notes + +## Overview + +The goal of this file is to eliminate the need to 'shell out' to yabai to query +window data needed to render stackline, which would have addressed +https://github.com/AdamWagner/stackline/issues/8 if @alin32 hadn't implemented +an even better fix faster than I could ;_) + +Originally, I thought the main problem with relying on `yabai` was the 0.03s sleep required to ensure that the new window state is, in fact, represented in the query response from `yabai`. I've since noticed secondary downsides, such as overall performance. Specifically, `yabai` is frequently sluggish during something simple like focusing a window. This is, I presume, a side effect of `stackline` pummelling yabai with dozens of (mostly superfluous) `query` invocations. :confusion: + +So, instead, I want to try try using hammerspoon's mature (if complicated) hs.window.filter and hs.window modules to achieve the same goal natively within hammerspon. + +I also hope for tertiary benefits: + +- easier to implement enhancements that we haven't even considered yet +- easier to maintain +- drop the jq dependency + + + + + +┌────────┐ +│ Status │ +└────────┘ + +## 2020-08-16 + +### Changes + +- Support stack focus "events"! Now, a stack takes on a new look when all windows become unfocused, with the last-active window distinct from the rest. This required a fair bit more complexity than expected, but is unavoidable (I think). There's a minor, barely noticable performance hit, too (not yet a problem, tho). +- Centralized indicator config settings & consistent "current style" retrieval. Reduced reliance on magic numbers (indicator style is more purely from user config settings now). +- Store a reference to the stack on each window, so any window can easily call stack methods. This allowed `redrawOtherAppWindows()` to move into the window class, where it's less awkward. +- Resolved bug in which unfocused same-app windows would 'flash focus' briefly + +### Multi-monitor support is still a `?` + +Stacks refresh on every space/monitor change, wasting resources shelling out to yabai & redrawing all indicators from scratch. + +Instead, it might better to update our data model to store: `screens[] → spaces[] → stacks[] → windows[]` … and then only update on *window* change events. + + +## 2020-08-09 + +This update makes adds performance, reliability, and even some new functionality: + +- Hammerspoon is now responsible for querying and processing macOS window data. Hammerspoon's ability to coalesce the swarm of asynchronous change events that were causing your fans to spin up is a major improvement over calling `yabai -m query --windows --space …` dozens of times a minute. The move also made it possible to take a more traditional OOP approach, which has made tracking and mutating all of this desktop state a bit simpler. Unfortunately, it's still necessary to call out `yabai`, as it's the the keeper of each window's `stack-index` — a key bit of info that, afaict, is neither available elsewhere nor inferrable. That said, `yabai` is invoked _much less frequently_ in this update. +- In addition to only updating data when it's actually necessary, special attention has been given to _changing focus within a stack_: the POC blew away all of the UI state and _regenerated it from scratch … every … time … a window gained/lost focus. That approach is easier to think about, but it's far too slow to be useful. In this version, indicators should be _snappy_ when changing focus :) + +There's also some fun new functionality: + +- Stack indicators are always positioned on the side of the window that's closest to the edge of the screen. This allows for tight `window_gaps`, — even with `showIcons` enabled. +- Magic numbers are less entangled in the implementation details (though it's still pretty bad) — and a few of them have even been abstracted into easy-to-mess with configuration settings. The new `config.lua` file isn't that exciting yet (it's mostly boilerplate), but I think the _next_ update will bring the much-needed configuration mojo. + +**NOTE:** even though this update focused on performance and reliability, there are _still plenty of bugs_. One particularly annoying bug appears to be [Hammerspoon's fault](https://github.com/Hammerspoon/hammerspoon/issues/2400), and required ugly workarounds to get a so-so result. + +Also I'm still very new to lua and find its behavior (particularly its silence about errors) pretty baffling … it's quite hard to diagnose — or — even _notice_ small problems, which of course means they eventually become large, messy problems. ( ͡° ʖ̯ ͡°) If you're a lua pro and you're reading this, it'd be great to get your critique. --- + +## 2020-08-02 + +We're not yet using any of the code in this file to actually render the indiators or query ata — all of that is still achieved via the "old" methods. + +However, `query.lua` IS being required by ./core.lua and runs one every window focus event, and the resulting "stack" data is printed to the hammerspoon console. + +The stack data structure differs from that used in ./stack.lua enough that it won't work as a drop-in replacement. I think that's fine (and it wouldn't be worth attempting to make this a non-breaking change, esp. since (hopefully?) no one is rellying on `stackline` for daily computing. + +┌──────┐ +│ Next │ +└──────┘ +- [x] Integrate appropriate functionality in Query module the Core module +- [x] Integrate appropriate functionality in Query module into the Stack module +- [x] Update key Stack module functions to have basic compatiblity with the new + data structure +- [x] Simplify / refine Stack functions to leverage the benefits of having access to the hs.window module for each tracked window +- [x] Fix egregious indicator lag when switching the focused window in a stack + + +## 2020-08-01 + +https://github.com/AdamWagner/stackline is a proof-of-concept for visualizing the # of windows in a stack & the currently active window. + +There is much crucial fuctionality that is either missing or broken. For example, stack indicators do not refresh when: + +1. the tree is rotated or mirrored +2. updating padding or gap values +3. a stacked window is warped out of the stack +4. app icons are toggled on/off + + + + +## Challenges & resolutions + +┌──────────────────────────────────────┐ +│ coalescing a torrent of async events │ +└──────────────────────────────────────┘ + +`hs.timer.delayed` has done a great job taming the flood of window-change events that can occur when, say, resizing windows in a tiled layout. + +NOTE: alternative: https://github.com/CommandPost/CommandPost/blob/develop/src/extensions/cp/deferred/init.lua This extension makes it simple to defer multiple actions after a delay from the initial execution. + +Unlike `hs.timer.delayed`, the delay will not be extended with subsequent `run()` calls, but the delay will trigger again if `run()` is called again later. + + +┌──────────────────────────────────────┐ +│ Methods to group windows into stacks │ +└──────────────────────────────────────┘ +TOP-LEFT ONLY +Rationale: Identifying frames by topLeft (frame.x, frame.y) of each window +addresses macos MIN WIN SIZE EDGE CASE that can result in a stacked +window NOT sharing the same dimensions. +PRO: + ensures such windows will be members of the stack +CON: + zoom-parent & zoom-fullscreen windows will ALSO be counted as stack members +PROPER FIX + Filter out windows with a 0 stack-index using yabai data + +NOTE: 'stackID' groups by full frame, so windows with min-size > stack +width will not be stacked properly. See above ↑ }}} + + +┌────────────────┐ +│ Draw vs Redraw │ +└────────────────┘ +I originally apporached this with a "reactjs" mindset — _destroy everythig on every render — that'll keep things simple! + +Unfortunately, it also made things _SLOW_. + +The costliest aspect of drawing the indicators from scratch each time is +fetching the icon & generating the icons's image. + +When it exists, it's MUCH faster/snappier to modify the attributes of existing canvas instances to achieve the desired "focused window has changed" effect. Obviously, 100ms isn't going to ruin anyone's day… But it _is_ the difference between: + +- "Hey, I wonder if _stackline_ is what's been driing the fans at full blast recently? It _does_ seem to struggle…" + +And: + +- "Damnit, Chrome. When will the Chrome team get their shit together? This is ridiculous… _—force quit—_" + + +┌───────────────────────────────────┐ +│ windows of the same app (hs bug) │ +└───────────────────────────────────┘ +There's a very annoying Hammerspoon bug that needed to be worked around: windowUnfocused event not fired for same-app windows. + +See https://github.com/Hammerspoon/hammerspoon/issues/2400 for more detail. + +NOTE: v1 *substantially* slowed down indicator redraw when focus changes. So much so as to justify storing an "otherAppWindows" field on each window. See history below: + +v1: Search for stack by window: + E.g., local stack = stacksMgr:findStackByWindow(stackedWin) + +v2: Lookup stack from window instead of searching by window ID: + local stack = stackedWin.stack + +v3: Store `otherAppWindows` directly on window: + +Related: + +- `./stack.lua:22` +- `./stack.lua:30` diff --git a/lib/self.lua b/lib/self.lua new file mode 100644 index 0000000..22cc707 --- /dev/null +++ b/lib/self.lua @@ -0,0 +1,194 @@ +local getmt = getmetatable +local setmt = setmetatable +local Class = {} +Class.__index = Class +Class.__name = "Object" +Class.__parent = {Class} + +local function dump(t, name, indent) + local cart + local autoref + + -- luacheck: ignore + local function isemptytable(t) + return next(t) == nil + end + + local function basicSerialize(o) + local so = tostring(o) + if type(o) == "function" then + local info = debug.getinfo(o, "S") + if info.what == "C" then + return string.format("%q", so .. ", C function") + else + return ("%s, in %d-%d %s"):format(so, info.linedefined, + info.lastlinedefined, info.source) + end + elseif type(o) == "number" or type(o) == "boolean" then + return so + else + return string.format("%q", so) + end + end + + local function addtocart(value, _name, _indent, saved, field) + indent = indent or "" + saved = saved or {} + field = field or name + cart = cart .. indent .. field + if type(value) ~= "table" then + cart = cart .. " = " .. basicSerialize(value) .. "\n" + else + if saved[value] then + cart = cart .. " = {} -- " .. saved[value] .. + " (self reference)\n" + autoref = autoref .. name .. " = " .. saved[value] .. "\n" + else + saved[value] = name + if isemptytable(value) then + cart = cart .. " = {}\n" + else + cart = cart .. " = {\n" + for k, v in pairs(value) do + k = basicSerialize(k) + local fname = string.format("%s[%s]", name, k) + field = string.format("[%s]", k) + addtocart(v, fname, indent .. " ", saved, field) + end + cart = cart .. indent .. "}\n" + end + end + end + end + + name = name or "__unnamed__" + if type(t) ~= "table" then + return name .. " = " .. basicSerialize(t) + end + cart, autoref = "", "" + addtocart(t, name, indent) + return cart .. autoref +end + +local function err(exp, msg, ...) + msg = msg:format(...) + if not (exp) then + error(msg, 0) + else + return exp + end +end + +function Class:create(name, parent, def, G) + err(type(name) == "string" or type(name) == "nil", + "Object.new: bad argument #1, string expected, got %s", type(name)) + err(type(parent) == "table" or type(parent) == "nil", + "Object.new: bad argument #2, class expected, got %s", type(parent)) + err(type(def) == "table" or type(def) == "nil", + "Object.new: bad argument #3, table expected, got %s", type(def)) + err(type(G) == "boolean" or type(G) == "nil", + "Object.new: bad argument #4, boolean expected, got %s", type(G)) + + local cls = def or {} + cls.__parent = {self} + + if parent then + table.insert(cls.__parent, parent) + for k, v in pairs(parent) do + if not cls[k] then + cls[k] = v + end + end + for i, v in ipairs(parent.__parent) do + if not (parent.__parent[i] == (self or cls or parent)) then + table.insert(cls.__parent, v) + end + end + end + + cls.__index = cls + cls.__name = name or "__AnonymousClass__" + + setmt(cls, self) + if G then + err(name, "Object.new: no name for global class") + err(not _G[name], "Object.new: class '%s' already exists", name) + rawset(_G, name, cls) + else + return cls + end +end + +function Class:uses(...) + err(self.__name ~= "Object", + "Object.uses: attempt to modify parent class 'Object'") + local va = {...} + err(#va >= 1, "Object.uses: one or more classes expected, got %d", #va) + for idx, cls in pairs(va) do + err(type(va[idx]) == "table", + "Object.uses: bad argument #%d, class expected, got %s", idx, + type(va[idx])) + for k, v in pairs(cls) do + if type(v) == "function" and not rawget(self, k) then + rawset(self, k, v) + end + end + end +end + +function Class:is(cls) + err(type(cls) == "table", "Object.is: bad argument, class expected, got %s", + type(cls)) + for i, _v in ipairs(self.__parent) do + if self.__parent[i] == cls or self.__name == cls.__name then + return true + end + end + return false +end + +function Class:isClass(obj) + if not obj or type(obj) ~= "table" then + return nil + elseif getmt(obj) == self then + return true + elseif getmt(getmt(obj)) == self then + return true + else + return false + end +end + +function Class:dump(details, indent) + err(type(details) == "boolean" or "nil", + "Object.dump: bad argument #1, boolean expected, got %s", type(details)) + err(type(indent) == "string" or "nil", + "Object.dump: bad argument #2, string expected, got %s", type(indent)) + if details then + return dump(getmt(self), self.__name, indent) + else + return dump(self, self.__name, indent) + end +end + +function Class:__call(...) + if self.__name == "Object" then + return self:create(...) + end + local o = setmt({}, self) + if rawget(self, "new") then + o:new(...) + elseif rawget(self, "init") then + o:init(...) + else + err(nil, "%s: no constructor defined", o.__name) + end + return o +end + +function Class:__tostring() + return ("Class '%s'"):format(self.__name) +end +setmt(Class, Class) + +return Class diff --git a/lib/utils.lua b/lib/utils.lua new file mode 100644 index 0000000..67664a2 --- /dev/null +++ b/lib/utils.lua @@ -0,0 +1,416 @@ +-- OTHERS ---------------------------------------------------------------------- +-- https://github.com/luapower/glue/blob/master/glue.lua +-- https://github.com/Desvelao/f/blob/master/f/table.lua (new in 2020) +-- https://github.com/moriyalb/lamda (based on ramda, updated May 2020, 27 stars) +-- https://github.com/EvandroLG/Hash.lua (new - updated Aug 2020, 7 stars) +-- https://github.com/Mudlet/Mudlet/tree/development/src/mudlet-lua/lua ← Very unusual / interesting lua utils +-- +utils = {} + +-- Alias hs.fnutils methods {{{ +utils.map = hs.fnutils.map +utils.filter = hs.fnutils.filter +utils.reduce = hs.fnutils.reduce +utils.partial = hs.fnutils.partial +utils.each = hs.fnutils.each +utils.contains = hs.fnutils.contains +utils.some = hs.fnutils.some +utils.any = hs.fnutils.some -- also rename 'some()' to 'any()' +utils.concat = hs.fnutils.concat +utils.copy = hs.fnutils.copy +-- }}} + +-- FROM: https://github.com/rxi/lume/blob/master/lume.lua +function utils.isarray(x) -- {{{ + return type(x) == "table" and x[1] ~= nil +end -- }}} +local getiter = function(x) -- {{{ + if utils.isarray(x) then + return ipairs + elseif type(x) == "table" then + return pairs + end + error("expected table", 3) +end -- }}} +function utils.invert(t) -- {{{ + local rtn = {} + for k, v in pairs(t) do + rtn[v] = k + end + return rtn +end -- }}} +function utils.keys(t) -- {{{ + local rtn = {} + local iter = getiter(t) + for k in iter(t) do + rtn[#rtn + 1] = k + end + return rtn +end -- }}} +function utils.find(t, value) -- {{{ + local iter = getiter(t) + result = nil + for k, v in iter(t) do + print('value looking for') + print(value) + print('key matching against') + print(k) + print('are they equal?') + print(k == value) + if k == value then + result = v + end + end + -- utils.pheader('result') + print(result) + return result +end -- }}} +-- END lume.lua + +-- underscore.lua +function utils.identity(value) -- {{{ + return value +end -- }}} +function utils.iter(list_or_iter) -- {{{ + if type(list_or_iter) == "function" then + return list_or_iter + end + + return coroutine.wrap(function() + for i = 1, #list_or_iter do + coroutine.yield(list_or_iter[i]) + end + end) +end -- }}} +function utils.values(t) -- {{{ + local values = {} + for _k, v in pairs(t) do + values[#values + 1] = v + end + return values +end -- }}} +function utils.extend(destination, source) -- {{{ + for k, v in pairs(source) do + destination[k] = v + end + return destination +end -- }}} +function utils.include(list, value) -- {{{ + for i in Underscore.iter(list) do + if i == value then + return true + end + end + return false +end -- }}} +function utils.any(list, func) -- {{{ + for i in utils.iter(list) do + if func(i) then + return true + end + end + return false +end -- }}} +-- end underscore.lua + +function utils.keyBind(hyper, keyFuncTable) -- {{{ + for key, fn in pairs(keyFuncTable) do + hs.hotkey.bind(hyper, key, fn) + end +end -- }}} + +utils.length = function(t) -- {{{ + local count = 0 + for _ in pairs(t) do + count = count + 1 + end + return count +end -- }}} + +utils.indexOf = function(t, object) -- {{{ + if type(t) ~= "table" then + error("table expected, got " .. type(t), 2) + end + + for i, v in pairs(t) do + if object == v then + return i + end + end +end -- }}} + +function utils.isEqual(a, b) -- {{{ + --[[ + This function takes 2 values as input and returns true if they are equal + and false if not. a and b can numbers, strings, booleans, tables and nil. + --]] + + local function isEqualTable(t1, t2) + + if t1 == t2 then + return true + end + + -- luacheck: ignore + for k, v in pairs(t1) do + + if type(t1[k]) ~= type(t2[k]) then + return false + end + + if type(t1[k]) == "table" then + if not isEqualTable(t1[k], t2[k]) then + return false + end + else + if t1[k] ~= t2[k] then + return false + end + end + end + + for k, v in pairs(t2) do + + if type(t2[k]) ~= type(t1[k]) then + return false + end + + if type(t2[k]) == "table" then + if not isEqualTable(t2[k], t1[k]) then + return false + end + else + if t2[k] ~= t1[k] then + return false + end + end + end + + return true + end + + if type(a) ~= type(b) then + return false + end + + if type(a) == "table" then + return isEqualTable(a, b) + else + return (a == b) + end + +end -- }}} + +local function filter_row(row, key_constraints) -- {{{ + -- Check if a row matches the specified key constraints. + -- @param row The row to check + -- @param key_constraints The key constraints to apply + -- @return A boolean result + + -- Loop through all constraints + for k, v in pairs(key_constraints) do + if v and not row[k] then + -- The row is missing the key entirely, + -- definitely not a match + return false + end + + -- Wrap the key and constraint values in arrays, + -- if they're not arrays already (so we can loop through them) + local actual_values = type(row[k]) == "table" and row[k] or {row[k]} + local required_values = type(v) == "table" and v or {v} + + -- Loop through the values we *need* to find + for i = 1, #required_values do + local found + -- Loop through the values actually present + for j = 1, #actual_values do + if actual_values[j] == required_values[i] then + -- This object has the required value somewhere in the key, + -- no need to look any farther + found = true + break + end + end + + if not found then + return false + end + end + end + + return true +end -- }}} + +function utils.pick(input, key_values) -- {{{ + -- Filter an array, returning entries matching `key_values`. + -- @param input The array to process + -- @param key_values A table of keys mapped to their viable values + -- @return An array of matches + local result = {} + utils.each(key_values, function(k) + result[k] = input[k] + end) + return result +end -- }}} + +function utils.p(data, howDeep) -- {{{ + local depth = howDeep or 3 + if type(data) == 'table' then + print(hs.inspect(data, {depth = depth})) + else + print(hs.inspect(data, {depth = depth})) + end +end -- }}} + +function utils.pdivider(str) -- {{{ + str = string.upper(str) or "" + print("=========", str, "==========") +end -- }}} + +function utils.pheader(str) -- {{{ + print('\n\n\n') + print("========================================") + print(string.upper(str), '==========') + print("========================================") +end -- }}} + +function utils.groupBy(t, f) -- {{{ + -- FROM: https://github.com/pyrodogg/AdventOfCode/blob/1ff5baa57c0a6a86c40f685ba6ab590bd50c2148/2019/lua/util.lua#L149 + local res = {} + for _k, v in pairs(t) do + local g + if type(f) == 'function' then + g = f(v) + elseif type(f) == 'string' and v[f] ~= nil then + g = v[f] + else + error('Invalid group parameter [' .. f .. ']') + end + + if res[g] == nil then + res[g] = {} + end + table.insert(res[g], v) + end + return res +end -- }}} + +function utils.tableCopyShallow(orig) -- {{{ + -- FROM: https://github.com/XavierCHN/go/blob/master/game/go/scripts/vscripts/utils/table.lua + local orig_type = type(orig) + local copy + if orig_type == 'table' then + copy = {} + for orig_key, orig_value in pairs(orig) do + copy[orig_key] = orig_value + end + else -- number, string, boolean, etc + copy = orig + end + return copy +end -- }}} + +function utils.equal(a, b) -- {{{ + if #a ~= #b then + return false + end + + for i, _ in ipairs(a) do + if b[i] ~= a[i] then + return false + end + end + + return true +end -- }}} + +function utils.Set(list) -- {{{ + local set = {} + for _, l in ipairs(list) do + set[l] = true + end + return set +end -- }}} + +function utils.partial(f, ...) -- {{{ + -- FROM: https://www.reddit.com/r/lua/comments/fh2go5/a_partialcurry_implementation_of_mine_hope_you/ + -- WHEN: 2020-08-08 + local unpack = unpack or table.unpack -- Lua 5.3 moved unpack + local a = {...} + local a_len = select("#", ...) + return function(...) + local tmp = {...} + local tmp_len = select("#", ...) + -- Merge arg lists + for i = 1, tmp_len do + a[a_len + i] = tmp[i] + end + return f(unpack(a, 1, a_len + tmp_len)) + end +end -- }}} + +function utils.greaterThan(n) -- {{{ + return function(t) + return #t > n + end +end -- }}} + +function utils.getFields(t, fields) -- {{{ + -- FROM: https://stackoverflow.com/questions/41417971/a-better-way-to-assign-multiple-return-values-to-table-keys-in-lua + -- WHEN: 2020-08-09 + -- USAGE: + -- local bnot, band, bor = get_fields(require("bit"), {"bnot", "band", "bor"}) + local values = {} + for k, field in ipairs(fields) do + values[k] = t[field] + end + return (table.unpack or unpack)(values, 1, #fields) +end -- }}} + +function utils.setFields(tab, fields, ...) -- {{{ + -- USAGE: + -- image.size = set_fields({}, {"width", "height"}, image.data:getDimensions()) + -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --- + -- USAGE EXAMPLE #2: {{{ + -- Swap the values on-the-fly! + + -- local function get_weekdays() + -- return "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" + -- end + + -- -- we want to save returned values in different order + -- local weekdays = set_fields({}, {7,1,2,3,4,5,6}, get_weekdays()) + -- -- now weekdays contains {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} + -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --- + -- USAGE EXAMPLE #3: + -- local function get_coords_x_y_z() + -- return 111, 222, 333 -- x, y, z of the point + -- end + -- -- we want to get the projection of the point on the ground plane local projection = {y = 0} + -- -- projection.y will be preserved, projection.x and projection.z will be modified + + -- set_fields(projection, {"x", "", "z"}, get_coords_x_y_z()) + + -- -- now projection contains {x = 111, y = 0, z = 333} + -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --- + -- Usage example #4: + -- -- If require("some_module") returns a module with plenty of functions + -- -- inside, but you need only a few of them: + -- local bnot, band, bor = get_fields(require("bit"), {"bnot", "band", "bor"}) + -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --- + -- }}} + + -- fields is an array of field names + -- (use empty string to skip value at corresponging position) + local values = {...} + for k, field in ipairs(fields) do + if field ~= "" then + tab[field] = values[k] + end + end + return tab +end -- }}} + +return utils + diff --git a/stackline/config.lua b/stackline/config.lua new file mode 100644 index 0000000..2132bcf --- /dev/null +++ b/stackline/config.lua @@ -0,0 +1,82 @@ +local handleSignal = function(_, msgID, msg) -- {{{ + if msgID == 900 then + return "version:2.0a" + end + + if msgID == 500 then + local key, _value = msg:match(".+:([%a_-]+):([%a%d_-]+)") + if key == "toggle_icons" then + stackConfig:toggle('showIcons') -- global var + end + end + return "ok" +end -- }}} + +-- ┌────────┐ +-- │ config │ +-- └────────┘ + +local StackConfig = {} + +function StackConfig:new() + local config = {id = 'stackline', store = hs.settings} + + setmetatable(config, self) + self.__index = self + return config +end + +function StackConfig:get(key) + local settingPath = self:makePath(key) + return self.store.get(settingPath) +end + +function StackConfig:set(key, val) + local settingPath = self:makePath(key) + self.store.set(settingPath, val) + return self.store.get(settingPath) +end + +function StackConfig:setEach(settingsTable) + for key, value in pairs(settingsTable) do + local settingPath = self:makePath(key) + self.store.set(settingPath, value) + end + return self +end + +function StackConfig:getOrSet(key, val) + local settingPath = self:makePath(key) + local existingVal = self.store.get(settingPath) + if val ~= nil then -- set if val provided + self.store.set(settingPath, val) + return val + else + return existingVal + end +end + +function StackConfig:toggle(key) + local toggledVal = not self:get(key) -- if key is not yet set, initial toggle is "on" + self:set(key, toggledVal) + return toggledVal +end + +function StackConfig:makePath(key) + return self.id .. '-' .. key +end + +function StackConfig:registerWatchers() + local key = 'showIcons' + local identifier = self:makePath(key .. '-handler') + local settingPath = self:makePath(key) + self.store.watchKey(identifier, settingPath, function(_val) + Sm:toggleIcons() + end) + return self +end + +-- luacheck: ignore +ipcConfigPort = hs.ipc.localPort('stackline-config', handleSignal) + +return StackConfig diff --git a/stackline/core.lua b/stackline/core.lua deleted file mode 100644 index 8afe86f..0000000 --- a/stackline/core.lua +++ /dev/null @@ -1,77 +0,0 @@ -require("hs.ipc") - -local Stack = require 'stackline.stackline.stack' -local tut = require 'stackline.utils.table-utils' - -print(hs.settings.bundleID) - -function getOrSet(key, val) - local existingVal = hs.settings.get(key) - if existingVal == nil then - hs.settings.set(key, val) - return val - end - return existingVal -end - -local showIcons = getOrSet("showIcons", false) -wsi = Stack:newStackManager(showIcons) - -local shouldRestack = tut.Set{ - "application_terminated", - "application_launched", - "window_created", - "window_destroyed", - "window_resized", - "window_moved", - "toggle_icons", -} - -local shouldClean = tut.Set{ - "application_hidden", - "application_visible", - "window_deminimized", - "window_minimized", -} - -function configHandler(_, msgID, msg) - if msgID == 900 then - return "version:2.0a" - end - - if msgID == 500 then - key, value = msg:match(".+:([%a_-]+):([%a%d_-]+)") - if key == "toggle_icons" then - showIcons = not showIcons - hs.settings.set("showIcons", showIcons) - end - - if shouldRestack[key] then - wsi.cleanup() - wsi = Stack:newStackManager(showIcons) - end - wsi.update(shouldClean[key]) - end - return "ok" -end - -function yabaiSignalHandler(_, msgID, msg) - if msgID == 900 then - return "version:2.0a" - end - - if msgID == 500 then - event = msg:match(".+:([%a_]+)") - - if shouldRestack[event] then - wsi.cleanup() - wsi = Stack:newStackManager(showIcons) - end - wsi.update(shouldClean[event]) - end - return "ok" -end - - -ipcEventsPort = hs.ipc.localPort("stackline-events", yabaiSignalHandler) -ipcConfigPort = hs.ipc.localPort("stackline-config", configHandler) \ No newline at end of file diff --git a/stackline/query.lua b/stackline/query.lua new file mode 100644 index 0000000..6107151 --- /dev/null +++ b/stackline/query.lua @@ -0,0 +1,131 @@ +-- NOTES: Functionality from this file can be completely factored out into +-- stack.lua and stackline.lua. In fact, I've already done this once, but was +-- riding a bit too fast and found myself in a place where nothing worked, and I +-- didn't know why. So, this mess lives another day. Conceptually, it'll be +-- pretty easy to put this stuff where it belongs. +local u = require 'stackline.lib.utils' + +local scriptPath = hs.configdir .. '/stackline/bin/yabai-get-stack-idx' + +-- stackline modules +local Window = require 'stackline.stackline.window' +local Query = {} + +function Query:getWinStackIdxs() -- {{{ + -- call out to yabai to get stack-indexes + hs.task.new("/usr/local/bin/dash", function(_code, stdout, _stderr) + self.winStackIdxs = hs.json.decode(stdout) + end, {scriptPath}):start() +end -- }}} + +function Query:groupWindows(ws) -- {{{ + -- Given windows from hs.window.filter: + -- 1. Create stackline window objects + -- 2. Group wins by `stackId` prop (aka top-left frame coords) + -- 3. If at least one such group, also group wins by app (to workaround hs bug unfocus event bug) + local byStack + local byApp + + local windows = u.map(ws, function(w) + return Window:new(w) + end) + + -- See 'stackId' def @ /window.lua:233 + byStack = u.filter(u.groupBy(windows, 'stackId'), u.greaterThan(1)) -- stacks have >1 window, so ignore 'groups' of 1 + + if u.length(byStack) > 0 then + -- app names are keys in group + local stackedWins = u.reduce(u.values(byStack), u.concat) + byApp = u.groupBy(stackedWins, 'app') + end + + self.appWindows = byApp + self.stacks = byStack +end -- }}} + +function Query:removeGroupedWin(win) + self.stacks = u.map(self.stacks, function(stack) + return u.filter(stack, function(w) + return w.id ~= win.id + end) + end) +end + +function Query:mergeWinStackIdxs() -- {{{ + -- merge windowID <> stack-index mapping queried from yabai into window objs + + function assignStackIndex(win) + local stackIdx = self.winStackIdxs[tostring(win.id)] + + if stackIdx == 0 then + -- DONE: Fix error stackline/window.lua:95: attempt to perform arithmetic on a nil value (field 'stackIdx') + -- Remove windows with stackIdx == 0. Such windows overlap exactly with + -- other (potentially stacked) windows, and so are grouped with them, + -- but they are NOT stacked according to yabai. + -- Windows that belong to a *real* stack have stackIdx > 0. + self:removeGroupedWin(win) + end + + -- set the stack idx + win.stackIdx = stackIdx + end + + u.each(self.stacks, function(stack) + u.each(stack, assignStackIndex) + end) + +end -- }}} + +function shouldRestack(new) -- {{{ + -- Analyze self.stacks to determine if a stack refresh is needed + -- • change num stacks (+/-) + -- • changes to existing stack + -- • change position + -- • change num windows (win added / removed) + + local curr = Sm:getSummary() + new = Sm:getSummary(u.values(new)) + + if curr.numStacks ~= new.numStacks then + print('num stacks changed') + return true + end + + if not u.equal(curr.topLeft, new.topLeft) then + print('position changed') + return true + end + + if not u.equal(curr.numWindows, new.numWindows) then + print('num windows changed') + return true + end +end -- }}} + +function Query:windowsCurrentSpace() -- {{{ + self:groupWindows(wfd:getWindows()) -- set self.stacks & self.appWindows + + local extantStacks = Sm:get() + local extantStackSummary = Sm:getSummary() + local extantStackExists = extantStackSummary.numStacks > 0 + + local shouldRefresh = extantStackExists and + shouldRestack(self.stacks, extantStacks) or true + if shouldRefresh then + -- TODO: revisit in a future update. This is kind of an edge case — there are bigger fish to fry. + -- stacksMgr:dimOccluded() + self:getWinStackIdxs() -- set self.winStackIdxs (async shell call to yabai) + + function whenStackIdxDone() + self:mergeWinStackIdxs() -- Add the stack indexes from yabai to the hs window data + Sm:ingest(self.stacks, self.appWindows, extantStackExists) -- hand over to the Stack module + end + + local pollingInterval = 0.1 + hs.timer.waitUntil(function() + return self.winStackIdxs ~= nil + end, whenStackIdxDone, pollingInterval) + end +end -- }}} + +return Query diff --git a/stackline/stack.lua b/stackline/stack.lua index 43286b0..815f86a 100644 --- a/stackline/stack.lua +++ b/stackline/stack.lua @@ -1,129 +1,122 @@ -local _ = require 'stackline.utils.utils' -local Window = require 'stackline.stackline.window' -local tut = require 'stackline.utils.table-utils' -local under = require 'stackline.utils.underscore' - -local Stack = {} - -function Stack:toggleIcons() -- {{{ - self.showIcons = not self.showIcons - Stack.update() -end -- }}} - -function Stack:each_win_id(fn) -- {{{ - _.each(self.tabStacks, function(stack) - local winIds = _.map(under.values(stack), function(w) - return w.id +local u = require 'stackline.lib.utils' +local Class = require 'stackline.lib.self' +-- NOTE: using simple 'self' library fixed the issue of only 1 of N stacks +-- responding to focus events. Experimented with even smaller libs, but only +-- 'self' worked so far. + +-- args: Class(className, parentClass, table [define methods], isGlobal) +local Stack = Class("Stack", nil, { + windows = {}, + + new = function(self, stackedWindows) -- {{{ + self.windows = stackedWindows + end, -- }}} + + get = function(self) -- {{{ + return self.windows + end, -- }}} + + getHs = function(self) -- {{{ + return u.map(self.windows, function(w) + return w._win end) + end, -- }}} + + frame = function(self) -- {{{ + -- All stacked windows have the same dimensions, + -- so the 1st Hs window's frame is ~= to the stack's frame + -- FIXME: Incorrect when the 1st window has min-size < stack width + -- See ./query.lua:104 + return self.windows[1]._win:frame() + end, -- }}} + + eachWin = function(self, fn) -- {{{ + for _idx, win in pairs(self.windows) do + fn(win) + end + end, -- }}} + + getOtherAppWindows = function(self, win) -- {{{ + -- NOTE: may not need when HS issue #2400 is closed + return u.filter(self:get(), function(w) + u.pheader('window in getOtherAppWindows') + u.p(w) + return w.app == win.app + end) + end, -- }}} - for i = 1, #winIds do - -- ┌────────────────────┐ - -- the main event! - -- └────────────────────┘ - -- hs.alert.show(winIds[i]) + anyFocused = function(self) -- {{{ + return u.any(self.windows, function(w) + return w:isFocused() + end) + end, -- }}} - fn(winIds[i]) -- Call the `fn` provided with win ID - end - end) -end -- }}} - --- NOTE: A window must be *in* a stack to be found with this method! -function Stack:findWindow(wid) -- {{{ - for _idx, stack in pairs(self.tabStacks) do - extantWin = stack[wid] - if extantWin then - return extantWin - end - end -end -- }}} - -function Stack:cleanup() -- {{{ - for key, stack in pairs(self.tabStacks) do - -- For each window, clear canvas element - _.each(stack, function(w) - w.indicator:delete() + resetAllIndicators = function(self) -- {{{ + self:eachWin(function(win) + win:setupIndicator() + win:drawIndicator() end) + end, -- }}} - self.tabStacks[key] = nil - end -end -- }}} - -function Stack:newStack(stack, stackId) -- {{{ - local extantStack = self.tabStacks[stackId] - if not extantStack then - self.tabStacks[stackId] = {} - end - - for k, w in pairs(stack) do - if not extantStack then - local win = Window:new(w) - win:process(self.showIcons, k) - win.indicator:show() - win.stackId = stackId -- set stackId on win for easy lookup later - self.tabStacks[stackId][win.id] = win - - else - local extantWin = extantStack[w.id] - local win = Window:new(w) - - if (type(extantWin) == 'nil') or - not (extantWin.focused == win.focused) then - extantWin.indicator:delete() - win:process(self.showIcons, k) - win.indicator:show() - win.stackId = stackId -- set stackId on win for easy lookup later - self.tabStacks[stackId][win.id] = win + redrawAllIndicators = function(self, opts) -- {{{ + self:eachWin(function(win) + if win.id ~= opts.except then + win:redrawIndicator() end - end - end -end -- }}} - -function Stack:ingest(windowData) -- {{{ - _.each(windowData, function(winGroup) - local stackId = table.concat(_.map(winGroup, function(w) - return w.id - end), '') - Stack:newStack(winGroup, stackId) - end) -end -- }}} - -function Stack:update(shouldClean) -- {{{ - if shouldClean then - Stack:cleanup() - end - - local yabai_get_stacks = 'stackline/bin/yabai-get-stacks' - - hs.task.new("/bin/dash", function(_code, stdout) - local windowData = hs.json.decode(stdout) - Stack:ingest(windowData) - end, {yabai_get_stacks}):start() -end -- }}} - -function Stack:newStackManager(showIcons) - self.tabStacks = {} - self.showIcons = showIcons - return { - ingest = function(windowData) - return self:ingest(windowData) - end, - update = self.update, - cleanup = function() - return self:cleanup() - end, - toggleIcons = function() - return self:toggleIcons() - end, - findWindow = function(wid) - return self:findWindow(wid) - end, - each_win = function(wid) - return self:each_win_id(wid) - end, - get_win_str = function() - return Stack.win_str - end, - } -end + end) + end, -- }}} + + deleteAllIndicators = function(self) -- {{{ + self:eachWin(function(win) + win:deleteIndicator() + end) + end, -- }}} + + -- all occlusion-related methods currently disabled, but should be revisted soon + -- dimAllIndicators = function(self) -- {{{ + -- self:eachWin(function(win) + -- win:drawIndicator({unfocusedAlpha = 1}) + -- end) + -- end, -- }}} + + -- restoreAlpha = function(self) -- {{{ + -- self:eachWin(function(win) + -- win:drawIndicator({unfocusedAlpha = nil}) + -- end) + -- end, -- }}} + + -- isWindowOccludedBy = function(self, otherWin, win) -- {{{ + -- -- Test uses optional 'win' arg if provided, + -- -- otherwise test uses 1st window of stack + -- local stackedFrame = win and win:frame() or self:frame() + -- return stackedFrame:inside(otherWin:frame()) + -- end, -- }}} + + -- isOccluded = function(self) -- {{{ + -- -- FIXES: https://github.com/AdamWagner/stackline/issues/11 + -- -- When a stack that has "zoom-parent": 1 occludes another stack, the + -- -- occluded stack's indicators shouldn't be displaed + + -- -- Returns true if any non-stack window occludes the stack's frame. + -- -- This can occur when an unstacked window is zoomed to cover a stack. + -- -- In this situation, we want to hide or dim the occluded stack's indicators + + -- local stackedHsWins = self:getHs() + + -- function notInStack(hsWin) + -- return not u.include(stackedHsWins, hsWin) + -- end + + -- local windowsCurrSpace = wfd:getWindows() + -- local nonStackWindows = u.filter(windowsCurrSpace, notInStack) + + -- -- true if *any* non-stacked windows occlude the stack's frame + -- -- NOTE: u.any() works, hs.fnutils.some does NOT work :~ + -- local stackIsOccluded = u.any(u.map(nonStackWindows, function(w) + -- return self:isWindowOccludedBy(w) + -- end)) + -- return stackIsOccluded + -- end, -- }}} +}) return Stack diff --git a/stackline/stackline.lua b/stackline/stackline.lua new file mode 100644 index 0000000..bc9a5fb --- /dev/null +++ b/stackline/stackline.lua @@ -0,0 +1,81 @@ +require("hs.ipc") +print(hs.settings.bundleID) + +local StackConfig = require 'stackline.stackline.config' +local Stackmanager = require 'stackline.stackline.stackmanager' +local wf = hs.window.filter + +-- ┌────────┐ +-- │ config │ +-- └────────┘ +local config = {showIcons = true, enableTmpFixForHsBug = true} + +-- ┌─────────┐ +-- │ globals │ +-- └─────────┘ +-- instantiate instances of key classes and assign to global table (_G) +_G.stackConfig = StackConfig:new():setEach(config):registerWatchers() +_G.Sm = Stackmanager:new(showIcons) +_G.wfd = wf.new():setOverrideFilter{ + visible = true, -- (i.e. not hidden and not minimized) + fullscreen = false, + currentSpace = true, + allowRoles = 'AXStandardWindow', +} + +-- TODO: review how @alin32 structured window (and config!) events into +-- 'shouldRestack' and 'shouldClean' and apply those ideas here. +local windowEvents = { + -- window added + wf.windowCreated, + wf.windowUnhidden, + wf.windowUnminimized, + + -- window changed + wf.windowFullscreened, + wf.windowUnfullscreened, + wf.windowMoved, -- NOTE: includes move AND resize events + + -- window removed + wf.windowDestroyed, + wf.windowHidden, + wf.windowMinimized, +} + +-- TO CONFIRM: Compared to calling wsi.update() directly in wf:subscribe +-- callback, even a delay of "0" appears to coalesce events as desired. +local queryWindowState = hs.timer.delayed.new(0.30, function() + Sm:update() +end) + +-- ┌───────────────────────────────┐ +-- │ window events → update stacks │ +-- └───────────────────────────────┘ +wfd:subscribe(windowEvents, function() + -- callback args: window, app, event + queryWindowState:start() +end) + +hs.spaces.watcher.new(function() + -- Added 2020-08-12 to fill the gap of hs._asm.undocumented.spaces + queryWindowState:start() +end):start() + +function redrawWinIndicator(hsWin, _app, _event) -- {{{ + -- Dedicated redraw method to *adjust* the existing canvas element is WAY + -- faster than deleting the entire indicator & rebuilding it from scratch, + -- particularly since this skips querying the app icon & building the icon image. + local stackedWin = Sm:findWindow(hsWin:id()) + if stackedWin then -- when falsey, the focused win is not stacked + stackedWin:redrawIndicator() + end +end -- }}} + +wfd:subscribe(wf.windowFocused, redrawWinIndicator) + +wfd:subscribe({wf.windowNotVisible, wf.windowUnfocused}, redrawWinIndicator) + +-- always update on load +Sm:update() + +return {config = _G.stackConfig, manager = _G.Sm} diff --git a/stackline/stackmanager.lua b/stackline/stackmanager.lua new file mode 100644 index 0000000..593a0eb --- /dev/null +++ b/stackline/stackmanager.lua @@ -0,0 +1,129 @@ +local u = require 'stackline.lib.utils' + +-- stackline modules +local Query = require 'stackline.stackline.query' +local Stack = require 'stackline.stackline.stack' + +-- ┌──────────────┐ +-- │ Stack module │ +-- └──────────────┘ + +local Stackmanager = {} +function Stackmanager:update() -- {{{ + Query:windowsCurrentSpace() -- calls Stack:ingest when ready +end -- }}} + +function Stackmanager:new() -- {{{ + self.tabStacks = {} + self.showIcons = stackConfig:get('showIcons') + return self +end -- }}} + +function Stackmanager:ingest(windowGroups, appWindows, shouldClean) -- {{{ + local stacksCount = u.length(windowGroups) + if shouldClean or (stacksCount == 0) then + self:cleanup() + end + + for stackId, groupedWindows in pairs(windowGroups) do + local stack = Stack(groupedWindows) + stack.id = stackId + u.each(stack.windows, function(win) + -- win.otherAppWindows needed to workaround Hammerspoon issue #2400 + win.otherAppWindows = u.filter(appWindows[win.app], function(w) + return w.id ~= win.id + end) + win.stack = stack -- enables calling stack methods from window + end) + table.insert(self.tabStacks, stack) + self:resetAllIndicators() + end +end -- }}} + +function Stackmanager:get() -- {{{ + return self.tabStacks +end -- }}} + +function Stackmanager:eachStack(fn) -- {{{ + for _stackId, stack in pairs(self.tabStacks) do + fn(stack) + end +end -- }}} + +function Stackmanager:cleanup() -- {{{ + self:eachStack(function(stack) + stack:deleteAllIndicators() + end) + self.tabStacks = {} +end -- }}} + +function Stackmanager:getSummary(external) -- {{{ + -- Summarizes all stacks on the current space, making it easy to determine + -- what needs to be updated (if anything) + local stacks = external or self.tabStacks + return { + numStacks = #stacks, + topLeft = u.map(stacks, function(s) + local windows = external and s or s.windows + return windows[1].topLeft + end), + dimensions = u.map(stacks, function(s) + local windows = external and s or s.windows + return windows[1].stackId + end), + numWindows = u.map(stacks, function(s) + local windows = external and s or s.windows + return #windows + end), + } +end -- }}} + +function Stackmanager:resetAllIndicators() -- {{{ + self:eachStack(function(stack) + stack:resetAllIndicators() + end) +end -- }}} + +function Stackmanager:toggleIcons() -- {{{ + self.showIcons = not self.showIcons + self:resetAllIndicators() +end -- }}} + +function Stackmanager:findWindow(wid) -- {{{ + -- NOTE: A window must be *in* a stack to be found with this method! + for _stackId, stack in pairs(self.tabStacks) do + for _idx, win in pairs(stack.windows) do + if win.id == wid then + return win + end + end + end +end -- }}} + +function Stackmanager:findStackByWindow(win) -- {{{ + -- NOTE: may not need when Hammerspoon #2400 is closed + -- NOTE 2: Currently unused, since reference to "otherAppWindows" is sstored + -- directly on each window. Likely to be useful, tho, so keeping it around. + for _stackId, stack in pairs(self.tabStacks) do + if stack.id == win.stackId then + return stack + end + end +end -- }}} + +function Stackmanager:getShowIconsState() -- {{{ + return self.showIcons +end -- }}} + +-- function Stackmanager:dimOccluded() -- {{{ +-- -- disabled for now, but should revisit soon +-- self:eachStack(function(stack) +-- if stack:isOccluded() then +-- stack:dimAllIndicators() +-- else +-- stack:restoreAlpha() +-- end +-- end) +-- end -- }}} + +return Stackmanager diff --git a/stackline/window.lua b/stackline/window.lua index ac72dd8..fcf0b8e 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -1,158 +1,353 @@ -local _ = require 'stackline.utils.utils' +local u = require 'stackline.lib.utils' +-- ┌───────────────┐ +-- │ Window module │ +-- └───────────────┘ local Window = {} +-- TODO: Click on indicator to activate target window (like tabs) https://github.com/AdamWagner/stackline/issues/19 --- FROM: How to chain metatables: https://stackoverflow.com/questions/8109790/chain-lua-metatables -local metatbl = {} --- luacheck: ignore -function metatbl.__index(intbl, key) -- {{{ - -- luacheck: ignore - for i, mtbl in ipairs(metatbl.tbls) do - local mmethod = mtbl.__index - if (type(mmethod) == "function") then - local ret = mmethod(table, key) - if ret then - return ret - end - else - if mmethod[key] then - return mmethod[key] - end - end - return nil - end -end -- }}} - --- luacheck: ignore -function Window:new(w) -- {{{ +function Window:new(hsWin) -- {{{ local ws = { - id = w.id, -- window id - app = w.app, -- 0 if unfocused, 1 if focused - focused = w.focused == 1, -- convert 0,1 into boolean 0 if unfocused, 1 if focused - frame = w.frame, -- x,y,w,h of window - frameFlat = w.frameFlat, -- x|y of window - indicator = nil, -- the canvas element + title = hsWin:title(), -- window title + app = hsWin:application():name(), -- app name (string) + id = hsWin:id(), -- window id (string) NOTE: the ID is the same as yabai! So we could interopt if we need to + frame = hsWin:frame(), -- x,y,w,h of window (table) + stackIdx = hsWin.stackIdx, -- only from yabai, unfort. + stackId = self:makeStackId(hsWin).stackId, -- "{{x}|{y}|{w}|{h}" e.g., "35|63|1185|741" (string) + topLeft = self:makeStackId(hsWin).topLeft, -- "{{x}|{y}" e.g., "35|63" (string) + _win = hsWin, -- hs.window object (table) + indicator = nil, -- the canvas element (table) } - setmetatable(ws, self) self.__index = self return ws end -- }}} -function Window.__eq(a, b) -- {{{ - -- FIXME: unused as of 2020-07-31 - local t1 = a.id - local t2 = b.id - local existComp = {id = a.id, frame = a.frameFlat, focused = a.focused} - local currComp = {id = b.id, frame = b.frameFlat, focused = b.focused} - local isEqual = _.isEqual(existComp, currComp) - return isEqual +function Window:isFocused() -- {{{ + local focusedWin = hs.window.focusedWindow() + if focusedWin == nil then + return false + end + local isFocused = self.id == focusedWin:id() + return isFocused end -- }}} --- metatable testing {{{ -local Test = {} -function Test:new(name, age) - local test = {name = name, age = age} - - local mmt = { - __add = function(a, b) - return a.age + b.age - end, - __eq = function(a, b) - return a.age == b.age - end, - } - setmetatable(test, mmt) - return test -end - --- local amy = Test:new('amy', 18) --- local adam = Test:new('adam', 33) --- local carl = Test:new('carl', 18) - --- print('amy equals adam?', amy == adam) --- print('amy equals carl?', amy == carl) --- print('amy plus adam?', (amy + adam)) --- print('amy plus carl?', (amy + carl)) --- print('amy plus amy?', (amy + amy)) --- }}} - --- TODO: ↑ Convert to .__eq metatable -function Window:setNeedsUpdated(extant) -- {{{ - local isEqual = _.isEqual(existComp, currComp) - self.needsUpdated = not isEqual +function Window:isStackFocused() -- {{{ + return self.stack:anyFocused() end -- }}} -function Window:process(showIcons, currTabIdx) -- {{{ +function Window:setupIndicator() -- {{{ -- Config - self.showIcons = showIcons - local unfocused_color = {white = 0.9, alpha = 0.30} - local focused_color = {white = 0.9, alpha = 0.99} - local padding = 4 - local iconPadding = 4 - local aspectRatio = 5 - local size = 32 - - local width = self.showIcons and size or (size / aspectRatio) - - local shit = self.frame.x - (width + padding) - self.canvas_frame = { - x = shit, - y = self.frame.y + 2, - w = self.frame.w, - h = self.frame.h, + self.showIcons = Sm:getShowIconsState() + self:isStackFocused() + + -- TODO: move into stackConfig module (somehow… despite its lack of support for nested keys :/) + self.config = { + color = {white = 0.90}, + alpha = 1, + + dimmer = 2.5, -- larger numbers increase contrast b/n focused & unfocused states + iconDimmer = 1.1, -- custom dimmer for icons + + size = 32, + radius = 3, + padding = 4, + iconPadding = 4, + pillThinness = 6, + + vertSpacing = 1.2, + offset = {y = 2, x = 4}, + -- example: overlapped with window + percent top offset + -- offset = { y = self.frame.h * 0.1, x = -(self.width / 2) } + + shouldFade = true, + fadeDuration = 0.2, } + local c = self.config -- alias config for convenience + + -- computed from config + self.width = self.showIcons and c.size or (c.size / c.pillThinness) + self.iconRadius = self.width / 3 + + -- Set canvas to fill entire screen + local screenFrame = self._win:screen():frame() + self.canvas_frame = screenFrame + + -- Display indicators on + -- left edge of windows on the left side of the screen, & + -- right edge of windows on the right side of the screen + self.side = self:getScreenSide() + local xval + + -- DONE: Limit the stack left/right side to the screen boundary so it doesn't go off screen https://github.com/AdamWagner/stackline/issues/21 + if self.side == 'right' then + xval = (self.frame.x + self.frame.w) + c.offset.x + if xval + self.width > screenFrame.w then + -- don't go beyond the right screen edge + xval = screenFrame.w - self.width + end + else + xval = self.frame.x - (self.width + c.offset.x) + xval = math.max(xval, 0) -- don't go beyond left screen edge + end + + -- Store canvas elements indexes to reference via :elementAttribute() + -- https://www.hammerspoon.org/docs/hs.canvas.html#elementAttribute + self.rectIdx = 1 + self.iconIdx = 2 + + -- NOTE: self.stackIdx comes from yabai. Window is stacked if stackIdx > 0 self.indicator_rect = { - x = 0, - y = ((currTabIdx - 1) * size * 1.1), - w = width, - h = size, + x = xval, + y = self.frame.y + c.offset.y + + ((self.stackIdx - 1) * c.size * c.vertSpacing), + w = self.width, + h = c.size, } self.icon_rect = { - x = iconPadding, - y = self.indicator_rect.y + iconPadding, - w = self.indicator_rect.w - (iconPadding * 2), - h = self.indicator_rect.h - (iconPadding * 2), + x = xval + c.iconPadding, + y = self.indicator_rect.y + c.iconPadding, + w = self.indicator_rect.w - (c.iconPadding * 2), + h = self.indicator_rect.h - (c.iconPadding * 2), } +end -- }}} - self.color_opts = { - bg = self.focused and focused_color or unfocused_color, - canvasAlpha = self.focused and 1 or 0.2, - imageAlpha = self.focused and 1 or 0.4, - } +function Window:drawIndicator(overrideOpts) -- {{{ + -- should there be a dedicated "Indicator" class to perform the actual drawing? - self:draw_indicator() + local opts = u.extend(self.config, overrideOpts or {}) + local radius = self.showIcons and self.iconRadius or opts.radius + local fadeDuration = opts.shouldFade and opts.fadeDuration or 0 -end -- }}} + self.focus = self:isFocused() + self.stackFocus = true -function Window:iconFromAppName() -- {{{ - appBundle = hs.appfinder.appFromName(self.app):bundleID() - return hs.image.imageFromAppBundle(appBundle) -end -- }}} + if self.indicator then + self.indicator:delete() + end -function Window:draw_indicator() -- {{{ self.indicator = hs.canvas.new(self.canvas_frame) - local width = self.indicator_rect.w - local radius = self.showIcons and (self.indicator_rect.w / 4.0) or 4.0 - self.indicator:appendElements({ + self.indicator:insertElement({ type = "rectangle", - action = "fill", - fillColor = self.color_opts.bg, + action = "fill", -- options: strokeAndFill, stroke, fill + fillColor = self:getColorAttrs(self.stackFocus, self.focus).bg, frame = self.indicator_rect, roundedRectRadii = {xRadius = radius, yRadius = radius}, - }) + padding = 60, + withShadow = true, + shadow = self:getShadowAttrs(), + }, self.rectIdx) if self.showIcons then - self.indicator:appendElements({ + -- TODO [low priority]: Figure out how to prevent clipping when adding a subtle shadow + -- to the icon to help distinguish icons with a near-white edge.Note + -- that `padding` attribute, which works for rects, does not work for images. + self.indicator:insertElement({ type = "image", image = self:iconFromAppName(), frame = self.icon_rect, - imageAlpha = self.color_opts.imageAlpha, - }) + imageAlpha = self:getColorAttrs(self.stackFocus, self.focus).img, + }, self.iconIdx) + end + + self.indicator:show(fadeDuration) +end -- }}} + +function Window:redrawIndicator() -- {{{ + local isWindowFocused = self:isFocused() + local isStackFocused = self:isStackFocused() + + -- has stack, window focus changed? + local stackFocusChange = isStackFocused ~= self.stackFocus + local windowFocusChange = isWindowFocused ~= self.focus + + -- permutations of stack, window change combos + local noChange = not stackFocusChange and not windowFocusChange + local bothChange = stackFocusChange and windowFocusChange + local onlyStackChange = stackFocusChange and not windowFocusChange + local onlyWinChange = not stackFocusChange and windowFocusChange + + -- LOGIC: Redraw according to what changed. + -- Supports indicating the *last-active* window in an unfocused stack. + if noChange then + -- bail early if there's nothing to do + return false + + elseif bothChange then + -- If both change, it means a *focused* window's stack is now unfocused. + self.stackFocus = isStackFocused + self.stack:redrawAllIndicators({except = self.id}) + -- Despite the window being unfocused, do *not* update self.focus + -- (unfocused stack + focused window = last-active window) + + elseif onlyWinChange then + -- changing window focus within a stack + self.focus = isWindowFocused + + if self.focus and stackConfig:get('enableTmpFixForHsBug') then + self:unfocusOtherAppWindows() + end + + elseif onlyStackChange then + -- aka, already unfocused window's stack is now unfocused, too + -- so update stackFocus + self.stackFocus = isStackFocused + + -- if only stack changed *and* win is focused, it means a previously + -- unfocused stack is now focused, so redraw other window indicators + if isWindowFocused then + self.stack:redrawAllIndicators({except = self.id}) + end + end + + if not self.indicator then + self:setupIndicator() + end + + -- ACTION: Update canvas values + local f = self.focus + local rect = self.indicator[self.rectIdx] + local icon = self.indicator[self.iconIdx] + + local colorAttrs = self:getColorAttrs(self.stackFocus, self.focus) + rect.fillColor = colorAttrs.bg + if self.showIcons then + icon.imageAlpha = colorAttrs.img + end + rect.shadow = self:getShadowAttrs(f) +end -- }}} + +function Window:getScreenSide() -- {{{ + local thresh = 0.75 + local screenWidth = self._win:screen():fullFrame().w + + local leftEdge = self.frame.x + local rightEdge = self.frame.x + self.frame.w + + local percR = 1 - ((screenWidth - rightEdge) / screenWidth) + local percL = (screenWidth - leftEdge) / screenWidth + + local side = (percR > thresh and percL < thresh) and 'right' or 'left' + + return side + + -- TODO [low-priority]: BUG: Right-side window incorrectly reports as a left-side window with + -- very large padding settings. Will need to consider coordinates from both + -- sides of a window. Impact is minimal with smaller threshold (<= 0.75). + + -- TODO [very-low-priority]: find a way to use hs.window.filter.windowsTo{Dir} + -- to determine side instead of percLeft/Right + -- https://www.hammerspoon.org/docs/hs.window.filter.html#windowsToWest + -- wfd:windowsToWest(self._win) + -- https://www.hammerspoon.org/docs/hs.window.html#windowsToWest + -- self._win:windowsToSouth() +end -- }}} + +function Window:getColorAttrs(isStackFocused, isWinFocused) -- {{{ + local opts = self.config + -- Lookup bg color and image alpha based on stack + window focus + -- e.g., fillColor = self:getColorAttrs(self.stackFocus, self.focus).bg + -- iconAlpha = self:getColorAttrs(self.stackFocus, self.focus).img + local colorLookup = { + stack = { + ['true'] = { + window = { + ['true'] = { + bg = u.extend(opts.color, {alpha = opts.alpha}), + img = opts.alpha, + }, + ['false'] = { + bg = u.extend(u.copy(opts.color), + {alpha = opts.alpha / opts.dimmer}), + img = opts.alpha / opts.iconDimmer, + }, + }, + }, + ['false'] = { + window = { + ['true'] = { + bg = u.extend(u.copy(opts.color), { + alpha = opts.alpha / (opts.dimmer / 1.2), + }), + -- last-focused icon stays full alpha when stack unfocused + img = opts.alpha, + }, + ['false'] = { + bg = u.extend(u.copy(opts.color), { + alpha = Sm:getShowIconsState() and 0 or 0.2, + }), + -- unfocused icon has slightly lower alpha when stack also unfocused + img = opts.alpha / + (opts.iconDimmer + (opts.iconDimmer * 0.70)), + }, + }, + }, + }, + } + -- end + + local isStackFocusedKey = tostring(isStackFocused) + local isWinFocusedKey = tostring(isWinFocused) + return colorLookup.stack[isStackFocusedKey].window[isWinFocusedKey] +end -- }}} + +function Window:getShadowAttrs() -- {{{ + -- less opaque & blurry when iconsDisabled + -- even less opaque & blurry when unfocused + local iconsDisabledDimmer = Sm:getShowIconsState() and 1 or 5 + local alphaDimmer = (self.focus and 3 or 4) * iconsDisabledDimmer + local blurDimmer = (self.focus and 15.0 or 7.0) / iconsDisabledDimmer + + -- Shadows should cast outwards toward the screen edges as if due to the glow of onscreen windows… + -- …or, if you prefer, from a light source originating from the center of the screen. + local xDirection = (self.side == 'left') and -1 or 1 + local offset = { + h = (self.focus and 3.0 or 2.0) * -1.0, + w = ((self.focus and 7.0 or 6.0) * xDirection) / iconsDisabledDimmer, + } + + -- 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') + -- Here's a super crude POC that uses the indicator's stack index such that + -- higher indicators have a negative Y offset and lower indicators have a positive Y offset + -- h = (self.focus and 3.0 or 2.0 - (2 + (self.stackIdx * 5))) * -1.0, + + return { + blurRadius = blurDimmer, + color = {alpha = 1 / alphaDimmer}, -- TODO align all alpha values to be defined like this (1/X) + offset = offset, + } +end -- }}} + +function Window:iconFromAppName() -- {{{ + appBundle = hs.appfinder.appFromName(self.app):bundleID() + return hs.image.imageFromAppBundle(appBundle) +end -- }}} + +function Window:makeStackId(hsWin) -- {{{ + local frame = hsWin:frame():floor() + local x = frame.x + local y = frame.y + local w = frame.w + local h = frame.h + return { + topLeft = table.concat({x, y}, '|'), + stackId = table.concat({x, y, w, h}, '|'), + } +end -- }}} + +function Window:deleteIndicator() -- {{{ + if self.indicator then + self.indicator:delete(self.config.fadeDuration) end end -- }}} +function Window:unfocusOtherAppWindows() -- {{{ + u.each(self.otherAppWindows, function(w) + w:redrawIndicator() + end) +end -- }}} + return Window diff --git a/utils/flatten.lua b/utils/flatten.lua deleted file mode 100644 index 537f7ec..0000000 --- a/utils/flatten.lua +++ /dev/null @@ -1,132 +0,0 @@ ---[[ - - Copyright (C) 2018 Masatoshi Teruya - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - THE SOFTWARE. - - flatten.lua - lua-table-flatten - Created by Masatoshi Teruya on 18/05/21. - ---]] --- file-scope variables -local type = type -local next = next -local tostring = tostring ---- constants -local INF_POS = math.huge -local INF_NEG = -INF_POS - ---- isFinite --- @param n --- @return ok -local function isFinite(n) - return type(n) == 'number' and (n < INF_POS and n > INF_NEG) -end - ---- encode --- @param key --- @param val --- @return key --- @return val -local function encode(key, val) - -- default do-nothing - return key, val -end - ---- setAsTable --- @param tbl --- @param key --- @param val -local function setAsTable(tbl, key, val) tbl[key] = val end - ---- _flatten --- @param tbl --- @param maxdepth --- @param encoder --- @param depth --- @param prefix --- @param circular --- @param setter --- @param res --- @return res --- @return err -local function _flatten(tbl, maxdepth, encoder, depth, prefix, res, circular, - setter) - local k, v = next(tbl) - - while k do - if type(v) ~= 'table' then - setter(res, encoder(prefix .. k, v)) - else - local ref = tostring(v) - - -- set value except circular referenced value - if not circular[ref] then - if maxdepth > 0 and depth >= maxdepth then - setter(res, prefix .. k, v) - else - circular[ref] = true - _flatten(v, maxdepth, encoder, depth + 1, - prefix .. k .. '.', res, circular, setter) - circular[ref] = nil - end - end - end - - k, v = next(tbl, k) - end - - return res -end - ---- flatten --- @param tbl --- @param maxdepth --- @param encoder --- @param setter --- @return res -local function flatten(tbl, maxdepth, encoder, setter) - -- veirfy arguments - if type(tbl) ~= 'table' then error('tbl must be table') end - - if maxdepth == nil then - maxdepth = 0 - elseif not isFinite(maxdepth) then - error('maxdepth must be finite number') - end - - -- use default encode function - if encoder == nil then - encoder = encode - elseif type(encoder) ~= 'function' then - error('encoder must be function') - end - - -- use default setter - if setter == nil then - setter = setAsTable - elseif type(setter) ~= 'function' then - error('setter must be function') - end - - return _flatten(tbl, maxdepth, encoder, 1, '', {}, {[tostring(tbl)] = true}, - setter) -end - -return flatten diff --git a/utils/underscore.lua b/utils/underscore.lua deleted file mode 100644 index 7415190..0000000 --- a/utils/underscore.lua +++ /dev/null @@ -1,425 +0,0 @@ --- Copyright (c) 2009 Marcus Irven --- --- Permission is hereby granted, free of charge, to any person --- obtaining a copy of this software and associated documentation --- files (the "Software"), to deal in the Software without --- restriction, including without limitation the rights to use, --- copy, modify, merge, publish, distribute, sublicense, and/or sell --- copies of the Software, and to permit persons to whom the --- Software is furnished to do so, subject to the following --- conditions: --- --- The above copyright notice and this permission notice shall be --- included in all copies or substantial portions of the Software. --- --- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, --- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES --- OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND --- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT --- HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, --- WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING --- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR --- OTHER DEALINGS IN THE SOFTWARE. - ---- Underscore is a set of utility functions for dealing with --- iterators, arrays, tables, and functions. - -local Underscore = { funcs = {} } -Underscore.__index = Underscore - -function Underscore.__call(_, value) - return Underscore:new(value) -end - -function Underscore:new(value, chained) - return setmetatable({ _val = value, chained = chained or false }, self) -end - -function Underscore.iter(list_or_iter) - if type(list_or_iter) == "function" then return list_or_iter end - - return coroutine.wrap(function() - for i=1,#list_or_iter do - coroutine.yield(list_or_iter[i]) - end - end) -end - -function Underscore.range(start_i, end_i, step) - if end_i == nil then - end_i = start_i - start_i = 1 - end - step = step or 1 - local range_iter = coroutine.wrap(function() - for i=start_i, end_i, step do - coroutine.yield(i) - end - end) - return Underscore:new(range_iter) -end - ---- Identity function. This function looks useless, but is used throughout Underscore as a default. --- @name _.identity --- @param value any object --- @return value --- @usage _.identity("foo") --- => "foo" -function Underscore.identity(value) - return value -end - --- chaining - -function Underscore:chain() - self.chained = true - return self -end - -function Underscore:value() - return self._val -end - --- iter - -function Underscore.funcs.each(list, func) - for i in Underscore.iter(list) do - func(i) - end - return list -end - -function Underscore.funcs.map(list, func) - local mapped = {} - for i in Underscore.iter(list) do - mapped[#mapped+1] = func(i) - end - return mapped -end - -function Underscore.funcs.reduce(list, memo, func) - for i in Underscore.iter(list) do - memo = func(memo, i) - end - return memo -end - -function Underscore.funcs.detect(list, func) - for i in Underscore.iter(list) do - if func(i) then return i end - end - return nil -end - -function Underscore.funcs.select(list, func) - local selected = {} - for i in Underscore.iter(list) do - if func(i) then selected[#selected+1] = i end - end - return selected -end - -function Underscore.funcs.reject(list, func) - local selected = {} - for i in Underscore.iter(list) do - if not func(i) then selected[#selected+1] = i end - end - return selected -end - -function Underscore.funcs.all(list, func) - func = func or Underscore.identity - - -- TODO what should happen with an empty list? - for i in Underscore.iter(list) do - if not func(i) then return false end - end - return true -end - -function Underscore.funcs.any(list, func) - func = func or Underscore.identity - - -- TODO what should happen with an empty list? - for i in Underscore.iter(list) do - if func(i) then return true end - end - return false -end - -function Underscore.funcs.include(list, value) - for i in Underscore.iter(list) do - if i == value then return true end - end - return false -end - -function Underscore.funcs.invoke(list, function_name, ...) - local args = {...} - Underscore.funcs.each(list, function(i) i[function_name](i, unpack(args)) end) - return list -end - -function Underscore.funcs.pluck(list, propertyName) - return Underscore.funcs.map(list, function(i) return i[propertyName] end) -end - -function Underscore.funcs.min(list, func) - func = func or Underscore.identity - - return Underscore.funcs.reduce(list, { item = nil, value = nil }, function(min, item) - if min.item == nil then - min.item = item - min.value = func(item) - else - local value = func(item) - if value < min.value then - min.item = item - min.value = value - end - end - return min - end).item -end - -function Underscore.funcs.max(list, func) - func = func or Underscore.identity - - return Underscore.funcs.reduce(list, { item = nil, value = nil }, function(max, item) - if max.item == nil then - max.item = item - max.value = func(item) - else - local value = func(item) - if value > max.value then - max.item = item - max.value = value - end - end - return max - end).item -end - -function Underscore.funcs.to_array(list) - local array = {} - for i in Underscore.iter(list) do - array[#array+1] = i - end - return array -end - -function Underscore.funcs.reverse(list) - local reversed = {} - for i in Underscore.iter(list) do - table.insert(reversed, 1, i) - end - return reversed -end - -function Underscore.funcs.sort(iter, comparison_func) - local array = iter - if type(iter) == "function" then - array = Underscore.funcs.to_array(iter) - end - table.sort(array, comparison_func) - return array -end - --- arrays - -function Underscore.funcs.first(array, n) - if n == nil then - return array[1] - else - local first = {} - n = math.min(n,#array) - for i=1,n do - first[i] = array[i] - end - return first - end -end - -function Underscore.funcs.rest(array, index) - index = index or 2 - local rest = {} - for i=index,#array do - rest[#rest+1] = array[i] - end - return rest -end - -function Underscore.funcs.slice(array, start_index, length) - local sliced_array = {} - - start_index = math.max(start_index, 1) - local end_index = math.min(start_index+length-1, #array) - for i=start_index, end_index do - sliced_array[#sliced_array+1] = array[i] - end - return sliced_array -end - -function Underscore.funcs.flatten(array) - local all = {} - - for ele in Underscore.iter(array) do - if type(ele) == "table" then - local flattened_element = Underscore.funcs.flatten(ele) - Underscore.funcs.each(flattened_element, function(e) all[#all+1] = e end) - else - all[#all+1] = ele - end - end - return all -end - -function Underscore.funcs.push(array, item) - table.insert(array, item) - return array -end - -function Underscore.funcs.pop(array) - return table.remove(array) -end - -function Underscore.funcs.shift(array) - return table.remove(array, 1) -end - -function Underscore.funcs.unshift(array, item) - table.insert(array, 1, item) - return array -end - -function Underscore.funcs.join(array, separator) - return table.concat(array, separator) -end - --- objects - -function Underscore.funcs.keys(obj) - local keys = {} - for k,v in pairs(obj) do - keys[#keys+1] = k - end - return keys -end - -function Underscore.funcs.values(obj) - local values = {} - for k,v in pairs(obj) do - values[#values+1] = v - end - return values -end - -function Underscore.funcs.extend(destination, source) - for k,v in pairs(source) do - destination[k] = v - end - return destination -end - -function Underscore.funcs.is_empty(obj) - return next(obj) == nil -end - --- Originally based on penlight's deepcompare() -- http://luaforge.net/projects/penlight/ -function Underscore.funcs.is_equal(o1, o2, ignore_mt) - local ty1 = type(o1) - local ty2 = type(o2) - if ty1 ~= ty2 then return false end - - -- non-table types can be directly compared - if ty1 ~= 'table' then return o1 == o2 end - - -- as well as tables which have the metamethod __eq - local mt = getmetatable(o1) - if not ignore_mt and mt and mt.__eq then return o1 == o2 end - - local is_equal = Underscore.funcs.is_equal - - for k1,v1 in pairs(o1) do - local v2 = o2[k1] - if v2 == nil or not is_equal(v1,v2, ignore_mt) then return false end - end - for k2,v2 in pairs(o2) do - local v1 = o1[k2] - if v1 == nil then return false end - end - return true -end - --- functions - -function Underscore.funcs.compose(...) - local function call_funcs(funcs, ...) - if #funcs > 1 then - return funcs[1](call_funcs(_.rest(funcs), ...)) - else - return funcs[1](...) - end - end - - local funcs = {...} - return function(...) - return call_funcs(funcs, ...) - end -end - -function Underscore.funcs.wrap(func, wrapper) - return function(...) - return wrapper(func, ...) - end -end - -function Underscore.funcs.curry(func, argument) - return function(...) - return func(argument, ...) - end -end - -function Underscore.functions() - return Underscore.keys(Underscore.funcs) -end - --- add aliases -Underscore.methods = Underscore.functions - -Underscore.funcs.for_each = Underscore.funcs.each -Underscore.funcs.collect = Underscore.funcs.map -Underscore.funcs.inject = Underscore.funcs.reduce -Underscore.funcs.foldl = Underscore.funcs.reduce -Underscore.funcs.filter = Underscore.funcs.select -Underscore.funcs.every = Underscore.funcs.all -Underscore.funcs.some = Underscore.funcs.any -Underscore.funcs.head = Underscore.funcs.first -Underscore.funcs.tail = Underscore.funcs.rest - -local function wrap_functions_for_oo_support() - local function value_and_chained(value_or_self) - local chained = false - if getmetatable(value_or_self) == Underscore then - chained = value_or_self.chained - value_or_self = value_or_self._val - end - return value_or_self, chained - end - - local function value_or_wrap(value, chained) - if chained then value = Underscore:new(value, true) end - return value - end - - for fn, func in pairs(Underscore.funcs) do - Underscore[fn] = function(obj_or_self, ...) - local obj, chained = value_and_chained(obj_or_self) - return value_or_wrap(func(obj, ...), chained) - end - end -end - -wrap_functions_for_oo_support() - -return Underscore:new() diff --git a/utils/utils.lua b/utils/utils.lua deleted file mode 100644 index e4d0491..0000000 --- a/utils/utils.lua +++ /dev/null @@ -1,305 +0,0 @@ -utils = {} - -utils.map = hs.fnutils.map -utils.concat = hs.fnutils.concat -utils.each = hs.fnutils.each - -utils.filter = function(t, f) - local out = {} - for k, v in pairs(t) do - if (f(k, v)) then - out[k] = v - end - end - return out -end - -function utils.keyBind(hyper, keyFuncTable) - for key, fn in pairs(keyFuncTable) do - hs.hotkey.bind(hyper, key, fn) - end -end - -utils.length = function(t) - local count = 0 - for _ in pairs(t) do - count = count + 1 - end - return count -end - -utils.indexOf = function(t, object) - if type(t) ~= "table" then - error("table expected, got " .. type(t), 2) - end - - for i, v in pairs(t) do - if object == v then - return i - end - end -end - ---[[ -This function takes 2 values as input and returns true if they are equal -and false if not. a and b can numbers, strings, booleans, tables and nil. ---]] -function utils.isEqual(a, b) - - local function isEqualTable(t1, t2) - - if t1 == t2 then - return true - end - - -- luacheck: ignore - for k, v in pairs(t1) do - - if type(t1[k]) ~= type(t2[k]) then - return false - end - - if type(t1[k]) == "table" then - if not isEqualTable(t1[k], t2[k]) then - return false - end - else - if t1[k] ~= t2[k] then - return false - end - end - end - - for k, v in pairs(t2) do - - if type(t2[k]) ~= type(t1[k]) then - return false - end - - if type(t2[k]) == "table" then - if not isEqualTable(t2[k], t1[k]) then - return false - end - else - if t2[k] ~= t1[k] then - return false - end - end - end - - return true - end - - if type(a) ~= type(b) then - return false - end - - if type(a) == "table" then - return isEqualTable(a, b) - else - return (a == b) - end - -end - --- Functions stolen from lume.lua --- (I should probably just use the library itself) --- https://github.com/rxi/lume/blob/master/lume.lua -function utils.isarray(x) - return type(x) == "table" and x[1] ~= nil -end - -local getiter = function(x) - if utils.isarray(x) then - return ipairs - elseif type(x) == "table" then - return pairs - end - error("expected table", 3) -end -function utils.invert(t) - local rtn = {} - for k, v in pairs(t) do - rtn[v] = k - end - return rtn -end -function utils.keys(t) - local rtn = {} - local iter = getiter(t) - for k in iter(t) do - rtn[#rtn + 1] = k - end - return rtn -end - -function utils.find(t, value) - local iter = getiter(t) - result = nil - for k, v in iter(t) do - print('value looking for') - print(value) - print('key matching against') - print(k) - print('are they equal?') - print(k == value) - if k == value then - result = v - end - end - utils.pheader('result') - print(result) - return result -end --- END lume.lua pillaging - ---- Check if a row matches the specified key constraints. --- @param row The row to check --- @param key_constraints The key constraints to apply --- @return A boolean result -local function filter_row(row, key_constraints) - -- Loop through all constraints - for k, v in pairs(key_constraints) do - if v and not row[k] then - -- The row is missing the key entirely, - -- definitely not a match - return false - end - - -- Wrap the key and constraint values in arrays, - -- if they're not arrays already (so we can loop through them) - local actual_values = type(row[k]) == "table" and row[k] or {row[k]} - local required_values = type(v) == "table" and v or {v} - - -- Loop through the values we *need* to find - for i = 1, #required_values do - local found - -- Loop through the values actually present - for j = 1, #actual_values do - if actual_values[j] == required_values[i] then - -- This object has the required value somewhere in the key, - -- no need to look any farther - found = true - break - end - end - - if not found then - return false - end - end - end - - return true -end - ---- Filter an array, returning entries matching `key_values`. --- @param input The array to process --- @param key_values A table of keys mapped to their viable values --- @return An array of matches -function utils.pick(input, key_values) - local result = {} - utils.each(key_values, function(k) - result[k] = input[k] - end) - return result -end - -local print = print -local tconcat = table.concat -local tinsert = table.insert -local srep = string.rep -local type = type -local pairs = pairs -local tostring = tostring -local next = next - -function utils.print_r(root) - local cache = {[root] = "."} - local function _dump(t, space, name) - local temp = {} - tinsert(temp, "\n") - for k, v in pairs(t) do - local key = tostring(k) - if cache[v] then - tinsert(temp, "+" .. key .. " {" .. cache[v] .. "}") - elseif type(v) == "table" then - local new_key = name .. "." .. key - cache[v] = new_key - tinsert(temp, "+" .. key .. - _dump(v, space .. (next(t, k) and "|" or " ") .. - srep(" ", #key), new_key)) - else - tinsert(temp, "+" .. key .. " [" .. tostring(v) .. "]") - end - end - return tconcat(temp, "\n" .. space) - end - print(_dump(root, "", "")) -end - ---- Recursively print out a Lua value. --- @param value The value to print --- @param indent Indentation level (defaults to 0) --- @param no_newline If true, won't print a newline at the end -function utils.deep_print(value, indent, no_newline) - indent = indent or 0 - - if type(value) == "table" then - print("{") - for k, v in pairs(value) do - io.write(string.rep(" ", indent + 2) .. "[") - deep_print(k, indent + 2, true) - io.write("] = ") - deep_print(v, indent + 2, true) - print(";") - end - io.write(string.rep(" ", indent) .. "}") - elseif type(value) == "string" then - io.write(("%q"):format(value)) - else - io.write(tostring(value)) - end - - if not no_newline then - print() - end -end - -function utils.p(...) - -- Auto-inspect non-string values when printing for debugging: - -- Example 1: - -- p('# to be cleaned: ', table.length(self.tabStacks)) - -- -> "# to be cleaned: 2" - -- Example 2: - -- p('keys be cleaned: ', table.keys(self.tabStacks)) - -- -> "keys be cleaned: { "6207671375", "63771631066207041183" }" - - result = {} - - -- How to handle variable arguments - -- https://www.lua.org/pil/5.2.html - for i = 1, select("#", ...) do - local x = select(i, ...) - if type(x) == 'string' then - table.insert(result, x) - else - table.insert(result, hs.inspect(x)) - end - end - print(table.unpack(result), '\n') -end - -function utils.pdivider(str) - str = string.upper(str) or "" - print("=========", str, "==========") -end - -function utils.pheader(str) - print('\n\n\n') - print("========================================") - print(string.upper(str), '==========') - print("========================================") -end - -return utils