feat(hyprlua): real independent-pan "columns" layout (custom Lua layout)

Replaces the scrolling-preset "columns" with a genuine custom tiling layout
(hl.layout.register "lua:columns"): a row of columns where each column is an
INDEPENDENTLY panning vertical strip. Moving focus up/down inside a column pans
only that column to keep the focus in view; the other columns stay frozen — the
combination-lock behaviour that the built-in scrolling engine (single shared tape
offset) structurally can't do.

- columns.lua: recalculate places every window box; per-workspace state holds each
  window's column assignment and each column's pan offset. layout_msg handles the
  focus/move/count/orient commands. New windows fill the least-populated column;
  changing the column count reflows evenly.
- init.lua: for Lua layouts (lua = true), orientation is sent via layout_msg
  ("orient h|v") instead of the global scrolling:direction option.
- hypr-nav: forwards focus/move/count for the columns layout to hl.dsp.layout(...);
  the built-in scrolling path is unchanged.
- binds.lua: Super+Ctrl+Alt+H/J/K/L manage the column / windows-per-view count
  (axes follow the workspace's horizontal/vertical orientation).

Menu dropdown for "Columns" now selects orientation (Horizontal / Vertical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUN7gg6GGfnToghMijLm5Y
main
Amir Alexander Abdelbaki 2026-07-07 10:08:34 +02:00
parent 6631405570
commit 48505a5726
4 changed files with 251 additions and 36 deletions

View File

@ -1,34 +1,219 @@
-- Columns: the scrolling engine, presented as independent columns. Each column is a
-- stack of windows; windows move up/down within their column and columns move
-- left/right — every column independently (this is exactly what the scrolling
-- backend's per-column tape already does).
-- 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.
--
-- The one menu control is orientation — which way the tape scrolls — chosen from a
-- dropdown of two friendly options (see dir_labels):
-- * "Left / Right" -> columns sit side by side, the tape scrolls horizontally
-- (scrolling:direction = "right").
-- * "Up / Down" -> columns are stacked as rows, the tape scrolls vertically
-- (scrolling:direction = "down").
-- 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.
--
-- Shares the scrolling backend and all scrolling:* options with the "scrolling"
-- layout; direction is a global Hyprland option, so switching orientation here
-- affects every scrolling/columns workspace (same limitation as scrolling.lua).
-- 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} }
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
end
return s
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 all windows evenly across the new column count (by insertion order)
local addrs = {}
for _, tg in ipairs(t) 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
elseif verb == "orient" then
s.orient = (arg == "v") and "v" or "h"
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 = "scrolling", -- hyprland general.layout value
directional = true,
dirs = { "right", "down" },
dir_labels = { "Left / Right", "Up / Down" },
default_dir = "right",
fit_method = true, -- exposes the "Centered follow" toggle in the menu
config = {
scrolling = {
direction = "right",
column_width = 0.5,
focus_fit_method = 1,
follow_focus = true,
fullscreen_on_one_column = true,
},
},
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,
}

View File

@ -87,7 +87,11 @@ function M.set(ws, name, dir)
local s = M.registry[name]; if not s then return end
ws = tostring(ws)
hl.workspace_rule({ workspace = ws, layout = s.backend })
if s.directional and dir and dir ~= "" then
if s.lua then
-- custom Lua layout: orientation/options are per-layout state, set via a message
-- to the active workspace's layout (not the global scrolling:direction option).
if dir and dir ~= "" then hl.dispatch(hl.dsp.layout("orient " .. dir)) end
elseif s.directional and dir and dir ~= "" then
hl.config({ scrolling = { direction = dir } })
end
M.current[ws] = name -- remember so the menu can show the real per-ws layout

View File

@ -128,6 +128,14 @@ hl.bind(mainMod .. " + SHIFT + l", hl.dsp.exec_cmd(nav .. " move r"))
hl.bind(mainMod .. " + SHIFT + k", hl.dsp.exec_cmd(nav .. " move u"))
hl.bind(mainMod .. " + SHIFT + j", hl.dsp.exec_cmd(nav .. " move d"))
-- Columns layout: manage how many columns / windows-per-view (no-op in other layouts).
-- Horizontal ws: H/L = fewer/more columns, K/J = fewer/more windows-per-view.
-- Vertical ws: K/J = fewer/more columns, H/L = fewer/more windows-per-view.
hl.bind(mainMod .. " + CTRL + ALT + h", hl.dsp.exec_cmd(nav .. " count h"))
hl.bind(mainMod .. " + CTRL + ALT + l", hl.dsp.exec_cmd(nav .. " count l"))
hl.bind(mainMod .. " + CTRL + ALT + k", hl.dsp.exec_cmd(nav .. " count k"))
hl.bind(mainMod .. " + CTRL + ALT + j", hl.dsp.exec_cmd(nav .. " count j"))
-- cross-monitor: focus / move the active window to the previous/next monitor.
-- Works in every layout (including scrolling, where the directional keys deliberately
-- do NOT jump monitors — see hypr-nav). +1/-1 cycle through the monitors.

View File

@ -15,11 +15,14 @@ mapped to its literal on-screen direction:
(Crossing to another monitor has its own explicit binds — Super+,/. and
Super+Shift+,/. — so it isn't overloaded onto the arrows here.)
For any non-scrolling layout (dwindle/master/monocle) it is a straight pass-through, so
nothing else changes.
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.
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)
"""
from __future__ import annotations
@ -31,7 +34,8 @@ import sys
DIRS = {"l": ("x", -1, "left"), "r": ("x", 1, "right"),
"u": ("y", -1, "up"), "d": ("y", 1, "down")}
SCROLLING_LAYOUTS = {"scrolling", "columns"}
SCROLLING_LAYOUTS = {"scrolling"} # built-in scrolling tape
COLUMNS_LAYOUT = "columns" # our custom independent-pan Lua layout
STATE = os.path.expanduser("~/.cache/astal-menu/layouts-state.json")
@ -92,15 +96,29 @@ def _overlap(a, b):
def main() -> int:
if len(sys.argv) != 3 or sys.argv[1] not in ("focus", "move") \
or sys.argv[2] not in DIRS:
# 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"):
print(__doc__)
return 2
kind, key = sys.argv[1], sys.argv[2]
axis, sign, dir_word = DIRS[key]
valid = set("hjkl") if kind == "count" else set(DIRS)
if key not in valid:
print(__doc__)
return 2
ws = hypr_json("activeworkspace") or {}
if active_layout_name(ws.get("id")) not in SCROLLING_LAYOUTS:
layout = active_layout_name(ws.get("id"))
# Custom "columns" layout owns all of these — forward the command to its layout_msg.
if layout == COLUMNS_LAYOUT:
dispatch(f'hl.dsp.layout("{kind} {key}")')
return 0
if kind == "count":
return 0 # column/row count only means something in columns
axis, sign, dir_word = DIRS[key]
if layout not in SCROLLING_LAYOUTS:
plain(kind, dir_word) # dwindle/master/monocle: unchanged
return 0