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
parent
6631405570
commit
48505a5726
|
|
@ -1,34 +1,219 @@
|
||||||
-- Columns: the scrolling engine, presented as independent columns. Each column is a
|
-- Columns: a custom (Lua) tiling layout — a row of columns, each an INDEPENDENTLY
|
||||||
-- stack of windows; windows move up/down within their column and columns move
|
-- panning vertical strip. Moving focus up/down inside a column pans only that column
|
||||||
-- left/right — every column independently (this is exactly what the scrolling
|
-- to keep the focused window in view; the other columns stay frozen. Like the wheels
|
||||||
-- backend's per-column tape already does).
|
-- of a combination lock, every column scrolls on its own.
|
||||||
--
|
--
|
||||||
-- The one menu control is orientation — which way the tape scrolls — chosen from a
|
-- This is a real custom layout (hl.layout.register), not the built-in scrolling
|
||||||
-- dropdown of two friendly options (see dir_labels):
|
-- engine — that one has a single shared tape offset and cannot pan columns apart.
|
||||||
-- * "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").
|
|
||||||
--
|
--
|
||||||
-- Shares the scrolling backend and all scrolling:* options with the "scrolling"
|
-- Controls (routed here from binds.lua via `hl.dsp.layout("<cmd>")` → layout_msg):
|
||||||
-- layout; direction is a global Hyprland option, so switching orientation here
|
-- focus l/r/u/d — l/r step between columns, u/d step within the focused column
|
||||||
-- affects every scrolling/columns workspace (same limitation as scrolling.lua).
|
-- (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 {
|
return {
|
||||||
name = "columns",
|
name = "columns",
|
||||||
label = "Columns",
|
label = "Columns",
|
||||||
backend = "scrolling", -- hyprland general.layout value
|
backend = "lua:columns", -- custom Lua layout, addressed with the lua: prefix
|
||||||
directional = true,
|
lua = true, -- init.lua sends orientation via layout_msg, not scrolling:direction
|
||||||
dirs = { "right", "down" },
|
directional = true, -- reuse the menu's direction dropdown for orientation
|
||||||
dir_labels = { "Left / Right", "Up / Down" },
|
dirs = { "h", "v" },
|
||||||
default_dir = "right",
|
dir_labels = { "Horizontal", "Vertical" },
|
||||||
fit_method = true, -- exposes the "Centered follow" toggle in the menu
|
default_dir = "h",
|
||||||
config = {
|
fit_method = false,
|
||||||
scrolling = {
|
|
||||||
direction = "right",
|
|
||||||
column_width = 0.5,
|
|
||||||
focus_fit_method = 1,
|
|
||||||
follow_focus = true,
|
|
||||||
fullscreen_on_one_column = true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,11 @@ function M.set(ws, name, dir)
|
||||||
local s = M.registry[name]; if not s then return end
|
local s = M.registry[name]; if not s then return end
|
||||||
ws = tostring(ws)
|
ws = tostring(ws)
|
||||||
hl.workspace_rule({ workspace = ws, layout = s.backend })
|
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 } })
|
hl.config({ scrolling = { direction = dir } })
|
||||||
end
|
end
|
||||||
M.current[ws] = name -- remember so the menu can show the real per-ws layout
|
M.current[ws] = name -- remember so the menu can show the real per-ws layout
|
||||||
|
|
|
||||||
|
|
@ -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 + k", hl.dsp.exec_cmd(nav .. " move u"))
|
||||||
hl.bind(mainMod .. " + SHIFT + j", hl.dsp.exec_cmd(nav .. " move d"))
|
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.
|
-- cross-monitor: focus / move the active window to the previous/next monitor.
|
||||||
-- Works in every layout (including scrolling, where the directional keys deliberately
|
-- Works in every layout (including scrolling, where the directional keys deliberately
|
||||||
-- do NOT jump monitors — see hypr-nav). +1/-1 cycle through the monitors.
|
-- do NOT jump monitors — see hypr-nav). +1/-1 cycle through the monitors.
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,14 @@ mapped to its literal on-screen direction:
|
||||||
(Crossing to another monitor has its own explicit binds — Super+,/. and
|
(Crossing to another monitor has its own explicit binds — Super+,/. and
|
||||||
Super+Shift+,/. — so it isn't overloaded onto the arrows here.)
|
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
|
For the custom "columns" layout every command is forwarded to that layout's own
|
||||||
nothing else changes.
|
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
|
Usage: hypr-nav focus l|r|u|d
|
||||||
hypr-nav move 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
|
from __future__ import annotations
|
||||||
|
|
@ -31,7 +34,8 @@ import sys
|
||||||
|
|
||||||
DIRS = {"l": ("x", -1, "left"), "r": ("x", 1, "right"),
|
DIRS = {"l": ("x", -1, "left"), "r": ("x", 1, "right"),
|
||||||
"u": ("y", -1, "up"), "d": ("y", 1, "down")}
|
"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")
|
STATE = os.path.expanduser("~/.cache/astal-menu/layouts-state.json")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -92,15 +96,29 @@ def _overlap(a, b):
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
if len(sys.argv) != 3 or sys.argv[1] not in ("focus", "move") \
|
# focus/move take a screen key l|r|u|d; count takes a vim key h|j|k|l.
|
||||||
or sys.argv[2] not in DIRS:
|
if len(sys.argv) != 3 or sys.argv[1] not in ("focus", "move", "count"):
|
||||||
print(__doc__)
|
print(__doc__)
|
||||||
return 2
|
return 2
|
||||||
kind, key = sys.argv[1], sys.argv[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 {}
|
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
|
plain(kind, dir_word) # dwindle/master/monocle: unchanged
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue