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)'`.
|
-- 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue