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)'`.
require("layouts.init")
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
-- panning vertical strip. Moving focus up/down inside a column pans only that column
-- to keep the focused window in view; the other columns stay frozen. Like the wheels
-- of a combination lock, every column scrolls on its own.
-- Columns: a custom (Lua) tiling layout — a row of vertical columns. Think of it as
-- one long horizontal strip of columns that spans every monitor: focus/move flow off
-- the edge of one monitor onto the next, so it behaves like a huge row of scrolling
-- workspaces rather than a set of isolated boxes.
--
-- This is a real custom layout (hl.layout.register), not the built-in scrolling
-- engine — that one has a single shared tape offset and cannot pan columns apart.
-- Each column is an INDEPENDENTLY-SCROLLING vertical strip (the whole point): when a
-- 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):
-- focus l/r/u/d — l/r step between columns, u/d step within the focused column
-- (which then pans to reveal the focus). Axes swap when vertical.
-- move l/r/u/d — move the focused window between columns / reorder in its column.
-- focus l/r/u/d — l/r step between columns (off the edge → next/prev monitor),
-- u/d step within the focused column (which pans to it). Swap vertical.
-- 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.
-- 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).
--
-- State is per workspace; a window's column assignment and each column's pan offset
-- persist across relayouts.
-- State is per workspace; a window's column assignment, order, both weight tables and
-- 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 function publish()
local parts = {}
@ -33,10 +60,28 @@ local function publish()
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 s = state[wsid]
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
publish()
end
@ -52,6 +97,7 @@ local function reflow(s, targets)
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 k in pairs(s.pair) do s.pair[k] = nil end -- a redistribute breaks parked pairs
end
local function win_ws_id(win)
@ -73,7 +119,8 @@ local function focused_addr()
end
-- 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 present, tgt_of = {}, {}
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
end
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
local foc = focused_addr()
-- new windows fill the least-populated column so they spread out.
local counts = {}
for c = 1, s.ncols do counts[c] = 0 end
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
end
for addr in pairs(present) do
if not s.assign[addr] then
local fresh = {}
for addr in pairs(present) do if not s.assign[addr] then fresh[#fresh + 1] = addr end end
local function least_col()
local best, bestn = 1, math.huge
for c = 1, s.ncols do if counts[c] < bestn then best, bestn = c, counts[c] end end
s.assign[addr] = best; counts[best] = counts[best] + 1
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
end
end
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
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 = {}
for c = 1, s.ncols do cols[c] = {} 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 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
local colLen = primLen * ((s.colw[c] or 1) / csum)
local list = cols[c]
local m = #list
if m > 0 then
-- primary axis is split into ncols; the secondary axis stacks the windows,
-- each sized to 1/rows of the viewport, and pans.
local colSpan, colStart, winSec, secStart, secTotal
if horizontal then
colSpan, colStart = A.w / s.ncols, A.x + (c - 1) * (A.w / s.ncols)
winSec, secStart, secTotal = A.h / rows, A.y, A.h
-- group the column into rows: two mutually-paired, adjacent windows share one
-- row side by side (a "parked" pair); every other window is its own row. Pairing
-- removes a row, so the column closes up around a park — no gap.
local rowlist = {}
local i = 1
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
colSpan, colStart = A.h / s.ncols, A.y + (c - 1) * (A.h / s.ncols)
winSec, secStart, secTotal = A.w / rows, A.x, A.w
rowlist[#rowlist + 1] = { a }; i = i + 1
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
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
-- overfull: keep natural heights and PAN so the focused row stays in view.
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 top + winSec - pan > secTotal then pan = top + winSec - secTotal end
if bottom - pan > secLen then pan = bottom - secLen end
end
pan = math.max(0, math.min(pan, total - secLen))
end
pan = math.max(0, math.min(pan, math.max(0, m * winSec - secTotal)))
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
and { x = colStart, y = secStart + off, w = colSpan, h = winSec }
or { x = secStart + off, y = colStart, w = winSec, h = colSpan }
and { x = ppos, y = spos, w = plen, h = slen }
or { x = spos, y = ppos, w = slen, h = plen }
local tg = tgt_of[addr]
if tg then pcall(function() tg:place(box) 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
cpos = cpos + colLen
end
end
@ -170,14 +312,23 @@ local function layout_msg(ctx, cmd)
local fi = 1
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 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 between(arg) then
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])
hl.dispatch(hl.dsp.focus({ window = "address:" .. cols[nc][ni] }))
end
@ -190,14 +341,48 @@ local function layout_msg(ctx, cmd)
elseif verb == "move" then
if not foc then return end
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
local nc = fc + (decrease(arg) and -1 or 1)
if nc < 1 then
hl.dispatch(hl.dsp.window.move({ monitor = "-1" })) -- carry to prev monitor
elseif nc > s.ncols then
hl.dispatch(hl.dsp.window.move({ monitor = "+1" })) -- carry to next monitor
else
local list = cols[fc]
local ni = fi + (decrease(arg) and -1 or 1)
if list and ni >= 1 and ni <= #list then
local other = list[ni]
s.seq[foc], s.seq[other] = s.seq[other], s.seq[foc]
s.assign[foc] = nc
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
elseif verb == "count" then
-- horizontal ws: h/l change column count, k/j change windows-per-view.

View File

@ -105,5 +105,24 @@ function M.set_fit(method)
hl.config({ scrolling = { focus_fit_method = tonumber(method) or 0 } })
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()
return M

View File

@ -148,21 +148,42 @@ hl.bind(mainMod .. " + SHIFT + period", hl.dsp.window.move({ monitor = "+1" }))
-- mouse drag / resize
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 })
--------------------
---- RESIZE --------
--------------------
hl.bind(mainMod .. " + ALT + right", hl.dsp.window.resize({ x = 10, y = 0, relative = true }), { repeating = true })
hl.bind(mainMod .. " + ALT + left", hl.dsp.window.resize({ x = -10, y = 0, relative = true }), { repeating = true })
hl.bind(mainMod .. " + ALT + up", hl.dsp.window.resize({ x = 0, y = -10, relative = true }), { repeating = true })
hl.bind(mainMod .. " + ALT + down", hl.dsp.window.resize({ x = 0, y = 10, relative = true }), { repeating = true })
hl.bind(mainMod .. " + ALT + l", hl.dsp.window.resize({ x = 10, y = 0, relative = true }), { repeating = true })
hl.bind(mainMod .. " + ALT + h", hl.dsp.window.resize({ x = -10, y = 0, relative = true }), { repeating = true })
hl.bind(mainMod .. " + ALT + k", hl.dsp.window.resize({ x = 0, y = -10, relative = true }), { repeating = true })
hl.bind(mainMod .. " + ALT + j", hl.dsp.window.resize({ x = 0, y = 10, relative = true }), { repeating = true })
-- Routed through hypr-nav: in the Columns layout ALT-resize retunes the focused
-- column's width / window's height (weights); everywhere else it's a plain screen-
-- relative resize, exactly as before. l/h narrower, r/l wider, k/up shorter, j/down taller.
hl.bind(mainMod .. " + ALT + right", hl.dsp.exec_cmd(nav .. " resize r"), { repeating = true })
hl.bind(mainMod .. " + ALT + left", hl.dsp.exec_cmd(nav .. " resize l"), { repeating = true })
hl.bind(mainMod .. " + ALT + up", hl.dsp.exec_cmd(nav .. " resize u"), { repeating = true })
hl.bind(mainMod .. " + ALT + down", hl.dsp.exec_cmd(nav .. " resize d"), { 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 ----
@ -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 + h", hl.dsp.window.move({ workspace = "r-1" }))
-- scroll through workspaces
hl.bind(mainMod .. " + mouse_down", hl.dsp.focus({ workspace = "r+1" }))
hl.bind(mainMod .. " + mouse_up", hl.dsp.focus({ workspace = "r-1" }))
-- Super + wheel: travel along the focused strip in the scrolling/columns layouts
-- (next/prev window in the tape or focused column); falls back to switching workspaces
-- 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)
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.)
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-
view). For any other layout (dwindle/master/monocle) focus/move is a straight
pass-through, so nothing else changes.
handler (focus/move drive its columns and flow across monitors at the edges, resize
adjusts column-width / window-height weights, count changes the column count). For any
other layout (dwindle/master/monocle) focus/move/resize is a straight pass-through, so
nothing else changes.
scroll travels along the focused strip: within the focused column for "columns", along
the tape for the built-in scroller, and (unchanged) between workspaces everywhere else —
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
@ -55,8 +62,13 @@ def dispatch(lua: str) -> None:
def plain(kind: str, dir_word: str) -> None:
if kind == "focus":
dispatch(f'hl.dsp.focus({{ direction = "{dir_word}" }})')
else:
elif kind == "move":
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:
@ -96,12 +108,17 @@ def _overlap(a, b):
def main() -> int:
# focus/move take a screen key l|r|u|d; count takes a vim key h|j|k|l.
if len(sys.argv) != 3 or sys.argv[1] not in ("focus", "move", "count"):
# 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", "resize", "count", "scroll"):
print(__doc__)
return 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:
print(__doc__)
return 2
@ -109,13 +126,33 @@ def main() -> int:
ws = hypr_json("activeworkspace") or {}
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 kind == "scroll":
dispatch(f'hl.dsp.layout("focus {key}")')
else:
dispatch(f'hl.dsp.layout("{kind} {key}")')
return 0
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]
if layout not in SCROLLING_LAYOUTS: