From 910c5da86ccaddd0b23731389716f77f2187f06a Mon Sep 17 00:00:00 2001 From: Stamatios Nicolis Date: Sat, 7 Jun 2025 14:05:54 +0200 Subject: [PATCH] Add WindowManagerPlus spoon window management spoon for Hammerspoon that provides tiling and floating window management for macOS. Automatic tiling (bsp/master/stacking), manual tiling when in floating mode. Source: https://gitlab.com/snicolis/windowmanagerplus.git --- Source/WindowManagerPlus.spoon/README.md | 216 +++ Source/WindowManagerPlus.spoon/api.lua | 403 +++++ Source/WindowManagerPlus.spoon/core.lua | 1564 ++++++++++++++++++++ Source/WindowManagerPlus.spoon/helpers.lua | 212 +++ Source/WindowManagerPlus.spoon/init.lua | 570 +++++++ Source/WindowManagerPlus.spoon/ui.lua | 264 ++++ 6 files changed, 3229 insertions(+) create mode 100644 Source/WindowManagerPlus.spoon/README.md create mode 100644 Source/WindowManagerPlus.spoon/api.lua create mode 100644 Source/WindowManagerPlus.spoon/core.lua create mode 100644 Source/WindowManagerPlus.spoon/helpers.lua create mode 100644 Source/WindowManagerPlus.spoon/init.lua create mode 100644 Source/WindowManagerPlus.spoon/ui.lua diff --git a/Source/WindowManagerPlus.spoon/README.md b/Source/WindowManagerPlus.spoon/README.md new file mode 100644 index 00000000..2f558c95 --- /dev/null +++ b/Source/WindowManagerPlus.spoon/README.md @@ -0,0 +1,216 @@ +# WindowManagerPlus + +A window management spoon for [Hammerspoon](https://www.hammerspoon.org/) that provides tiling and floating window management for macOS. + +## Overview + +WindowManagerPlus offers four different layout modes and attempts to automatically handle common window management scenarios. It includes basic floating window controls and tries to intelligently detect settings windows and dialogs. + +## Features + +- **Multiple Layout Modes**: BSP tree, Master/Stack, Stacking, and Floating layouts +- **Floating Window Management**: Corner positioning and size cycling +- **Automatic Window Detection**: Attempts to identify and handle settings windows and dialogs +- **Per-Space Configuration**: Different layouts can be used for different macOS Spaces +- **Visual Indicators**: Optional window focus indicators and layout feedback + +## Installation + +### Requirements +- macOS 10.12 or later +- [Hammerspoon](https://www.hammerspoon.org/) 0.9.76 or later + +### Installation Steps + +1. Download the `WindowManagerPlus.spoon` file +2. Double-click to install, or manually copy to `~/.hammerspoon/Spoons/` +3. Add to your `~/.hammerspoon/init.lua`: + +```lua +local wm = hs.loadSpoon("WindowManagerPlus") +wm:bindHotkeys({ + cycleLayout = {{"alt", "cmd"}, "space"}, + focus = {{"alt", "cmd"}, "j"}, + toggleFloatingWindow = {{"alt", "cmd"}, "f"}, +}):start() +``` + +## Basic Usage + +### Essential Hotkeys + +```lua +wm:bindHotkeys({ + -- Core functions + cycleLayout = {{"alt", "cmd"}, "space"}, -- Switch between layouts + focus = {{"alt", "cmd"}, "j"}, -- Focus next window + toggleFloatingWindow = {{"alt", "cmd"}, "f"}, -- Float/unfloat window + + -- Layout adjustments + toggleLayoutOrientation = {{"alt", "ctrl"}, "t"}, -- Toggle split/orientation + balanceLayout = {{"alt", "ctrl"}, "b"}, -- Reset window sizes + + -- Window resizing (for tiled layouts) + resizeLeft = {{"alt", "ctrl"}, "left"}, + resizeRight = {{"alt", "ctrl"}, "right"}, + resizeUp = {{"alt", "ctrl"}, "up"}, + resizeDown = {{"alt", "ctrl"}, "down"}, + + -- Floating window positioning + floatingPositionUp = {{"alt"}, "up"}, -- Top-left corner + floatingPositionDown = {{"alt"}, "down"}, -- Bottom-right corner + floatingPositionLeft = {{"alt"}, "left"}, -- Bottom-left corner + floatingPositionRight = {{"alt"}, "right"}, -- Top-right corner + floatingFullscreen = {{"alt"}, "return"}, -- Fullscreen cycling +}) +``` + +## Layout Modes + +### BSP (Binary Space Partitioning) +Recursively splits the screen space based on the number of windows. Works well when you have many windows of similar importance. + +### Master/Stack +One primary window occupies the main area while other windows are stacked in the remaining space. The master window can be positioned on the left, top, right, or bottom of the screen. + +### Stacking +All windows are layered full-screen. Navigate between them using the focus hotkey. + +### Floating +Windows can be positioned manually. Includes corner positioning with automatic size cycling and fullscreen size options. + +## Configuration + +### Basic Configuration + +```lua +wm:configure({ + gap = 8, -- Pixel gap between windows + usePerSpaceLayouts = true, -- Remember layout per Space +}) +``` + +### Color Customization + +```lua +wm:configure({ + modeColors = { + bsp = "#FF6B35", + master = "#004E98", + floating = "#A7C957", + stacking = "#9B2226" + } +}) +``` + +### Window Detection + +```lua +wm:configure({ + -- Add keywords for automatic settings window detection + settingsKeywords = { + "settings", "preferences", "config", "options" + }, + + -- Set threshold for small windows (auto-float) + smallWindowThreshold = { + width = 450, + height = 320 + }, + + -- Exclude specific applications + excludedApps = { + "Calculator", + "Activity Monitor" + } +}) +``` + +### Performance Tuning + +```lua +wm:configure({ + resizing = { + step = 0.05, -- Resize increment (5%) + minRatio = 0.15, -- Minimum split ratio + maxRatio = 0.85 -- Maximum split ratio + }, + + timing = { + layoutUpdateDelay = 0.08, -- Layout update delay + spaceChangeDelay = 0.12 -- Space change delay + } +}) +``` + +## Available Hotkey Functions + +### Core Functions +- `cycleLayout` - Cycle through available layouts +- `chooseLayout` - Show layout selection menu +- `focus` - Focus next window +- `toggleFloatingWindow` - Toggle floating state for current window + +### Layout Control +- `toggleLayoutOrientation` - Toggle split direction (BSP) or master orientation (Master) +- `toggleSplitDirection` - Change BSP split direction +- `toggleMasterOrientation` - Change master window position +- `balanceLayout` - Reset all splits to default ratios +- `showLayoutInfo` - Display current layout information + +### Window Resizing +- `resizeLeft`, `resizeRight`, `resizeUp`, `resizeDown` - Resize current window + +### Floating Window Management +- `floatingPositionUp/Down/Left/Right` - Position at screen corners with size cycling +- `floatingFullscreen` - Cycle through fullscreen sizes +- `floatingNextScreen` - Move to next screen +- `floatingHalfLeft/Right/Top/Bottom` - Standard half-screen positions +- `floatingCenter` - Center window +- `floatingMaximize` - Maximize window + +### Window Arrangement +- `rotateWindowsClockwise/CounterClockwise` - Rotate window positions +- `moveToPosition1` through `moveToPosition6` - Move to specific positions + +## Window Detection + +WindowManagerPlus attempts to automatically identify certain types of windows: + +- Settings and preferences windows (by title keywords) +- System dialogs and alerts +- Small windows that are likely popups or dialogs +- Applications that are better left unmanaged + +The detection can be customized by adding keywords or adjusting size thresholds. + +## Troubleshooting + +**Windows not being managed:** +- Check if the application is in the excluded apps list +- Verify the window isn't being detected as a settings/dialog window +- Try manually toggling the floating state + +**Layout not updating:** +- Restart Hammerspoon +- Check the Hammerspoon console for error messages +- Try using the balance function to force a layout refresh + +**Performance issues:** +- Increase the layout update delay in configuration +- Add problematic applications to the excluded apps list +- Disable visual indicators if not needed + +## Technical Notes + +WindowManagerPlus saves its configuration and window state automatically. Settings persist across Hammerspoon restarts and macOS reboots. + +The spoon uses Hammerspoon's window filtering and space management APIs. It attempts to be compatible with macOS's native window management features like Mission Control and Spaces. + +## License + +MIT License - see LICENSE file for details. + +## Acknowledgments + +This project draws inspiration from other tiling window managers in the macOS ecosystem, particularly [Amethyst](https://github.com/ianyh/Amethyst), [yabai](https://github.com/koekeishiya/yabai), [AeroSpace](https://github.com/nikitabobko/AeroSpace) and [EnhancedSpaces](https://github.com/franzbu/EnhancedSpaces.spoon/tree/main). Built using the Hammerspoon automation platform. \ No newline at end of file diff --git a/Source/WindowManagerPlus.spoon/api.lua b/Source/WindowManagerPlus.spoon/api.lua new file mode 100644 index 00000000..66ef9b92 --- /dev/null +++ b/Source/WindowManagerPlus.spoon/api.lua @@ -0,0 +1,403 @@ +--- === WindowManagerPlus.api === +--- +--- API module for WindowManagerPlus +--- +--- This module provides the main API interface for WindowManagerPlus, including +--- hotkey bindings, layout management, and window manager lifecycle control. + +local API = {} +API.__index = API + +--- WindowManagerPlus.api:new(spoon) +--- Constructor +--- Creates a new API instance +--- +--- Parameters: +--- * spoon - The WindowManagerPlus spoon instance +--- +--- Returns: +--- * API object +function API:new(spoon) + return setmetatable({spoon = spoon}, self) +end + +--- WindowManagerPlus.api:cycleLayout() +--- Method +--- Cycles through available layouts +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Updates the layout for the current space +--- * Shows an alert with the new layout name +function API:cycleLayout() + local current = self.spoon:getLayoutForCurrentSpace() + local newIndex = (current % #self.spoon.layouts) + 1 + self.spoon:setLayoutForCurrentSpace(newIndex) + self.spoon.core:showAlert("Layout: " .. self.spoon.layouts[newIndex]:upper()) + self.spoon.state.layoutChanged = true + self.spoon:updateLayout() +end + +--- WindowManagerPlus.api:chooseLayout() +--- Method +--- Shows a chooser dialog for layout selection +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Displays a Hammerspoon chooser with all available layouts +--- * The current layout is marked with "Current" subtitle +function API:chooseLayout() + local choices = {} + for i, name in ipairs(self.spoon.layouts) do + table.insert(choices, { + text = name:upper(), + subText = name == self.spoon.layouts[self.spoon:getLayoutForCurrentSpace()] and "Current" or "", + index = i + }) + end + + hs.chooser.new(function(choice) + if choice then + self.spoon:setLayoutForCurrentSpace(choice.index) + self.spoon.state.layoutChanged = true + self.spoon:updateLayout() + end + end):choices(choices):show() +end + +--- WindowManagerPlus.api:togglePerSpaceLayouts() +--- Method +--- Toggles per-space layout memory +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * When enabled, each macOS Space remembers its own layout +--- * When disabled, all Spaces use the same layout +function API:togglePerSpaceLayouts() + self.spoon.config.usePerSpaceLayouts = not self.spoon.config.usePerSpaceLayouts + self.spoon.core:showAlert("Per-space layouts: " .. (self.spoon.config.usePerSpaceLayouts and "ON" or "OFF")) + self.spoon:saveSettings() +end + +--- WindowManagerPlus.api:bindHotkeys(mapping) +--- Method +--- Binds hotkeys for WindowManagerPlus functions +--- +--- Parameters: +--- * mapping - A table of hotkey mappings where keys are function names and values are hotkey definitions +--- +--- Returns: +--- * The WindowManagerPlus spoon instance for method chaining +--- +--- Notes: +--- * Available functions: +--- * Core: focus, toggleFloatingWindow +--- * Layout: cycleLayout, chooseLayout, togglePerSpaceLayouts, toggleLayoutOrientation +--- * Resizing: resizeLeft, resizeRight, resizeUp, resizeDown, balanceLayout +--- * Floating: floatingPositionUp/Down/Left/Right, floatingFullscreen, floatingNextScreen +--- * Window: showLayoutInfo, rotateWindowsClockwise/CounterClockwise, moveToPosition1-6 +--- +--- Example: +--- ```lua +--- wm:bindHotkeys({ +--- cycleLayout = {{"alt", "cmd"}, "space"}, +--- focus = {{"alt", "cmd"}, "j"} +--- }) +--- ``` +function API:bindHotkeys(mapping) + local core, spoon = self.spoon.core, self.spoon + + local def = { + -- Core functions + focus = function() core:focusNextWindow() end, + toggleFloatingWindow = function() core:toggleFloatingWindow() end, + + -- Layout management + cycleLayout = function() self:cycleLayout() end, + chooseLayout = function() self:chooseLayout() end, + togglePerSpaceLayouts = function() self:togglePerSpaceLayouts() end, + toggleLayoutOrientation = function() core:toggleLayoutOrientation() end, + + -- Legacy bindings + toggleSplitDirection = function() core:toggleSplitDirection() end, + toggleMasterOrientation = function() core:toggleMasterOrientation() end, + + -- Window resizing + resizeLeft = function() core:resizeFocusedWindow("left") end, + resizeRight = function() core:resizeFocusedWindow("right") end, + resizeUp = function() core:resizeFocusedWindow("up") end, + resizeDown = function() core:resizeFocusedWindow("down") end, + balanceLayout = function() core:balanceBSP() end, + balanceBSP = function() core:balanceBSP() end, -- Legacy + + -- Floating window management + floatingPositionUp = function() core:floatingWindowPosition("up") end, + floatingPositionDown = function() core:floatingWindowPosition("down") end, + floatingPositionLeft = function() core:floatingWindowPosition("left") end, + floatingPositionRight = function() core:floatingWindowPosition("right") end, + floatingFullscreen = function() core:floatingWindowFullscreen() end, + floatingNextScreen = function() core:floatingWindowNextScreen() end, + floatingHalfLeft = function() core:floatingWindowHalves("left_half") end, + floatingHalfRight = function() core:floatingWindowHalves("right_half") end, + floatingHalfTop = function() core:floatingWindowHalves("top_half") end, + floatingHalfBottom = function() core:floatingWindowHalves("bottom_half") end, + floatingCenter = function() core:floatingWindowHalves("center") end, + floatingMaximize = function() core:floatingWindowHalves("maximize") end, + + -- Layout info and window management + showLayoutInfo = function() core:showLayoutInfo() end, + rotateWindowsClockwise = function() core:rotateWindows(true) end, + rotateWindowsCounterClockwise = function() core:rotateWindows(false) end, + + } + + -- Generate position functions dynamically + for i = 1, 6 do + def["moveToPosition" .. i] = function() core:moveToPosition(i) end + end + + hs.spoons.bindHotkeysToSpec(def, mapping) + return spoon +end + +--- WindowManagerPlus.api:start() +--- Method +--- Starts the window manager +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The WindowManagerPlus spoon instance +--- +--- Notes: +--- * Sets up window filters and event watchers +--- * Initializes floating window detection +--- * Performs initial layout update +function API:start() + local spoon = self.spoon + + -- Safety function to get timing values with defaults + local function getTiming(key, default) + if spoon.config and spoon.config.timing and type(spoon.config.timing[key]) == "number" then + return spoon.config.timing[key] + else + print("WARNING: Using default for timing." .. key .. " = " .. default) + return default + end + end + + local updateLayout = function() + if not spoon.state.ignoreNextUpdate then + local delay = getTiming("layoutUpdateDelay", 0.08) + hs.timer.doAfter(delay, function() + spoon:updateLayout(false) + end) + end + end + + -- Create a filter for tab-aware applications + local tabApps = { + ["Finder"] = true, + ["Terminal"] = true, + ["iTerm2"] = true, + ["Safari"] = true, + ["Google Chrome"] = true, + ["Firefox"] = true, + ["Microsoft Edge"] = true, + ["Arc"] = true, + } + + -- Main window filter with selective event handling + local windowFilter = hs.window.filter.default + + -- For non-tab apps, subscribe to all events + windowFilter:setAppFilter(".*", { + rejectTitles = {"^$"}, -- Reject empty titles + allowRoles = {"AXStandardWindow"}, + }) + + -- Subscribe to events that should trigger layout updates + windowFilter:subscribe({ + hs.window.filter.windowCreated, + hs.window.filter.windowDestroyed, + hs.window.filter.windowMinimized, + hs.window.filter.windowUnminimized, + }, updateLayout) + + -- Special handling for windowsChanged event + windowFilter:subscribe(hs.window.filter.windowsChanged, function(win, appName) + -- Skip update if it's a tab app and window count hasn't changed + if win and tabApps[appName or win:application():name()] then + local currentWindows = spoon.helpers:getRelevantWindows() + if #currentWindows == #spoon.state.windows then + return -- Skip update - likely just a tab change + end + end + updateLayout() + end) + + windowFilter:subscribe(hs.window.filter.windowFocused, function(win) + if win then + win:raise() + spoon.ui:updateAll() + end + end) + + windowFilter:subscribe(hs.window.filter.windowMoved, function(win) + if win and not (spoon.state.floatingWindows[win:id()] or spoon.state.ignoreNextUpdate) then + -- Check if this is a real move vs a tab-related repositioning + local app = win:application() + if app and tabApps[app:name()] then + -- For tab apps, only update if the window actually moved significantly + local lastFrame = spoon.state.lastWindowFrames and spoon.state.lastWindowFrames[win:id()] + local currentFrame = win:frame() + + if lastFrame and math.abs(lastFrame.x - currentFrame.x) < 5 and + math.abs(lastFrame.y - currentFrame.y) < 5 then + return -- Skip minor movements that might be tab-related + end + end + + local delay = getTiming("layoutUpdateDelay", 0.08) + hs.timer.doAfter(delay, function() + spoon:updateLayout(true) + end) + end + end) + + -- Space change watcher + self.spaceWatcher = hs.spaces.watcher.new(function() + local delay = getTiming("spaceChangeDelay", 0.12) + hs.timer.doAfter(delay, function() + spoon.state.layoutChanged = true + spoon:updateLayout() + end) + end):start() + + -- Dialog and system window filters + local function setupFloatingFilter(appFilters, allowRoles) + local filter = hs.window.filter.new(false) + if allowRoles then + filter:setAppFilter(".*", {allowRoles = allowRoles}) + else + for app, enabled in pairs(appFilters) do + filter:setAppFilter(app, enabled) + end + end + + filter:subscribe(hs.window.filter.windowCreated, function(win) + if win then + spoon.state.floatingWindows[win:id()] = true + if allowRoles then -- Center dialogs + local frame, screen = win:frame(), hs.screen.mainScreen():frame() + frame.x = screen.x + (screen.w - frame.w) / 2 + frame.y = screen.y + (screen.h - frame.h) / 2 + win:setFrame(frame) + end + end + end) + end + + -- Setup dialog filter + setupFloatingFilter(nil, {"AXDialog", "AXSheet", "AXSystemDialog"}) + + -- Setup system apps filter + setupFloatingFilter({ + ["System Preferences"] = true, + ["System Settings"] = true, + ["Activity Monitor"] = true, + ["Console"] = true, + ["Disk Utility"] = true, + ["Keychain Access"] = true + }) + + -- Initialize frame tracking for tab detection + spoon.state.lastWindowFrames = {} + hs.timer.doEvery(1, function() + local frames = {} + for _, win in ipairs(hs.window.allWindows()) do + if win and win:id() then + frames[win:id()] = win:frame() + end + end + spoon.state.lastWindowFrames = frames + end) + + -- Initial setup + local initialDelay = getTiming("initialSetupDelay", 0.15) + hs.timer.doAfter(initialDelay, function() + spoon.state.layoutChanged = true + spoon:updateLayout() + spoon.core:showAlert("WindowManager+ Ready") + end) + + return spoon +end + +--- WindowManagerPlus.api:setExcludedApps(appList) +--- Method +--- Sets the list of excluded applications +--- +--- Parameters: +--- * appList - A table of application names to exclude from window management +--- +--- Returns: +--- * The WindowManagerPlus spoon instance +--- +--- Notes: +--- * Excluded apps will not be managed by WindowManagerPlus +--- * Can be a list table {"App1", "App2"} or key-value table {App1 = true, App2 = true} +function API:setExcludedApps(appList) + self.spoon.config.excludedApps = {} + if type(appList) == "table" then + for k, v in pairs(appList) do + local key, val = type(k) == "number" and v or k, type(k) == "number" and true or v + self.spoon.config.excludedApps[key] = val + self.spoon.config.excludedApps[string.lower(key)] = val + end + end + return self.spoon +end + +--- WindowManagerPlus.api:configure(config) +--- Method +--- Configures WindowManagerPlus settings +--- +--- Parameters: +--- * config - A table of configuration options +--- +--- Returns: +--- * The WindowManagerPlus spoon instance +--- +--- Notes: +--- * See README.md for all available configuration options +--- * Configuration is merged with existing settings +function API:configure(config) + for k, v in pairs(config) do + if k == "excludedApps" then + self:setExcludedApps(v) + else + self.spoon.config[k] = v + end + end + self.spoon:processColors() + return self.spoon +end + +return API \ No newline at end of file diff --git a/Source/WindowManagerPlus.spoon/core.lua b/Source/WindowManagerPlus.spoon/core.lua new file mode 100644 index 00000000..1f106251 --- /dev/null +++ b/Source/WindowManagerPlus.spoon/core.lua @@ -0,0 +1,1564 @@ +--- === WindowManagerPlus.core === +--- +--- Core functionality for WindowManagerPlus +--- +--- This module contains the main layout algorithms, window management functions, +--- and user interaction handlers for WindowManagerPlus. + +local Core = {} +Core.__index = Core + +--- WindowManagerPlus.core:new(spoon) +--- Constructor +--- Creates a new Core instance +--- +--- Parameters: +--- * spoon - The WindowManagerPlus spoon instance +--- +--- Returns: +--- * Core object +function Core:new(spoon) + return setmetatable({spoon = spoon}, self) +end + +-- Alert Management + +--- WindowManagerPlus.core:showAlert(message) +--- Method +--- Shows a styled alert message +--- +--- Parameters: +--- * message - String message to display +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Alerts can be disabled via config.alerts.enabled +--- * Alert appearance is customizable via config.alerts +function Core:showAlert(message) + if not self.spoon.config.alerts.enabled then return end + + if self.spoon.state.alertUUID then + hs.alert.closeSpecific(self.spoon.state.alertUUID) + end + + local config = self.spoon.config.alerts + local alertConfig = { + strokeWidth = config.strokeWidth, + fillColor = config.fillColor, + textColor = config.textColor, + textSize = config.textSize, + radius = config.radius, + fadeInDuration = config.fadeIn, + fadeOutDuration = config.fadeOut + } + + if config.position == "bottom" then alertConfig.atScreenEdge = 2 end + self.spoon.state.alertUUID = hs.alert.show(message, alertConfig, config.duration) +end + +-- Helper Functions + +--- WindowManagerPlus.core:getWindowIndex(winId) +--- Method +--- Gets the index of a window in the current window list +--- +--- Parameters: +--- * winId - Window ID to search for +--- +--- Returns: +--- * Number index or nil if not found +--- +--- Notes: +--- * Used internally for window position operations +function Core:getWindowIndex(winId) + for i, w in ipairs(self.spoon.state.windows) do + if w:id() == winId then return i end + end +end + +--- WindowManagerPlus.core:isVerticalSplit(splitDirection, screen) +--- Method +--- Determines if a split should be vertical based on direction and screen aspect ratio +--- +--- Parameters: +--- * splitDirection - String: "vertical", "horizontal", or nil (auto) +--- * screen - Screen frame table with x, y, w, h +--- +--- Returns: +--- * Boolean - true for vertical split, false for horizontal +--- +--- Notes: +--- * Auto mode uses screen aspect ratio (vertical if width > height * 1.2) +function Core:isVerticalSplit(splitDirection, screen) + return splitDirection == "vertical" or + (splitDirection ~= "horizontal" and screen.w > screen.h * 1.2) +end + +--- WindowManagerPlus.core:adjustRatio(ratio, increase) +--- Method +--- Adjusts a split ratio by the configured step amount +--- +--- Parameters: +--- * ratio - Current ratio (0-1) +--- * increase - Boolean, true to increase, false to decrease +--- +--- Returns: +--- * Number - New ratio clamped between minRatio and maxRatio +--- +--- Notes: +--- * Step size is configured via config.resizing.step +function Core:adjustRatio(ratio, increase) + local config = self.spoon.config.resizing + local step = increase and config.step or -config.step + return math.max(config.minRatio, math.min(config.maxRatio, ratio + step)) +end + +--- WindowManagerPlus.core:layoutStack(area, windows, isVertical) +--- Method +--- Arranges windows in a stack within the given area +--- +--- Parameters: +--- * area - Table with x, y, w, h defining the stack area +--- * windows - Array of window objects to stack +--- * isVertical - Boolean, true for vertical stack, false for horizontal +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Used by master layout for the stack area +--- * Respects gap configuration between windows +function Core:layoutStack(area, windows, isVertical) + if #windows == 0 then return end + local gap = self.spoon.config.gap + local count = #windows + + if isVertical then + local height = (area.h - gap * (count - 1)) / count + for i, win in ipairs(windows) do + win:setFrame({ + x = area.x, + y = area.y + (i - 1) * (height + gap), + w = area.w, + h = height + }) + end + else + local width = (area.w - gap * (count - 1)) / count + for i, win in ipairs(windows) do + win:setFrame({ + x = area.x + (i - 1) * (width + gap), + y = area.y, + w = width, + h = area.h + }) + end + end +end + +-- Layout Functions + +--- WindowManagerPlus.core:layoutBsp(screen, windows) +--- Method +--- Applies Binary Space Partitioning layout +--- +--- Parameters: +--- * screen - Screen frame to layout within +--- * windows - Array of windows to arrange +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Uses different algorithms for 2, 3, and 4+ windows +--- * Maintains split ratios and directions from state +function Core:layoutBsp(screen, windows) + local count = #windows + if count == 0 then return end + if count == 1 then + windows[1]:setFrame(screen) + return + end + + if count == 2 then + self:layout2Windows(screen, windows) + elseif count == 3 then + self:layout3Windows(screen, windows) + else + self:layoutBalanced(screen, windows) + end +end + +--- WindowManagerPlus.core:layout2Windows(screen, windows) +--- Method +--- Layouts exactly 2 windows with configurable split +--- +--- Parameters: +--- * screen - Screen frame +--- * windows - Array of exactly 2 windows +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Split direction from state.splitDirections["main"] +--- * Split ratio from state.splitRatios["main"] +function Core:layout2Windows(screen, windows) + local ratio = self.spoon.state.splitRatios["main"] or 0.5 + local isVertical = self:isVerticalSplit(self.spoon.state.splitDirections["main"], screen) + local gap = self.spoon.config.gap + + local frames = {} + if isVertical then + frames[1] = {x = screen.x, y = screen.y, w = screen.w * ratio - gap/2, h = screen.h} + frames[2] = {x = screen.x + screen.w * ratio + gap/2, y = screen.y, + w = screen.w * (1 - ratio) - gap/2, h = screen.h} + else + frames[1] = {x = screen.x, y = screen.y, w = screen.w, h = screen.h * ratio - gap/2} + frames[2] = {x = screen.x, y = screen.y + screen.h * ratio + gap/2, + w = screen.w, h = screen.h * (1 - ratio) - gap/2} + end + + for i, frame in ipairs(frames) do + windows[i]:setFrame(frame) + end +end + +--- WindowManagerPlus.core:layout3Windows(screen, windows) +--- Method +--- Layouts exactly 3 windows with special arrangements +--- +--- Parameters: +--- * screen - Screen frame +--- * windows - Array of exactly 3 windows +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Supports horizontal mode: 1 top, 2 bottom +--- * Supports left_main mode: 1 left, 2 right stacked +--- * Supports right_main mode: 2 left stacked, 1 right +function Core:layout3Windows(screen, windows) + local mainRatio = self.spoon.state.splitRatios["main"] or 0.5 + local secondaryRatio = self.spoon.state.splitRatios["secondary"] or 0.5 + local gap = self.spoon.config.gap + local layoutMode = self.spoon.state.splitDirections["layout_mode"] or "left_main" + local isHorizontal = self.spoon.state.splitDirections["main"] == "horizontal" + + if isHorizontal then + -- Top window, bottom two side-by-side + windows[1]:setFrame({x = screen.x, y = screen.y, w = screen.w, h = screen.h * mainRatio - gap/2}) + + local bottomY = screen.y + screen.h * mainRatio + gap/2 + local bottomH = screen.h * (1 - mainRatio) - gap/2 + local bottomW = screen.w * secondaryRatio + + windows[2]:setFrame({x = screen.x, y = bottomY, w = bottomW - gap/2, h = bottomH}) + windows[3]:setFrame({x = screen.x + bottomW + gap/2, y = bottomY, + w = screen.w - bottomW - gap/2, h = bottomH}) + else + -- Vertical layouts + local mainFrame, stackArea = {}, {} + local rightX = screen.x + screen.w * mainRatio + gap/2 + local rightW = screen.w * (1 - mainRatio) - gap/2 + local rightH = screen.h * secondaryRatio + + if layoutMode == "right_main" then + -- Left two stacked, right main + local leftW = screen.w * (1 - mainRatio) - gap/2 + windows[1]:setFrame({x = screen.x, y = screen.y, w = leftW, h = rightH - gap/2}) + windows[3]:setFrame({x = screen.x, y = screen.y + rightH + gap/2, + w = leftW, h = screen.h - rightH - gap/2}) + windows[2]:setFrame({x = rightX, y = screen.y, w = rightW, h = screen.h}) + else + -- Default: left main, right two stacked + windows[1]:setFrame({x = screen.x, y = screen.y, w = screen.w * mainRatio - gap/2, h = screen.h}) + windows[2]:setFrame({x = rightX, y = screen.y, w = rightW, h = rightH - gap/2}) + windows[3]:setFrame({x = rightX, y = screen.y + rightH + gap/2, + w = rightW, h = screen.h - rightH - gap/2}) + end + end +end + +--- WindowManagerPlus.core:layoutBalanced(screen, windows) +--- Method +--- Layouts 4+ windows using recursive binary space partitioning +--- +--- Parameters: +--- * screen - Screen frame +--- * windows - Array of 4 or more windows +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Recursively splits space in half for balanced tree +--- * Maintains window tree structure in state.windowTree +function Core:layoutBalanced(screen, windows) + if #windows <= 1 then + if #windows == 1 then + windows[1]:setFrame(screen) + self.spoon.state.windowTree[windows[1]:id()] = {parent = "root", position = "single", direction = "none"} + end + return + end + + self.spoon.state.windowTree = {} + + local function splitRecursive(wins, frame, splitId) + if #wins == 0 then return end + if #wins == 1 then + wins[1]:setFrame(frame) + if not self.spoon.state.windowTree[wins[1]:id()] then + self.spoon.state.windowTree[wins[1]:id()] = {parent = splitId, position = "single", direction = "none"} + end + return + end + + local isVertical = self:isVerticalSplit(self.spoon.state.splitDirections[splitId], frame) + local midPoint = math.ceil(#wins / 2) + local ratio = self.spoon.state.splitRatios[splitId] or 0.5 + local gap = self.spoon.config.gap + + local firstGroup, secondGroup = {}, {} + for i = 1, midPoint do table.insert(firstGroup, wins[i]) end + for i = midPoint + 1, #wins do table.insert(secondGroup, wins[i]) end + + local frame1, frame2 + if isVertical then + local width = frame.w * ratio + frame1 = {x = frame.x, y = frame.y, w = width - gap/2, h = frame.h} + frame2 = {x = frame.x + width + gap/2, y = frame.y, w = frame.w - width - gap/2, h = frame.h} + else + local height = frame.h * ratio + frame1 = {x = frame.x, y = frame.y, w = frame.w, h = height - gap/2} + frame2 = {x = frame.x, y = frame.y + height + gap/2, w = frame.w, h = frame.h - height - gap/2} + end + + local direction = isVertical and "vertical" or "horizontal" + for _, win in ipairs(firstGroup) do + self.spoon.state.windowTree[win:id()] = {parent = splitId, position = "first", direction = direction} + end + for _, win in ipairs(secondGroup) do + self.spoon.state.windowTree[win:id()] = {parent = splitId, position = "second", direction = direction} + end + + splitRecursive(firstGroup, frame1, splitId .. "_1") + splitRecursive(secondGroup, frame2, splitId .. "_2") + end + + splitRecursive(windows, screen, "balanced") +end + +--- WindowManagerPlus.core:layoutMaster(screen, windows) +--- Method +--- Applies Master/Stack layout +--- +--- Parameters: +--- * screen - Screen frame +--- * windows - Array of windows (first is master) +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Master orientation: left, right, top, or bottom +--- * Stack windows are arranged opposite to master +--- * Master ratio configurable via state.splitRatios["master"] +function Core:layoutMaster(screen, windows) + if #windows == 0 then return end + if #windows == 1 then + windows[1]:setFrame(screen) + return + end + + local gap = self.spoon.config.gap + local masterRatio = self.spoon.state.splitRatios["master"] or 0.6 + local orientation = self.spoon.state.splitDirections["master_orientation"] or "left" + local master = windows[1] + local stack = {table.unpack(windows, 2)} + + local masterFrame, stackArea = {}, {} + + if orientation == "left" then + masterFrame = {x = screen.x, y = screen.y, w = screen.w * masterRatio - gap/2, h = screen.h} + stackArea = {x = screen.x + screen.w * masterRatio + gap/2, y = screen.y, + w = screen.w * (1 - masterRatio) - gap/2, h = screen.h} + self:layoutStack(stackArea, stack, true) + elseif orientation == "right" then + stackArea = {x = screen.x, y = screen.y, w = screen.w * (1 - masterRatio) - gap/2, h = screen.h} + masterFrame = {x = screen.x + screen.w * (1 - masterRatio) + gap/2, y = screen.y, + w = screen.w * masterRatio - gap/2, h = screen.h} + self:layoutStack(stackArea, stack, true) + elseif orientation == "top" then + masterFrame = {x = screen.x, y = screen.y, w = screen.w, h = screen.h * masterRatio - gap/2} + stackArea = {x = screen.x, y = screen.y + screen.h * masterRatio + gap/2, + w = screen.w, h = screen.h * (1 - masterRatio) - gap/2} + self:layoutStack(stackArea, stack, false) + else -- "bottom" + stackArea = {x = screen.x, y = screen.y, w = screen.w, h = screen.h * (1 - masterRatio) - gap/2} + masterFrame = {x = screen.x, y = screen.y + screen.h * (1 - masterRatio) + gap/2, + w = screen.w, h = screen.h * masterRatio - gap/2} + self:layoutStack(stackArea, stack, false) + end + + master:setFrame(masterFrame) +end + +--- WindowManagerPlus.core:layoutStacking(screen, windows) +--- Method +--- Applies stacking layout (all windows fullscreen) +--- +--- Parameters: +--- * screen - Screen frame +--- * windows - Array of windows to stack +--- +--- Returns: +--- * None +--- +--- Notes: +--- * All windows are made fullscreen +--- * Focused window is raised to top +function Core:layoutStacking(screen, windows) + for _, win in ipairs(windows) do + win:setFrame(screen) + end + local focused = hs.window.focusedWindow() + if focused then focused:raise() end +end + +--- WindowManagerPlus.core:layoutFloating(screen, windows) +--- Method +--- Applies floating layout (no automatic positioning) +--- +--- Parameters: +--- * screen - Screen frame (unused) +--- * windows - Array of windows (unused) +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Windows maintain their current positions +--- * Use floating window commands to position windows +function Core:layoutFloating(screen, windows) + -- Windows maintain their current positions +end + +-- Master Layout Controls + +--- WindowManagerPlus.core:toggleMasterOrientation() +--- Method +--- Cycles through master window orientations +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Cycles: left → top → right → bottom → left +--- * Only works in master layout +function Core:toggleMasterOrientation() + local orientations = {"left", "top", "right", "bottom"} + local current = self.spoon.state.splitDirections["master_orientation"] or "left" + + local currentIndex = 1 + for i, orientation in ipairs(orientations) do + if orientation == current then currentIndex = i; break end + end + + local newOrientation = orientations[(currentIndex % #orientations) + 1] + self.spoon.state.splitDirections["master_orientation"] = newOrientation + self.spoon.state.layoutChanged = true + self:updateLayout(true) + + self:showAlert("Master: " .. newOrientation:gsub("^%l", string.upper)) + self.spoon:saveSettings() +end + +--- WindowManagerPlus.core:resizeMaster(direction) +--- Method +--- Resizes the master area in master layout +--- +--- Parameters: +--- * direction - String: "left", "right", "up", or "down" +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Adjusts master ratio based on direction and orientation +--- * Shows current ratio as percentage +function Core:resizeMaster(direction) + local win = hs.window.focusedWindow() + if not win or self.spoon.state.floatingWindows[win:id()] then return end + + local layout = self.spoon.layouts[self.spoon:getLayoutForCurrentSpace()] + if layout ~= "master" then + self:showAlert("Master resize only works in Master layout") + return + end + + local windows = self.spoon.state.windows + if #windows < 2 then return end + + local isMaster = (windows[1]:id() == win:id()) + local masterRatio = self.spoon.state.splitRatios["master"] or 0.6 + local orientation = self.spoon.state.splitDirections["master_orientation"] or "left" + + local canResize, increase, newOrientation = false, false, orientation + + if direction == "left" or direction == "right" then + if orientation == "left" or orientation == "right" then + canResize = true + increase = (orientation == "left" and ((isMaster and direction == "right") or (not isMaster and direction == "left"))) or + (orientation == "right" and ((isMaster and direction == "left") or (not isMaster and direction == "right"))) + else + canResize = true + newOrientation = direction == "left" and "right" or "left" + increase = direction == "right" + end + else -- up or down + if orientation == "top" or orientation == "bottom" then + canResize = true + increase = (orientation == "top" and ((isMaster and direction == "down") or (not isMaster and direction == "up"))) or + (orientation == "bottom" and ((isMaster and direction == "up") or (not isMaster and direction == "down"))) + else + canResize = true + newOrientation = direction == "up" and "bottom" or "top" + increase = direction == "down" + end + end + + if canResize then + if newOrientation ~= orientation then + self.spoon.state.splitDirections["master_orientation"] = newOrientation + self:showAlert("Master: " .. newOrientation:gsub("^%l", string.upper)) + end + + local newRatio = self:adjustRatio(masterRatio, increase) + self.spoon.state.splitRatios["master"] = newRatio + self.spoon.state.layoutChanged = true + self:updateLayout(true) + + local showRatio = function() + self:showAlert(string.format("Master: %d%%", newRatio * 100)) + end + + if newOrientation ~= orientation then + hs.timer.doAfter(0.3, showRatio) + else + showRatio() + end + + self.spoon:saveSettings() + else + self:showAlert("Cannot resize in this direction") + end +end + +-- Resize Functions + +--- WindowManagerPlus.core:resizeFocusedWindow(direction) +--- Method +--- Resizes the focused window in its split +--- +--- Parameters: +--- * direction - String: "left", "right", "up", or "down" +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Works differently in BSP vs Master layouts +--- * In BSP: resizes the split containing the window +--- * In Master: delegates to resizeMaster +function Core:resizeFocusedWindow(direction) + local win = hs.window.focusedWindow() + if not win or self.spoon.state.floatingWindows[win:id()] then return end + + local layout = self.spoon.layouts[self.spoon:getLayoutForCurrentSpace()] + + if layout == "master" then + self:resizeMaster(direction) + return + elseif layout ~= "bsp" then + return + end + + local winId = win:id() + local windowCount = #self.spoon.state.windows + + if windowCount < 2 then return end + + local focusedIndex = self:getWindowIndex(winId) + if not focusedIndex then return end + + local changed, ratio, splitName = false, 0, "" + + if windowCount == 2 then + changed, ratio, splitName = self:resize2Windows(direction, focusedIndex) + elseif windowCount == 3 then + changed, ratio, splitName = self:resize3Windows(direction, focusedIndex) + else + changed, ratio, splitName = self:resizeTreeWindows(direction, winId) + end + + if changed then + self.spoon.state.splitRatios[splitName] = ratio + self:updateLayout(true) + self:showAlert(string.format("%s: %d%%", splitName == "main" and "Main" or "Split", ratio * 100)) + self.spoon:saveSettings() + end +end + +--- WindowManagerPlus.core:resize2Windows(direction, focusedIndex) +--- Method +--- Handles resize for 2-window BSP layout +--- +--- Parameters: +--- * direction - Resize direction +--- * focusedIndex - Index of focused window (1 or 2) +--- +--- Returns: +--- * changed - Boolean, whether resize occurred +--- * ratio - New split ratio +--- * splitName - Name of the split ("main") +function Core:resize2Windows(direction, focusedIndex) + local screen = self.spoon.helpers:getScreenFrame() + local isVertical = self:isVerticalSplit(self.spoon.state.splitDirections["main"], screen) + local ratio = self.spoon.state.splitRatios["main"] or 0.5 + local isMainWindow = focusedIndex == 1 + + if (isVertical and (direction == "left" or direction == "right")) or + (not isVertical and (direction == "up" or direction == "down")) then + local increase = (isMainWindow and (direction == "right" or direction == "down")) or + (not isMainWindow and (direction == "left" or direction == "up")) + return true, self:adjustRatio(ratio, increase), "main" + end + + return false, ratio, "main" +end + +--- WindowManagerPlus.core:resize3Windows(direction, focusedIndex) +--- Method +--- Handles resize for 3-window BSP layout +--- +--- Parameters: +--- * direction - Resize direction +--- * focusedIndex - Index of focused window (1, 2, or 3) +--- +--- Returns: +--- * changed - Boolean, whether resize occurred +--- * ratio - New split ratio +--- * splitName - Name of the split ("main" or "secondary") +function Core:resize3Windows(direction, focusedIndex) + local layoutMode = self.spoon.state.splitDirections["layout_mode"] or "left_main" + local isHorizontal = self.spoon.state.splitDirections["main"] == "horizontal" + local mainRatio = self.spoon.state.splitRatios["main"] or 0.5 + local secondaryRatio = self.spoon.state.splitRatios["secondary"] or 0.5 + + if isHorizontal then + if focusedIndex == 1 then + if direction == "up" or direction == "down" then + return true, self:adjustRatio(mainRatio, direction == "down"), "main" + end + else + if direction == "up" or direction == "down" then + return true, self:adjustRatio(mainRatio, direction == "down"), "main" + elseif direction == "left" or direction == "right" then + local increase = (focusedIndex == 2 and direction == "right") or (focusedIndex == 3 and direction == "left") + return true, self:adjustRatio(secondaryRatio, increase), "secondary" + end + end + else + local isMainWindow = (layoutMode == "left_main" and focusedIndex == 1) or + (layoutMode == "right_main" and focusedIndex == 2) + + if direction == "left" or direction == "right" then + local increase = (layoutMode == "left_main" and ((isMainWindow and direction == "right") or (not isMainWindow and direction == "left"))) or + (layoutMode == "right_main" and ((isMainWindow and direction == "left") or (not isMainWindow and direction == "right"))) + return true, self:adjustRatio(mainRatio, increase), "main" + elseif not isMainWindow and (direction == "up" or direction == "down") then + local increase = ((layoutMode == "left_main" and ((focusedIndex == 2 and direction == "down") or (focusedIndex == 3 and direction == "up"))) or + (layoutMode == "right_main" and ((focusedIndex == 1 and direction == "down") or (focusedIndex == 3 and direction == "up")))) + return true, self:adjustRatio(secondaryRatio, increase), "secondary" + end + end + + return false, 0, "" +end + +--- WindowManagerPlus.core:resizeTreeWindows(direction, winId) +--- Method +--- Handles resize for 4+ window BSP layout using tree structure +--- +--- Parameters: +--- * direction - Resize direction +--- * winId - ID of focused window +--- +--- Returns: +--- * changed - Boolean, whether resize occurred +--- * ratio - New split ratio +--- * splitName - Name of the split being resized +function Core:resizeTreeWindows(direction, winId) + local winData = self.spoon.state.windowTree[winId] + if not winData or not winData.parent then return false, 0, "" end + + local splitId = winData.parent + local ratio = self.spoon.state.splitRatios[splitId] or 0.5 + + if (winData.direction == "vertical" and (direction == "left" or direction == "right")) or + (winData.direction == "horizontal" and (direction == "up" or direction == "down")) then + local increase = (winData.position == "first" and (direction == "right" or direction == "down")) or + (winData.position == "second" and (direction == "left" or direction == "up")) + return true, self:adjustRatio(ratio, increase), splitId + end + + -- Enhanced tree resize with coordination logic + local targetSplits = {} + local resizeDirection = (direction == "left" or direction == "right") and "vertical" or "horizontal" + + for currentSplitId, currentRatio in pairs(self.spoon.state.splitRatios) do + if currentSplitId ~= splitId then + local splitWindows = {} + for _, win in ipairs(self.spoon.state.windows) do + local testWinData = self.spoon.state.windowTree[win:id()] + if testWinData and testWinData.parent == currentSplitId and + testWinData.direction == resizeDirection then + table.insert(splitWindows, {win = win, data = testWinData}) + end + end + + if #splitWindows > 0 then + targetSplits[currentSplitId] = {ratio = currentRatio, windows = splitWindows} + end + end + end + + if next(targetSplits) then + local windowIndex = self:getWindowIndex(winId) + if windowIndex then + local bestSplitId, bestScore = nil, -1 + + for currentSplitId, splitInfo in pairs(targetSplits) do + local score = 0 + for _, winInfo in ipairs(splitInfo.windows) do + local otherIndex = self:getWindowIndex(winInfo.win:id()) + if otherIndex then + score = score + (1 / (math.abs(otherIndex - windowIndex) + 1)) + end + end + + if score > bestScore then + bestScore = score + bestSplitId = currentSplitId + end + end + + if bestSplitId then + local splitInfo = targetSplits[bestSplitId] + local windowCount = #self.spoon.state.windows + local increase = windowIndex <= windowCount / 2 and (direction == "right" or direction == "down") or + (direction == "left" or direction == "up") + + return true, self:adjustRatio(splitInfo.ratio, increase), bestSplitId + end + end + end + + return false, 0, "" +end + +-- Window Management + +--- WindowManagerPlus.core:updateLayout(preserveOrder) +--- Method +--- Updates the window layout for the current space +--- +--- Parameters: +--- * preserveOrder - Boolean, whether to preserve window order +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Main layout update function called by all layout changes +--- * Handles floating windows separately +--- * Updates UI indicators +function Core:updateLayout(preserveOrder) + if not self.spoon.state.windows then self.spoon.state.windows = {} end + + local windows = preserveOrder and #self.spoon.state.windows > 0 and + self:validateWindows() or self.spoon.helpers:getRelevantWindows() + + self.spoon.state.windows = windows + local screen = self.spoon.helpers:getScreenFrame() + local layout = self.spoon.layouts[self.spoon:getLayoutForCurrentSpace()] + + local layouts = { + bsp = function() self:layoutBsp(screen, windows) end, + master = function() self:layoutMaster(screen, windows) end, + stacking = function() self:layoutStacking(screen, windows) end, + floating = function() self:layoutFloating(screen, windows) end + } + + if layouts[layout] then layouts[layout]() end + + self:handleFloatingWindows() + self.spoon.ui:updateAll() + self.spoon:saveWindowOrderForCurrentSpace() + self.spoon.state.layoutChanged = false +end + +--- WindowManagerPlus.core:validateWindows() +--- Method +--- Validates and reorders the current window list +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * Array of valid windows in preserved order +--- +--- Notes: +--- * Removes closed windows from the list +--- * Adds new windows at the end +function Core:validateWindows() + local validWindows, validIds = {}, {} + + for _, win in ipairs(self.spoon.helpers:getRelevantWindows()) do + validIds[win:id()] = win + end + + for _, win in ipairs(self.spoon.state.windows) do + if validIds[win:id()] then + table.insert(validWindows, validIds[win:id()]) + validIds[win:id()] = nil + end + end + + for _, win in pairs(validIds) do + table.insert(validWindows, win) + end + + return validWindows +end + +--- WindowManagerPlus.core:handleFloatingWindows() +--- Method +--- Ensures floating windows stay on top +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Raises all floating windows above tiled windows +--- * Removes invalid floating window entries +function Core:handleFloatingWindows() + for id, _ in pairs(self.spoon.state.floatingWindows) do + local win = hs.window.get(id) + if win and self.spoon.helpers:isRelevantWindow(win) then + win:raise() + else + self.spoon.state.floatingWindows[id] = nil + end + end +end + +--- WindowManagerPlus.core:toggleLayoutOrientation() +--- Method +--- Universal orientation toggle for current layout +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * In BSP: toggles split direction +--- * In Master: toggles master orientation +--- * No effect in other layouts +function Core:toggleLayoutOrientation() + local layout = self.spoon.layouts[self.spoon:getLayoutForCurrentSpace()] + + if layout == "bsp" then + self:toggleSplitDirection() + elseif layout == "master" then + self:toggleMasterOrientation() + else + self:showAlert("Toggle orientation works in BSP and Master layouts") + end +end + +-- Split Direction Control + +--- WindowManagerPlus.core:toggleSplitDirection() +--- Method +--- Toggles the split direction for BSP layout +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Behavior varies by window count: +--- * 2 windows: cycles vertical → horizontal → auto +--- * 3 windows: cycles between layout modes +--- * 4+ windows: toggles split at focused window +function Core:toggleSplitDirection() + local win = hs.window.focusedWindow() + if not win or self.spoon.state.floatingWindows[win:id()] then + self:showAlert("No tiled window focused") + return + end + + local layout = self.spoon.layouts[self.spoon:getLayoutForCurrentSpace()] + if layout ~= "bsp" then + self:showAlert("Split direction only works in BSP layout") + return + end + + local windowCount = #self.spoon.state.windows + + if windowCount == 2 then + self:toggle2WindowSplit() + elseif windowCount == 3 then + self:toggle3WindowLayout() + else + self:toggleTreeSplit(win:id()) + end + + self:updateLayout(true) + self.spoon:saveSettings() +end + +--- WindowManagerPlus.core:toggle2WindowSplit() +--- Method +--- Toggles split direction for 2-window layout +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +function Core:toggle2WindowSplit() + local current = self.spoon.state.splitDirections["main"] + local states = {vertical = "horizontal", horizontal = nil, [nil] = "vertical"} + local messages = {vertical = "Horizontal", horizontal = "Auto", [nil] = "Vertical"} + + self.spoon.state.splitDirections["main"] = states[current] + self:showAlert("Split: " .. messages[current]) +end + +--- WindowManagerPlus.core:toggle3WindowLayout() +--- Method +--- Toggles layout mode for 3-window layout +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +function Core:toggle3WindowLayout() + local current = self.spoon.state.splitDirections["main"] + local layoutMode = self.spoon.state.splitDirections["layout_mode"] or "left_main" + + if current == "horizontal" then + self.spoon.state.splitDirections["main"] = nil + self.spoon.state.splitDirections["layout_mode"] = "left_main" + self:showAlert("Layout: Left Main") + elseif layoutMode == "left_main" then + self.spoon.state.splitDirections["layout_mode"] = "right_main" + self:showAlert("Layout: Right Main") + else + self.spoon.state.splitDirections["main"] = "horizontal" + self.spoon.state.splitDirections["layout_mode"] = "left_main" + self:showAlert("Layout: Horizontal") + end +end + +--- WindowManagerPlus.core:toggleTreeSplit(winId) +--- Method +--- Toggles split direction in tree layout (4+ windows) +--- +--- Parameters: +--- * winId - ID of the window whose split to toggle +--- +--- Returns: +--- * None +function Core:toggleTreeSplit(winId) + local winData = self.spoon.state.windowTree[winId] + if winData and winData.parent then + local splitId = winData.parent + local current = self.spoon.state.splitDirections[splitId] + local states = {vertical = "horizontal", horizontal = nil, [nil] = "vertical"} + local messages = {vertical = "Horizontal", horizontal = "Auto", [nil] = "Vertical"} + + self.spoon.state.splitDirections[splitId] = states[current] + self:showAlert("Split: " .. messages[current]) + else + self:showAlert("Cannot determine split") + end +end + +-- Balance and Window Operations + +--- WindowManagerPlus.core:balanceBSP() +--- Method +--- Resets all split ratios to default +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * In BSP: resets all splits to 50/50 +--- * In Master: resets master to 60% +function Core:balanceBSP() + local layout = self.spoon.layouts[self.spoon:getLayoutForCurrentSpace()] + + if layout == "bsp" then + self.spoon.state.splitRatios = {} + self:showAlert("Layout Balanced") + elseif layout == "master" then + self.spoon.state.splitRatios["master"] = 0.6 + self:showAlert("Master Balanced (60%)") + else + self:showAlert("Balance only works in BSP and Master layouts") + return + end + + self.spoon.state.layoutChanged = true + self:updateLayout() + self.spoon:saveSettings() +end + +--- WindowManagerPlus.core:rotateWindows(clockwise) +--- Method +--- Rotates window positions +--- +--- Parameters: +--- * clockwise - Boolean, true for clockwise rotation +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Works in BSP and Master layouts only +--- * Preserves window sizes, changes positions +function Core:rotateWindows(clockwise) + if not self.spoon.state.windows or #self.spoon.state.windows < 2 then + self:showAlert("Need at least 2 windows") + return + end + + local layout = self.spoon.layouts[self.spoon:getLayoutForCurrentSpace()] + if layout ~= "bsp" and layout ~= "master" then + self:showAlert("Rotation only works in BSP and Master layouts") + return + end + + local windows = self.spoon.state.windows + local rotated = {} + + self.spoon.state.ignoreNextUpdate = true + + if clockwise then + rotated[1] = windows[#windows] + for i = 1, #windows - 1 do rotated[i + 1] = windows[i] end + else + for i = 2, #windows do rotated[i - 1] = windows[i] end + rotated[#windows] = windows[1] + end + + self.spoon.state.windows = rotated + local screen = self.spoon.helpers:getScreenFrame() + + if layout == "bsp" then + self:layoutBsp(screen, rotated) + elseif layout == "master" then + self:layoutMaster(screen, rotated) + end + + self:showAlert(clockwise and "Rotated →" or "Rotated ←") + self.spoon:saveWindowOrderForCurrentSpace() + self.spoon:saveSettings() + + hs.timer.doAfter(self.spoon.config.timing.windowLockDuration, function() + self.spoon.state.ignoreNextUpdate = false + end) +end + +--- WindowManagerPlus.core:moveToPosition(position) +--- Method +--- Moves focused window to a specific position +--- +--- Parameters: +--- * position - Number, target position (1-based) +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Works in BSP and Master layouts +--- * Position 1 is leftmost/topmost +function Core:moveToPosition(position) + local win = hs.window.focusedWindow() + if not win or self.spoon.state.floatingWindows[win:id()] then + self:showAlert("Cannot move this window") + return + end + + local layout = self.spoon.layouts[self.spoon:getLayoutForCurrentSpace()] + if layout ~= "bsp" and layout ~= "master" then + self:showAlert("Move to position works in BSP and Master layouts") + return + end + + local windows = self.spoon.state.windows + if #windows == 0 or position < 1 or position > #windows then + self:showAlert(string.format("Position must be 1-%d", #windows)) + return + end + + local currentIndex = self:getWindowIndex(win:id()) + if not currentIndex or currentIndex == position then + self:showAlert("Already at position " .. position) + return + end + + self.spoon.state.ignoreNextUpdate = true + + local reordered = {} + local movingWindow = windows[currentIndex] + local sourceIdx = 1 + + for targetIdx = 1, #windows do + if targetIdx == position then + reordered[targetIdx] = movingWindow + else + if sourceIdx == currentIndex then sourceIdx = sourceIdx + 1 end + reordered[targetIdx] = windows[sourceIdx] + sourceIdx = sourceIdx + 1 + end + end + + self.spoon.state.windows = reordered + local screen = self.spoon.helpers:getScreenFrame() + + if layout == "bsp" then + self:layoutBsp(screen, reordered) + elseif layout == "master" then + self:layoutMaster(screen, reordered) + end + + self:showAlert(string.format("Position %d", position)) + self.spoon:saveWindowOrderForCurrentSpace() + self.spoon:saveSettings() + + hs.timer.doAfter(self.spoon.config.timing.windowLockDuration, function() + self.spoon.state.ignoreNextUpdate = false + end) +end + +--- WindowManagerPlus.core:toggleFloatingWindow() +--- Method +--- Toggles floating state of focused window +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Floating windows are excluded from tiling +--- * Centered when made floating +function Core:toggleFloatingWindow() + local win = hs.window.focusedWindow() + if not win or not self.spoon.helpers:isRelevantWindow(win) then return end + + local winId = win:id() + if self.spoon.state.floatingWindows[winId] then + self.spoon.state.floatingWindows[winId] = nil + self:showAlert("Window Tiled") + else + self.spoon.state.floatingWindows[winId] = true + local frame, screen = win:frame(), hs.screen.mainScreen():frame() + frame.x = screen.x + (screen.w - frame.w) / 2 + frame.y = screen.y + (screen.h - frame.h) / 2 + win:setFrame(frame) + self:showAlert("Window Floating") + end + + self.spoon.state.layoutChanged = true + self:updateLayout() + self.spoon:saveSettings() +end + +--- WindowManagerPlus.core:focusNextWindow() +--- Method +--- Focuses the next window in order +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Cycles through windows in layout order +--- * Updates UI indicators +function Core:focusNextWindow() + local current = hs.window.focusedWindow() + local windows = self.spoon.state.windows + + if #windows == 0 then return end + + local nextIndex = 1 + if current then + local currentIndex = self:getWindowIndex(current:id()) + if currentIndex then + nextIndex = (currentIndex % #windows) + 1 + end + end + + if windows[nextIndex] then + windows[nextIndex]:focus() + windows[nextIndex]:raise() + self.spoon.ui:updateAll() + end +end + +-- Floating Window Management + +-- Floating Window Management + +-- Internal utilities for floating window positioning +-- Contains helper functions and data for floating window operations +local FloatingUtils = { + steps = {1, 0.8, 0.66, 0.5, 0.33, 0.2}, + positions = { + left = function(screen, w, h) return {x = screen.x, y = screen.y, w = w, h = screen.h} end, + right = function(screen, w, h) return {x = screen.x + screen.w - w, y = screen.y, w = w, h = screen.h} end, + up = function(screen, w, h) return {x = screen.x, y = screen.y, w = screen.w, h = h} end, + down = function(screen, w, h) return {x = screen.x, y = screen.y + screen.h - h, w = screen.w, h = h} end, + ["up-left"] = function(screen, w, h) return {x = screen.x, y = screen.y, w = w, h = h} end, + ["up-right"] = function(screen, w, h) return {x = screen.x + screen.w - w, y = screen.y, w = w, h = h} end, + ["down-left"] = function(screen, w, h) return {x = screen.x, y = screen.y + screen.h - h, w = w, h = h} end, + ["down-right"] = function(screen, w, h) return {x = screen.x + screen.w - w, y = screen.y + screen.h - h, w = w, h = h} end + }, + + getDimensions = function(self, screen, position, widthStep, heightStep) + local w, h = screen.w * self.steps[widthStep], screen.h * self.steps[heightStep] + if position == "left" or position == "right" then h = screen.h + elseif position == "up" or position == "down" then w = screen.w end + return w, h + end, + + getAlertMessage = function(self, position, widthStep, heightStep) + local w, h = self.steps[widthStep] * 100, self.steps[heightStep] * 100 + + if position == "left" then return string.format("Left: %d%%W×100%%H", w) + elseif position == "right" then return string.format("Right: %d%%W×100%%H", w) + elseif position == "up" then return string.format("Top: 100%%W×%d%%H", h) + elseif position == "down" then return string.format("Bottom: 100%%W×%d%%H", h) + else + local parts = {} + for part in string.gmatch(position, "[^-]+") do + table.insert(parts, part:gsub("^%l", string.upper)) + end + return string.format("%s: %d%%W×%d%%H", table.concat(parts, "-"), w, h) + end + end, + + getNextPosition = function(self, currentSide, direction, state) + if not currentSide then + state.widthStep, state.heightStep = 1, 1 + return direction + elseif currentSide == direction then + if direction == "left" or direction == "right" then + state.widthStep = (state.widthStep % #self.steps) + 1 + else + state.heightStep = (state.heightStep % #self.steps) + 1 + end + return direction + elseif not string.find(currentSide, "-") then + if (currentSide == "left" or currentSide == "right") and (direction == "up" or direction == "down") then + state.heightStep = 1 + return direction .. "-" .. currentSide + elseif (currentSide == "up" or currentSide == "down") and (direction == "left" or direction == "right") then + state.widthStep = 1 + return currentSide .. "-" .. direction + else + state.widthStep, state.heightStep = 1, 1 + return direction + end + else + local parts = {} + for part in string.gmatch(currentSide, "[^-]+") do table.insert(parts, part) end + local vertical, horizontal = parts[1], parts[2] + + if direction == "left" or direction == "right" then + if direction == horizontal then + state.widthStep = (state.widthStep % #self.steps) + 1 + return currentSide + else + state.widthStep = 1 + return vertical .. "-" .. direction + end + else + if direction == vertical then + state.heightStep = (state.heightStep % #self.steps) + 1 + return currentSide + else + state.heightStep = 1 + return direction .. "-" .. horizontal + end + end + end + end +} + +--- WindowManagerPlus.core:floatingWindowPosition(direction) +--- Method +--- Positions floating window at screen edges/corners +--- +--- Parameters: +--- * direction - String: "up", "down", "left", or "right" +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Cycles through size steps when pressing same direction +--- * Combines directions for corner positioning +--- * Only works in floating layout +function Core:floatingWindowPosition(direction) + local win = hs.window.focusedWindow() + if not win then return end + + local layout = self.spoon.layouts[self.spoon:getLayoutForCurrentSpace()] + if layout ~= "floating" then + self:showAlert("Floating positioning only works in Floating layout") + return + end + + local screen = hs.screen.mainScreen():frame() + local winId = win:id() + + if not self.spoon.state.floatingSteps then self.spoon.state.floatingSteps = {} end + if not self.spoon.state.floatingSteps[winId] then + self.spoon.state.floatingSteps[winId] = {currentSide = nil, widthStep = 1, heightStep = 1} + end + + local state = self.spoon.state.floatingSteps[winId] + local newSide = FloatingUtils:getNextPosition(state.currentSide, direction, state) + local w, h = FloatingUtils:getDimensions(screen, newSide, state.widthStep, state.heightStep) + local frame = FloatingUtils.positions[newSide](screen, w, h) + + state.currentSide = newSide + win:setFrame(frame) + self:showAlert(FloatingUtils:getAlertMessage(newSide, state.widthStep, state.heightStep)) +end + +--- WindowManagerPlus.core:floatingWindowFullscreen() +--- Method +--- Cycles floating window through fullscreen sizes +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Cycles: 100% → 80% → 60% → 40% → 20% → 100% +--- * Window stays centered +--- * Only works in floating layout +function Core:floatingWindowFullscreen() + local win = hs.window.focusedWindow() + if not win then return end + + local layout = self.spoon.layouts[self.spoon:getLayoutForCurrentSpace()] + if layout ~= "floating" then + self:showAlert("Floating fullscreen only works in Floating layout") + return + end + + local screen = hs.screen.mainScreen():frame() + local winId = win:id() + + if not self.spoon.state.floatingSteps then self.spoon.state.floatingSteps = {} end + if not self.spoon.state.floatingSteps[winId] then self.spoon.state.floatingSteps[winId] = {} end + + local currentStep = self.spoon.state.floatingSteps[winId]["fullscreen"] or 0 + local steps = {1.0, 0.8, 0.6, 0.4, 0.2} + + currentStep = (currentStep % #steps) + 1 + self.spoon.state.floatingSteps[winId]["fullscreen"] = currentStep + + local ratio = steps[currentStep] + local newWidth, newHeight = screen.w * ratio, screen.h * ratio + + win:setFrame({ + x = screen.x + (screen.w - newWidth) / 2, + y = screen.y + (screen.h - newHeight) / 2, + w = newWidth, + h = newHeight + }) + self:showAlert(string.format("Fullscreen: %d%%", ratio * 100)) +end + +--- WindowManagerPlus.core:floatingWindowHalves(position) +--- Method +--- Positions floating window at standard positions +--- +--- Parameters: +--- * position - String: "left_half", "right_half", "top_half", "bottom_half", "center", or "maximize" +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Standard window positions for quick access +--- * Center uses 70% of screen size +--- * Only works in floating layout +function Core:floatingWindowHalves(position) + local win = hs.window.focusedWindow() + if not win then return end + + local layout = self.spoon.layouts[self.spoon:getLayoutForCurrentSpace()] + if layout ~= "floating" then + self:showAlert("Floating halves only works in Floating layout") + return + end + + local screen = hs.screen.mainScreen():frame() + local frames = { + left_half = {x = screen.x, y = screen.y, w = screen.w / 2, h = screen.h}, + right_half = {x = screen.x + screen.w / 2, y = screen.y, w = screen.w / 2, h = screen.h}, + top_half = {x = screen.x, y = screen.y, w = screen.w, h = screen.h / 2}, + bottom_half = {x = screen.x, y = screen.y + screen.h / 2, w = screen.w, h = screen.h / 2}, + center = function() + local ratio = 0.7 + local w, h = screen.w * ratio, screen.h * ratio + return {x = screen.x + (screen.w - w) / 2, y = screen.y + (screen.h - h) / 2, w = w, h = h} + end, + maximize = screen + } + + local messages = { + left_half = "Left Half", right_half = "Right Half", + top_half = "Top Half", bottom_half = "Bottom Half", + center = "Centered", maximize = "Maximized" + } + + local frame = type(frames[position]) == "function" and frames[position]() or frames[position] + if frame then + win:setFrame(frame) + self:showAlert(messages[position]) + + -- Reset steps when manually positioning + local winId = win:id() + if self.spoon.state.floatingSteps and self.spoon.state.floatingSteps[winId] then + self.spoon.state.floatingSteps[winId] = {} + end + end +end + +--- WindowManagerPlus.core:floatingWindowNextScreen() +--- Method +--- Moves floating window to next screen +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Cycles through available screens +--- * Only works in floating layout +function Core:floatingWindowNextScreen() + local win = hs.window.focusedWindow() + if not win then return end + + local layout = self.spoon.layouts[self.spoon:getLayoutForCurrentSpace()] + if layout ~= "floating" then + self:showAlert("Move to next screen only works in Floating layout") + return + end + + local currentScreen = win:screen() + local nextScreen = currentScreen:next() + + if nextScreen and nextScreen ~= currentScreen then + win:moveToScreen(nextScreen) + self:showAlert("Moved to " .. nextScreen:name()) + else + self:showAlert("Only one screen available") + end +end + +--- WindowManagerPlus.core:showLayoutInfo() +--- Method +--- Shows information about current layout +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Shows layout name, window count, and configuration +--- * More detailed for BSP and Master layouts +function Core:showLayoutInfo() + local layout = self.spoon.layouts[self.spoon:getLayoutForCurrentSpace()] + local windowCount = #self.spoon.state.windows + + if layout == "master" then + local orientation = self.spoon.state.splitDirections["master_orientation"] or "left" + local masterRatio = self.spoon.state.splitRatios["master"] or 0.6 + self:showAlert(string.format("Master: %s (%d%%, %d windows)", + orientation:gsub("^%l", string.upper), masterRatio * 100, windowCount)) + return + end + + if layout ~= "bsp" then + self:showAlert(string.format("Layout: %s (%d windows)", layout, windowCount)) + return + end + + local info = string.format("BSP: %d windows", windowCount) + + if windowCount == 2 then + local direction = self.spoon.state.splitDirections["main"] or "auto" + local ratio = self.spoon.state.splitRatios["main"] or 0.5 + info = string.format("BSP: %s split (%d%%)", direction, ratio * 100) + elseif windowCount == 3 then + local layoutMode = self.spoon.state.splitDirections["layout_mode"] or "left_main" + local isHorizontal = self.spoon.state.splitDirections["main"] == "horizontal" + info = isHorizontal and "BSP: Horizontal layout" or + string.format("BSP: %s", layoutMode:gsub("_", " ")) + end + + self:showAlert(info) +end + +return Core \ No newline at end of file diff --git a/Source/WindowManagerPlus.spoon/helpers.lua b/Source/WindowManagerPlus.spoon/helpers.lua new file mode 100644 index 00000000..98d48d5f --- /dev/null +++ b/Source/WindowManagerPlus.spoon/helpers.lua @@ -0,0 +1,212 @@ +--- === WindowManagerPlus.helpers === +--- +--- Helper functions for WindowManagerPlus +--- +--- This module provides window detection, filtering, and utility functions +--- for determining which windows should be managed by WindowManagerPlus. + +local Helpers = {} +Helpers.__index = Helpers + +--- WindowManagerPlus.helpers:new(spoon) +--- Constructor +--- Creates a new Helpers instance +--- +--- Parameters: +--- * spoon - The WindowManagerPlus spoon instance +--- +--- Returns: +--- * Helpers object +function Helpers:new(spoon) + return setmetatable({spoon = spoon}, self) +end + +--- WindowManagerPlus.helpers:isRelevantWindow(win) +--- Method +--- Determines if a window should be managed by WindowManagerPlus +--- +--- Parameters: +--- * win - hs.window object to check +--- +--- Returns: +--- * Boolean - true if window should be managed, false otherwise +--- +--- Notes: +--- * Checks multiple criteria to filter windows: +--- * Must be standard, visible window +--- * Application must not be in excludedApps list +--- * Title must not contain status/settings keywords +--- * Window must be larger than smallWindowThreshold +--- * Windows that fail checks may be marked as floating +--- * This is the main filtering function for window management +function Helpers:isRelevantWindow(win) + if not win or not win:isStandard() or not win:isVisible() then return false end + + local app = win:application() + if not app then return false end + + local appName = app:name() + if not appName then return false end + + -- Check excluded apps + local excluded = self.spoon.config.excludedApps + if excluded[appName] or excluded[string.lower(appName)] then return false end + + local title = win:title() or "" + local lowerTitle = string.lower(title) + + -- Check keywords and mark as floating if found + local function checkKeywords(keywords) + for _, keyword in ipairs(keywords) do + if string.find(lowerTitle, string.lower(keyword)) then + self.spoon.state.floatingWindows[win:id()] = true + return true + end + end + return false + end + + -- Check status and settings keywords + if checkKeywords(self.spoon.config.statusKeywords) or + checkKeywords(self.spoon.config.settingsKeywords) then + return false + end + + -- Advanced settings detection + if self:isSettingsWindow(win, appName, title) then + self.spoon.state.floatingWindows[win:id()] = true + return false + end + + -- Check small window threshold + local frame = win:frame() + local threshold = self.spoon.config.smallWindowThreshold + if frame.w < threshold.width and frame.h < threshold.height then + self.spoon.state.floatingWindows[win:id()] = true + return false + end + + return true +end + +--- WindowManagerPlus.helpers:isSettingsWindow(win, appName, title) +--- Method +--- Advanced detection for settings/preferences windows +--- +--- Parameters: +--- * win - hs.window object to check +--- * appName - Name of the application +--- * title - Window title +--- +--- Returns: +--- * Boolean - true if window appears to be a settings window +--- +--- Notes: +--- * Uses multiple heuristics to detect settings windows: +--- * Window subrole (AXDialog, AXSystemDialog) +--- * Title patterns (preferences, settings, options, etc.) +--- * App-specific patterns (e.g., "AppName Preferences") +--- * Size-based detection for small windows with settings keywords +--- * Supports multiple languages for settings keywords +--- * This catches settings windows that might not be caught by simple keyword matching +function Helpers:isSettingsWindow(win, appName, title) + -- Check window subrole + local subrole = win:subrole() + if subrole then + local lowerSubrole = string.lower(subrole) + if subrole == "AXSystemDialog" or subrole == "AXDialog" or + string.find(lowerSubrole, "pref") or string.find(lowerSubrole, "setting") then + return true + end + end + + -- App-specific patterns - consolidated into table + local appPatterns = { + [appName .. " Preferences"] = true, + [appName .. " Settings"] = true, + [appName .. " Options"] = true, + ["System Preferences"] = (appName == "System Preferences"), + ["System Settings"] = (appName == "System Settings"), + ["Preferences"] = true, + ["Settings"] = true, + } + + if appPatterns[title] then return true end + + -- Pattern matching - consolidated arrays + local settingsPatterns = { + "^preferences$", "^settings$", "^options$", "^configuration$", + "preferences$", "settings$", "options$", " settings$", " preferences$", + "^about ", "^inspector ", "^properties ", "account settings", + "privacy settings", "security settings", "network settings", + "display settings", "audio settings", "keyboard settings" + } + + local lowerTitle = string.lower(title) + for _, pattern in ipairs(settingsPatterns) do + if string.match(lowerTitle, pattern) then return true end + end + + -- Size-based detection with keywords + local frame = win:frame() + if frame.w < 800 and frame.h < 600 then + local settingsWords = {"setting", "preference", "config", "option"} + for _, word in ipairs(settingsWords) do + if string.find(lowerTitle, word) then return true end + end + end + + return false +end + +--- WindowManagerPlus.helpers:getRelevantWindows() +--- Method +--- Gets all windows that should be managed +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * Array of hs.window objects that are relevant and not floating +--- +--- Notes: +--- * Filters all windows through isRelevantWindow +--- * Excludes windows marked as floating +--- * Returns windows in the order they appear on screen +--- * This is the main function for getting windows to tile +function Helpers:getRelevantWindows() + local windows = {} + for _, win in ipairs(hs.window.allWindows()) do + if self:isRelevantWindow(win) and not self.spoon.state.floatingWindows[win:id()] then + table.insert(windows, win) + end + end + return windows +end + +--- WindowManagerPlus.helpers:getScreenFrame() +--- Method +--- Gets the usable screen area with gap applied +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * Table with x, y, w, h representing the usable screen area +--- +--- Notes: +--- * Applies the configured gap to all edges of the screen +--- * Returns coordinates for the main screen +--- * The returned frame is where windows will be tiled +function Helpers:getScreenFrame() + local screen = hs.screen.mainScreen():frame() + local gap = self.spoon.config.gap + return { + x = screen.x + gap, + y = screen.y + gap, + w = screen.w - (gap * 2), + h = screen.h - (gap * 2) + } +end + +return Helpers \ No newline at end of file diff --git a/Source/WindowManagerPlus.spoon/init.lua b/Source/WindowManagerPlus.spoon/init.lua new file mode 100644 index 00000000..df36a81f --- /dev/null +++ b/Source/WindowManagerPlus.spoon/init.lua @@ -0,0 +1,570 @@ +--- === WindowManagerPlus === +--- +--- A powerful window management system for macOS via Hammerspoon +--- +--- WindowManagerPlus provides multiple tiling layouts (BSP, Master/Stack, Stacking, Floating) +--- with intelligent window detection, per-space layout memory, and visual indicators. +--- +--- Download: https://gitlab.com/snicolis/windowmanagerplus +--- +--- Example configuration: +--- ```lua +--- local wm = hs.loadSpoon("WindowManagerPlus") +--- wm:configure({ +--- gap = 8, +--- usePerSpaceLayouts = true, +--- excludedApps = {"Calculator", "System Preferences"} +--- }):bindHotkeys({ +--- cycleLayout = {{"alt", "cmd"}, "space"}, +--- focus = {{"alt", "cmd"}, "j"}, +--- toggleFloatingWindow = {{"alt", "cmd"}, "f"}, +--- resizeLeft = {{"alt", "ctrl"}, "left"}, +--- resizeRight = {{"alt", "ctrl"}, "right"} +--- }):start() +--- ``` + +local obj = {} +obj.__index = obj + +-- Metadata +obj.name = "WindowManagerPlus" +obj.version = "0.1.0" +obj.author = "Stamatios Nicolis " +obj.homepage = "https://gitlab.com/snicolis/windowmanagerplus" +obj.license = "MIT - https://opensource.org/licenses/MIT" + +--- WindowManagerPlus.config +--- Variable +--- Configuration table for WindowManagerPlus +--- +--- The configuration table supports the following keys: +--- * `gap` - Pixel gap between windows (default: 8) +--- * `excludedApps` - Table of application names to exclude from management +--- * `statusKeywords` - Keywords for detecting status windows (default: {"status", "copy", "progress", "loading", "downloading"}) +--- * `settingsKeywords` - Keywords for detecting settings windows +--- * `usePerSpaceLayouts` - Whether to remember layouts per Space (default: true) +--- * `resizing` - Table with `step` (resize increment), `minRatio`, `maxRatio` for split ratios +--- * `timing` - Table with various timing delays in seconds +--- * `smallWindowThreshold` - Table with `width` and `height` for auto-floating small windows +--- * `focusedWindowIndicator` - Table configuring the focus indicator appearance +--- * `stackIndicators` - Table configuring stack layout indicators +--- * `alerts` - Table configuring alert appearance and behavior +--- * `modeColors` - Table of colors for each layout mode (hex strings or color tables) +obj.config = { + gap = 8, + excludedApps = {}, + statusKeywords = {"status", "copy", "progress", "loading", "downloading"}, + settingsKeywords = { + -- English + "settings", "preferences", "prefs", "options", "configuration", "config", + "setup", "properties", "about", "inspector", "info", "information", + -- App-specific patterns + "preference", "setting", "option", "configure", "account", "profile", + "general", "advanced", "privacy", "security", "network", "display", + -- Other languages + "préférences", "einstellungen", "configuración", "impostazioni", "設定", "环境设置" + }, + usePerSpaceLayouts = true, + + -- Consolidated sub-configs + resizing = {step = 0.05, minRatio = 0.15, maxRatio = 0.85}, + timing = {layoutUpdateDelay = 0.08, spaceChangeDelay = 0.12, initialSetupDelay = 0.15, windowLockDuration = 0.4}, + smallWindowThreshold = {width = 450, height = 320}, + focusedWindowIndicator = {width = 50, height = 4, opacity = 0.9, borderRadius = 2}, + stackIndicators = {enabled = true, position = "bottomLeft", width = 40, height = 5, spacing = 5, maxIndicators = 10, borderRadius = 2.5}, + alerts = {enabled = true, position = "bottom", duration = 0.8, fadeIn = 0.15, fadeOut = 0.25, strokeWidth = 0, + fillColor = {white = 0.05, alpha = 0.85}, textColor = {white = 0.95, alpha = 1}, textSize = 15, radius = 8}, + + -- Mode colors + modeColors = { + bsp = {red = 0.929, green = 0.682, blue = 0.286, alpha = 1}, + master = {red = 0.286, green = 0.729, blue = 0.929, alpha = 1}, + floating = {red = 0.820, green = 0.286, blue = 0.357, alpha = 1}, + stacking = {red = 0.0, green = 0.475, blue = 0.549, alpha = 1}, + } +} + +--- WindowManagerPlus.layouts +--- Variable +--- Available layout modes +--- +--- The following layouts are available: +--- * `bsp` - Binary Space Partitioning tree layout +--- * `master` - Master/Stack layout with one main window +--- * `stacking` - Full-screen stacking layout +--- * `floating` - Free-form floating windows +obj.layouts = {"bsp", "master", "stacking", "floating"} + +--- WindowManagerPlus.settingsFile +--- Variable +--- Path to the settings persistence file +--- +--- Default: `~/.hammerspoon/wm_settings.json` +obj.settingsFile = os.getenv("HOME") .. "/.hammerspoon/wm_settings.json" + +--- WindowManagerPlus.state +--- Variable +--- Internal state tracking (read-only) +--- +--- This table contains the current state of the window manager. +--- Users should not modify this directly. +obj.state = { + currentLayout = 1, windows = {}, floatingWindows = {}, floatingSteps = {}, splitRatios = {}, + splitDirections = {}, windowTree = {}, spaceLayouts = {}, spaceWindowOrder = {}, + layoutChanged = false, ignoreNextUpdate = false, alertUUID = nil, lastWindowFrames = {} +} + +-- Load modules +local Core = dofile(hs.spoons.resourcePath("core.lua")) +local UI = dofile(hs.spoons.resourcePath("ui.lua")) +local Helpers = dofile(hs.spoons.resourcePath("helpers.lua")) +local API = dofile(hs.spoons.resourcePath("api.lua")) + +--- WindowManagerPlus:hexToColor(hex, alpha) +--- Method +--- Converts hex color string to Hammerspoon color table +--- +--- Parameters: +--- * hex - Hex color string (e.g., "#FF6B35") +--- * alpha - Optional alpha value (0-1, default: 1.0) +--- +--- Returns: +--- * Color table with red, green, blue, alpha values (0-1) +--- +--- Notes: +--- * Used internally for color configuration processing +function obj:hexToColor(hex, alpha) + if type(hex) == "table" then return hex end + + hex = hex:gsub("#", "") + return { + red = tonumber(hex:sub(1, 2), 16) / 255, + green = tonumber(hex:sub(3, 4), 16) / 255, + blue = tonumber(hex:sub(5, 6), 16) / 255, + alpha = alpha or 1.0 + } +end + +--- WindowManagerPlus:processColors() +--- Method +--- Processes color configuration values +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Converts hex strings to color tables +--- * Called automatically during init and configure +function obj:processColors() + -- Convert hex colors in modeColors + for layout, color in pairs(self.config.modeColors) do + if type(color) == "string" then + self.config.modeColors[layout] = self:hexToColor(color) + end + end + + -- Convert alert colors + local alerts = self.config.alerts + if type(alerts.fillColor) == "string" then + alerts.fillColor = self:hexToColor(alerts.fillColor, 0.85) + end + if type(alerts.textColor) == "string" then + alerts.textColor = self:hexToColor(alerts.textColor, 1.0) + end +end + +--- WindowManagerPlus:init() +--- Method +--- Initializes the WindowManagerPlus spoon +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The WindowManagerPlus object +--- +--- Notes: +--- * Called automatically when the spoon is loaded +--- * Loads settings and initializes all modules +function obj:init() + self:processColors() + + -- Initialize modules + self.helpers = Helpers:new(self) + self.ui = UI:new(self) + self.core = Core:new(self) + self.api = API:new(self) + + self:loadSettings() + + -- Apply space-specific settings + if self.config.usePerSpaceLayouts then + local spaceID = self:getCurrentSpaceID() + if spaceID and self.state.spaceLayouts[spaceID] then + self.state.currentLayout = self.state.spaceLayouts[spaceID] + end + end + + return self +end + +--- WindowManagerPlus:getCurrentSpaceID() +--- Method +--- Gets the current macOS Space ID +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * String Space ID or nil if unable to determine +--- +--- Notes: +--- * Used internally for per-space layout management +function obj:getCurrentSpaceID() + local screen = hs.screen.mainScreen() + return screen and tostring(hs.spaces.activeSpaceOnScreen(screen)) +end + +--- WindowManagerPlus:getLayoutForCurrentSpace() +--- Method +--- Gets the layout index for the current Space +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * Number - Layout index (1-4) +--- +--- Notes: +--- * Returns the per-space layout if enabled, otherwise the global layout +function obj:getLayoutForCurrentSpace() + if not self.config.usePerSpaceLayouts then return self.state.currentLayout end + local spaceID = self:getCurrentSpaceID() + return spaceID and self.state.spaceLayouts[spaceID] or self.state.currentLayout +end + +--- WindowManagerPlus:setLayoutForCurrentSpace(layoutIndex) +--- Method +--- Sets the layout for the current Space +--- +--- Parameters: +--- * layoutIndex - Number (1-4) indicating the layout to use +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Updates both the global and per-space layout settings +function obj:setLayoutForCurrentSpace(layoutIndex) + self.state.currentLayout = layoutIndex + if self.config.usePerSpaceLayouts then + local spaceID = self:getCurrentSpaceID() + if spaceID then self.state.spaceLayouts[spaceID] = layoutIndex end + end +end + +--- WindowManagerPlus:saveWindowOrderForCurrentSpace() +--- Method +--- Saves the current window order for the active Space +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Used internally to preserve window order across layout changes +function obj:saveWindowOrderForCurrentSpace() + local spaceID = self:getCurrentSpaceID() + if not spaceID or not self.state.windows then return end + + local windowOrder = {} + for i, win in ipairs(self.state.windows) do + if win and win:id() and win:isStandard() then + local app = win:application() + if app then + table.insert(windowOrder, { + id = win:id(), app = app:name(), title = win:title() or "", + bundleID = app:bundleID() or "", index = i + }) + end + end + end + + self.state.spaceWindowOrder[spaceID] = windowOrder +end + +--- WindowManagerPlus:restoreWindowOrderForCurrentSpace() +--- Method +--- Restores the saved window order for the current Space +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * Boolean - true if order was restored, false otherwise +--- +--- Notes: +--- * Used internally during layout updates +function obj:restoreWindowOrderForCurrentSpace() + local spaceID = self:getCurrentSpaceID() + if not spaceID or not self.state.spaceWindowOrder[spaceID] then return false end + + local savedOrder = self.state.spaceWindowOrder[spaceID] + local currentWindows = self.helpers:getRelevantWindows() + + -- Create window map and restore order + local windowMap = {} + for _, win in ipairs(currentWindows) do windowMap[win:id()] = win end + + local orderedWindows, usedIds = {}, {} + + -- First pass: restore existing windows in saved order + for _, saved in ipairs(savedOrder) do + if windowMap[saved.id] and not usedIds[saved.id] then + table.insert(orderedWindows, windowMap[saved.id]) + usedIds[saved.id] = true + end + end + + -- Second pass: add remaining windows + for _, win in ipairs(currentWindows) do + if not usedIds[win:id()] then + table.insert(orderedWindows, win) + end + end + + if #orderedWindows > 0 then + self.state.windows = orderedWindows + return true + end + + return false +end + +--- WindowManagerPlus:updateLayout(preserveOrder) +--- Method +--- Updates the window layout +--- +--- Parameters: +--- * preserveOrder - Boolean, whether to preserve window order (default: auto-detect) +--- +--- Returns: +--- * None +--- +--- Notes: +--- * This is the main layout update function +--- * Automatically handles per-space layouts and window order preservation +function obj:updateLayout(preserveOrder) + if self.config.usePerSpaceLayouts then + local spaceID = self:getCurrentSpaceID() + if spaceID then + self.state.currentLayout = self.state.spaceLayouts[spaceID] or self.state.currentLayout + end + end + + if not preserveOrder then + preserveOrder = self:restoreWindowOrderForCurrentSpace() + end + + self.core:updateLayout(preserveOrder) + self:saveSettings() +end + +--- WindowManagerPlus:loadSettings() +--- Method +--- Loads settings from the persistence file +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Called automatically during init +--- * Settings are stored in JSON format +function obj:loadSettings() + local file = io.open(self.settingsFile, "r") + if not file then return end + + local content = file:read("*all") + file:close() + + local success, settings = pcall(hs.json.decode, content) + if not success or not settings then return end + + -- Load state (excluding runtime-only fields) + local excludedStateKeys = {alertUUID = true, floatingWindows = true, floatingSteps = true, lastWindowFrames = true} + for k, v in pairs(settings) do + if self.state[k] ~= nil and not excludedStateKeys[k] then + self.state[k] = v + end + end + + -- Convert floating window/step IDs from strings to numbers + local function convertIds(source, target) + if source then + for k, v in pairs(source) do + target[tonumber(k)] = v + end + end + end + + convertIds(settings.floatingWindows, self.state.floatingWindows) + convertIds(settings.floatingSteps, self.state.floatingSteps) + + -- Load config with deep merge + if settings.config then + for k, v in pairs(settings.config) do + if type(v) == "table" and type(self.config[k]) == "table" then + for nested_k, nested_v in pairs(v) do + self.config[k][nested_k] = nested_v + end + else + self.config[k] = v + end + end + end +end + +--- WindowManagerPlus:saveSettings() +--- Method +--- Saves current settings to the persistence file +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Called automatically after configuration changes +--- * Only saves user-configurable settings, not runtime state +function obj:saveSettings() + -- Convert floating IDs to strings for JSON serialization + local function convertIdsToStrings(source) + local result = {} + for id, v in pairs(source) do + result[tostring(id)] = v + end + return result + end + + local settings = { + -- State + currentLayout = self.state.currentLayout, + splitRatios = self.state.splitRatios, + splitDirections = self.state.splitDirections, + floatingWindows = convertIdsToStrings(self.state.floatingWindows), + floatingSteps = convertIdsToStrings(self.state.floatingSteps), + spaceLayouts = self.state.spaceLayouts, + spaceWindowOrder = self.state.spaceWindowOrder, + + -- Config (only user-modifiable parts) + config = { + usePerSpaceLayouts = self.config.usePerSpaceLayouts, + settingsKeywords = self.config.settingsKeywords, + resizing = self.config.resizing, + timing = self.config.timing, + smallWindowThreshold = self.config.smallWindowThreshold, + focusedWindowIndicator = self.config.focusedWindowIndicator, + alerts = self.config.alerts, + stackIndicators = self.config.stackIndicators + } + } + + local success, json = pcall(hs.json.encode, settings) + if success and json then + local file = io.open(self.settingsFile, "w") + if file then + file:write(json) + file:close() + end + end +end + +--- WindowManagerPlus:configure(config) +--- Method +--- Configures WindowManagerPlus settings +--- +--- Parameters: +--- * config - Table of configuration options (see WindowManagerPlus.config for available keys) +--- +--- Returns: +--- * The WindowManagerPlus object for method chaining +--- +--- Notes: +--- * Configuration is merged with existing settings +--- * Colors can be specified as hex strings or color tables +--- +--- Example: +--- ```lua +--- wm:configure({ +--- gap = 10, +--- modeColors = { +--- bsp = "#FF6B35", +--- master = "#4ECDC4" +--- } +--- }) +--- ``` +function obj:configure(config) + return self.api:configure(config) +end + +--- WindowManagerPlus:bindHotkeys(mapping) +--- Method +--- Binds hotkeys to WindowManagerPlus functions +--- +--- Parameters: +--- * mapping - Table of hotkey definitions where keys are function names +--- +--- Returns: +--- * The WindowManagerPlus object for method chaining +--- +--- Notes: +--- * See api.lua for all available function names +--- * Hotkey format: {modifiers, key} +--- +--- Example: +--- ```lua +--- wm:bindHotkeys({ +--- cycleLayout = {{"alt", "cmd"}, "space"}, +--- focus = {{"alt", "cmd"}, "j"}, +--- toggleFloatingWindow = {{"alt", "cmd"}, "f"} +--- }) +--- ``` +function obj:bindHotkeys(mapping) + return self.api:bindHotkeys(mapping) +end + +--- WindowManagerPlus:start() +--- Method +--- Starts the window manager +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The WindowManagerPlus object +--- +--- Notes: +--- * Must be called after configuration and hotkey binding +--- * Sets up all event watchers and performs initial layout +--- +--- Example: +--- ```lua +--- hs.loadSpoon("WindowManagerPlus") +--- :configure({gap = 8}) +--- :bindHotkeys({cycleLayout = {{"alt", "cmd"}, "space"}}) +--- :start() +--- ``` +function obj:start() + return self.api:start() +end + +return obj \ No newline at end of file diff --git a/Source/WindowManagerPlus.spoon/ui.lua b/Source/WindowManagerPlus.spoon/ui.lua new file mode 100644 index 00000000..eba89e6b --- /dev/null +++ b/Source/WindowManagerPlus.spoon/ui.lua @@ -0,0 +1,264 @@ +--- === WindowManagerPlus.ui === +--- +--- UI module for WindowManagerPlus +--- +--- This module provides visual indicators and feedback for WindowManagerPlus, including +--- focused window indicators, stack indicators, and visual feedback elements. + +local UI = {} +UI.__index = UI + +--- WindowManagerPlus.ui:new(spoon) +--- Constructor +--- Creates a new UI instance +--- +--- Parameters: +--- * spoon - The WindowManagerPlus spoon instance +--- +--- Returns: +--- * UI object +--- +--- Notes: +--- * Initializes canvas objects for visual indicators +--- * Canvas objects are created as needed and cleaned up automatically +function UI:new(spoon) + local o = setmetatable({}, self) + o.spoon = spoon + o.focusedWindowCanvas = nil + o.stackIndicatorsCanvas = nil + return o +end + +--- WindowManagerPlus.ui:updateFocusedWindowIndicator() +--- Method +--- Updates the visual indicator for the currently focused window +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Creates a small colored indicator at the bottom center of the focused window +--- * The indicator color matches the current layout mode +--- * Not shown in stacking layout (since all windows overlap) +--- * Automatically cleaned up when focus changes +--- * Uses config values: focusedWindowIndicator.width, height, opacity, borderRadius +function UI:updateFocusedWindowIndicator() + -- Clean up existing indicator + if self.focusedWindowCanvas then + self.focusedWindowCanvas:delete() + self.focusedWindowCanvas = nil + end + + local layout = self.spoon.layouts[self.spoon:getLayoutForCurrentSpace()] + if layout == "stacking" then return end + + local win = hs.window.focusedWindow() + if not win or not self.spoon.helpers:isRelevantWindow(win) then return end + + local frame = win:frame() + local color = self.spoon.config.modeColors[layout] + + -- Safe config access with defaults + local indicatorConfig = self.spoon.config.focusedWindowIndicator or {} + local indicatorWidth = indicatorConfig.width or 50 + local indicatorHeight = indicatorConfig.height or 4 + local opacity = indicatorConfig.opacity or 0.9 + local borderRadius = indicatorConfig.borderRadius or 2 + + -- Create a small indicator at the bottom center of the window + local x = frame.x + (frame.w - indicatorWidth) / 2 + local y = frame.y + frame.h - indicatorHeight + + self.focusedWindowCanvas = hs.canvas.new({ + x = x, + y = y, + w = indicatorWidth, + h = indicatorHeight + }) + + self.focusedWindowCanvas:level(hs.canvas.windowLevels.overlay) + + -- Create element with safe rounded rect radii + local element = { + type = "rectangle", + action = "fill", + fillColor = { + red = color.red or 0.5, + green = color.green or 0.5, + blue = color.blue or 0.5, + alpha = opacity + } + } + + -- Only add roundedRectRadii if borderRadius is valid + if type(borderRadius) == "number" and borderRadius > 0 then + element.roundedRectRadii = { + xRadius = borderRadius, + yRadius = borderRadius + } + end + + self.focusedWindowCanvas:appendElements({element}) + self.focusedWindowCanvas:show() +end + +--- WindowManagerPlus.ui:updateStackIndicators() +--- Method +--- Updates the visual indicators for stacked windows +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Only shown in stacking layout mode +--- * Displays small rectangles representing each window in the stack +--- * The focused window indicator is highlighted in the layout color +--- * Other windows shown in neutral gray +--- * Position configurable: bottomLeft, bottomRight, topLeft, topRight +--- * Uses config values: stackIndicators.enabled, position, width, height, spacing, maxIndicators, borderRadius +function UI:updateStackIndicators() + -- Clean up existing indicators + if self.stackIndicatorsCanvas then + self.stackIndicatorsCanvas:delete() + self.stackIndicatorsCanvas = nil + end + + local layout = self.spoon.layouts[self.spoon:getLayoutForCurrentSpace()] + if layout ~= "stacking" then + return + end + + -- Safe config access with defaults + local stackConfig = self.spoon.config.stackIndicators or {} + if not stackConfig.enabled then return end + + local windows = self.spoon.state.windows + if #windows == 0 then return end + + local screen = hs.screen.mainScreen():frame() + local focusedWindow = hs.window.focusedWindow() + + -- Safe config values with defaults + local position = stackConfig.position or "bottomLeft" + local indicatorWidth = stackConfig.width or 40 + local indicatorHeight = stackConfig.height or 5 + local spacing = stackConfig.spacing or 5 + local maxIndicators = stackConfig.maxIndicators or 10 + local borderRadius = stackConfig.borderRadius or 2.5 + local gap = self.spoon.config.gap or 8 + + -- Calculate position based on config + local startX, startY + local numToShow = math.min(#windows, maxIndicators) + local totalWidth = (indicatorWidth + spacing) * numToShow - spacing + + if position == "bottomLeft" then + startX = screen.x + gap + startY = screen.y + screen.h - gap - indicatorHeight + elseif position == "bottomRight" then + startX = screen.x + screen.w - gap - totalWidth + startY = screen.y + screen.h - gap - indicatorHeight + elseif position == "topLeft" then + startX = screen.x + gap + startY = screen.y + gap + else -- topRight + startX = screen.x + screen.w - gap - totalWidth + startY = screen.y + gap + end + + self.stackIndicatorsCanvas = hs.canvas.new({ + x = startX, + y = startY, + w = totalWidth, + h = indicatorHeight + }) + self.stackIndicatorsCanvas:level(hs.canvas.windowLevels.overlay) + + -- Draw indicators for each window + local elements = {} + for i = 1, numToShow do + local win = windows[i] + local isFocused = focusedWindow and win:id() == focusedWindow:id() + + -- Use layout color for focused window, gray for others + local stackingColor = self.spoon.config.modeColors.stacking or {red = 0.0, green = 0.475, blue = 0.549, alpha = 1} + local color = isFocused and stackingColor or {red = 0.5, green = 0.5, blue = 0.5, alpha = 0.5} + + local element = { + type = "rectangle", + action = "fill", + fillColor = color, + frame = { + x = (i - 1) * (indicatorWidth + spacing), + y = 0, + w = indicatorWidth, + h = indicatorHeight + } + } + + -- Only add roundedRectRadii if borderRadius is valid + if type(borderRadius) == "number" and borderRadius > 0 then + element.roundedRectRadii = { + xRadius = borderRadius, + yRadius = borderRadius + } + end + + table.insert(elements, element) + end + + self.stackIndicatorsCanvas:appendElements(elements) + self.stackIndicatorsCanvas:show() +end + +--- WindowManagerPlus.ui:updateAll() +--- Method +--- Updates all visual indicators +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Convenience method that updates both focused window and stack indicators +--- * Called automatically when window focus changes or layout updates +--- * Safe to call frequently - handles cleanup of existing indicators +function UI:updateAll() + self:updateFocusedWindowIndicator() + self:updateStackIndicators() +end + +--- WindowManagerPlus.ui:cleanup() +--- Method +--- Cleans up all visual indicators +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Removes all canvas objects and frees resources +--- * Called automatically when the spoon is stopped +--- * Safe to call multiple times +function UI:cleanup() + if self.focusedWindowCanvas then + self.focusedWindowCanvas:delete() + self.focusedWindowCanvas = nil + end + if self.stackIndicatorsCanvas then + self.stackIndicatorsCanvas:delete() + self.stackIndicatorsCanvas = nil + end +end + +return UI \ No newline at end of file