├── README.md ├── Spoons └── RoundedCorners.spoon │ ├── docs.json │ └── init.lua ├── config.lua ├── init.lua └── modules ├── app_switcher.lua ├── lru_cache.lua ├── pomodoor.lua ├── tiler.lua └── window_memory.lua /README.md: -------------------------------------------------------------------------------- 1 | # ZoneTilerWM 2 | 3 | ZoneTilerWM is a modular and powerful tiling window manager for macOS, built on top of Hammerspoon. It combines zone-based window organization, intelligent multi-monitor support, powerful keyboard navigation, and productivity tools like an integrated Pomodoro timer. 4 | 5 | ## Implemented Features 6 | 7 | * **Zone-Based Window Management**: Define reusable window zones using a flexible grid-based coordinate system. 8 | * **Smart Screen Detection**: Automatically adapts to screen size, resolution, orientation, and known device patterns. 9 | * **Multi-Screen Support**: Move and focus windows across screens with ease. 10 | * **Focus Control**: Instantly switch focus within a zone or across monitors using intuitive shortcuts. 11 | * **Application Switching**: Bind hotkeys to launch or toggle commonly used applications. 12 | * **Window Memory**: Remembers window placements and restores them intelligently. 13 | * **Pomodoro Timer**: Visual work/rest timer with screen indicators. 14 | * **Modular Architecture**: Easy to maintain, configure, and extend. 15 | * **Centralized Configuration**: All behavior is defined in a single `config.lua` file. 16 | 17 | ## Still TODO 18 | 19 | * [ ] Application-aware layouts (save preferred zones per app) 20 | * [ ] Automatically arrange windows on launch 21 | * [ ] Dynamic resizing of rows and columns 22 | * [ ] Zen mode (minimize all but active window) 23 | * [ ] Adaptive window sizing based on content 24 | * [ ] Persistent layout save/load 25 | * [ ] Support for macOS Spaces 26 | * [ ] Window stacking in zones 27 | * [ ] Visual grid overlay 28 | * [ ] Mouse-driven zone selection 29 | * [ ] Layout presets for workflows 30 | 31 | --- 32 | 33 | ## Installation 34 | 35 | 1. Download and install [Hammerspoon](https://www.hammerspoon.org/). 36 | 2. Clone this repository into `~/.hammerspoon/`. 37 | 3. Launch Hammerspoon and reload configuration. 38 | 4. Edit `config.lua` to customize zones, keybindings, and apps. 39 | 40 | --- 41 | 42 | ## Project Structure 43 | 44 | ```text 45 | ~/.hammerspoon/ 46 | ├── init.lua # Entry point 47 | ├── config.lua # Configuration for keys, layouts, and features 48 | └── modules/ 49 | ├── tiler.lua # Main tiling engine 50 | ├── window_memory.lua # Window memory and recall 51 | ├── app_switcher.lua # App hotkey binding module 52 | ├── pomodoor.lua # Pomodoro timer display and logic 53 | └── lru_cache.lua # Helper LRU cache for window focus history 54 | ``` 55 | 56 | --- 57 | 58 | ## Grid Coordinate System 59 | 60 | Used to define tile zones like `a1` (top-left) to `d3` (bottom-right). 61 | 62 | ```text 63 | a b c d 64 | +----+----+----+----+ 65 | 1 | a1 | b1 | c1 | d1 | 66 | +----+----+----+----+ 67 | 2 | a2 | b2 | c2 | d2 | 68 | +----+----+----+----+ 69 | 3 | a3 | b3 | c3 | d3 | 70 | +----+----+----+----+ 71 | ``` 72 | 73 | --- 74 | 75 | ## Default Keyboard Shortcuts 76 | 77 | ### Zone Window Placement (Ctrl+Cmd) 78 | 79 | Grid is mapped to your keyboard: 80 | 81 | ```text 82 | y u i o 83 | h j k l 84 | n m , . 85 | ``` 86 | 87 | * `y` → top-left cycle 88 | * `h` → left side zones 89 | * `n` → bottom-left zones 90 | * `u` → middle-top cycle 91 | * `j` → center cycle 92 | * `m` → bottom-middle 93 | * `i` → top-right cycle 94 | * `k` → right-mid 95 | * `,` → bottom-right 96 | * `o` → wide top-right 97 | * `l` → wide right side 98 | * `.` → wide bottom-right 99 | * `0` → center/fullscreen toggle 100 | 101 | ### Move Window Across Screens 102 | 103 | * `Ctrl+Cmd+p` → Move window to next screen 104 | * `Ctrl+Cmd+;` → Move window to previous screen 105 | 106 | ### Focus Windows in Zones (Cycle) 107 | 108 | * `Shift+Ctrl+Cmd+[zone key]` → Focus on windows in zone 109 | * `Shift+Ctrl+Cmd+p` → Move focus to next screen 110 | * `Shift+Ctrl+Cmd+;` → Move focus to previous screen 111 | 112 | ### App Launching (Shift+Ctrl) 113 | 114 | * `Shift+Ctrl+[key]` → Toggle mapped app 115 | * `Shift+Ctrl+/` → Display app keybindings help 116 | 117 | ### Pomodoro Timer 118 | 119 | * `Ctrl+Cmd+9` → Start timer 120 | * `Ctrl+Cmd+0` → Pause/reset 121 | * `Shift+Ctrl+Cmd+0` → Reset work count 122 | 123 | ### Utility 124 | 125 | * `Hyper+-` → Show window hints 126 | * `Hyper+=` → Open Activity Monitor 127 | * `Shift+Ctrl+Cmd+R` → Reload config 128 | 129 | --- 130 | 131 | ## Configuration Overview 132 | 133 | All settings are centralized in `config.lua`: 134 | 135 | * Keybindings (`config.keys`) 136 | * Application hotkeys (`config.appCuts`) 137 | * App switching behaviors (e.g. ambiguous mappings) 138 | * Tiling layouts per screen 139 | * Window margin and spacing 140 | * Pomodoro visual settings 141 | 142 | You can define zones using coordinates (e.g., `"a1:b2"`) or names (`"center"`, `"right-half"`). 143 | 144 | --- 145 | 146 | ## Screen Detection Logic 147 | 148 | ZoneTilerWM detects screen layouts in this order: 149 | 150 | 1. Exact match from `custom layouts` 151 | 2. Regex pattern match for common brands/models 152 | 3. Screen size (e.g., 4x3 for large 27" screens) 153 | 4. Orientation-specific logic 154 | 5. Fallback to resolution-based default 155 | 156 | You can extend the detection logic in `config.lua` under `config.tiler.screen_detection`. 157 | 158 | --- 159 | 160 | ## Troubleshooting 161 | 162 | * Set `config.tiler.debug = true` to debug screen detection or zone placement 163 | * Use the Hammerspoon console to check layout messages 164 | * Reload config: `Shift+Ctrl+Cmd+R` 165 | * Add screen pattern or custom name if detection fails 166 | 167 | --- 168 | 169 | ## Credits 170 | 171 | * Powered by [Hammerspoon](https://www.hammerspoon.org/) 172 | * Inspired by grid-based WMs like Amethyst and yabai 173 | * Pomodoro adapted from the Pomodoro Technique 174 | -------------------------------------------------------------------------------- /Spoons/RoundedCorners.spoon/docs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Command": [], 4 | "Constant": [], 5 | "Constructor": [], 6 | "Deprecated": [], 7 | "Field": [], 8 | "Function": [], 9 | "Method": [ 10 | { 11 | "def": "RoundedCorners:start()", 12 | "desc": "Starts RoundedCorners", 13 | "doc": "Starts RoundedCorners\n\nParameters:\n * None\n\nReturns:\n * The RoundedCorners object\n\nNotes:\n * This will draw the rounded screen corners and start watching for changes in screen sizes/layouts, reacting accordingly", 14 | "name": "start", 15 | "notes": [ 16 | " * This will draw the rounded screen corners and start watching for changes in screen sizes/layouts, reacting accordingly" 17 | ], 18 | "parameters": [ 19 | " * None" 20 | ], 21 | "returns": [ 22 | " * The RoundedCorners object" 23 | ], 24 | "signature": "RoundedCorners:start()", 25 | "stripped_doc": "", 26 | "type": "Method" 27 | }, 28 | { 29 | "def": "RoundedCorners:stop()", 30 | "desc": "Stops RoundedCorners", 31 | "doc": "Stops RoundedCorners\n\nParameters:\n * None\n\nReturns:\n * The RoundedCorners object\n\nNotes:\n * This will remove all rounded screen corners and stop watching for changes in screen sizes/layouts", 32 | "name": "stop", 33 | "notes": [ 34 | " * This will remove all rounded screen corners and stop watching for changes in screen sizes/layouts" 35 | ], 36 | "parameters": [ 37 | " * None" 38 | ], 39 | "returns": [ 40 | " * The RoundedCorners object" 41 | ], 42 | "signature": "RoundedCorners:stop()", 43 | "stripped_doc": "", 44 | "type": "Method" 45 | } 46 | ], 47 | "Variable": [ 48 | { 49 | "def": "RoundedCorners.allScreens", 50 | "desc": "Controls whether corners are drawn on all screens or just the primary screen. Defaults to true", 51 | "doc": "Controls whether corners are drawn on all screens or just the primary screen. Defaults to true", 52 | "name": "allScreens", 53 | "signature": "RoundedCorners.allScreens", 54 | "stripped_doc": "", 55 | "type": "Variable" 56 | }, 57 | { 58 | "def": "RoundedCorners.level", 59 | "desc": "Controls which level of the screens the corners are drawn at. See `hs.canvas.windowLevels` for more information. Defaults to `screenSaver + 1`", 60 | "doc": "Controls which level of the screens the corners are drawn at. See `hs.canvas.windowLevels` for more information. Defaults to `screenSaver + 1`", 61 | "name": "level", 62 | "signature": "RoundedCorners.level", 63 | "stripped_doc": "", 64 | "type": "Variable" 65 | }, 66 | { 67 | "def": "RoundedCorners.radius", 68 | "desc": "Controls the radius of the rounded corners, in points. Defaults to 6", 69 | "doc": "Controls the radius of the rounded corners, in points. Defaults to 6", 70 | "name": "radius", 71 | "signature": "RoundedCorners.radius", 72 | "stripped_doc": "", 73 | "type": "Variable" 74 | } 75 | ], 76 | "desc": "Give your screens rounded corners", 77 | "doc": "Give your screens rounded corners\n\nDownload: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/RoundedCorners.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/RoundedCorners.spoon.zip)", 78 | "items": [ 79 | { 80 | "def": "RoundedCorners.allScreens", 81 | "desc": "Controls whether corners are drawn on all screens or just the primary screen. Defaults to true", 82 | "doc": "Controls whether corners are drawn on all screens or just the primary screen. Defaults to true", 83 | "name": "allScreens", 84 | "signature": "RoundedCorners.allScreens", 85 | "stripped_doc": "", 86 | "type": "Variable" 87 | }, 88 | { 89 | "def": "RoundedCorners.level", 90 | "desc": "Controls which level of the screens the corners are drawn at. See `hs.canvas.windowLevels` for more information. Defaults to `screenSaver + 1`", 91 | "doc": "Controls which level of the screens the corners are drawn at. See `hs.canvas.windowLevels` for more information. Defaults to `screenSaver + 1`", 92 | "name": "level", 93 | "signature": "RoundedCorners.level", 94 | "stripped_doc": "", 95 | "type": "Variable" 96 | }, 97 | { 98 | "def": "RoundedCorners.radius", 99 | "desc": "Controls the radius of the rounded corners, in points. Defaults to 6", 100 | "doc": "Controls the radius of the rounded corners, in points. Defaults to 6", 101 | "name": "radius", 102 | "signature": "RoundedCorners.radius", 103 | "stripped_doc": "", 104 | "type": "Variable" 105 | }, 106 | { 107 | "def": "RoundedCorners:start()", 108 | "desc": "Starts RoundedCorners", 109 | "doc": "Starts RoundedCorners\n\nParameters:\n * None\n\nReturns:\n * The RoundedCorners object\n\nNotes:\n * This will draw the rounded screen corners and start watching for changes in screen sizes/layouts, reacting accordingly", 110 | "name": "start", 111 | "notes": [ 112 | " * This will draw the rounded screen corners and start watching for changes in screen sizes/layouts, reacting accordingly" 113 | ], 114 | "parameters": [ 115 | " * None" 116 | ], 117 | "returns": [ 118 | " * The RoundedCorners object" 119 | ], 120 | "signature": "RoundedCorners:start()", 121 | "stripped_doc": "", 122 | "type": "Method" 123 | }, 124 | { 125 | "def": "RoundedCorners:stop()", 126 | "desc": "Stops RoundedCorners", 127 | "doc": "Stops RoundedCorners\n\nParameters:\n * None\n\nReturns:\n * The RoundedCorners object\n\nNotes:\n * This will remove all rounded screen corners and stop watching for changes in screen sizes/layouts", 128 | "name": "stop", 129 | "notes": [ 130 | " * This will remove all rounded screen corners and stop watching for changes in screen sizes/layouts" 131 | ], 132 | "parameters": [ 133 | " * None" 134 | ], 135 | "returns": [ 136 | " * The RoundedCorners object" 137 | ], 138 | "signature": "RoundedCorners:stop()", 139 | "stripped_doc": "", 140 | "type": "Method" 141 | } 142 | ], 143 | "name": "RoundedCorners", 144 | "stripped_doc": "\nDownload: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/RoundedCorners.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/RoundedCorners.spoon.zip)", 145 | "submodules": [], 146 | "type": "Module" 147 | } 148 | ] -------------------------------------------------------------------------------- /Spoons/RoundedCorners.spoon/init.lua: -------------------------------------------------------------------------------- 1 | --- === RoundedCorners === 2 | --- 3 | --- Give your screens rounded corners 4 | --- 5 | --- Download: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/RoundedCorners.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/RoundedCorners.spoon.zip) 6 | local obj = {} 7 | obj.__index = obj 8 | 9 | -- Metadata 10 | obj.name = "RoundedCorners" 11 | obj.version = "1.0" 12 | obj.author = "Chris Jones " 13 | obj.homepage = "https://github.com/Hammerspoon/Spoons" 14 | obj.license = "MIT - https://opensource.org/licenses/MIT" 15 | 16 | obj.corners = {} 17 | obj.screenWatcher = nil 18 | 19 | --- RoundedCorners.allScreens 20 | --- Variable 21 | --- Controls whether corners are drawn on all screens or just the primary screen. Defaults to true 22 | obj.allScreens = true 23 | 24 | --- RoundedCorners.radius 25 | --- Variable 26 | --- Controls the radius of the rounded corners, in points. Defaults to 6 27 | obj.radius = 6 28 | 29 | --- RoundedCorners.level 30 | --- Variable 31 | --- Controls which level of the screens the corners are drawn at. See `hs.canvas.windowLevels` for more information. Defaults to `screenSaver + 1` 32 | obj.level = hs.canvas.windowLevels["screenSaver"] + 1 33 | 34 | -- Internal function used to find our location, so we know where to load files from 35 | local function script_path() 36 | local str = debug.getinfo(2, "S").source:sub(2) 37 | return str:match("(.*/)") 38 | end 39 | obj.spoonPath = script_path() 40 | 41 | function obj:init() 42 | self.screenWatcher = hs.screen.watcher.new(function() self:screensChanged() end) 43 | end 44 | 45 | --- RoundedCorners:start() 46 | --- Method 47 | --- Starts RoundedCorners 48 | --- 49 | --- Parameters: 50 | --- * None 51 | --- 52 | --- Returns: 53 | --- * The RoundedCorners object 54 | --- 55 | --- Notes: 56 | --- * This will draw the rounded screen corners and start watching for changes in screen sizes/layouts, reacting accordingly 57 | function obj:start() 58 | self.screenWatcher:start() 59 | self:render() 60 | return self 61 | end 62 | 63 | --- RoundedCorners:stop() 64 | --- Method 65 | --- Stops RoundedCorners 66 | --- 67 | --- Parameters: 68 | --- * None 69 | --- 70 | --- Returns: 71 | --- * The RoundedCorners object 72 | --- 73 | --- Notes: 74 | --- * This will remove all rounded screen corners and stop watching for changes in screen sizes/layouts 75 | function obj:stop() 76 | self.screenWatcher:stop() 77 | self:deleteAllCorners() 78 | return self 79 | end 80 | 81 | -- Delete all the corners 82 | function obj:deleteAllCorners() 83 | hs.fnutils.each(self.corners, function(corner) corner:delete() end) 84 | self.corners = {} 85 | end 86 | 87 | -- React to the screens having changed 88 | function obj:screensChanged() 89 | self:deleteAllCorners() 90 | self:render() 91 | end 92 | 93 | -- Get the screens to draw on, given the user's settings 94 | function obj:getScreens() 95 | if self.allScreens then 96 | return hs.screen.allScreens() 97 | else 98 | return {hs.screen.primaryScreen()} 99 | end 100 | end 101 | 102 | -- Draw the corners 103 | function obj:render() 104 | local screens = self:getScreens() 105 | local radius = self.radius 106 | hs.fnutils.each(screens, function(screen) 107 | local screenFrame = screen:fullFrame() 108 | local cornerData = { 109 | { frame={x=screenFrame.x, y=screenFrame.y}, center={x=radius,y=radius} }, 110 | { frame={x=screenFrame.x + screenFrame.w - radius, y=screenFrame.y}, center={x=0,y=radius} }, 111 | { frame={x=screenFrame.x, y=screenFrame.y + screenFrame.h - radius}, center={x=radius,y=0} }, 112 | { frame={x=screenFrame.x + screenFrame.w - radius, y=screenFrame.y + screenFrame.h - radius}, center={x=0,y=0} }, 113 | } 114 | for _,data in pairs(cornerData) do 115 | self.corners[#self.corners+1] = hs.canvas.new({x=data.frame.x,y=data.frame.y,w=radius,h=radius}):appendElements( 116 | { action="build", type="rectangle", }, 117 | { action="clip", type="circle", center=data.center, radius=radius, reversePath=true, }, 118 | { action="fill", type="rectangle", frame={x=0, y=0, w=radius, h=radius, }, fillColor={ alpha=1, }}, 119 | { type="resetClip", } 120 | ):behavior(hs.canvas.windowBehaviors.canJoinAllSpaces):level(self.level):show() 121 | end 122 | end) 123 | end 124 | 125 | return obj 126 | -------------------------------------------------------------------------------- /config.lua: -------------------------------------------------------------------------------- 1 | -- Configuration file for Hammerspoon settings 2 | local config = {} 3 | 4 | -- Key combinations 5 | config.keys = { 6 | mash = {"ctrl", "cmd"}, 7 | mash_app = {"shift", "ctrl"}, 8 | mash_shift = {"shift", "ctrl", "cmd"}, 9 | HYPER = {"shift", "ctrl", "alt", "cmd"} 10 | } 11 | 12 | -- Application switcher settings 13 | config.app_switcher = { 14 | -- Apps that need menu-based hiding 15 | hide_workaround_apps = {'Arc'}, 16 | 17 | -- Apps that require exact mapping between launch name and display name 18 | special_app_mappings = { 19 | ["bambustudio"] = "bambu studio" -- Launch name → Display name 20 | }, 21 | 22 | -- Ambiguous app pairs that should not be considered matching 23 | ambiguous_apps = {{'notion', 'notion calendar'}, {'notion', 'notion mail'}} 24 | } 25 | 26 | -- Application shortcuts with direct lowercase mapping 27 | config.appCuts = { 28 | q = 'BambuStudio', 29 | w = 'Whatsapp', 30 | e = 'Finder', 31 | r = 'Cronometer', 32 | t = 'Ghostty', 33 | a = 'Notion', 34 | s = 'Notion Mail', 35 | d = 'Notion Calendar', 36 | f = 'Zen', 37 | g = 'Gmail', 38 | z = 'Nimble Commander', 39 | x = 'Claude', 40 | c = 'Arc', 41 | v = 'Visual Studio Code', 42 | b = 'YouTube Music' 43 | } 44 | 45 | config.hyperAppCuts = { 46 | q = 'IBKR Desktop', 47 | w = 'Weather', 48 | e = 'Clock', 49 | r = 'Discord', 50 | t = 'ChatGpt', 51 | a = 'KeePassXC' 52 | } 53 | 54 | -- Pomodoro settings 55 | config.pomodoro = { 56 | enable_color_bar = true, 57 | work_period_sec = 52 * 60, -- 52 minutes 58 | rest_period_sec = 17 * 60, -- 17 minutes 59 | indicator_height = 0.2, -- ratio from the height of the menubar (0..1) 60 | indicator_alpha = 0.3, 61 | indicator_in_all_spaces = true, 62 | color_time_remaining = hs.drawing.color.green, 63 | color_time_used = hs.drawing.color.red 64 | } 65 | 66 | -- Simplified Tiler settings 67 | config.tiler = { 68 | debug = true, 69 | modifier = {"ctrl", "cmd"}, 70 | focus_modifier = {"shift", "ctrl", "cmd"}, 71 | 72 | margins = { 73 | enabled = true, 74 | size = 5, 75 | screen_edge = true 76 | }, 77 | -- Apps that require special handling (Disable internal window management) 78 | problem_apps = {"Firefox", "Zen"}, 79 | -- Screen detection configuration 80 | screen_detection = { 81 | -- Special screen name patterns and corresponding layout type 82 | patterns = { 83 | ["DELL.*U32"] = "4x3", -- Dell 32-inch monitors 84 | ["DELL U3223QE"] = "4x3", -- Exact match 85 | ["LG.*QHD"] = "1x3", -- LG QHD in portrait mode 86 | ["Built[-]?in"] = "2x2", -- MacBook built-in displays 87 | ["Color LCD"] = "2x2", -- MacBook displays 88 | ["internal"] = "2x2", -- Internal displays 89 | ["MacBook"] = "2x2" -- MacBook displays 90 | }, 91 | 92 | -- Size-based layout selection 93 | sizes = { 94 | large = { 95 | min = 27, 96 | layout = "4x3" 97 | }, -- 27" and larger - 4x3 grid 98 | medium = { 99 | min = 24, 100 | max = 26.9, 101 | layout = "3x3" 102 | }, -- 24-26" - 3x3 grid 103 | standard = { 104 | min = 20, 105 | max = 23.9, 106 | layout = "3x2" 107 | }, -- 20-23" - 3x2 grid 108 | small = { 109 | max = 19.9, 110 | layout = "2x2" 111 | } -- Under 20" - 2x2 grid 112 | }, 113 | 114 | -- Portrait mode detection 115 | portrait = { 116 | large = { 117 | min = 23, 118 | layout = "1x3" 119 | }, -- 23" and larger in portrait 120 | small = { 121 | max = 22.9, 122 | layout = "1x2" 123 | } -- Under 23" in portrait 124 | } 125 | }, 126 | 127 | -- Grid specifications 128 | grids = { 129 | ["4x3"] = { 130 | cols = 4, 131 | rows = 3 132 | }, 133 | ["3x3"] = { 134 | cols = 3, 135 | rows = 3 136 | }, 137 | ["3x2"] = { 138 | cols = 3, 139 | rows = 2 140 | }, 141 | ["2x2"] = { 142 | cols = 2, 143 | rows = 2 144 | }, 145 | ["1x3"] = { 146 | cols = 1, 147 | rows = 3 148 | }, 149 | ["1x2"] = { 150 | cols = 1, 151 | rows = 2 152 | } 153 | }, 154 | 155 | -- Custom layouts for specific screens (by exact name) 156 | custom_screens = { 157 | ["DELL U3223QE"] = { 158 | grid = { 159 | cols = 4, 160 | rows = 3 161 | }, 162 | layout = "4x3" 163 | }, 164 | ["LG IPS QHD"] = { 165 | grid = { 166 | cols = 1, 167 | rows = 3 168 | }, 169 | layout = "1x3" 170 | } 171 | }, 172 | 173 | -- Layout configurations - zone key -> tile definitions 174 | layouts = { 175 | -- 4x3 layout (large displays) 176 | ["4x3"] = { 177 | ["y"] = {"a1:a2", "a1", "a1:b2"}, 178 | ["h"] = {"a1:b3", "a1:a3", "a1:c3", "a2"}, 179 | ["n"] = {"a3", "a2:a3", "a3:b3"}, 180 | ["u"] = {"b1:b3", "b1:b2", "b1"}, 181 | ["j"] = {"b1:c3", "b1:b3", "b2", "b1:d3"}, 182 | ["m"] = {"b1:b3", "b2:c3", "b3"}, 183 | ["i"] = {"d1:d3", "d1:d2", "d1"}, 184 | ["k"] = {"c1:d3", "c1:c3", "c2"}, 185 | [","] = {"d1:d3", "d2:d3", "d3"}, 186 | ["o"] = {"c1:d1", "d1", "c1:d2"}, 187 | ["l"] = {"d1:d3", "c1:d3", "b1:d3", "d2"}, 188 | ["."] = {"d3", "d2:d3", "c3:d3"}, 189 | ["0"] = {"b2:c2", "a1:d3", "center"} 190 | }, 191 | 192 | -- 2x2 layout (small displays, laptop) 193 | ["2x2"] = { 194 | ["y"] = {"a1", "a1:a2", "a1:b1"}, -- Top-left 195 | ["h"] = {"a1:a2", "a1:b2"}, -- Left tile 196 | ["n"] = {"a2", "a2:b2"}, -- Bottom-left 197 | ["u"] = {"a1:b1", "b1"}, -- Top tile 198 | ["j"] = {"a1:b2"}, -- Center (full screen) 199 | ["m"] = {"a2:b2", "b2"}, -- Bottom tile 200 | ["i"] = {"b1", "a1:b1"}, -- Top-right 201 | ["k"] = {"b1:b2", "b2"}, -- Right tile 202 | [","] = {"b2", "a2:b2"}, -- Bottom-right 203 | ["0"] = {"center", "a1:b2", "a1:b1"} 204 | }, 205 | 206 | -- 3x2 layout 207 | ["3x2"] = { 208 | ["y"] = {"a1", "a1:a2"}, 209 | ["h"] = {"a1:a2", "a1:b2"}, 210 | ["n"] = {"a2", "a1:a2"}, 211 | ["u"] = {"b1", "b1:b2"}, 212 | ["j"] = {"b1:b2", "a1:c2"}, 213 | ["m"] = {"b2", "b1:b2"}, 214 | ["i"] = {"c1", "c1:c2"}, 215 | ["k"] = {"c1:c2", "b1:c2"}, 216 | [","] = {"c2", "c1:c2"}, 217 | ["0"] = {"center", "b1:b2", "a1:c2"} 218 | }, 219 | 220 | -- 3x3 layout 221 | ["3x3"] = { 222 | ["y"] = {"a1", "a1:a2", "a1:b1"}, 223 | ["h"] = {"a1:a3", "a1:a2", "a2"}, 224 | ["n"] = {"a3", "a2:a3", "a3:b3"}, 225 | ["u"] = {"b1", "b1:b2", "a1:b1"}, 226 | ["j"] = {"b2", "b1:b3", "a1:c3"}, 227 | ["m"] = {"b3", "b2:b3", "a3:c3"}, 228 | ["i"] = {"c1", "c1:c2", "b1:c1"}, 229 | ["k"] = {"c1:c3", "c2:c3", "c2"}, 230 | [","] = {"c3", "c2:c3", "b3:c3"}, 231 | ["0"] = {"b2", "center", "a1:c3"} 232 | }, 233 | 234 | -- Portrait layout 235 | ["1x3"] = { 236 | ["y"] = {"a1", "a1:a2"}, 237 | ["h"] = {"a2", "a1:a3"}, 238 | ["n"] = {"a3", "a2:a3"}, 239 | ["0"] = {"a1:a3", "a2", "center"} 240 | }, 241 | 242 | -- 1x2 layout 243 | ["1x2"] = { 244 | ["y"] = {"a1"}, 245 | ["h"] = {"a2"}, 246 | ["0"] = {"a1:a2", "a1", "a2"} 247 | }, 248 | 249 | -- Default fallback for any layout/key not specifically defined 250 | ["default"] = { 251 | ["y"] = {"top-half"}, 252 | ["h"] = {"left-half"}, 253 | ["n"] = {"bottom-half"}, 254 | ["u"] = {"top-half"}, 255 | ["j"] = {"full"}, 256 | ["m"] = {"bottom-half"}, 257 | ["i"] = {"top-half"}, 258 | ["k"] = {"right-half"}, 259 | [","] = {"bottom-half"}, 260 | ["0"] = {"center", "full"} 261 | } 262 | }, 263 | 264 | -- Smart placement settings 265 | smart_placement = { 266 | enabled = true, -- Enable/disable smart placement 267 | cell_size = 50, -- Grid cell size for placement calculation 268 | exclude_apps = {"Hammerspoon", "Alfred", "Raycast"} -- Apps to exclude from smart placement 269 | }, 270 | 271 | -- Focus cycling settings 272 | flash_on_focus = true, -- Visual feedback when cycling focus 273 | overlap_threshold = 0.5, -- Minimum overlap to consider window in zone (50%) 274 | focus_cycle_all_tiles = false, -- If true, cycles through all tiles in zone, not just current tile 275 | 276 | -- Cache settings 277 | cache_size = { 278 | positions = 500, -- Window position cache size 279 | window_info = 200 -- Window info cache size 280 | } 281 | } 282 | 283 | -- Window memory settings 284 | config.window_memory = { 285 | enabled = true, -- Enable/disable window memory 286 | debug = true, -- Enable debug logging 287 | 288 | -- Directory to store position cache files 289 | cache_dir = os.getenv("HOME") .. "/.config/tiler", 290 | 291 | -- Hotkey configuration 292 | hotkeys = { 293 | capture = {"9", {"shift", "ctrl", "alt", "cmd"}}, -- HYPER+9 to capture all window positions 294 | restore = {"0", {"shift", "ctrl", "alt", "cmd"}} -- HYPER+0 to restore remembered positions 295 | }, 296 | 297 | -- Apps to exclude from window memory 298 | excluded_apps = {"System Settings", "System Preferences", "Activity Monitor", "Calculator", "Photo Booth", 299 | "Hammerspoon", "KeyCastr", "Installer"}, 300 | 301 | -- Fallback auto-tiling settings (used when no cached position exists) 302 | auto_tile_fallback = true, 303 | default_zone = "0", 304 | app_zones = { 305 | ["Arc"] = "k", 306 | ["iTerm"] = "h", 307 | ["Visual Studio Code"] = "h", 308 | ["Notion"] = "j", 309 | ["Spotify"] = "i" 310 | } 311 | } 312 | 313 | return config 314 | -------------------------------------------------------------------------------- /init.lua: -------------------------------------------------------------------------------- 1 | -- Hammerspoon configuration 2 | local config = require "config" 3 | 4 | -- Load modules 5 | local pom = require "modules.pomodoor" 6 | local tiler = require "modules.tiler" 7 | local appSwitcher = require "modules.app_switcher" 8 | local window_memory = require "modules.window_memory" 9 | 10 | -- Get key combinations from config 11 | local mash = config.keys.mash 12 | local mash_app = config.keys.mash_app 13 | local mash_shift = config.keys.mash_shift 14 | local HYPER = config.keys.HYPER 15 | 16 | --[[ 17 | Initializes custom keybindings 18 | ]] 19 | local function init_custom_binding() 20 | -- Window hints shortcut 21 | hs.hotkey.bind(HYPER, '-', hs.hints.windowHints) 22 | 23 | -- Pomodoro bindings - using function wrappers to avoid errors 24 | hs.hotkey.bind(mash, '9', function() 25 | pom.enable() 26 | end) 27 | hs.hotkey.bind(mash, '0', function() 28 | pom.disable() 29 | end) 30 | hs.hotkey.bind(mash_shift, '0', function() 31 | pom.reset_work() 32 | end) 33 | 34 | -- Activity Monitor shortcut 35 | hs.hotkey.bind(HYPER, "=", function() 36 | appSwitcher.toggle_app("Activity Monitor") 37 | end) 38 | 39 | -- Hot reload configuration 40 | hs.hotkey.bind(mash_shift, "R", function() 41 | hs.reload() 42 | hs.alert.show("Config reloaded!") 43 | end) 44 | end 45 | 46 | --[[ 47 | Main initialization function 48 | ]] 49 | local function init() 50 | print("-------------- Loading Hammerspoon config --------------") 51 | 52 | -- Disable animation for speed 53 | hs.window.animationDuration = 0 54 | 55 | -- Load Spoons 56 | hs.loadSpoon("RoundedCorners") 57 | spoon.RoundedCorners:start() 58 | 59 | -- Initialize simplified tiler 60 | tiler.start() 61 | 62 | if config.window_memory and config.window_memory.enabled then 63 | window_memory.init(tiler) 64 | window_memory.setup_hotkeys() -- Optional, for manual capture/restore 65 | end 66 | 67 | -- Initialize app switching 68 | appSwitcher.init_bindings(config.appCuts, config.hyperAppCuts, mash_app, HYPER) 69 | 70 | -- Initialize custom keybindings 71 | init_custom_binding() 72 | 73 | print("Hammerspoon configuration loaded successfully!") 74 | end 75 | 76 | -- Start the configuration 77 | init() 78 | -------------------------------------------------------------------------------- /modules/app_switcher.lua: -------------------------------------------------------------------------------- 1 | -- App switching module for Hammerspoon 2 | local config = require "config" 3 | local appSwitcher = {} 4 | 5 | -- Cache frequently accessed functions 6 | local appLaunchOrFocus = hs.application.launchOrFocus 7 | 8 | -- Get app switcher settings from config 9 | local hide_workaround_apps = config.app_switcher.hide_workaround_apps 10 | local special_app_mappings = config.app_switcher.special_app_mappings 11 | local ambiguous_apps = config.app_switcher.ambiguous_apps 12 | 13 | --[[ 14 | Determines if two app names are ambiguous (one might be part of another) 15 | 16 | @param app_name (string) First application name to compare 17 | @param title (string) Second application name to compare 18 | @return (boolean) true if the app names are known to be ambiguous, false otherwise 19 | ]] 20 | local function ambiguous_app_name(app_name, title) 21 | -- Some application names are ambiguous - may be part of a different app name or vice versa. 22 | -- this function disambiguates some known applications. 23 | for _, tuple in ipairs(ambiguous_apps) do 24 | if (app_name == tuple[1] and title == tuple[2]) or (app_name == tuple[2] and title == tuple[1]) then 25 | return true 26 | end 27 | end 28 | 29 | return false 30 | end 31 | 32 | --[[ 33 | Toggles an application between focused and hidden states 34 | 35 | @param app (string) The name of the application to toggle 36 | ]] 37 | function appSwitcher.toggle_app(app) 38 | -- Get information about currently focused app and target app 39 | local front_app = hs.application.frontmostApplication() 40 | local front_app_name = front_app:name() 41 | local front_app_lower = front_app_name:lower() 42 | local target_app_name = app 43 | local target_app_lower = app:lower() 44 | 45 | -- Check if the front app is the one we're trying to toggle 46 | local switching_to_same_app = false 47 | 48 | -- Handle special app mappings (launch name ≠ display name) 49 | if special_app_mappings[target_app_lower] == front_app_lower or (target_app_lower == front_app_lower) then 50 | switching_to_same_app = true 51 | end 52 | 53 | -- Check if they're related apps with different naming conventions 54 | if not ambiguous_app_name(front_app_lower, target_app_lower) then 55 | if string.find(front_app_lower, target_app_lower) or string.find(target_app_lower, front_app_lower) then 56 | switching_to_same_app = true 57 | end 58 | end 59 | 60 | if switching_to_same_app then 61 | -- Handle apps that need special hiding via menu 62 | for _, workaround_app in ipairs(hide_workaround_apps) do 63 | if front_app_name == workaround_app then 64 | front_app:selectMenuItem("Hide " .. front_app_name) 65 | return 66 | end 67 | end 68 | 69 | -- Normal hiding 70 | front_app:hide() 71 | return 72 | end 73 | 74 | -- Not on target app, so launch or focus it 75 | appLaunchOrFocus(target_app_name) 76 | end 77 | 78 | --[[ 79 | Displays help screen with keyboard shortcuts 80 | ]] 81 | function appSwitcher.display_help(appCuts, hyperAppCuts) 82 | local help_text = nil 83 | 84 | if not help_text then 85 | local t = {"Keyboard shortcuts\n", "--------------------\n"} 86 | 87 | for key, app in pairs(appCuts) do 88 | table.insert(t, "Control + CMD + " .. key .. "\t :\t" .. app .. "\n") 89 | end 90 | 91 | for key, app in pairs(hyperAppCuts) do 92 | table.insert(t, "HYPER + " .. key .. "\t:\t" .. app .. "\n") 93 | end 94 | 95 | help_text = table.concat(t) 96 | end 97 | 98 | hs.alert.show(help_text, 2) 99 | end 100 | 101 | --[[ 102 | Initializes application shortcut keybindings 103 | ]] 104 | function appSwitcher.init_bindings(appCuts, hyperAppCuts, mash_app, HYPER) 105 | for key, app in pairs(appCuts) do 106 | if app and app:match("%S") then -- check for non-nil and non-whitespace app names 107 | hs.hotkey.bind(mash_app, key, function() 108 | appSwitcher.toggle_app(app) 109 | end) 110 | end 111 | end 112 | 113 | for key, app in pairs(hyperAppCuts) do 114 | if app and app:match("%S") then -- check for non-nil and non-whitespace app names 115 | hs.hotkey.bind(HYPER, key, function() 116 | appSwitcher.toggle_app(app) 117 | end) 118 | end 119 | end 120 | 121 | -- Help binding 122 | hs.hotkey.bind(mash_app, ';', function() 123 | appSwitcher.display_help(appCuts, hyperAppCuts) 124 | end) 125 | end 126 | 127 | return appSwitcher 128 | -------------------------------------------------------------------------------- /modules/lru_cache.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | LRU Cache for Hammerspoon 3 | ========================= 4 | 5 | A simple but effective Least Recently Used (LRU) cache implementation. 6 | This module provides a general-purpose caching mechanism with configurable size limits. 7 | 8 | Usage example: 9 | ------------- 10 | local lru_cache = require "lru_cache" 11 | 12 | -- Create a new cache with default limit of 1000 items 13 | local my_cache = lru_cache.new() 14 | 15 | -- Or with a custom limit 16 | local small_cache = lru_cache.new(100) 17 | 18 | -- Set a value 19 | my_cache:set("key1", "value1") 20 | 21 | -- Get a value (returns nil if not found) 22 | local value = my_cache:get("key1") 23 | 24 | -- Check if a key exists 25 | if my_cache:has("key1") then 26 | -- do something 27 | end 28 | 29 | -- Remove a key 30 | my_cache:remove("key1") 31 | 32 | -- Clear the entire cache 33 | my_cache:clear() 34 | 35 | -- Get statistics 36 | local stats = my_cache:stats() 37 | print("Cache hits:", stats.hits) 38 | print("Cache misses:", stats.misses) 39 | print("Current size:", stats.size) 40 | ]] -- Create the module 41 | local lru_cache = {} 42 | 43 | -- Create a new LRU cache 44 | function lru_cache.new(max_size) 45 | -- Default to 1000 items if not specified 46 | max_size = max_size or 1000 47 | 48 | -- Create the cache object 49 | local cache = { 50 | -- The actual cache storage - keys map to values 51 | _storage = {}, 52 | 53 | -- The linked list for LRU ordering 54 | -- Head is most recently used, tail is least recently used 55 | _head = nil, 56 | _tail = nil, 57 | 58 | -- Map keys to their nodes in the linked list for O(1) lookup 59 | _nodes = {}, 60 | 61 | -- Statistics 62 | _hits = 0, 63 | _misses = 0, 64 | 65 | -- Configuration 66 | _max_size = max_size, 67 | _current_size = 0 68 | } 69 | 70 | -- Set metatable for OO-style usage 71 | setmetatable(cache, { 72 | __index = lru_cache 73 | }) 74 | 75 | return cache 76 | end 77 | 78 | -- Create a new node for the linked list 79 | local function create_node(key, value) 80 | return { 81 | key = key, 82 | value = value, 83 | next = nil, 84 | prev = nil 85 | } 86 | end 87 | 88 | -- Add a node to the head of the list (most recently used) 89 | local function add_to_head(cache, node) 90 | if not cache._head then 91 | -- Empty list 92 | cache._head = node 93 | cache._tail = node 94 | else 95 | -- Add to head 96 | node.next = cache._head 97 | cache._head.prev = node 98 | cache._head = node 99 | end 100 | end 101 | 102 | -- Remove a node from the list 103 | local function remove_node(cache, node) 104 | if node.prev then 105 | node.prev.next = node.next 106 | else 107 | -- This was the head 108 | cache._head = node.next 109 | end 110 | 111 | if node.next then 112 | node.next.prev = node.prev 113 | else 114 | -- This was the tail 115 | cache._tail = node.prev 116 | end 117 | 118 | -- Clear node references 119 | node.next = nil 120 | node.prev = nil 121 | end 122 | 123 | -- Move a node to the head (mark as recently used) 124 | local function move_to_head(cache, node) 125 | if cache._head == node then 126 | -- Already at head 127 | return 128 | end 129 | 130 | -- Remove from current position 131 | remove_node(cache, node) 132 | 133 | -- Add to head 134 | add_to_head(cache, node) 135 | end 136 | 137 | -- Remove the least recently used item (tail) 138 | local function remove_tail(cache) 139 | if not cache._tail then 140 | return nil 141 | end 142 | 143 | local tail = cache._tail 144 | remove_node(cache, tail) 145 | 146 | return tail 147 | end 148 | 149 | -- Check if a key exists in the cache 150 | function lru_cache:has(key) 151 | return self._nodes[key] ~= nil 152 | end 153 | 154 | -- Get a value from the cache 155 | function lru_cache:get(key) 156 | local node = self._nodes[key] 157 | 158 | if not node then 159 | self._misses = self._misses + 1 160 | return nil 161 | end 162 | 163 | -- Move to front (mark as recently used) 164 | move_to_head(self, node) 165 | 166 | self._hits = self._hits + 1 167 | return node.value 168 | end 169 | 170 | -- Set a value in the cache 171 | function lru_cache:set(key, value) 172 | -- Check if key already exists 173 | local node = self._nodes[key] 174 | 175 | if node then 176 | -- Update existing node 177 | node.value = value 178 | move_to_head(self, node) 179 | return 180 | end 181 | 182 | -- Check if we need to evict 183 | if self._current_size >= self._max_size then 184 | -- Remove the least recently used item 185 | local tail = remove_tail(self) 186 | if tail then 187 | self._nodes[tail.key] = nil 188 | self._storage[tail.key] = nil 189 | self._current_size = self._current_size - 1 190 | end 191 | end 192 | 193 | -- Create new node 194 | local new_node = create_node(key, value) 195 | 196 | -- Add to data structures 197 | self._nodes[key] = new_node 198 | self._storage[key] = value 199 | add_to_head(self, new_node) 200 | 201 | -- Increment size 202 | self._current_size = self._current_size + 1 203 | end 204 | 205 | -- Remove a key from the cache 206 | function lru_cache:remove(key) 207 | local node = self._nodes[key] 208 | 209 | if not node then 210 | return false 211 | end 212 | 213 | -- Remove from linked list 214 | remove_node(self, node) 215 | 216 | -- Remove from data structures 217 | self._nodes[key] = nil 218 | self._storage[key] = nil 219 | 220 | -- Decrement size 221 | self._current_size = self._current_size - 1 222 | 223 | return true 224 | end 225 | 226 | -- Clear the entire cache 227 | function lru_cache:clear() 228 | self._storage = {} 229 | self._nodes = {} 230 | self._head = nil 231 | self._tail = nil 232 | self._current_size = 0 233 | 234 | -- We don't reset statistics 235 | end 236 | 237 | -- Reset statistics 238 | function lru_cache:reset_stats() 239 | self._hits = 0 240 | self._misses = 0 241 | end 242 | 243 | -- Get cache statistics 244 | function lru_cache:stats() 245 | return { 246 | hits = self._hits, 247 | misses = self._misses, 248 | size = self._current_size, 249 | max_size = self._max_size, 250 | hit_ratio = self._hits / math.max(1, (self._hits + self._misses)) 251 | } 252 | end 253 | 254 | -- Create a memoized version of a function using this cache 255 | function lru_cache:memoize(func) 256 | return function(...) 257 | -- Create a key from the arguments 258 | local args = {...} 259 | local key = "" 260 | 261 | for i, arg in ipairs(args) do 262 | -- Simple serialization for basic types 263 | if type(arg) == "table" then 264 | -- For tables, we use a simple recursive approach 265 | -- This won't handle cycles or complex objects well 266 | key = key .. "T" .. tostring(i) .. "{" 267 | for k, v in pairs(arg) do 268 | key = key .. tostring(k) .. ":" .. tostring(v) .. "," 269 | end 270 | key = key .. "}" 271 | else 272 | key = key .. tostring(arg) .. "|" 273 | end 274 | end 275 | 276 | -- Check cache 277 | local result = self:get(key) 278 | if result ~= nil then 279 | return result 280 | end 281 | 282 | -- Calculate, cache, and return 283 | result = func(...) 284 | self:set(key, result) 285 | return result 286 | end 287 | end 288 | 289 | -- Return a serialized version of a key suitable for caching 290 | function lru_cache.key_maker(...) 291 | local args = {...} 292 | local key = "" 293 | 294 | for i, arg in ipairs(args) do 295 | if type(arg) == "table" then 296 | key = key .. "T" .. tostring(i) .. "{" 297 | -- Sort keys for consistent serialization 298 | local sorted_keys = {} 299 | for k in pairs(arg) do 300 | table.insert(sorted_keys, k) 301 | end 302 | table.sort(sorted_keys) 303 | 304 | for _, k in ipairs(sorted_keys) do 305 | local v = arg[k] 306 | key = key .. tostring(k) .. ":" .. tostring(v) .. "," 307 | end 308 | key = key .. "}" 309 | else 310 | key = key .. tostring(arg) .. "|" 311 | end 312 | end 313 | 314 | return key 315 | end 316 | 317 | -- Return the module 318 | return lru_cache 319 | -------------------------------------------------------------------------------- /modules/pomodoor.lua: -------------------------------------------------------------------------------- 1 | --- Pomodoro module 2 | -------------------------------------------------------------------------------- 3 | local config = require "config" 4 | 5 | local pom = {} 6 | pom.bar = { 7 | indicator_height = config.pomodoro.indicator_height, 8 | indicator_alpha = config.pomodoro.indicator_alpha, 9 | indicator_in_all_spaces = config.pomodoro.indicator_in_all_spaces, 10 | color_time_remaining = config.pomodoro.color_time_remaining, 11 | color_time_used = config.pomodoro.color_time_used, 12 | 13 | c_left = hs.drawing.rectangle(hs.geometry.rect(0, 0, 0, 0)), 14 | c_used = hs.drawing.rectangle(hs.geometry.rect(0, 0, 0, 0)) 15 | } 16 | 17 | pom.config = { 18 | enable_color_bar = config.pomodoro.enable_color_bar, 19 | work_period_sec = config.pomodoro.work_period_sec, 20 | rest_period_sec = config.pomodoro.rest_period_sec 21 | } 22 | 23 | pom.var = { 24 | is_active = false, 25 | disable_count = 0, 26 | work_count = 0, 27 | curr_active_type = "work", -- {"work", "rest"} 28 | time_left = pom.config.work_period_sec, 29 | max_time_sec = pom.config.work_period_sec 30 | } 31 | 32 | -------------------------------------------------------------------------------- 33 | -- Color bar for pomodoor 34 | -------------------------------------------------------------------------------- 35 | 36 | local function pom_del_indicators() 37 | pom.bar.c_left:delete() 38 | pom.bar.c_used:delete() 39 | end 40 | 41 | local function pom_draw_on_menu(target_draw, screen, offset, width, fill_color) 42 | local screeng = screen:fullFrame() 43 | local screen_frame_height = screen:frame().y 44 | local screen_full_frame_height = screeng.y 45 | local height_delta = screen_frame_height - screen_full_frame_height 46 | local height = pom.bar.indicator_height * (height_delta) 47 | 48 | target_draw:setSize(hs.geometry.rect(screeng.x + offset, screen_full_frame_height, width, height)) 49 | target_draw:setTopLeft(hs.geometry.point(screeng.x + offset, screen_full_frame_height)) 50 | target_draw:setFillColor(fill_color) 51 | target_draw:setFill(true) 52 | target_draw:setAlpha(pom.bar.indicator_alpha) 53 | target_draw:setLevel(hs.drawing.windowLevels.overlay) 54 | target_draw:setStroke(false) 55 | if pom.bar.indicator_in_all_spaces then 56 | target_draw:setBehavior(hs.drawing.windowBehaviors.canJoinAllSpaces) 57 | end 58 | target_draw:show() 59 | end 60 | 61 | local function pom_draw_indicator(time_left, max_time) 62 | local main_screen = hs.screen.mainScreen() 63 | local screeng = main_screen:fullFrame() 64 | 65 | local time_ratio = time_left / max_time 66 | local width = math.ceil(screeng.w * time_ratio) 67 | local left_width = screeng.w - width 68 | 69 | pom_draw_on_menu(pom.bar.c_left, main_screen, left_width, width, pom.bar.color_time_remaining) 70 | pom_draw_on_menu(pom.bar.c_used, main_screen, 0, left_width, pom.bar.color_time_used) 71 | 72 | end 73 | -------------------------------------------------------------------------------- 74 | 75 | -- update display 76 | local function pom_update_display() 77 | local time_min = math.floor((pom.var.time_left / 60)) 78 | local time_sec = pom.var.time_left - (time_min * 60) 79 | local str = string.format("[%s|%02d:%02d|#%02d]", pom.var.curr_active_type, time_min, time_sec, pom.var.work_count) 80 | pom_menu:setTitle(str) 81 | end 82 | 83 | -- stop the clock 84 | -- Stateful: 85 | -- * Disabling once will pause the countdown 86 | -- * Disabling twice will reset the countdown 87 | -- * Disabling trice will shut down and hide the pomodoro timer 88 | function pom.disable() 89 | local pom_was_active = pom.var.is_active 90 | pom.var.is_active = false 91 | 92 | if (pom.var.disable_count == 0) then 93 | if (pom_was_active) then 94 | pom_timer:stop() 95 | end 96 | elseif (pom.var.disable_count == 1) then 97 | pom.var.time_left = pom.config.work_period_sec 98 | pom.var.curr_active_type = "work" 99 | pom_update_display() 100 | elseif (pom.var.disable_count >= 2) then 101 | if pom_menu == nil then 102 | pom.var.disable_count = 2 103 | return 104 | end 105 | 106 | pom_menu:delete() 107 | pom_menu = nil 108 | pom_timer:stop() 109 | pom_timer = nil 110 | pom_del_indicators() 111 | end 112 | 113 | pom.var.disable_count = pom.var.disable_count + 1 114 | end 115 | 116 | -- update pomodoro timer 117 | local function pom_update_time() 118 | if pom.var.is_active == false then 119 | return 120 | else 121 | pom.var.time_left = pom.var.time_left - 1 122 | 123 | if (pom.var.time_left <= 0) then 124 | pom.disable() 125 | if pom.var.curr_active_type == "work" then 126 | hs.alert.show("Work Complete!", 2) 127 | pom.var.work_count = pom.var.work_count + 1 128 | pom.var.curr_active_type = "rest" 129 | pom.var.time_left = pom.config.rest_period_sec 130 | pom.var.max_time_sec = pom.config.rest_period_sec 131 | else 132 | hs.alert.show("Done resting", 2) 133 | pom.var.curr_active_type = "work" 134 | pom.var.time_left = pom.config.work_period_sec 135 | pom.var.max_time_sec = pom.config.work_period_sec 136 | end 137 | end 138 | 139 | -- draw color bar indicator, if enabled. 140 | if (pom.config.enable_color_bar == true) then 141 | pom_draw_indicator(pom.var.time_left, pom.var.max_time_sec) 142 | end 143 | end 144 | end 145 | 146 | -- update menu display 147 | local function pom_update_menu() 148 | pom_update_time() 149 | pom_update_display() 150 | end 151 | 152 | local function pom_create_menu(pom_origin) 153 | if pom_menu == nil then 154 | pom_menu = hs.menubar.new() 155 | pom.bar.c_left = hs.drawing.rectangle(hs.geometry.rect(0, 0, 0, 0)) 156 | pom.bar.c_used = hs.drawing.rectangle(hs.geometry.rect(0, 0, 0, 0)) 157 | end 158 | end 159 | 160 | -- start the pomodoro timer 161 | function pom.enable() 162 | pom.var.disable_count = 0; 163 | if (pom.var.is_active) then 164 | return 165 | end 166 | 167 | pom_create_menu() 168 | pom_timer = hs.timer.new(1, pom_update_menu) 169 | 170 | pom.var.is_active = true 171 | pom_timer:start() 172 | end 173 | 174 | -- reset work count 175 | function pom.reset_work() 176 | pom.var.work_count = 0; 177 | end 178 | 179 | -- Return the module 180 | return pom 181 | -------------------------------------------------------------------------------- /modules/tiler.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Simplified Zone Tiler for Hammerspoon 3 | ==================================== 4 | 5 | Hierarchy: Monitor → Zone → Tile 6 | - Each monitor has unique stable ID 7 | - Each zone is a collection of tiles (window positions) 8 | - Cross-monitor movement preserves zone+tile or uses cache 9 | ]] local config = require "config" 10 | local tiler = {} 11 | 12 | -- Window memory integration 13 | local window_memory = nil 14 | 15 | -- Debug logging 16 | local function debug_log(...) 17 | if tiler.debug then 18 | local args = {...} 19 | local message = table.concat(args, " ") 20 | print("[Tiler] " .. message) 21 | end 22 | end 23 | 24 | -- State variable for managing focus cycling 25 | local current_focus_cycle_manager = { 26 | zone_key = nil, -- The zone key for the active cycle (e.g., "h") 27 | monitor_id_logical = nil, -- The logical monitor ID for the cycle 28 | window_ids_in_order = {}, -- An ordered array of window IDs for the current cycle 29 | current_idx_in_cycle_list = 0 -- 0 means "uninitialized" or "before the first window" 30 | -- Otherwise, it's the 1-based index of the last window focused in this cycle 31 | } 32 | 33 | ------------------------------------------ 34 | -- Smart placement 35 | ------------------------------------------ 36 | 37 | -- Smart placement module 38 | local smart_placement = {} 39 | 40 | -- Compute distance map for empty space finding 41 | function smart_placement.compute_distance_map(screen, cell_size) 42 | local screen_frame = screen:frame() 43 | local grid_width = math.ceil(screen_frame.w / cell_size) 44 | local grid_height = math.ceil(screen_frame.h / cell_size) 45 | 46 | -- Initialize grid 47 | local grid = {} 48 | for i = 1, grid_height do 49 | grid[i] = {} 50 | for j = 1, grid_width do 51 | grid[i][j] = 0 -- 0 = empty 52 | end 53 | end 54 | 55 | -- Mark occupied cells 56 | for _, win in pairs(hs.window.allWindows()) do 57 | if win:screen():id() == screen:id() and win:isStandard() then 58 | local frame = win:frame() 59 | local x1 = math.floor((frame.x - screen_frame.x) / cell_size) + 1 60 | local y1 = math.floor((frame.y - screen_frame.y) / cell_size) + 1 61 | local x2 = math.ceil((frame.x + frame.w - screen_frame.x) / cell_size) 62 | local y2 = math.ceil((frame.y + frame.h - screen_frame.y) / cell_size) 63 | 64 | for i = math.max(1, y1), math.min(grid_height, y2) do 65 | for j = math.max(1, x1), math.min(grid_width, x2) do 66 | grid[i][j] = 1 -- 1 = occupied 67 | end 68 | end 69 | end 70 | end 71 | 72 | -- BFS to compute distances from occupied cells 73 | local distance = {} 74 | local queue = {} 75 | 76 | for i = 1, grid_height do 77 | distance[i] = {} 78 | for j = 1, grid_width do 79 | if grid[i][j] == 1 then 80 | distance[i][j] = 0 81 | table.insert(queue, {i, j}) 82 | else 83 | distance[i][j] = -1 -- -1 = unvisited 84 | end 85 | end 86 | end 87 | 88 | local directions = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}} 89 | local head = 1 90 | while head <= #queue do 91 | local cell = queue[head] 92 | head = head + 1 93 | local i, j = cell[1], cell[2] 94 | 95 | for _, dir in pairs(directions) do 96 | local ni, nj = i + dir[1], j + dir[2] 97 | if ni >= 1 and ni <= grid_height and nj >= 1 and nj <= grid_width and distance[ni][nj] == -1 then 98 | distance[ni][nj] = distance[i][j] + 1 99 | table.insert(queue, {ni, nj}) 100 | end 101 | end 102 | end 103 | 104 | return distance, screen_frame 105 | end 106 | 107 | -- Find best position for a window 108 | function smart_placement.find_best_position(screen, window_width, window_height) 109 | local cell_size = config.tiler.smart_placement and config.tiler.smart_placement.cell_size or 50 110 | local distance_map, screen_frame = smart_placement.compute_distance_map(screen, cell_size) 111 | 112 | local grid_width = math.ceil(screen_frame.w / cell_size) 113 | local grid_height = math.ceil(screen_frame.h / cell_size) 114 | local window_grid_width = math.ceil(window_width / cell_size) 115 | local window_grid_height = math.ceil(window_height / cell_size) 116 | 117 | local best_score = -1 118 | local best_pos = { 119 | x = screen_frame.x + 100, -- Default fallback x 120 | y = screen_frame.y + 100 -- Default fallback y 121 | } 122 | 123 | for i = 1, math.max(1, grid_height - window_grid_height + 1) do 124 | for j = 1, math.max(1, grid_width - window_grid_width + 1) do 125 | local min_distance_in_rect = math.huge 126 | local covered_occupied_cell = false 127 | 128 | for di = 0, window_grid_height - 1 do 129 | for dj = 0, window_grid_width - 1 do 130 | if i + di <= grid_height and j + dj <= grid_width then 131 | local dist = distance_map[i + di][j + dj] 132 | if dist == 0 then -- This cell is occupied 133 | covered_occupied_cell = true 134 | break 135 | end 136 | if dist < min_distance_in_rect then 137 | min_distance_in_rect = dist 138 | end 139 | end 140 | end 141 | if covered_occupied_cell then 142 | break 143 | end 144 | end 145 | 146 | if not covered_occupied_cell and min_distance_in_rect > best_score then 147 | best_score = min_distance_in_rect 148 | best_pos = { 149 | x = screen_frame.x + (j - 1) * cell_size, 150 | y = screen_frame.y + (i - 1) * cell_size 151 | } 152 | end 153 | end 154 | end 155 | debug_log("Smart placement best score:", best_score, "at x:", best_pos.x, "y:", best_pos.y) 156 | return best_pos 157 | end 158 | 159 | -- Place window smartly 160 | function smart_placement.place_window(window) 161 | if not window or not window:isStandard() then 162 | return false 163 | end 164 | 165 | if not config.tiler.smart_placement or not config.tiler.smart_placement.enabled then 166 | return false 167 | end 168 | 169 | -- Exclude configured apps from smart placement 170 | if config.tiler.smart_placement.exclude_apps then 171 | local app_name = window:application():name() 172 | for _, excluded_app in ipairs(config.tiler.smart_placement.exclude_apps) do 173 | if app_name == excluded_app then 174 | debug_log("Skipping smart placement for excluded app:", app_name) 175 | return false 176 | end 177 | end 178 | end 179 | 180 | local screen = window:screen() 181 | if not screen then 182 | return false 183 | end 184 | 185 | -- Skip if window is already in a tiler-managed zone 186 | if window_state and window_state.get and window_state.get(window:id()) then 187 | debug_log("Skipping smart placement, window already in a zone:", window:application():name()) 188 | return false 189 | end 190 | 191 | local frame = window:frame() 192 | local pos = smart_placement.find_best_position(screen, frame.w, frame.h) 193 | 194 | window:setFrame({ 195 | x = pos.x, 196 | y = pos.y, 197 | w = frame.w, -- Keep original width 198 | h = frame.h -- Keep original height 199 | }) 200 | 201 | debug_log("Smart placed window", window:application():name(), "at x:", pos.x, "y:", pos.y) 202 | return true 203 | end 204 | 205 | ------------------------------------------ 206 | -- Monitor Management 207 | ------------------------------------------ 208 | 209 | local monitors = { 210 | -- Stable monitor IDs that persist across reconnections 211 | registry = {}, -- monitor_key -> {id, name, frame, logical_id} 212 | next_logical_id = 1 213 | } 214 | 215 | -- Generate stable monitor key from screen properties 216 | local function get_monitor_key(screen) 217 | local frame = screen:frame() 218 | local name = screen:name() 219 | -- Use position + resolution + name for stable identification 220 | return string.format("%s_%.0f_%.0f_%dx%d", name:gsub("[%s%-]", "_"), frame.x, frame.y, frame.w, frame.h) 221 | end 222 | 223 | -- Get or create stable monitor ID 224 | function monitors.get_id(screen) 225 | local key = get_monitor_key(screen) 226 | 227 | if not monitors.registry[key] then 228 | monitors.registry[key] = { 229 | system_id = screen:id(), 230 | name = screen:name(), 231 | frame = screen:frame(), 232 | logical_id = monitors.next_logical_id, 233 | key = key 234 | } 235 | monitors.next_logical_id = monitors.next_logical_id + 1 236 | debug_log("Registered new monitor:", key, "logical_id:", monitors.registry[key].logical_id) 237 | else 238 | -- Update system ID and frame in case it changed (e.g. screen arrangement, resolution) 239 | monitors.registry[key].system_id = screen:id() 240 | monitors.registry[key].frame = screen:frame() 241 | monitors.registry[key].name = screen:name() -- Name might change too 242 | end 243 | 244 | return monitors.registry[key].logical_id 245 | end 246 | 247 | -- Get screen by monitor ID 248 | function monitors.get_screen(monitor_id) 249 | for _, data in pairs(monitors.registry) do 250 | if data.logical_id == monitor_id then 251 | -- Find current screen with this system ID 252 | for _, screen_obj in ipairs(hs.screen.allScreens()) do 253 | if screen_obj:id() == data.system_id then 254 | return screen_obj 255 | end 256 | end 257 | -- If not found by system_id, try to find by key 258 | for _, screen_obj in ipairs(hs.screen.allScreens()) do 259 | if get_monitor_key(screen_obj) == data.key then 260 | debug_log("Found monitor", monitor_id, "by key after system_id mismatch. Updating registry.") 261 | -- Update the registry with the new system_id 262 | monitors.registry[data.key].system_id = screen_obj:id() 263 | monitors.registry[data.key].frame = screen_obj:frame() 264 | monitors.registry[data.key].name = screen_obj:name() 265 | return screen_obj 266 | end 267 | end 268 | debug_log("Could not find screen for monitor_id:", monitor_id, "system_id:", data.system_id) 269 | return nil 270 | end 271 | end 272 | debug_log("Monitor ID not found in registry:", monitor_id) 273 | return nil 274 | end 275 | 276 | -- Expose monitors for window_memory 277 | tiler.monitors = monitors 278 | 279 | ------------------------------------------ 280 | -- Zone and Tile Management 281 | ------------------------------------------ 282 | 283 | local zones = { 284 | -- Zone templates: zone_key -> tile_definitions[] 285 | templates = {}, 286 | 287 | -- Active zones: monitor_id -> zone_key -> tiles[] 288 | by_monitor = {} 289 | } 290 | 291 | -- Create tile from grid coordinates or named position 292 | local function create_tile(screen, coords, rows, cols) 293 | local frame = screen:frame() 294 | local w, h, x, y = frame.w, frame.h, frame.x, frame.y 295 | 296 | -- Handle named positions 297 | if type(coords) == "string" then 298 | if coords == "full" then 299 | return { 300 | x = x, 301 | y = y, 302 | w = w, 303 | h = h 304 | } 305 | elseif coords == "center" then 306 | return { 307 | x = x + w / 4, 308 | y = y + h / 4, 309 | w = w / 2, 310 | h = h / 2 311 | } 312 | elseif coords == "left-half" then 313 | return { 314 | x = x, 315 | y = y, 316 | w = w / 2, 317 | h = h 318 | } 319 | elseif coords == "right-half" then 320 | return { 321 | x = x + w / 2, 322 | y = y, 323 | w = w / 2, 324 | h = h 325 | } 326 | elseif coords == "top-half" then 327 | return { 328 | x = x, 329 | y = y, 330 | w = w, 331 | h = h / 2 332 | } 333 | elseif coords == "bottom-half" then 334 | return { 335 | x = x, 336 | y = y + h / 2, 337 | w = w, 338 | h = h / 2 339 | } 340 | end 341 | 342 | -- Parse grid coordinates like "a1:b2" or "a1" 343 | local col_start_char, row_start_str, col_end_char, row_end_str = coords:match( 344 | "([a-z])([0-9]+):?([a-z]?)([0-9]*)") 345 | if col_start_char and row_start_str then 346 | local col_start = string.byte(col_start_char) - string.byte('a') + 1 347 | local row_start = tonumber(row_start_str) 348 | local col_end = col_end_char ~= "" and (string.byte(col_end_char) - string.byte('a') + 1) or col_start 349 | local row_end = row_end_str ~= "" and tonumber(row_end_str) or row_start 350 | 351 | local col_width = w / cols 352 | local row_height = h / rows 353 | local tile_x = x + (col_start - 1) * col_width 354 | local tile_y = y + (row_start - 1) * row_height 355 | local tile_w = (col_end - col_start + 1) * col_width 356 | local tile_h = (row_end - row_start + 1) * row_height 357 | 358 | if tiler.margins and tiler.margins.enabled then 359 | local margin = tiler.margins.size or 0 360 | local apply_left_margin = (col_start == 1 and tiler.margins.screen_edge) or (col_start > 1) 361 | local apply_top_margin = (row_start == 1 and tiler.margins.screen_edge) or (row_start > 1) 362 | local apply_right_margin = (col_end == cols and tiler.margins.screen_edge) or (col_end < cols) 363 | local apply_bottom_margin = (row_end == rows and tiler.margins.screen_edge) or (row_end < rows) 364 | 365 | if apply_left_margin then 366 | tile_x = tile_x + margin 367 | tile_w = tile_w - margin 368 | end 369 | if apply_top_margin then 370 | tile_y = tile_y + margin 371 | tile_h = tile_h - margin 372 | end 373 | if apply_right_margin then 374 | tile_w = tile_w - margin 375 | end 376 | if apply_bottom_margin then 377 | tile_h = tile_h - margin 378 | end 379 | end 380 | return { 381 | x = tile_x, 382 | y = tile_y, 383 | w = tile_w, 384 | h = tile_h 385 | } 386 | end 387 | end 388 | debug_log("Could not create tile for coords:", coords, "on screen", screen:name()) 389 | return nil 390 | end 391 | 392 | -- Get zone layout for screen 393 | local function get_zone_layout_config(screen) 394 | local frame = screen:frame() 395 | local name = screen:name() 396 | local is_portrait = frame.h > frame.w 397 | 398 | -- Check custom screens first 399 | if config.tiler.custom_screens then 400 | for screen_name_pattern, custom_config in pairs(config.tiler.custom_screens) do 401 | -- Allow exact match or pattern match for custom_screens key 402 | if name == screen_name_pattern or name:match(screen_name_pattern) then 403 | debug_log("Using custom screen layout for:", name, "->", custom_config.layout) 404 | return config.tiler.grids[custom_config.layout], custom_config.layout 405 | end 406 | end 407 | end 408 | 409 | -- Check pattern matches 410 | if config.tiler.screen_detection and config.tiler.screen_detection.patterns then 411 | for pattern, layout_key in pairs(config.tiler.screen_detection.patterns) do 412 | if name:match(pattern) then 413 | debug_log("Screen name", name, "matched pattern", pattern, "-> using layout", layout_key) 414 | return config.tiler.grids[layout_key], layout_key 415 | end 416 | end 417 | end 418 | 419 | -- Default based on resolution and orientation 420 | local layout_key 421 | if is_portrait then 422 | if config.tiler.screen_detection and config.tiler.screen_detection.portrait then 423 | if frame.h >= (config.tiler.screen_detection.portrait.large and 424 | config.tiler.screen_detection.portrait.large.min_height_for_layout_check or 2000) then 425 | layout_key = config.tiler.screen_detection.portrait.large.layout 426 | else 427 | layout_key = config.tiler.screen_detection.portrait.small.layout 428 | end 429 | else -- Fallback if portrait config is missing 430 | layout_key = frame.w >= 1440 and "1x3" or "1x2" 431 | end 432 | else 433 | local aspect_ratio = frame.w / frame.h 434 | if frame.w >= 3840 then 435 | layout_key = "4x3" 436 | elseif frame.w >= 3440 or aspect_ratio > 2.0 then 437 | layout_key = "4x3" 438 | elseif frame.w >= 2560 then 439 | layout_key = "3x3" 440 | elseif frame.w >= 1920 then 441 | layout_key = "3x2" 442 | else 443 | layout_key = "2x2" 444 | end 445 | end 446 | debug_log("Using default layout for screen:", name, "->", layout_key, "Portrait:", is_portrait) 447 | return config.tiler.grids[layout_key], layout_key 448 | end 449 | 450 | -- Initialize zones for a monitor 451 | function zones.create_for_monitor(monitor_id, screen) 452 | local grid_config, layout_key = get_zone_layout_config(screen) 453 | 454 | if not grid_config or not layout_key then 455 | debug_log("Failed to get layout for monitor", monitor_id, screen:name(), "- using default 2x2.") 456 | grid_config = config.tiler.grids["2x2"] 457 | layout_key = "2x2" 458 | end 459 | 460 | local rows = grid_config.rows 461 | local cols = grid_config.cols 462 | 463 | zones.by_monitor[monitor_id] = {} 464 | debug_log("Creating zones for monitor", monitor_id, screen:name(), "layout_key:", layout_key, "grid:", 465 | cols .. "x" .. rows) 466 | 467 | local zone_definitions = config.tiler.layouts[layout_key] or config.tiler.layouts["default"] 468 | 469 | if not zone_definitions then 470 | debug_log("No zone definitions found for layout_key:", layout_key, "- using default definitions.") 471 | zone_definitions = config.tiler.layouts["default"] 472 | end 473 | if not zone_definitions then 474 | debug_log("CRITICAL: No default zone definitions found in config.tiler.layouts. Zones will not be created.") 475 | return 476 | end 477 | 478 | for zone_key, tile_coords_array in pairs(zone_definitions) do 479 | if zone_key ~= "default" then -- "default" itself is not a zone key for hotkeys 480 | local tiles_for_this_zone = {} 481 | for _, coords_str in ipairs(tile_coords_array) do 482 | local tile = create_tile(screen, coords_str, rows, cols) 483 | if tile then 484 | table.insert(tiles_for_this_zone, tile) 485 | end 486 | end 487 | if #tiles_for_this_zone > 0 then 488 | zones.by_monitor[monitor_id][zone_key] = tiles_for_this_zone 489 | else 490 | debug_log("No tiles created for zone", zone_key, "on monitor", monitor_id, "for layout", layout_key) 491 | end 492 | end 493 | end 494 | end 495 | 496 | -- Get zone for monitor 497 | function zones.get(monitor_id, zone_key) 498 | if zones.by_monitor[monitor_id] and zones.by_monitor[monitor_id][zone_key] then 499 | return zones.by_monitor[monitor_id][zone_key] 500 | end 501 | return nil 502 | end 503 | 504 | ------------------------------------------ 505 | -- Window State Management 506 | ------------------------------------------ 507 | 508 | window_state = { 509 | -- window_id -> {monitor_id, zone_key, tile_index} 510 | positions = {}, 511 | 512 | -- app_name -> monitor_id -> {zone_key, tile_index} 513 | app_memory = {} 514 | } 515 | 516 | -- Set window position 517 | function window_state.set(window_id, monitor_id, zone_key, tile_index) 518 | window_state.positions[window_id] = { 519 | monitor_id = monitor_id, 520 | zone_key = zone_key, 521 | tile_index = tile_index 522 | } 523 | -- App memory update 524 | local window = hs.window.get(window_id) 525 | if window then 526 | local app_name = window:application():name() 527 | if not window_state.app_memory[app_name] then 528 | window_state.app_memory[app_name] = {} 529 | end 530 | window_state.app_memory[app_name][monitor_id] = { 531 | zone_key = zone_key, 532 | tile_index = tile_index 533 | } 534 | 535 | -- Notify window_memory if available 536 | if window_memory and window_memory.on_window_positioned then 537 | window_memory.on_window_positioned(window, monitor_id, zone_key, tile_index) 538 | end 539 | end 540 | end 541 | 542 | -- Get window position 543 | function window_state.get(window_id) 544 | return window_state.positions[window_id] 545 | end 546 | 547 | -- Get remembered position for app on monitor 548 | function window_state.get_app_memory(app_name, monitor_id) 549 | return window_state.app_memory[app_name] and window_state.app_memory[app_name][monitor_id] 550 | end 551 | 552 | -- Clean up window state 553 | function window_state.cleanup(window_id) 554 | debug_log("Cleaning up window state for ID:", window_id) 555 | window_state.positions[window_id] = nil 556 | end 557 | 558 | -- Expose window_state for window_memory 559 | tiler.window_state = window_state 560 | 561 | ------------------------------------------ 562 | -- Rectangle Utility Functions 563 | ------------------------------------------ 564 | local function frames_match(frame1, frame2, tolerance) 565 | tolerance = tolerance or 10 566 | if not frame1 or not frame2 then 567 | return false 568 | end 569 | return math.abs(frame1.x - frame2.x) <= tolerance and math.abs(frame1.y - frame2.y) <= tolerance and 570 | math.abs((frame1.w or frame1.width or 0) - (frame2.w or frame2.width or 0)) <= tolerance and 571 | math.abs((frame1.h or frame1.height or 0) - (frame2.h or frame2.height or 0)) <= tolerance 572 | end 573 | 574 | ------------------------------------------ 575 | -- Window Utility Functions 576 | ------------------------------------------ 577 | local function is_problem_app(app_name) 578 | if not tiler.problem_apps or not app_name then 579 | return false 580 | end 581 | local lower_app_name = app_name:lower() 582 | for _, name in ipairs(tiler.problem_apps) do 583 | if name:lower() == lower_app_name then 584 | return true 585 | end 586 | end 587 | return false 588 | end 589 | 590 | local function apply_frame(window, frame, force_screen_obj) 591 | if not window or not window:isStandard() or not frame then 592 | debug_log("apply_frame: Invalid window or frame.") 593 | return false 594 | end 595 | local valid_frame = { 596 | x = frame.x, 597 | y = frame.y, 598 | w = frame.w or frame.width, 599 | h = frame.h or frame.height 600 | } 601 | if not (type(valid_frame.x) == "number" and type(valid_frame.y) == "number" and type(valid_frame.w) == "number" and 602 | valid_frame.w > 0 and type(valid_frame.h) == "number" and valid_frame.h > 0) then 603 | debug_log("apply_frame: Invalid frame parameters - x,y,w,h must be positive numbers.", hs.inspect(valid_frame)) 604 | return false 605 | end 606 | 607 | if force_screen_obj and window:screen():id() ~= force_screen_obj:id() then 608 | debug_log("Moving window", window:application():name(), "to screen:", force_screen_obj:name()) 609 | window:moveToScreen(force_screen_obj, false, true, 0) 610 | end 611 | 612 | local saved_duration = hs.window.animationDuration 613 | hs.window.animationDuration = 0 614 | local success = window:setFrame(valid_frame) 615 | hs.window.animationDuration = saved_duration 616 | 617 | if not success then 618 | debug_log("apply_frame: setFrame failed for window", window:application():name()) 619 | end 620 | return success 621 | end 622 | 623 | -- Special handling for problem apps using accessibility API 624 | local function apply_frame_to_problem_app(window, frame, app_name) 625 | if not window or not frame then 626 | return false 627 | end 628 | 629 | debug_log("Using accessibility API for problem app:", app_name) 630 | 631 | local app = window:application() 632 | local ax_app = hs.axuielement.applicationElement(app) 633 | 634 | -- Store original settings 635 | local was_enhanced = ax_app.AXEnhancedUserInterface 636 | local original_animation_duration = hs.window.animationDuration 637 | 638 | -- Disable enhanced UI and animation 639 | ax_app.AXEnhancedUserInterface = false 640 | hs.window.animationDuration = 0 641 | 642 | -- Apply the frame 643 | window:setFrame(frame) 644 | 645 | -- Restore original settings 646 | hs.window.animationDuration = original_animation_duration 647 | ax_app.AXEnhancedUserInterface = was_enhanced 648 | 649 | return true 650 | end 651 | 652 | local function apply_tile(window, tile, screen_obj) 653 | if not window or not window:isStandard() or not tile then 654 | debug_log("apply_tile: Invalid window or tile.") 655 | return false 656 | end 657 | local app_name = window:application():name() 658 | if is_problem_app(app_name) then 659 | return apply_frame_to_problem_app(window, tile, app_name, screen_obj) 660 | else 661 | return apply_frame(window, tile, screen_obj) 662 | end 663 | end 664 | 665 | -- Move window to zone/tile 666 | function tiler.move_window_to_zone(zone_key) 667 | local window = hs.window.focusedWindow() 668 | if not window then 669 | debug_log("No focused window for move_window_to_zone"); 670 | return false 671 | end 672 | 673 | local window_id = window:id() 674 | local screen_obj = window:screen() 675 | local monitor_id = monitors.get_id(screen_obj) 676 | 677 | debug_log("Moving window", window_id, "(", window:application():name(), ") to zone", zone_key, "on monitor", 678 | monitor_id) 679 | 680 | local current_pos = window_state.get(window_id) 681 | local zone_tiles = zones.get(monitor_id, zone_key) 682 | 683 | if not zone_tiles or #zone_tiles == 0 then 684 | debug_log("No tiles found for zone", zone_key, "on monitor", monitor_id, "(", screen_obj:name(), ")") 685 | -- Try to create zones for this monitor if they are missing 686 | if not zones.by_monitor[monitor_id] then 687 | debug_log("Zones not initialized for monitor", monitor_id, screen_obj:name(), ". Initializing now.") 688 | zones.create_for_monitor(monitor_id, screen_obj) 689 | zone_tiles = zones.get(monitor_id, zone_key) 690 | if not zone_tiles or #zone_tiles == 0 then 691 | debug_log("Still no tiles after re-initialization for zone", zone_key) 692 | return false 693 | end 694 | else 695 | debug_log("Zone key", zone_key, "specifically not found for monitor", monitor_id) 696 | return false 697 | end 698 | end 699 | 700 | local tile_index_to_apply = 1 -- 1-based index 701 | if current_pos and current_pos.zone_key == zone_key and current_pos.monitor_id == monitor_id then 702 | tile_index_to_apply = (current_pos.tile_index % #zone_tiles) + 1 703 | end 704 | 705 | local tile_to_apply = zone_tiles[tile_index_to_apply] 706 | if not tile_to_apply then 707 | debug_log("Tile index", tile_index_to_apply, "out of bounds for zone", zone_key) 708 | return false 709 | end 710 | 711 | if apply_tile(window, tile_to_apply, screen_obj) then 712 | window_state.set(window_id, monitor_id, zone_key, tile_index_to_apply) 713 | debug_log("Applied tile", tile_index_to_apply, "of zone", zone_key, "to window", window:application():name()) 714 | return true 715 | end 716 | debug_log("Failed to apply tile for window", window:application():name()) 717 | return false 718 | end 719 | 720 | -- Position window from memory (called by window_memory) 721 | function tiler.position_window_from_memory(window, monitor_id, zone_key, tile_index) 722 | if not window or not window:isStandard() then 723 | return false 724 | end 725 | 726 | local screen_obj = monitors.get_screen(monitor_id) 727 | if not screen_obj then 728 | debug_log("Could not find screen for monitor ID:", monitor_id) 729 | return false 730 | end 731 | 732 | local zone_tiles = zones.get(monitor_id, zone_key) 733 | if not zone_tiles or not zone_tiles[tile_index] then 734 | debug_log("Could not find tile", tile_index, "in zone", zone_key, "on monitor", monitor_id) 735 | return false 736 | end 737 | 738 | local tile = zone_tiles[tile_index] 739 | if apply_tile(window, tile, screen_obj) then 740 | window_state.set(window:id(), monitor_id, zone_key, tile_index) 741 | debug_log("Positioned window from memory: zone", zone_key, "tile", tile_index) 742 | return true 743 | end 744 | 745 | return false 746 | end 747 | 748 | -- Move window to next/previous monitor 749 | function tiler.move_window_to_monitor(direction) 750 | local window = hs.window.focusedWindow() 751 | if not window then 752 | return false 753 | end 754 | 755 | local window_id = window:id() 756 | local app_name = window:application():name() 757 | local current_screen_obj = window:screen() 758 | local current_monitor_id = monitors.get_id(current_screen_obj) 759 | 760 | local all_screens = hs.screen.allScreens() 761 | if #all_screens < 2 then 762 | return false 763 | end 764 | 765 | local current_screen_idx_in_list = -1 766 | for i, s in ipairs(all_screens) do 767 | if s:id() == current_screen_obj:id() then 768 | current_screen_idx_in_list = i 769 | break 770 | end 771 | end 772 | if current_screen_idx_in_list == -1 then 773 | return false 774 | end 775 | 776 | local target_screen_idx 777 | if direction == "next" then 778 | target_screen_idx = (current_screen_idx_in_list % #all_screens) + 1 779 | else -- "previous" 780 | target_screen_idx = (current_screen_idx_in_list - 2 + #all_screens) % #all_screens + 1 781 | end 782 | local target_screen_obj = all_screens[target_screen_idx] 783 | local target_monitor_id = monitors.get_id(target_screen_obj) 784 | 785 | debug_log("Moving window", app_name, "from monitor", current_monitor_id, "to monitor", target_monitor_id, "(", 786 | target_screen_obj:name(), ")") 787 | 788 | -- Check if zones exist for target monitor, create if not 789 | if not zones.by_monitor[target_monitor_id] then 790 | debug_log("Initializing zones for target monitor", target_monitor_id, target_screen_obj:name()) 791 | zones.create_for_monitor(target_monitor_id, target_screen_obj) 792 | end 793 | 794 | local remembered_pos = window_state.get_app_memory(app_name, target_monitor_id) 795 | if remembered_pos then 796 | local zone_tiles = zones.get(target_monitor_id, remembered_pos.zone_key) 797 | if zone_tiles and zone_tiles[remembered_pos.tile_index] then 798 | if apply_tile(window, zone_tiles[remembered_pos.tile_index], target_screen_obj) then 799 | window_state.set(window_id, target_monitor_id, remembered_pos.zone_key, remembered_pos.tile_index) 800 | debug_log("Moved", app_name, "to remembered position on monitor", target_monitor_id) 801 | return true 802 | end 803 | end 804 | end 805 | 806 | local current_tiler_pos = window_state.get(window_id) 807 | if current_tiler_pos then 808 | local zone_tiles = zones.get(target_monitor_id, current_tiler_pos.zone_key) 809 | if zone_tiles then 810 | local tile_idx_to_try = math.min(current_tiler_pos.tile_index, #zone_tiles) 811 | if zone_tiles[tile_idx_to_try] and apply_tile(window, zone_tiles[tile_idx_to_try], target_screen_obj) then 812 | window_state.set(window_id, target_monitor_id, current_tiler_pos.zone_key, tile_idx_to_try) 813 | debug_log("Moved", app_name, "to equivalent zone/tile on monitor", target_monitor_id) 814 | return true 815 | end 816 | end 817 | end 818 | 819 | -- Last resort: move to a default zone (e.g., "0" or "j") on target monitor 820 | local default_zone_keys = {"0", "j"} 821 | for _, dz_key in ipairs(default_zone_keys) do 822 | local zone_tiles = zones.get(target_monitor_id, dz_key) 823 | if zone_tiles and zone_tiles[1] then 824 | if apply_tile(window, zone_tiles[1], target_screen_obj) then 825 | window_state.set(window_id, target_monitor_id, dz_key, 1) 826 | debug_log("Moved", app_name, "to default zone '", dz_key, "' on monitor", target_monitor_id) 827 | return true 828 | end 829 | end 830 | end 831 | 832 | -- If all else fails, just move it to the screen without tiling 833 | debug_log("Could not find suitable tile, moving window", app_name, "to screen", target_screen_obj:name(), 834 | "without tiling.") 835 | window:moveToScreen(target_screen_obj) 836 | window_state.cleanup(window_id) 837 | return true 838 | end 839 | 840 | -- Helper function to get window stacking order (Z-order) 841 | local function get_window_z_order(window) 842 | local ordered_windows = hs.window.orderedWindows() 843 | for i, w in ipairs(ordered_windows) do 844 | if w:id() == window:id() then 845 | return i -- Lower number means more on top 846 | end 847 | end 848 | return 999999 -- Should not happen for a valid window 849 | end 850 | 851 | -- Helper function to check if window is already in list 852 | local function is_window_in_list_by_id(window_id, window_list_of_structs) 853 | for _, zw_struct in ipairs(window_list_of_structs) do 854 | if zw_struct.window_id == window_id then 855 | return true 856 | end 857 | end 858 | return false 859 | end 860 | 861 | -- Helper function to collect windows for a zone 862 | local function collect_zone_windows(monitor_id, zone_key, screen_obj, zone_tiles_for_this_zone) 863 | local zone_windows_collected = {} 864 | local overlap_threshold = config.tiler.overlap_threshold or 0.5 865 | 866 | if not screen_obj then 867 | debug_log("collect_zone_windows: screen_obj is nil for monitor_id", monitor_id, "zone_key", zone_key) 868 | return zone_windows_collected 869 | end 870 | if not zone_tiles_for_this_zone or #zone_tiles_for_this_zone == 0 then 871 | debug_log("collect_zone_windows: No tiles provided for zone", zone_key, "on monitor", monitor_id) 872 | return zone_windows_collected 873 | end 874 | 875 | -- Phase 1: Add explicitly assigned windows 876 | for _, win in ipairs(hs.window.allWindows()) do 877 | if win:isStandard() and not win:isMinimized() and win:screen():id() == screen_obj:id() then 878 | local pos = window_state.get(win:id()) 879 | if pos and pos.monitor_id == monitor_id and pos.zone_key == zone_key then 880 | if not is_window_in_list_by_id(win:id(), zone_windows_collected) then 881 | table.insert(zone_windows_collected, { 882 | window = win, 883 | window_id = win:id(), 884 | app_name = win:application():name(), 885 | tile_index = pos.tile_index, 886 | explicit = true, 887 | z_order = get_window_z_order(win) 888 | }) 889 | end 890 | end 891 | end 892 | end 893 | 894 | -- Phase 2: Add windows by overlap 895 | for tile_idx, tile_frame in ipairs(zone_tiles_for_this_zone) do 896 | for _, win in ipairs(hs.window.allWindows()) do 897 | if win:isStandard() and not win:isMinimized() and win:screen():id() == screen_obj:id() then 898 | if not is_window_in_list_by_id(win:id(), zone_windows_collected) then 899 | local overlap = calculate_overlap_percentage(win:frame(), tile_frame) 900 | if overlap >= overlap_threshold then 901 | table.insert(zone_windows_collected, { 902 | window = win, 903 | window_id = win:id(), 904 | app_name = win:application():name(), 905 | tile_index = tile_idx, 906 | explicit = false, 907 | z_order = get_window_z_order(win), 908 | overlap_debug = overlap 909 | }) 910 | end 911 | end 912 | end 913 | end 914 | end 915 | return zone_windows_collected 916 | end 917 | 918 | -- Helper function to sort zone windows (for initial cycle order) 919 | local function sort_zone_windows_for_intuitive_order(zone_windows_list) 920 | table.sort(zone_windows_list, function(a, b) 921 | if a.tile_index ~= b.tile_index then 922 | return a.tile_index < b.tile_index 923 | end 924 | if a.explicit ~= b.explicit then 925 | return a.explicit 926 | end 927 | return a.z_order < b.z_order 928 | end) 929 | end 930 | 931 | -- Main focus cycling function (STATEFUL REWRITE) 932 | function tiler.focus_zone_windows(target_zone_key) 933 | local focused_window_before_call = hs.window.focusedWindow() 934 | if not focused_window_before_call then 935 | debug_log("focus_zone_windows: No focused window to start.") 936 | return false 937 | end 938 | 939 | local focused_window_id_before_call = focused_window_before_call:id() 940 | local current_screen_obj = focused_window_before_call:screen() 941 | if not current_screen_obj then 942 | debug_log("focus_zone_windows: Focused window has no screen.") 943 | return false 944 | end 945 | local current_monitor_id = monitors.get_id(current_screen_obj) 946 | 947 | debug_log(string.format("=== Focus zone '%s' on %s (%s) ===", target_zone_key, current_screen_obj:name(), 948 | current_monitor_id)) 949 | debug_log(string.format("Window focused before call: %s (ID: %s)", focused_window_before_call:application():name(), 950 | focused_window_id_before_call)) 951 | 952 | local cfcm = current_focus_cycle_manager -- shorthand 953 | 954 | -- Determine if the cycle needs to be rebuilt 955 | local needs_rebuild = false 956 | if target_zone_key ~= cfcm.zone_key or current_monitor_id ~= cfcm.monitor_id_logical or #cfcm.window_ids_in_order == 957 | 0 then 958 | needs_rebuild = true 959 | debug_log(string.format( 960 | "Rebuilding cycle: Zone/Monitor changed or cycle empty. Target Zone: %s, Current Cycle Zone: %s. Target Monitor: %s, Current Cycle Monitor: %s. Stored cycle items: %d", 961 | target_zone_key, cfcm.zone_key or "nil", current_monitor_id, cfcm.monitor_id_logical or "nil", 962 | #cfcm.window_ids_in_order)) 963 | else 964 | -- Cycle seems to be for the same zone/monitor. Check if content is still valid. 965 | local zone_tiles_for_check = zones.get(current_monitor_id, target_zone_key) 966 | if not zone_tiles_for_check or #zone_tiles_for_check == 0 then 967 | debug_log("No tiles for zone " .. target_zone_key .. " during validation. Forcing rebuild.") 968 | needs_rebuild = true 969 | else 970 | local actual_windows_in_zone_now = collect_zone_windows(current_monitor_id, target_zone_key, 971 | current_screen_obj, zone_tiles_for_check) 972 | 973 | if #actual_windows_in_zone_now ~= #cfcm.window_ids_in_order then 974 | needs_rebuild = true 975 | debug_log(string.format("Rebuilding cycle: Number of windows in zone changed. Stored: %d, Actual: %d.", 976 | #cfcm.window_ids_in_order, #actual_windows_in_zone_now)) 977 | else 978 | -- Check if the window IDs match exactly 979 | local actual_ids_set = {} 980 | for _, zw in ipairs(actual_windows_in_zone_now) do 981 | actual_ids_set[zw.window_id] = true 982 | end 983 | for _, id_in_stored_list in ipairs(cfcm.window_ids_in_order) do 984 | if not actual_ids_set[id_in_stored_list] then 985 | needs_rebuild = true 986 | debug_log("Rebuilding cycle: A window from the stored cycle (ID: " .. id_in_stored_list .. 987 | ") is no longer in the zone.") 988 | break 989 | end 990 | end 991 | end 992 | 993 | if not needs_rebuild then 994 | -- If set of windows is the same, check if user manually focused a different window WITHIN the zone 995 | local focused_is_in_zone_currently = false 996 | for _, id_in_list in ipairs(cfcm.window_ids_in_order) do 997 | if id_in_list == focused_window_id_before_call then 998 | focused_is_in_zone_currently = true; 999 | break 1000 | end 1001 | end 1002 | 1003 | if not focused_is_in_zone_currently then 1004 | needs_rebuild = true 1005 | debug_log("Rebuilding cycle: Window focused before call (ID: " .. focused_window_id_before_call .. 1006 | ") is not in the current cycle list. Forcing rebuild.") 1007 | end 1008 | end 1009 | end 1010 | end 1011 | 1012 | if needs_rebuild then 1013 | debug_log( 1014 | "Rebuilding focus cycle list for zone '" .. target_zone_key .. "' on monitor " .. current_monitor_id .. 1015 | "...") 1016 | cfcm.zone_key = target_zone_key 1017 | cfcm.monitor_id_logical = current_monitor_id 1018 | cfcm.window_ids_in_order = {} 1019 | cfcm.current_idx_in_cycle_list = 0 -- Reset index 1020 | 1021 | local zone_tiles = zones.get(current_monitor_id, target_zone_key) 1022 | if not zone_tiles or #zone_tiles == 0 then 1023 | debug_log("No tiles found for zone '" .. target_zone_key .. "' on monitor " .. current_monitor_id .. 1024 | ". Cycle cleared.") 1025 | return false 1026 | end 1027 | 1028 | local collected_windows = collect_zone_windows(current_monitor_id, target_zone_key, current_screen_obj, 1029 | zone_tiles) 1030 | if #collected_windows == 0 then 1031 | debug_log("No windows found in zone '" .. target_zone_key .. "'. Cycle cleared.") 1032 | return false 1033 | end 1034 | 1035 | sort_zone_windows_for_intuitive_order(collected_windows) 1036 | 1037 | debug_log("Sorted windows for new cycle order:") 1038 | for i, zw in ipairs(collected_windows) do 1039 | table.insert(cfcm.window_ids_in_order, zw.window_id) 1040 | debug_log(string.format(" %d: %s (ID: %s, tile %d, z:%d, %s)", i, zw.app_name, zw.window_id, zw.tile_index, 1041 | zw.z_order, zw.explicit and "explicit" or "overlap " .. (zw.overlap_debug or ""))) 1042 | end 1043 | 1044 | -- Determine starting index for the new/rebuilt cycle 1045 | local initial_focus_target_idx_in_new_list = 0 1046 | for i, id_in_list in ipairs(cfcm.window_ids_in_order) do 1047 | if id_in_list == focused_window_id_before_call then 1048 | initial_focus_target_idx_in_new_list = i 1049 | debug_log(string.format("Window focused before call (ID: %s) found at index %d in new cycle list.", 1050 | focused_window_id_before_call, i)) 1051 | break 1052 | end 1053 | end 1054 | cfcm.current_idx_in_cycle_list = initial_focus_target_idx_in_new_list 1055 | if initial_focus_target_idx_in_new_list == 0 then 1056 | debug_log( 1057 | "Window focused before call not in new cycle list. Cycle will start from the beginning of the new list.") 1058 | end 1059 | end 1060 | 1061 | if #cfcm.window_ids_in_order == 0 then 1062 | debug_log("No windows available for cycling in zone '" .. target_zone_key .. "'.") 1063 | return false 1064 | end 1065 | 1066 | -- Advance the cycle index 1067 | local num_windows_in_cycle = #cfcm.window_ids_in_order 1068 | cfcm.current_idx_in_cycle_list = (cfcm.current_idx_in_cycle_list % num_windows_in_cycle) + 1 1069 | 1070 | local window_id_to_focus = cfcm.window_ids_in_order[cfcm.current_idx_in_cycle_list] 1071 | local window_to_focus = hs.window.get(window_id_to_focus) 1072 | 1073 | if window_to_focus and window_to_focus:isStandard() and not window_to_focus:isMinimized() then 1074 | debug_log(string.format("Cycling to window %d of %d: %s (ID: %s)", cfcm.current_idx_in_cycle_list, 1075 | num_windows_in_cycle, window_to_focus:application():name(), window_id_to_focus)) 1076 | window_to_focus:focus() 1077 | 1078 | if config.tiler.flash_on_focus then 1079 | local frame = window_to_focus:frame() 1080 | local flash = hs.canvas.new(frame):appendElements({ 1081 | type = "rectangle", 1082 | action = "fill", 1083 | fillColor = { 1084 | red = 0.5, 1085 | green = 0.5, 1086 | blue = 1.0, 1087 | alpha = 0.3 1088 | } 1089 | }) 1090 | flash:show() 1091 | hs.timer.doAfter(0.2, function() 1092 | flash:delete() 1093 | end) 1094 | end 1095 | return true 1096 | else 1097 | debug_log(string.format( 1098 | "Window ID %s (at new cycle index %d) to focus was not found, invalid, or not standard. Cycle may be stale.", 1099 | window_id_to_focus, cfcm.current_idx_in_cycle_list)) 1100 | cfcm.zone_key = nil -- Force full rebuild next time 1101 | cfcm.window_ids_in_order = {} 1102 | cfcm.current_idx_in_cycle_list = 0 1103 | return false 1104 | end 1105 | end 1106 | 1107 | -- Debug function to inspect a specific zone 1108 | function tiler.debug_zone(zone_key) 1109 | local fe = hs.window.focusedWindow() 1110 | if not fe then 1111 | print("No focused window to determine screen"); 1112 | return 1113 | end 1114 | local screen = fe:screen() 1115 | if not screen then 1116 | print("Focused window has no screen"); 1117 | return 1118 | end 1119 | 1120 | local monitor_id = monitors.get_id(screen) 1121 | print("=== Debugging zone '" .. zone_key .. "' on " .. screen:name() .. " (Monitor Logical ID: " .. monitor_id .. 1122 | ") ===") 1123 | 1124 | local zone_tiles = zones.get(monitor_id, zone_key) 1125 | if not zone_tiles then 1126 | print("Zone '" .. zone_key .. "' not found on monitor " .. monitor_id) 1127 | if zones.by_monitor[monitor_id] then 1128 | print("\nAvailable zones on this monitor:") 1129 | for key, _ in pairs(zones.by_monitor[monitor_id]) do 1130 | print(" " .. key) 1131 | end 1132 | else 1133 | print("No zones initialized for this monitor.") 1134 | end 1135 | return 1136 | end 1137 | 1138 | print("\nZone '" .. zone_key .. "' has " .. #zone_tiles .. " tiles:") 1139 | for i, tile in ipairs(zone_tiles) do 1140 | print(string.format(" Tile %d: x=%.1f, y=%.1f, w=%.1f, h=%.1f", i, tile.x, tile.y, tile.w, tile.h)) 1141 | end 1142 | 1143 | local zone_windows = collect_zone_windows(monitor_id, zone_key, screen, zone_tiles) 1144 | sort_zone_windows_for_intuitive_order(zone_windows) 1145 | 1146 | print("\nWindows in zone '" .. zone_key .. "' (sorted for potential cycle): " .. #zone_windows) 1147 | for i, zw in ipairs(zone_windows) do 1148 | print(string.format(" %d: %s (ID: %s) - Tile %d, Explicit: %s, Z-Order: %d, Overlap: %.1f%%", i, zw.app_name, 1149 | zw.window_id, zw.tile_index, tostring(zw.explicit), zw.z_order, (zw.overlap_debug or 0) * 100)) 1150 | end 1151 | 1152 | print("\nCurrent Focus Cycle Manager State:") 1153 | print(hs.inspect(current_focus_cycle_manager)) 1154 | end 1155 | 1156 | -- Helper function for overlap calculation 1157 | function calculate_overlap_percentage(rect1, rect2) 1158 | if not rect1 or not rect2 or rect1.w <= 0 or rect1.h <= 0 then 1159 | return 0 1160 | end 1161 | local x_overlap = math.max(0, math.min(rect1.x + rect1.w, rect2.x + rect2.w) - math.max(rect1.x, rect2.x)) 1162 | local y_overlap = math.max(0, math.min(rect1.y + rect1.h, rect2.y + rect2.h) - math.max(rect1.y, rect2.y)) 1163 | local overlap_area = x_overlap * y_overlap 1164 | return overlap_area / (rect1.w * rect1.h) 1165 | end 1166 | 1167 | ------------------------------------------ 1168 | -- Event Handling 1169 | ------------------------------------------ 1170 | 1171 | local function handle_window_destroyed(window) 1172 | if window then 1173 | debug_log("Window destroyed:", window:id(), window:application() and window:application():name() or "N/A") 1174 | window_state.cleanup(window:id()) 1175 | -- Check if this window was part of the current focus cycle and invalidate if so 1176 | if current_focus_cycle_manager.zone_key then 1177 | local removed = false 1178 | for i = #current_focus_cycle_manager.window_ids_in_order, 1, -1 do 1179 | if current_focus_cycle_manager.window_ids_in_order[i] == window:id() then 1180 | table.remove(current_focus_cycle_manager.window_ids_in_order, i) 1181 | removed = true 1182 | break 1183 | end 1184 | end 1185 | if removed then 1186 | debug_log("Removed destroyed window from current focus cycle. Cycle list may be stale or empty.") 1187 | if #current_focus_cycle_manager.window_ids_in_order == 0 then 1188 | current_focus_cycle_manager.zone_key = nil -- Force full rebuild 1189 | current_focus_cycle_manager.current_idx_in_cycle_list = 0 1190 | elseif current_focus_cycle_manager.current_idx_in_cycle_list > 1191 | #current_focus_cycle_manager.window_ids_in_order then 1192 | current_focus_cycle_manager.current_idx_in_cycle_list = 1193 | #current_focus_cycle_manager.window_ids_in_order 1194 | end 1195 | -- Forcing a full rebuild on next focus might be safer than trying to adjust index here. 1196 | current_focus_cycle_manager.zone_key = nil 1197 | end 1198 | end 1199 | end 1200 | end 1201 | 1202 | local function handle_window_created(window) 1203 | -- Notify window_memory of new window 1204 | if window_memory and window_memory.on_window_created then 1205 | window_memory.on_window_created(window) 1206 | end 1207 | 1208 | -- Handle smart placement if enabled 1209 | if config.tiler.smart_placement and config.tiler.smart_placement.enabled then 1210 | if window and window:isStandard() then 1211 | -- Delay to allow window to fully initialize its properties 1212 | hs.timer.doAfter(0.2, function() 1213 | if window:isStandard() then -- Recheck, might have closed or changed 1214 | smart_placement.place_window(window) 1215 | end 1216 | end) 1217 | end 1218 | end 1219 | end 1220 | 1221 | local function handle_screen_change() 1222 | debug_log("Screen configuration changed") 1223 | hs.timer.doAfter(0.5, function() -- Delay to allow screens to settle 1224 | monitors.registry = {} -- Clear the old registry 1225 | monitors.next_logical_id = 1 1226 | zones.by_monitor = {} 1227 | for _, screen_obj in ipairs(hs.screen.allScreens()) do 1228 | local monitor_id = monitors.get_id(screen_obj) -- Re-register all monitors 1229 | zones.create_for_monitor(monitor_id, screen_obj) 1230 | end 1231 | -- Invalidate current focus cycle as monitor setup changed 1232 | current_focus_cycle_manager.zone_key = nil 1233 | current_focus_cycle_manager.window_ids_in_order = {} 1234 | current_focus_cycle_manager.current_idx_in_cycle_list = 0 1235 | debug_log("Reinitialized monitors and zones. Focus cycle invalidated.") 1236 | end) 1237 | end 1238 | 1239 | ------------------------------------------ 1240 | -- Initialization 1241 | ------------------------------------------ 1242 | 1243 | function tiler.start() 1244 | debug_log("Starting tiler") 1245 | 1246 | tiler.debug = config.tiler.debug 1247 | tiler.margins = config.tiler.margins 1248 | tiler.problem_apps = config.tiler.problem_apps 1249 | 1250 | for _, screen_obj in ipairs(hs.screen.allScreens()) do 1251 | local monitor_id = monitors.get_id(screen_obj) 1252 | zones.create_for_monitor(monitor_id, screen_obj) 1253 | end 1254 | 1255 | local modifier = config.tiler.modifier 1256 | local focus_modifier = config.tiler.focus_modifier 1257 | 1258 | local all_zone_keys = {} 1259 | if config.tiler.layouts then 1260 | for _, layout_config in pairs(config.tiler.layouts) do 1261 | for zone_key, _ in pairs(layout_config) do 1262 | if zone_key ~= "default" then 1263 | all_zone_keys[zone_key] = true 1264 | end 1265 | end 1266 | end 1267 | end 1268 | 1269 | local zone_key_list_for_debug = {} 1270 | for zk, _ in pairs(all_zone_keys) do 1271 | table.insert(zone_key_list_for_debug, zk) 1272 | end 1273 | debug_log("Registering hotkeys for zone keys:", table.concat(zone_key_list_for_debug, ", ")) 1274 | 1275 | for zone_key_str, _ in pairs(all_zone_keys) do 1276 | hs.hotkey.bind(modifier, zone_key_str, function() 1277 | tiler.move_window_to_zone(zone_key_str) 1278 | end) 1279 | if focus_modifier then 1280 | hs.hotkey.bind(focus_modifier, zone_key_str, function() 1281 | tiler.focus_zone_windows(zone_key_str) 1282 | end) 1283 | end 1284 | end 1285 | 1286 | hs.hotkey.bind(modifier, "p", function() 1287 | tiler.move_window_to_monitor("next") 1288 | end) 1289 | hs.hotkey.bind(modifier, ";", function() 1290 | tiler.move_window_to_monitor("previous") 1291 | end) 1292 | 1293 | -- Watch for window events 1294 | local window_filter_events = {hs.window.filter.windowDestroyed, hs.window.filter.windowCreated} 1295 | local window_watcher = hs.window.filter.new(window_filter_events) 1296 | window_watcher:subscribe(hs.window.filter.windowDestroyed, handle_window_destroyed) 1297 | window_watcher:subscribe(hs.window.filter.windowCreated, handle_window_created) 1298 | 1299 | local screen_watcher = hs.screen.watcher.new(handle_screen_change):start() 1300 | 1301 | debug_log("Tiler started successfully") 1302 | return tiler 1303 | end 1304 | 1305 | -- Set window_memory reference (called from init) 1306 | function tiler.set_window_memory(wm) 1307 | window_memory = wm 1308 | debug_log("Window memory integration enabled") 1309 | end 1310 | 1311 | return tiler 1312 | -------------------------------------------------------------------------------- /modules/window_memory.lua: -------------------------------------------------------------------------------- 1 | -- window_memory.lua 2 | -- Simple window position memory: load on startup, save on shutdown, auto-position new windows 3 | local window_memory = {} 4 | local config = require "config" 5 | local json = require "hs.json" 6 | 7 | -- Module state 8 | local tiler = nil -- Set during initialization 9 | local positions = {} -- app_name -> monitor_id -> {zone_key, tile_index} 10 | 11 | -- Debug logging 12 | local function debug_log(...) 13 | if window_memory.debug then 14 | local args = {...} 15 | print("[WindowMemory] " .. table.concat(args, " ")) 16 | end 17 | end 18 | 19 | -- Check if app should be excluded from memory 20 | local function is_excluded_app(app_name) 21 | if not config.window_memory or not config.window_memory.excluded_apps then 22 | return false 23 | end 24 | for _, excluded in ipairs(config.window_memory.excluded_apps) do 25 | if app_name == excluded then 26 | return true 27 | end 28 | end 29 | return false 30 | end 31 | 32 | -- Get cache filename 33 | local function get_cache_filename() 34 | local cache_dir = config.window_memory and config.window_memory.cache_dir or (os.getenv("HOME") .. "/.config/tiler") 35 | return cache_dir .. "/window_positions.json" 36 | end 37 | 38 | -- Ensure cache directory exists 39 | local function ensure_cache_dir() 40 | local cache_dir = config.window_memory and config.window_memory.cache_dir or (os.getenv("HOME") .. "/.config/tiler") 41 | os.execute("mkdir -p " .. cache_dir) 42 | end 43 | 44 | -- Load positions from disk 45 | local function load_positions() 46 | local filename = get_cache_filename() 47 | local file = io.open(filename, "r") 48 | if not file then 49 | debug_log("No existing cache file found") 50 | return 51 | end 52 | 53 | local content = file:read("*all") 54 | file:close() 55 | 56 | local success, data = pcall(function() 57 | return json.decode(content) 58 | end) 59 | 60 | if success and data and data.positions then 61 | -- Convert array back to nested structure 62 | for _, pos in ipairs(data.positions) do 63 | if not positions[pos.app_name] then 64 | positions[pos.app_name] = {} 65 | end 66 | positions[pos.app_name][pos.monitor_id] = { 67 | zone_key = pos.zone_key, 68 | tile_index = pos.tile_index 69 | } 70 | end 71 | debug_log("Loaded", #data.positions, "cached positions") 72 | else 73 | debug_log("Failed to parse cache file") 74 | end 75 | end 76 | 77 | -- Save current window positions to disk 78 | local function save_positions() 79 | debug_log("Saving all window positions...") 80 | 81 | -- Clear existing positions and rebuild from current state 82 | positions = {} 83 | 84 | -- Collect current positions from tiler's window state 85 | for _, window in ipairs(hs.window.allWindows()) do 86 | if window:isStandard() and not window:isMinimized() then 87 | local app_name = window:application():name() 88 | if not is_excluded_app(app_name) then 89 | local window_id = window:id() 90 | local pos = tiler.window_state.get(window_id) 91 | if pos and pos.zone_key and pos.tile_index then 92 | if not positions[app_name] then 93 | positions[app_name] = {} 94 | end 95 | positions[app_name][pos.monitor_id] = { 96 | zone_key = pos.zone_key, 97 | tile_index = pos.tile_index 98 | } 99 | end 100 | end 101 | end 102 | end 103 | 104 | -- Convert to array for JSON 105 | local positions_array = {} 106 | for app_name, monitors in pairs(positions) do 107 | for monitor_id, position in pairs(monitors) do 108 | table.insert(positions_array, { 109 | app_name = app_name, 110 | monitor_id = monitor_id, 111 | zone_key = position.zone_key, 112 | tile_index = position.tile_index 113 | }) 114 | end 115 | end 116 | 117 | -- Save to disk 118 | ensure_cache_dir() 119 | local filename = get_cache_filename() 120 | local data = { 121 | timestamp = os.time(), 122 | positions = positions_array 123 | } 124 | 125 | local json_str = json.encode(data) 126 | local file = io.open(filename, "w") 127 | if file then 128 | file:write(json_str) 129 | file:close() 130 | debug_log("Saved", #positions_array, "positions to disk") 131 | else 132 | debug_log("Failed to save cache file") 133 | end 134 | end 135 | 136 | -- Get remembered position for app on current monitor 137 | local function get_remembered_position(app_name, monitor_id) 138 | if is_excluded_app(app_name) then 139 | return nil 140 | end 141 | 142 | -- Check for position on current monitor first 143 | if positions[app_name] and positions[app_name][monitor_id] then 144 | return positions[app_name][monitor_id] 145 | end 146 | 147 | -- Check for position on any monitor as fallback 148 | if positions[app_name] then 149 | for _, position in pairs(positions[app_name]) do 150 | return position -- Return first found position 151 | end 152 | end 153 | 154 | return nil 155 | end 156 | 157 | -- Called by tiler when a window is positioned 158 | function window_memory.on_window_positioned(window, monitor_id, zone_key, tile_index) 159 | if not window or not window:isStandard() then 160 | return 161 | end 162 | 163 | local app_name = window:application():name() 164 | if is_excluded_app(app_name) then 165 | return 166 | end 167 | 168 | -- Store position 169 | if not positions[app_name] then 170 | positions[app_name] = {} 171 | end 172 | positions[app_name][monitor_id] = { 173 | zone_key = zone_key, 174 | tile_index = tile_index 175 | } 176 | 177 | debug_log("Remembered", app_name, "on monitor", monitor_id, "zone:", zone_key, "tile:", tile_index) 178 | end 179 | 180 | -- Called by tiler when a new window is created 181 | function window_memory.on_window_created(window) 182 | if not window or not window:isStandard() then 183 | return 184 | end 185 | 186 | local app_name = window:application():name() 187 | if is_excluded_app(app_name) then 188 | return 189 | end 190 | 191 | debug_log("New window created:", app_name) 192 | 193 | -- Wait for window to settle, then try to position it 194 | hs.timer.doAfter(0.3, function() 195 | if not window:isStandard() then 196 | return 197 | end 198 | 199 | local screen = window:screen() 200 | if not screen then 201 | return 202 | end 203 | 204 | local monitor_id = tiler.monitors.get_id(screen) 205 | 206 | -- Try remembered position first 207 | local remembered = get_remembered_position(app_name, monitor_id) 208 | 209 | if remembered then 210 | debug_log("Restoring", app_name, "to zone:", remembered.zone_key, "tile:", remembered.tile_index) 211 | tiler.position_window_from_memory(window, monitor_id, remembered.zone_key, remembered.tile_index) 212 | return 213 | end 214 | 215 | -- Try configured app zones 216 | if config.window_memory and config.window_memory.app_zones then 217 | local default_zone = config.window_memory.app_zones[app_name] 218 | if default_zone then 219 | debug_log("Using configured default zone for", app_name, ":", default_zone) 220 | window:focus() 221 | hs.timer.doAfter(0.1, function() 222 | tiler.move_window_to_zone(default_zone) 223 | end) 224 | return 225 | end 226 | end 227 | 228 | -- Try global default fallback 229 | if config.window_memory and config.window_memory.auto_tile_fallback then 230 | local default_zone = config.window_memory.default_zone or "0" 231 | debug_log("Auto-tiling", app_name, "to default zone:", default_zone) 232 | window:focus() 233 | hs.timer.doAfter(0.1, function() 234 | tiler.move_window_to_zone(default_zone) 235 | end) 236 | end 237 | end) 238 | end 239 | 240 | -- Check if window should be positioned (called by tiler) 241 | function window_memory.should_position_window(window) 242 | if not window or not window:isStandard() then 243 | return false 244 | end 245 | 246 | local app_name = window:application():name() 247 | return not is_excluded_app(app_name) 248 | end 249 | 250 | -- Save all window positions (for hotkey) 251 | function window_memory.save_all_positions() 252 | debug_log("Manually saving all window positions") 253 | save_positions() 254 | local count = 0 255 | for app_name, monitors in pairs(positions) do 256 | for _, _ in pairs(monitors) do 257 | count = count + 1 258 | end 259 | end 260 | debug_log("Saved positions for", count, "app/monitor combinations") 261 | return count 262 | end 263 | 264 | -- Restore all remembered positions (for hotkey) 265 | function window_memory.restore_all_positions() 266 | debug_log("Restoring all window positions") 267 | local count = 0 268 | 269 | for _, window in ipairs(hs.window.allWindows()) do 270 | if window:isStandard() and not window:isMinimized() then 271 | local app_name = window:application():name() 272 | if not is_excluded_app(app_name) then 273 | local screen = window:screen() 274 | if screen then 275 | local monitor_id = tiler.monitors.get_id(screen) 276 | local remembered = get_remembered_position(app_name, monitor_id) 277 | 278 | if remembered then 279 | debug_log("Restoring", app_name, "to zone:", remembered.zone_key, "tile:", remembered.tile_index) 280 | if tiler.position_window_from_memory(window, monitor_id, remembered.zone_key, 281 | remembered.tile_index) then 282 | count = count + 1 283 | end 284 | end 285 | end 286 | end 287 | end 288 | end 289 | 290 | debug_log("Restored positions for", count, "windows") 291 | return count 292 | end 293 | 294 | -- Set up hotkeys 295 | function window_memory.setup_hotkeys() 296 | if not config.window_memory or not config.window_memory.hotkeys then 297 | debug_log("No hotkey configuration found") 298 | return 299 | end 300 | 301 | -- Capture hotkey 302 | if config.window_memory.hotkeys.capture then 303 | local key = config.window_memory.hotkeys.capture[1] 304 | local mods = config.window_memory.hotkeys.capture[2] 305 | if key and mods then 306 | hs.hotkey.bind(mods, key, function() 307 | local count = window_memory.save_all_positions() 308 | hs.alert.show("Captured " .. count .. " window positions") 309 | end) 310 | debug_log("Set up capture hotkey:", table.concat(mods, "+") .. "+" .. key) 311 | end 312 | end 313 | 314 | -- Restore hotkey 315 | if config.window_memory.hotkeys.restore then 316 | local key = config.window_memory.hotkeys.restore[1] 317 | local mods = config.window_memory.hotkeys.restore[2] 318 | if key and mods then 319 | hs.hotkey.bind(mods, key, function() 320 | local count = window_memory.restore_all_positions() 321 | hs.alert.show("Restored " .. count .. " window positions") 322 | end) 323 | debug_log("Set up restore hotkey:", table.concat(mods, "+") .. "+" .. key) 324 | end 325 | end 326 | end 327 | 328 | -- Initialize window memory system 329 | function window_memory.init(tiler_module) 330 | tiler = tiler_module 331 | window_memory.debug = config.window_memory and config.window_memory.debug or false 332 | 333 | -- Set up integration with tiler 334 | tiler.set_window_memory(window_memory) 335 | 336 | -- Load positions from disk 337 | load_positions() 338 | 339 | -- Save positions on shutdown 340 | local existing_callback = hs.shutdownCallback 341 | hs.shutdownCallback = function() 342 | save_positions() 343 | if existing_callback then 344 | existing_callback() 345 | end 346 | end 347 | 348 | debug_log("Window memory system initialized") 349 | return window_memory 350 | end 351 | 352 | return window_memory 353 | --------------------------------------------------------------------------------