Skip to content

Commit

Permalink
Hello world.
Browse files Browse the repository at this point in the history
  • Loading branch information
jesseleite committed Mar 6, 2024
0 parents commit f79d2dd
Show file tree
Hide file tree
Showing 6 changed files with 485 additions and 0 deletions.
104 changes: 104 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# GridLayout.spoon

Save preset layouts for your most commonly used apps using hs.grid for positioning!

> _NOTE: Experimental! Things may change without notice._
## Rationale

Window managers like Divvy, Spectacle, Moom, etc. are rad, but are often too simplistic.

Automatic tiling managers like Yabai are also rad, but are often too intrusive.

This spoon is an opinionated 'best-of-both-worlds' approach, allowing you to control your own floating windows, but with an easy way to automatically position your most common used apps into nice grid-based preset layouts.

## Credit

This has largely been a back-and-forth collab between my lovely co-worker [Jason Varga](https://github.com/jasonvarga) and I, but Jason deserves much of the credit for this layout system ❤️‍🔥

Also special thank you to my brother-in-arms [Evan Travers](https://github.com/evantravers) for all the insanely helpful [Hammerspoon blog posts](https://evantravers.com/articles/tags/hammerspoon/) and example [Spoons](https://github.com/evantravers?tab=repositories&q=spoon), which have proven immensely useful in this Hammerspoon journey 💘

## Install

1. MacOS
2. [Hammerspoon](https://www.hammerspoon.org/go/)
3. Download a [release](https://github.com/jesseleite/GridLayout.spoon/releases) to `~/.hammerspoon/Spoons/Headspace.spoon`
4. Load the Spoon by adding the following code snippet to `~/.hammerspoon/init.lua`:

```lua
local layout = hs.loadSpoon('GridLayout'):start()
```

## Grid Configuration

```lua
layout
:setGrid('60x20')
:setMargins('15x15')
```

> _NOTE: These methods will set the above mentioned `hs.grid` methods for you, but will also inform GridLayout.spoon of your grid configuration as well._
## Apps Configuration

```lua
layout:apps({
WezTerm = { id = 'com.github.wez.wezterm' },
Brave = { id = 'com.brave.Browser' },
Slack = { id = 'com.tinyspeck.slackmacgap' },
Tower = { id = 'com.fournova.Tower3' },
Ray = { id = 'be.spatie.ray' },
Obsidian = { id = 'md.obsidian' },
})

```

> _NOTE: Extracting an [apps.lua](https://github.com/jesseleite/dotfiles/blob/master/hammerspoon/apps.lua) object/module in your hammerspoon config is recommended; It's a nice pattern for assigning app-specific hotkeys, etc. for use throughout your hammerspoon config, but inline is fine too!_
## Layouts Configuration

```lua
layout:setLayouts({
{
name = 'Standard Dev', -- Define a 'Standard Dev' layout
cells = {
{ '0,0 7x20' }, -- Cell 1
{ '7,0 21x20' }, -- Cell 2
{ '28,0 32x20' }, -- Cell 3
{ '42,2 16x16' }, -- Cell 4
},
apps = {
Ray = { cell = 1, open = true }, -- Assign to cell 1, and ensure app opens
Brave = { cell = 2, open = true }, -- Assign to cell 2, and ensure app opens
WezTerm = { cell = 3, open = true }, -- Assign to cell 3, and ensure app opens
Tower = { cell = 3 }, -- Assign to cell 3, app being open is optional
Slack = { cell = 4 }, -- Assign to cell 4, app being open is optional
},
},
{
name = 'Code Focused', -- Define a 'Code Focused' layout
cells = {
-- etc.
},
}
})
```

> _NOTE: You may define as many layouts as you wish! Extracting [layouts.lua](https://github.com/jesseleite/dotfiles/blob/master/hammerspoon/layouts.lua) object/module in your hammerspoon config is recommended, but inline is fine too!_
### Defining Layout Variants

WIP

## Available Action Methods

WIP

| Method | Params | Description |
| :--- | :--- | :--- |
| `layout:selectLayout()` | | Open fuzzy layout selector. |
| `layout:applyLayout()` | | Programatically apply a specific layout. |
| `layout:selectNextVariant()` | | Select next [layout variant](#defining-layout-variants). |
| `layout:bindToCell()` | | Bind currently focused app to a specific layout cell. |
| `layout:resetLayout()` | | Reset currently selected layout state. |
| `layout:resetAll()` | | Reset all in-memory GridLayout.spoon state. |
22 changes: 22 additions & 0 deletions events.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
local M = {}

-- Print focused window info to console for debugging purposes.
M.window_info = hs.window.filter.new():subscribe(hs.window.filter.windowFocused, function(window)
local f = window:frame()
print(hs.inspect({
Focused = {
['App Name'] = window:application():name(),
['Bundle ID'] = window:application():bundleID(),
['Window Title'] = window:title(),
['Window ID'] = window:id(),
['Window Frame'] = string.format("{x=%s,y=%s,w=%s,h=%s}", f._x, f._y, f._w, f._h),
}
}))
end)

-- Unsubscribe to all registered events.
function M:unsubscribeAll()
M.window_info:unsubscribeAll()
end

return M
72 changes: 72 additions & 0 deletions grid.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
-- This might be a nice FR / PR to hammerspoon, so we'll keep it out
-- of sight as a separate module that could be deleted later on.
--
-- Normally hs.grid.getCell() does not respect margins, so this helper
-- accepts a cell string and returns a frame with margins respected.
-- Most of this code was ripped from hs.grid.set(), and is just
-- modified to return a frame that respects margin settings.
--
-- Unfortunately, for this to work we also need to know the user's
-- desired margins, but there doesn't seem to be an easy way to
-- hs.grid.getMargins() or similar, so we need to set here.

local M = {}

local grid = require('hs.grid')
local geom = require('hs.geometry')
local screen = require('hs.screen')

local margins = geom'5x5'

function M.setMargins(mar)
mar=geom.new(mar)
if geom.type(mar)=='point' then mar=geom.size(mar.x,mar.y) end
if geom.type(mar)~='size' then error('invalid margins',2)end
margins=mar
end

local min,max = math.min,math.max

function M.getCellWithMargins(cell, scr)
scr=screen.find(scr)
if not scr then scr=hs.screen.mainScreen() end
cell=geom.new(cell)
local screenrect = grid.getGridFrame(scr)
local screengrid = grid.getGrid(scr)
cell.x=max(0,min(cell.x,screengrid.w-1)) cell.y=max(0,min(cell.y,screengrid.h-1))
cell.w=max(1,min(cell.w,screengrid.w-cell.x)) cell.h=max(1,min(cell.h,screengrid.h-cell.y))
local cellw, cellh = screenrect.w/screengrid.w, screenrect.h/screengrid.h
local newframe = {
x = (cell.x * cellw) + screenrect.x + margins.w,
y = (cell.y * cellh) + screenrect.y + margins.h,
w = cell.w * cellw - (margins.w * 2),
h = cell.h * cellh - (margins.h * 2),
}

if cell.h < screengrid.h and cell.h % 1 == 0 then
if cell.y ~= 0 then
newframe.h = newframe.h + margins.h / 2
newframe.y = newframe.y - margins.h / 2
end

if cell.y + cell.h ~= screengrid.h then
newframe.h = newframe.h + margins.h / 2
end
end

if cell.w < screengrid.w and cell.w % 1 == 0 then
if cell.x ~= 0 then
newframe.w = newframe.w + margins.w / 2
newframe.x = newframe.x - margins.w / 2
end

if cell.x + cell.w ~= screengrid.w then
newframe.w = newframe.w + margins.w / 2
end
end

-- Return newframe instead of setting window frame like hs.grid.set() does
return newframe
end

return M
92 changes: 92 additions & 0 deletions helpers.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
local M = {}

-- Extra grid helper, which maybe we can PR to hammerspoon and/or remove later.
M.grid = dofile(hs.spoons.resourcePath('grid.lua'))

-- Normalize layout table from spoon convention for use in hs.layout.apply().
function M.normalizeLayoutForApply(layout, state)
if not layout.apps then
return layout
end

local normalized = {}

for app,config in pairs(layout.apps) do
table.insert(normalized, M.normalizeElementForApply(
state.apps[app].id,
state.apps[app].window,
config.cell,
layout,
state
))
end

return normalized
end

-- Apply single layout element for use in hs.layout.apply.
function M.normalizeElementForApply(app_id, window, cell, layout, state)
return {
app_id,
window,
nil,
nil,
M.grid.getCellWithMargins(layout.cells[cell][state.current_layout_variant]),
}
end

-- Ensure app is open if `open = true` in layout.apps configuration.
-- Also ensure app is unhidden.
function M.ensureOpenWhenConfigured(layoutApps, allApps)
for name,config in pairs(layoutApps) do
local app
if config.open then
app = hs.application.open(allApps[name].id or name, 10, true)
else
app = hs.application.get(allApps[name].id or name)
end
if app == nil then return end
app:unhide()
end
end

-- Hide all windows except those relevant to the layout.apps configuration.
function M.hideAllWindowsExcept(layoutApps, allApps)
local allowedIds = {}
for name,_ in pairs(layoutApps) do
table.insert(allowedIds, allApps[name].id)
end
for _,window in pairs(hs.window.visibleWindows()) do
local app = window:application()
local found = hs.fnutils.find(allowedIds, function(allowedId)
return allowedId == app:bundleID()
end)
if not found then
app:hide()
end
end
end

-- List apps in cells
function M.listAppsInCells(layout, state)
local cells = {}

for key,_ in pairs(layout.cells) do
cells[key] = {
['key'] = key,
['apps'] = {},
}
end

for app,config in pairs(layout.apps) do
table.insert(cells[config.cell].apps, app)
end

for _,config in pairs(state.layout_customizations[state.current_layout_key] or {}) do
table.insert(cells[config.cell].apps, config.window:application():name()..' ('..config.window:title()..', '..config.window:id()..')')
end

return cells
end

return M
Loading

0 comments on commit f79d2dd

Please sign in to comment.