feat(hyprlua/columns): resizable+scrolling columns, window parking, reload-safe

Overhaul the custom "columns" Lua layout and its navigation wiring:

- Flexible sizing: per-column width weights + per-window height weights
  (Super+Alt resize), scale-to-fill when a column is underfull, scroll/pan
  when overfull. Drops the old fixed viewport/rows window heights.
- Window parking: move u/d within a column parks two windows side by side in
  one slot (persistent, split across the column axis with a divider parallel
  to the scroll direction; the column closes up so there is no gap); moving a
  parked window again pops it out past its partner.
- New windows stack into the working column (with a balance guard) instead of
  scattering, so two windows share a column/height by default.
- Cross-monitor focus/move at the column edges (relative +1/-1 selectors).
- hypr-nav: adds resize + scroll kinds; Super+wheel travels along the focused
  strip (column / tape) and falls back to workspace switch elsewhere.
- Super+Alt arrows/hjkl route resize through the layout; Super+rightclick
  auto-floats then resizes ONLY in columns (no pointer-resize hook for custom
  layouts) and is a plain resize everywhere else.
- Reload-safety: hyprctl reload dropped per-ws layout rules + Lua state,
  silently reverting columns workspaces to scrolling. init.lua now replays
  layouts-state.json (M.restore) and columns.lua re-seeds cols/rows/orient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-07-07 19:49:28 +02:00
parent 4c747221f2
commit 964ce103a0
5 changed files with 347 additions and 82 deletions

View File

@ -220,3 +220,4 @@ hl.device({
-- popup via `hyprctl eval 'layouts.set(ws, name, dir)'`. -- popup via `hyprctl eval 'layouts.set(ws, name, dir)'`.
require("layouts.init") require("layouts.init")
layouts.set_default("scrolling") layouts.set_default("scrolling")
layouts.restore() -- re-apply per-workspace layout choices so a reload doesn't drop them

View File

@ -1,25 +1,52 @@
-- Columns: a custom (Lua) tiling layout — a row of columns, each an INDEPENDENTLY -- Columns: a custom (Lua) tiling layout — a row of vertical columns. Think of it as
-- panning vertical strip. Moving focus up/down inside a column pans only that column -- one long horizontal strip of columns that spans every monitor: focus/move flow off
-- to keep the focused window in view; the other columns stay frozen. Like the wheels -- the edge of one monitor onto the next, so it behaves like a huge row of scrolling
-- of a combination lock, every column scrolls on its own. -- workspaces rather than a set of isolated boxes.
-- --
-- This is a real custom layout (hl.layout.register), not the built-in scrolling -- Each column is an INDEPENDENTLY-SCROLLING vertical strip (the whole point): when a
-- engine — that one has a single shared tape offset and cannot pan columns apart. -- column holds more windows than fit, it pans to keep the focused one in view while the
-- other columns stay put — like the wheels of a combination lock. Sizing is flexible,
-- nothing is a fixed fraction:
-- * Each COLUMN has a width weight; the viewport width is split between columns by
-- those weights. Resizing left/right grows/shrinks the focused column.
-- * Each WINDOW has a height weight. The base height of a weight-1 window is
-- viewport/`rows` (rows = windows-per-view). A column that is *underfull* stretches
-- its windows to fill (a lone window fills the column; two equal windows share it
-- 50/50) — so you can stack a file-explorer over a terminal in column 1 while
-- Firefox owns column 2. A column that is *overfull* keeps natural heights and
-- SCROLLS. Resizing up/down retunes the focused window's height weight.
-- Weights and per-column pan persist across relayouts.
--
-- This is a real custom layout (hl.layout.register), not the built-in scrolling engine —
-- that one has a single shared tape; this pans each column apart.
-- --
-- Controls (routed here from binds.lua via `hl.dsp.layout("<cmd>")` → layout_msg): -- Controls (routed here from binds.lua via `hl.dsp.layout("<cmd>")` → layout_msg):
-- focus l/r/u/d — l/r step between columns, u/d step within the focused column -- focus l/r/u/d — l/r step between columns (off the edge → next/prev monitor),
-- (which then pans to reveal the focus). Axes swap when vertical. -- u/d step within the focused column (which pans to it). Swap vertical.
-- move l/r/u/d — move the focused window between columns / reorder in its column. -- move l/r/u/d — l/r move between columns (off the edge → next/prev monitor).
-- u/d PARK/UNPARK within the column: moving toward a single neighbour
-- parks the two side by side in one slot (persistent; both keep the full
-- scroll-axis length, split across the column — a vertical divider when
-- horizontal — and the column closes up so there's no gap). Moving a
-- parked window again pops it out and past its partner.
-- resize l/r/u/d — l/r change the focused column's width, u/d the focused window's
-- height (weights, so neighbours give/take space). Axes swap vertical.
-- count h/j/k/l — horizontal ws: h/l = / columns, k/j = / windows-per-view. -- count h/j/k/l — horizontal ws: h/l = / columns, k/j = / windows-per-view.
-- vertical ws: k/j = / columns, h/l = / per-view. -- vertical ws: k/j = / columns, h/l = / per-view.
-- cols +/-/N — direct column-count set (the menu's stepper).
-- orient h/v — orientation (set from the menu dropdown). -- orient h/v — orientation (set from the menu dropdown).
-- --
-- State is per workspace; a window's column assignment and each column's pan offset -- State is per workspace; a window's column assignment, order, both weight tables and
-- persist across relayouts. -- each column's pan offset persist across relayouts.
local state = {} -- [wsid] = { ncols, rows, orient, assign={addr=col}, seq={addr=n}, nextseq, pan={col=px} } local state = {} -- [wsid] = { ncols, rows, orient, assign={addr=col}, seq={addr=n},
-- nextseq, colw={col=weight}, winh={addr=weight}, pan={col=px} }
-- Publish per-workspace column/row counts so the astal-menu can show a live stepper. -- Resize weights: base 1.0, clamped to a sane band so nothing collapses or hogs.
local RSTEP, RMIN, RMAX = 0.15, 0.25, 6.0
local function clampw(v) return math.max(RMIN, math.min(RMAX, v)) end
-- Publish per-workspace column count so the astal-menu can show a live stepper.
local STATE_FILE = (os.getenv("HOME") or "") .. "/.cache/astal-menu/columns-state.json" local STATE_FILE = (os.getenv("HOME") or "") .. "/.cache/astal-menu/columns-state.json"
local function publish() local function publish()
local parts = {} local parts = {}
@ -33,10 +60,28 @@ local function publish()
end) end)
end end
-- Seed ncols/rows/orient for a workspace from the last published state — a reload wipes
-- this module's Lua state, so without this the user's column count and orientation reset
-- to defaults every reload. (Per-window assignment/weights are not restored; they reflow.)
local function seed(wsid)
local out = {}
local f = io.open(STATE_FILE, "r")
if not f then return out end
local data = f:read("*a") or ""; f:close()
local blob = data:match('"' .. tostring(wsid) .. '"%s*:%s*(%b{})')
if not blob then return out end
out.ncols = tonumber(blob:match('"cols"%s*:%s*(%d+)'))
out.rows = tonumber(blob:match('"rows"%s*:%s*(%d+)'))
out.orient = blob:match('"orient"%s*:%s*"(%a+)"')
return out
end
local function ws_state(wsid) local function ws_state(wsid)
local s = state[wsid] local s = state[wsid]
if not s then if not s then
s = { ncols = 2, rows = 2, orient = "h", assign = {}, seq = {}, nextseq = 1, pan = {} } local sd = seed(wsid)
s = { ncols = sd.ncols or 2, rows = sd.rows or 2, orient = sd.orient or "h",
assign = {}, seq = {}, nextseq = 1, colw = {}, winh = {}, pan = {}, pair = {} }
state[wsid] = s state[wsid] = s
publish() publish()
end end
@ -52,6 +97,7 @@ local function reflow(s, targets)
end end
table.sort(addrs, function(a, b) return (s.seq[a] or 0) < (s.seq[b] or 0) end) table.sort(addrs, function(a, b) return (s.seq[a] or 0) < (s.seq[b] or 0) end)
for i, addr in ipairs(addrs) do s.assign[addr] = ((i - 1) % s.ncols) + 1 end for i, addr in ipairs(addrs) do s.assign[addr] = ((i - 1) % s.ncols) + 1 end
for k in pairs(s.pair) do s.pair[k] = nil end -- a redistribute breaks parked pairs
end end
local function win_ws_id(win) local function win_ws_id(win)
@ -73,7 +119,8 @@ local function focused_addr()
end end
-- Build the per-column ordered address lists from the live targets, reconciling the -- Build the per-column ordered address lists from the live targets, reconciling the
-- persistent assignment (drop closed windows, assign new ones to the focused column). -- persistent assignment (drop closed windows; a single new window stacks into the
-- working column, a batch spreads across the emptiest columns — see below).
local function columnize(s, targets) local function columnize(s, targets)
local present, tgt_of = {}, {} local present, tgt_of = {}, {}
for _, tg in ipairs(targets) do for _, tg in ipairs(targets) do
@ -81,26 +128,63 @@ local function columnize(s, targets)
if ok and addr then present[addr] = true; tgt_of[addr] = tg end if ok and addr then present[addr] = true; tgt_of[addr] = tg end
end end
for addr in pairs(s.assign) do for addr in pairs(s.assign) do
if not present[addr] then s.assign[addr] = nil; s.seq[addr] = nil end if not present[addr] then
local p = s.pair[addr]
if p then s.pair[p] = nil; s.pair[addr] = nil end -- release a parked partner
s.assign[addr] = nil; s.seq[addr] = nil; s.winh[addr] = nil
end
end end
local foc = focused_addr() local foc = focused_addr()
-- new windows fill the least-populated column so they spread out.
local counts = {} local counts = {}
for c = 1, s.ncols do counts[c] = 0 end for c = 1, s.ncols do counts[c] = 0 end
for addr, c in pairs(s.assign) do for addr, c in pairs(s.assign) do
if present[addr] and c >= 1 and c <= s.ncols then counts[c] = counts[c] + 1 end if present[addr] and c >= 1 and c <= s.ncols then counts[c] = counts[c] + 1 end
end end
for addr in pairs(present) do local fresh = {}
if not s.assign[addr] then for addr in pairs(present) do if not s.assign[addr] then fresh[#fresh + 1] = addr end end
local best, bestn = 1, math.huge local function least_col()
for c = 1, s.ncols do if counts[c] < bestn then best, bestn = c, counts[c] end end local best, bestn = 1, math.huge
s.assign[addr] = best; counts[best] = counts[best] + 1 for c = 1, s.ncols do if counts[c] < bestn then best, bestn = c, counts[c] end end
return best
end
if #fresh == 1 and next(s.assign) ~= nil then
-- ONE new window while others already exist → it stacks into the column the user
-- is working in (the new window itself grabbed focus, so use the remembered
-- last-focused column). Opening a 2nd window thus shares that column instead of
-- spreading to an empty one — that's how you get two windows at one height.
local c = s.lastcol
if not (c and c >= 1 and c <= s.ncols) then
-- focus memory not ready (a just-opened window's focus can lag the relayout)
-- → join the most-recently-opened window's column instead.
local best_seq = -1
for addr, col in pairs(s.assign) do
if present[addr] and (s.seq[addr] or 0) > best_seq then
best_seq = s.seq[addr]; c = col
end
end
if not (c and c >= 1 and c <= s.ncols) then c = least_col() end
end
-- ...but don't let one column run away: if it's already 2+ ahead of the emptiest,
-- send the newcomer to the emptiest instead. This both balances rapid opens and
-- keeps a reload (which re-adds every window one-by-one) from piling into column 1.
local lc = least_col()
if counts[c] - counts[lc] >= 2 then c = lc end
s.assign[fresh[1]] = c; counts[c] = counts[c] + 1
s.seq[fresh[1]] = s.nextseq; s.nextseq = s.nextseq + 1
else
-- first window, or several appearing at once (session start / restore) → spread
-- across the emptiest columns so a fresh session isn't one giant pile.
for _, addr in ipairs(fresh) do
local c = least_col()
s.assign[addr] = c; counts[c] = counts[c] + 1
s.seq[addr] = s.nextseq; s.nextseq = s.nextseq + 1 s.seq[addr] = s.nextseq; s.nextseq = s.nextseq + 1
end end
end end
for addr, c in pairs(s.assign) do for addr, c in pairs(s.assign) do
if c > s.ncols then s.assign[addr] = s.ncols elseif c < 1 then s.assign[addr] = 1 end if c > s.ncols then s.assign[addr] = s.ncols elseif c < 1 then s.assign[addr] = 1 end
end end
-- remember the focused window's column so the *next* new window stacks there.
if foc and s.assign[foc] then s.lastcol = s.assign[foc] end
local cols = {} local cols = {}
for c = 1, s.ncols do cols[c] = {} end for c = 1, s.ncols do cols[c] = {} end
for addr in pairs(present) do table.insert(cols[s.assign[addr]], addr) end for addr in pairs(present) do table.insert(cols[s.assign[addr]], addr) end
@ -120,40 +204,98 @@ local function recalculate(ctx)
local horizontal = (s.orient ~= "v") local horizontal = (s.orient ~= "v")
local rows = math.max(1, s.rows) local rows = math.max(1, s.rows)
-- primary axis is split between columns; the secondary axis stacks each column's
-- windows and pans (scrolls) when they overflow.
local primLen = horizontal and A.w or A.h
local primOrg = horizontal and A.x or A.y
local secLen = horizontal and A.h or A.w
local secOrg = horizontal and A.y or A.x
local csum = 0
for c = 1, s.ncols do csum = csum + (s.colw[c] or 1) end
if csum <= 0 then csum = 1 end
local cpos = primOrg
for c = 1, s.ncols do for c = 1, s.ncols do
local colLen = primLen * ((s.colw[c] or 1) / csum)
local list = cols[c] local list = cols[c]
local m = #list local m = #list
if m > 0 then if m > 0 then
-- primary axis is split into ncols; the secondary axis stacks the windows, -- group the column into rows: two mutually-paired, adjacent windows share one
-- each sized to 1/rows of the viewport, and pans. -- row side by side (a "parked" pair); every other window is its own row. Pairing
local colSpan, colStart, winSec, secStart, secTotal -- removes a row, so the column closes up around a park — no gap.
if horizontal then local rowlist = {}
colSpan, colStart = A.w / s.ncols, A.x + (c - 1) * (A.w / s.ncols) local i = 1
winSec, secStart, secTotal = A.h / rows, A.y, A.h while i <= m do
local a, b = list[i], list[i + 1]
if b and s.pair[a] == b and s.pair[b] == a then
rowlist[#rowlist + 1] = { a, b }; i = i + 2
else
rowlist[#rowlist + 1] = { a }; i = i + 1
end
end
-- base height of a weight-1 row is viewport / windows-per-view; a parked row
-- takes the larger of its two weights so neither window is clipped.
local unit = secLen / rows
local function wof(addr) local w = s.winh[addr] or 1; return (w > 0) and w or 1 end
local rh, total = {}, 0
for r, row in ipairs(rowlist) do
local w = (#row == 2) and math.max(wof(row[1]), wof(row[2])) or wof(row[1])
rh[r] = w * unit; total = total + rh[r]
end
-- row holding the focused window (drives the pan)
local frow
for r, row in ipairs(rowlist) do
for _, addr in ipairs(row) do if addr == foc then frow = r end end
end
local pan
if total <= secLen then
-- underfull: stretch rows to fill so there's no dead space.
local scale = secLen / total
for r = 1, #rowlist do rh[r] = rh[r] * scale end
pan = 0
else else
colSpan, colStart = A.h / s.ncols, A.y + (c - 1) * (A.h / s.ncols) -- overfull: keep natural heights and PAN so the focused row stays in view.
winSec, secStart, secTotal = A.w / rows, A.x, A.w pan = s.pan[c] or 0
if frow then
local top = 0
for r = 1, frow - 1 do top = top + rh[r] end
local bottom = top + rh[frow]
if top - pan < 0 then pan = top end
if bottom - pan > secLen then pan = bottom - secLen end
end
pan = math.max(0, math.min(pan, total - secLen))
end end
-- pan so the focused window (if in this column) stays in view; else clamp.
local fidx
for i, addr in ipairs(list) do if addr == foc then fidx = i end end
local pan = s.pan[c] or 0
if fidx then
local top = (fidx - 1) * winSec
if top - pan < 0 then pan = top end
if top + winSec - pan > secTotal then pan = top + winSec - secTotal end
end
pan = math.max(0, math.min(pan, math.max(0, m * winSec - secTotal)))
s.pan[c] = pan s.pan[c] = pan
for i, addr in ipairs(list) do
local off = (i - 1) * winSec - pan -- place a box given position/length on the primary (column) axis and the
-- secondary (scroll/stacking) axis, mapped to x/y per orientation.
local function place_box(addr, ppos, plen, spos, slen)
local box = horizontal local box = horizontal
and { x = colStart, y = secStart + off, w = colSpan, h = winSec } and { x = ppos, y = spos, w = plen, h = slen }
or { x = secStart + off, y = colStart, w = winSec, h = colSpan } or { x = spos, y = ppos, w = slen, h = plen }
local tg = tgt_of[addr] local tg = tgt_of[addr]
if tg then pcall(function() tg:place(box) end) end if tg then pcall(function() tg:place(box) end) end
end end
local spos = secOrg - pan
for r, row in ipairs(rowlist) do
if #row == 1 then
place_box(row[1], cpos, colLen, spos, rh[r])
else
-- parked pair: side by side, split across the column, both keeping the
-- full row length along the scroll axis (a vertical divider horizontally).
local halfp = colLen / 2
place_box(row[1], cpos, halfp, spos, rh[r])
place_box(row[2], cpos + halfp, halfp, spos, rh[r])
end
spos = spos + rh[r]
end
end end
cpos = cpos + colLen
end end
end end
@ -170,14 +312,23 @@ local function layout_msg(ctx, cmd)
local fi = 1 local fi = 1
if foc then for i, a in ipairs(cols[fc] or {}) do if a == foc then fi = i end end end if foc then for i, a in ipairs(cols[fc] or {}) do if a == foc then fi = i end end end
-- l/r or u/d perpendicular to the column axis means "between columns" -- l/r (horizontal) or u/d (vertical) run perpendicular to the column axis → these
-- act "between columns"; the other pair acts within the focused column.
local function between(a) return horizontal and (a == "l" or a == "r") or (not horizontal and (a == "u" or a == "d")) end local function between(a) return horizontal and (a == "l" or a == "r") or (not horizontal and (a == "u" or a == "d")) end
local function decrease(a) return a == "l" or a == "u" end local function decrease(a) return a == "l" or a == "u" end
local function unpair(addr)
local p = addr and s.pair[addr]
if p then s.pair[p] = nil; s.pair[addr] = nil end
end
if verb == "focus" then if verb == "focus" then
if between(arg) then if between(arg) then
local nc = fc + (decrease(arg) and -1 or 1) local nc = fc + (decrease(arg) and -1 or 1)
if nc >= 1 and nc <= s.ncols and cols[nc] and #cols[nc] > 0 then if nc < 1 then
hl.dispatch(hl.dsp.focus({ monitor = "-1" })) -- off the left edge
elseif nc > s.ncols then
hl.dispatch(hl.dsp.focus({ monitor = "+1" })) -- off the right edge
elseif cols[nc] and #cols[nc] > 0 then
local ni = math.min(fi, #cols[nc]) local ni = math.min(fi, #cols[nc])
hl.dispatch(hl.dsp.focus({ window = "address:" .. cols[nc][ni] })) hl.dispatch(hl.dsp.focus({ window = "address:" .. cols[nc][ni] }))
end end
@ -190,22 +341,56 @@ local function layout_msg(ctx, cmd)
elseif verb == "move" then elseif verb == "move" then
if not foc then return end if not foc then return end
if between(arg) then if between(arg) then
s.assign[foc] = math.max(1, math.min(s.ncols, fc + (decrease(arg) and -1 or 1))) unpair(foc) -- leaving the column un-parks it
else local nc = fc + (decrease(arg) and -1 or 1)
local list = cols[fc] if nc < 1 then
local ni = fi + (decrease(arg) and -1 or 1) hl.dispatch(hl.dsp.window.move({ monitor = "-1" })) -- carry to prev monitor
if list and ni >= 1 and ni <= #list then elseif nc > s.ncols then
local other = list[ni] hl.dispatch(hl.dsp.window.move({ monitor = "+1" })) -- carry to next monitor
s.seq[foc], s.seq[other] = s.seq[other], s.seq[foc] else
s.assign[foc] = nc
end end
else
-- PARK / UNPARK within the column. Moving toward a single neighbour parks the
-- two side by side in one slot — a persistent state, and the column closes up so
-- there's no gap. Moving a parked window again pops it out and past its partner.
local list = cols[fc]
local step = decrease(arg) and -1 or 1
local partner = s.pair[foc]
if partner then
-- parked → un-park and move the focused window past its partner.
s.pair[foc] = nil; s.pair[partner] = nil
local a, b = s.seq[foc] or 0, s.seq[partner] or 0
if (step > 0 and a < b) or (step < 0 and a > b) then
s.seq[foc], s.seq[partner] = b, a -- put foc on the far side
end
elseif list then
local nb = list[fi + step]
if nb and not s.pair[nb] then
s.pair[foc] = nb; s.pair[nb] = foc -- park side by side
elseif nb then
-- neighbour is already a parked pair → hop the focused window over both.
local nb2 = s.pair[nb]
local n1, n2 = s.seq[nb] or 0, s.seq[nb2] or 0
s.seq[foc] = (step > 0) and (math.max(n1, n2) + 0.5) or (math.min(n1, n2) - 0.5)
end
end
end
elseif verb == "resize" then
if between(arg) then
-- grow/shrink the focused column's width weight
s.colw[fc] = clampw((s.colw[fc] or 1) + (decrease(arg) and -RSTEP or RSTEP))
elseif foc then
-- grow/shrink the focused window's height weight within its column
s.winh[foc] = clampw((s.winh[foc] or 1) + (decrease(arg) and -RSTEP or RSTEP))
end end
elseif verb == "count" then elseif verb == "count" then
-- horizontal ws: h/l change column count, k/j change windows-per-view. -- horizontal ws: h/l change column count, k/j change windows-per-view.
-- vertical ws: k/j change column count, h/l change windows-per-view. -- vertical ws: k/j change column count, h/l change windows-per-view.
local more_cols = horizontal and "l" or "j" local more_cols = horizontal and "l" or "j"
local less_cols = horizontal and "h" or "k" local less_cols = horizontal and "h" or "k"
local more_rows = horizontal and "j" or "l" local more_rows = horizontal and "j" or "l"
local less_rows = horizontal and "k" or "h" local less_rows = horizontal and "k" or "h"
local changed_cols = false local changed_cols = false
if arg == more_cols then s.ncols = s.ncols + 1; changed_cols = true if arg == more_cols then s.ncols = s.ncols + 1; changed_cols = true
elseif arg == less_cols then s.ncols = math.max(1, s.ncols - 1); changed_cols = true elseif arg == less_cols then s.ncols = math.max(1, s.ncols - 1); changed_cols = true

View File

@ -105,5 +105,24 @@ function M.set_fit(method)
hl.config({ scrolling = { focus_fit_method = tonumber(method) or 0 } }) hl.config({ scrolling = { focus_fit_method = tonumber(method) or 0 } })
end end
-- Re-apply the saved per-workspace layout choices. `hyprctl reload` resets the Lua VM
-- AND drops every runtime `workspace_rule`, so without this any workspace the user had
-- switched to Columns (or another non-default layout) silently reverts to the default
-- while the menu still shows the old choice. We replay what M.set recorded in
-- layouts-state.json. Called from hyprland.lua right after set_default().
function M.restore()
local f = io.open(STATE, "r")
if not f then return end
local data = f:read("*a") or ""
f:close()
for ws, name in data:gmatch('"([^"]+)"%s*:%s*"([^"]+)"') do
local s = M.registry[name]
if s then
hl.workspace_rule({ workspace = tostring(ws), layout = s.backend })
M.current[tostring(ws)] = name
end
end
end
_G.layouts = M.load() _G.layouts = M.load()
return M return M

View File

@ -148,21 +148,42 @@ hl.bind(mainMod .. " + SHIFT + period", hl.dsp.window.move({ monitor = "+1" }))
-- mouse drag / resize -- mouse drag / resize
hl.bind(mainMod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) hl.bind(mainMod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
hl.bind(mainMod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true }) -- Super+rightclick resize. The custom Columns layout gives no mouse-resize hook, so ONLY
-- there the window is floated first (enable is a no-op if already floating) so it can be
-- mouse-dragged; re-tile afterwards with Super+V. In every other layout it's a plain
-- interactive resize, untouched. (Keyboard resize that stays tiled: Super+Alt + hjkl.)
local function active_ws_is_columns()
local ok, aw = pcall(hl.get_active_window)
if not ok or not aw then return false end
local wid
pcall(function() wid = aw.workspace.id end)
if not wid then return false end
local f = io.open((os.getenv("HOME") or "") .. "/.cache/astal-menu/layouts-state.json", "r")
if not f then return false end
local data = f:read("*a") or ""; f:close()
return data:match('"' .. tostring(wid) .. '"%s*:%s*"([^"]+)"') == "columns"
end
hl.bind(mainMod .. " + mouse:273", function()
if active_ws_is_columns() then hl.dispatch(hl.dsp.window.float({ action = "enable" })) end
hl.dispatch(hl.dsp.window.resize())
end, { mouse = true })
hl.bind(mainMod .. " + SHIFT + mouse:272", hl.dsp.window.resize(), { mouse = true }) hl.bind(mainMod .. " + SHIFT + mouse:272", hl.dsp.window.resize(), { mouse = true })
-------------------- --------------------
---- RESIZE -------- ---- RESIZE --------
-------------------- --------------------
hl.bind(mainMod .. " + ALT + right", hl.dsp.window.resize({ x = 10, y = 0, relative = true }), { repeating = true }) -- Routed through hypr-nav: in the Columns layout ALT-resize retunes the focused
hl.bind(mainMod .. " + ALT + left", hl.dsp.window.resize({ x = -10, y = 0, relative = true }), { repeating = true }) -- column's width / window's height (weights); everywhere else it's a plain screen-
hl.bind(mainMod .. " + ALT + up", hl.dsp.window.resize({ x = 0, y = -10, relative = true }), { repeating = true }) -- relative resize, exactly as before. l/h narrower, r/l wider, k/up shorter, j/down taller.
hl.bind(mainMod .. " + ALT + down", hl.dsp.window.resize({ x = 0, y = 10, relative = true }), { repeating = true }) hl.bind(mainMod .. " + ALT + right", hl.dsp.exec_cmd(nav .. " resize r"), { repeating = true })
hl.bind(mainMod .. " + ALT + l", hl.dsp.window.resize({ x = 10, y = 0, relative = true }), { repeating = true }) hl.bind(mainMod .. " + ALT + left", hl.dsp.exec_cmd(nav .. " resize l"), { repeating = true })
hl.bind(mainMod .. " + ALT + h", hl.dsp.window.resize({ x = -10, y = 0, relative = true }), { repeating = true }) hl.bind(mainMod .. " + ALT + up", hl.dsp.exec_cmd(nav .. " resize u"), { repeating = true })
hl.bind(mainMod .. " + ALT + k", hl.dsp.window.resize({ x = 0, y = -10, relative = true }), { repeating = true }) hl.bind(mainMod .. " + ALT + down", hl.dsp.exec_cmd(nav .. " resize d"), { repeating = true })
hl.bind(mainMod .. " + ALT + j", hl.dsp.window.resize({ x = 0, y = 10, relative = true }), { repeating = true }) hl.bind(mainMod .. " + ALT + l", hl.dsp.exec_cmd(nav .. " resize r"), { repeating = true })
hl.bind(mainMod .. " + ALT + h", hl.dsp.exec_cmd(nav .. " resize l"), { repeating = true })
hl.bind(mainMod .. " + ALT + k", hl.dsp.exec_cmd(nav .. " resize u"), { repeating = true })
hl.bind(mainMod .. " + ALT + j", hl.dsp.exec_cmd(nav .. " resize d"), { repeating = true })
-------------------- --------------------
---- WORKSPACES ---- ---- WORKSPACES ----
@ -187,9 +208,11 @@ hl.bind(mainMod .. " + CTRL + h", hl.dsp.focus({ workspace = "r-1"
hl.bind(mainMod .. " + CTRL + SHIFT + l", hl.dsp.window.move({ workspace = "r+1" })) hl.bind(mainMod .. " + CTRL + SHIFT + l", hl.dsp.window.move({ workspace = "r+1" }))
hl.bind(mainMod .. " + CTRL + SHIFT + h", hl.dsp.window.move({ workspace = "r-1" })) hl.bind(mainMod .. " + CTRL + SHIFT + h", hl.dsp.window.move({ workspace = "r-1" }))
-- scroll through workspaces -- Super + wheel: travel along the focused strip in the scrolling/columns layouts
hl.bind(mainMod .. " + mouse_down", hl.dsp.focus({ workspace = "r+1" })) -- (next/prev window in the tape or focused column); falls back to switching workspaces
hl.bind(mainMod .. " + mouse_up", hl.dsp.focus({ workspace = "r-1" })) -- in every other layout — see hypr-nav.
hl.bind(mainMod .. " + mouse_down", hl.dsp.exec_cmd(nav .. " scroll d"))
hl.bind(mainMod .. " + mouse_up", hl.dsp.exec_cmd(nav .. " scroll u"))
-- volume keys as workspace nav (when mainMod held) -- volume keys as workspace nav (when mainMod held)
hl.bind(mainMod .. " + XF86AudioRaiseVolume", hl.dsp.focus({ workspace = "r+1" }), { repeating = true }) hl.bind(mainMod .. " + XF86AudioRaiseVolume", hl.dsp.focus({ workspace = "r+1" }), { repeating = true })

View File

@ -16,13 +16,20 @@ mapped to its literal on-screen direction:
Super+Shift+,/. — so it isn't overloaded onto the arrows here.) Super+Shift+,/. — so it isn't overloaded onto the arrows here.)
For the custom "columns" layout every command is forwarded to that layout's own For the custom "columns" layout every command is forwarded to that layout's own
handler (focus/move drive its columns, count changes how many columns / windows-per- handler (focus/move drive its columns and flow across monitors at the edges, resize
view). For any other layout (dwindle/master/monocle) focus/move is a straight adjusts column-width / window-height weights, count changes the column count). For any
pass-through, so nothing else changes. other layout (dwindle/master/monocle) focus/move/resize is a straight pass-through, so
nothing else changes.
Usage: hypr-nav focus l|r|u|d scroll travels along the focused strip: within the focused column for "columns", along
hypr-nav move l|r|u|d the tape for the built-in scroller, and (unchanged) between workspaces everywhere else —
hypr-nav count h|j|k|l (columns layout only) so the mouse wheel means "next thing in this strip" wherever a strip exists.
Usage: hypr-nav focus l|r|u|d
hypr-nav move l|r|u|d
hypr-nav resize l|r|u|d
hypr-nav count h|j|k|l (columns layout only)
hypr-nav scroll u|d
""" """
from __future__ import annotations from __future__ import annotations
@ -55,8 +62,13 @@ def dispatch(lua: str) -> None:
def plain(kind: str, dir_word: str) -> None: def plain(kind: str, dir_word: str) -> None:
if kind == "focus": if kind == "focus":
dispatch(f'hl.dsp.focus({{ direction = "{dir_word}" }})') dispatch(f'hl.dsp.focus({{ direction = "{dir_word}" }})')
else: elif kind == "move":
dispatch(f'hl.dsp.window.move({{ direction = "{dir_word}" }})') dispatch(f'hl.dsp.window.move({{ direction = "{dir_word}" }})')
else: # resize — native, screen-relative (10px step, matches the old ALT binds)
axis, sign, _ = DIRS[{"left": "l", "right": "r", "up": "u", "down": "d"}[dir_word]]
dx = 10 * sign if axis == "x" else 0
dy = 10 * sign if axis == "y" else 0
dispatch(f"hl.dsp.window.resize({{ x = {dx}, y = {dy}, relative = true }})")
def active_layout_name(wsid) -> str | None: def active_layout_name(wsid) -> str | None:
@ -96,12 +108,17 @@ def _overlap(a, b):
def main() -> int: def main() -> int:
# focus/move take a screen key l|r|u|d; count takes a vim key h|j|k|l. # focus/move/resize take a screen key l|r|u|d; count a vim key h|j|k|l; scroll u|d.
if len(sys.argv) != 3 or sys.argv[1] not in ("focus", "move", "count"): if len(sys.argv) != 3 or sys.argv[1] not in ("focus", "move", "resize", "count", "scroll"):
print(__doc__) print(__doc__)
return 2 return 2
kind, key = sys.argv[1], sys.argv[2] kind, key = sys.argv[1], sys.argv[2]
valid = set("hjkl") if kind == "count" else set(DIRS) if kind == "count":
valid = set("hjkl")
elif kind == "scroll":
valid = {"u", "d"}
else:
valid = set(DIRS)
if key not in valid: if key not in valid:
print(__doc__) print(__doc__)
return 2 return 2
@ -109,13 +126,33 @@ def main() -> int:
ws = hypr_json("activeworkspace") or {} ws = hypr_json("activeworkspace") or {}
layout = active_layout_name(ws.get("id")) layout = active_layout_name(ws.get("id"))
# Custom "columns" layout owns all of these — forward the command to its layout_msg. # Custom "columns" layout owns all of these — forward to its layout_msg. scroll
# travels within the focused column, i.e. focus up/down along that strip.
if layout == COLUMNS_LAYOUT: if layout == COLUMNS_LAYOUT:
dispatch(f'hl.dsp.layout("{kind} {key}")') if kind == "scroll":
dispatch(f'hl.dsp.layout("focus {key}")')
else:
dispatch(f'hl.dsp.layout("{kind} {key}")')
return 0 return 0
if kind == "count": if kind == "count":
return 0 # column/row count only means something in columns return 0 # column count only means something in columns
if kind == "scroll":
if layout in SCROLLING_LAYOUTS:
# travel along the tape: wheel-down = forward, wheel-up = backward.
primary = tape_axis()
fwd, back = ("down", "up") if primary == "y" else ("right", "left")
plain("focus", fwd if key == "d" else back)
else:
# no strip here — keep the classic wheel-switches-workspace behaviour.
dispatch(f'hl.dsp.focus({{ workspace = "{"r+1" if key == "d" else "r-1"}" }})')
return 0
if kind == "resize":
# native, screen-relative resize everywhere that isn't columns (incl. scrolling).
plain("resize", DIRS[key][2])
return 0
axis, sign, dir_word = DIRS[key] axis, sign, dir_word = DIRS[key]
if layout not in SCROLLING_LAYOUTS: if layout not in SCROLLING_LAYOUTS: