Dotfiles/desktopenvs/hyprlua/hypr/layouts/columns.lua

247 lines
11 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

-- 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.
--
-- 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.
--
-- 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.
-- count h/j/k/l — horizontal ws: h/l = / columns, k/j = / windows-per-view.
-- vertical ws: k/j = / columns, h/l = / per-view.
-- 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.
local state = {} -- [wsid] = { ncols, rows, orient, assign={addr=col}, seq={addr=n}, nextseq, pan={col=px} }
-- Publish per-workspace column/row counts 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 = {}
for wsid, s in pairs(state) do
parts[#parts + 1] = string.format('"%s":{"cols":%d,"rows":%d,"orient":"%s"}',
tostring(wsid), s.ncols, s.rows, s.orient)
end
pcall(function()
local f = io.open(STATE_FILE, "w")
if f then f:write("{" .. table.concat(parts, ",") .. "}\n"); f:close() end
end)
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 = {} }
state[wsid] = s
publish()
end
return s
end
-- Reflow every window evenly across the current column count, by insertion order.
local function reflow(s, targets)
local addrs = {}
for _, tg in ipairs(targets) do
local ok, addr = pcall(function() return tg.window.address end)
if ok and addr then addrs[#addrs + 1] = addr end
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
end
local function win_ws_id(win)
local ok, ws = pcall(function() return win.workspace end)
if ok and ws ~= nil then
local ok2, id = pcall(function() return ws.id end)
if ok2 and id then return id end
local id2 = tostring(ws):match("%((%-?%d+)")
if id2 then return tonumber(id2) end
end
return 0
end
local function focused_addr()
local aw = hl.get_active_window and hl.get_active_window()
if not aw then return nil end
local ok, a = pcall(function() return aw.address end)
return ok and a or nil
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).
local function columnize(s, targets)
local present, tgt_of = {}, {}
for _, tg in ipairs(targets) do
local ok, addr = pcall(function() return tg.window.address end)
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
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
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
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
for c = 1, s.ncols do
table.sort(cols[c], function(a, b) return (s.seq[a] or 0) < (s.seq[b] or 0) end)
end
return cols, tgt_of, foc
end
local function recalculate(ctx)
local t = ctx.targets
if type(t) ~= "table" or not t[1] then return end
local A = ctx.area
if type(A) ~= "table" then return end
local s = ws_state(win_ws_id(t[1].window))
local cols, tgt_of, foc = columnize(s, t)
local horizontal = (s.orient ~= "v")
local rows = math.max(1, s.rows)
for c = 1, s.ncols do
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
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
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
local box = horizontal
and { x = colStart, y = secStart + off, w = colSpan, h = winSec }
or { x = secStart + off, y = colStart, w = winSec, h = colSpan }
local tg = tgt_of[addr]
if tg then pcall(function() tg:place(box) end) end
end
end
end
end
local function layout_msg(ctx, cmd)
local t = ctx.targets
if type(t) ~= "table" or not t[1] then return end
local s = ws_state(win_ws_id(t[1].window))
local verb, arg = tostring(cmd or ""):match("^(%S+)%s*(%S*)$")
if not verb then return end
local cols, _, foc = columnize(s, t)
local horizontal = (s.orient ~= "v")
local fc = (foc and s.assign[foc]) or 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
-- l/r or u/d perpendicular to the column axis means "between columns"
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
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
local ni = math.min(fi, #cols[nc])
hl.dispatch(hl.dsp.focus({ window = "address:" .. cols[nc][ni] }))
end
else
local ni = fi + (decrease(arg) and -1 or 1)
if cols[fc] and ni >= 1 and ni <= #cols[fc] then
hl.dispatch(hl.dsp.focus({ window = "address:" .. cols[fc][ni] }))
end
end
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]
end
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 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
elseif arg == more_rows then s.rows = s.rows + 1
elseif arg == less_rows then s.rows = math.max(1, s.rows - 1) end
if changed_cols then reflow(s, t) end
publish()
elseif verb == "cols" then
-- direct column-count change (the menu's (-)[N](+) stepper).
if arg == "+" or arg == "+1" then s.ncols = s.ncols + 1
elseif arg == "-" or arg == "-1" then s.ncols = math.max(1, s.ncols - 1)
else local n = tonumber(arg); if n then s.ncols = math.max(1, math.floor(n)) end end
reflow(s, t)
publish()
elseif verb == "orient" then
s.orient = (arg == "v") and "v" or "h"
publish()
end
end
-- Register the layout (guard against the double-register that a live reload can cause).
pcall(function()
hl.layout.register("columns", { recalculate = recalculate, layout_msg = layout_msg })
end)
-- Spec consumed by the layout registry (init.lua) + the astal-menu dropdown.
return {
name = "columns",
label = "Columns",
backend = "lua:columns", -- custom Lua layout, addressed with the lua: prefix
lua = true, -- init.lua sends orientation via layout_msg, not scrolling:direction
directional = true, -- reuse the menu's direction dropdown for orientation
dirs = { "h", "v" },
dir_labels = { "Horizontal", "Vertical" },
default_dir = "h",
fit_method = false,
stepper = true, -- menu shows a "Columns: (-)[N](+)" count stepper
}