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
parent
4c747221f2
commit
964ce103a0
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 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
|
||||
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
|
||||
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
|
||||
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
|
||||
colSpan, colStart = A.h / s.ncols, A.y + (c - 1) * (A.h / s.ncols)
|
||||
winSec, secStart, secTotal = A.w / rows, A.x, A.w
|
||||
-- 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 bottom - pan > secLen then pan = bottom - secLen end
|
||||
end
|
||||
pan = math.max(0, math.min(pan, total - secLen))
|
||||
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
|
||||
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,22 +341,56 @@ 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)))
|
||||
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]
|
||||
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
|
||||
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.
|
||||
-- vertical ws: k/j change column count, h/l change windows-per-view.
|
||||
local more_cols = horizontal and "l" or "j"
|
||||
local less_cols = horizontal and "h" or "k"
|
||||
local more_rows = horizontal and "j" or "l"
|
||||
local less_rows = horizontal and "k" or "h"
|
||||
local more_cols = horizontal and "l" or "j"
|
||||
local less_cols = horizontal and "h" or "k"
|
||||
local more_rows = horizontal and "j" or "l"
|
||||
local less_rows = horizontal and "k" or "h"
|
||||
local changed_cols = false
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
Usage: hypr-nav focus l|r|u|d
|
||||
hypr-nav move l|r|u|d
|
||||
hypr-nav count h|j|k|l (columns layout only)
|
||||
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:
|
||||
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
|
||||
|
||||
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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue