diff --git a/desktopenvs/hyprlua/hypr/layouts/columns.lua b/desktopenvs/hyprlua/hypr/layouts/columns.lua index 21f50d3..8815797 100644 --- a/desktopenvs/hyprlua/hypr/layouts/columns.lua +++ b/desktopenvs/hyprlua/hypr/layouts/columns.lua @@ -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("")` → 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, } diff --git a/desktopenvs/hyprlua/hypr/layouts/init.lua b/desktopenvs/hyprlua/hypr/layouts/init.lua index 267c524..198ee00 100644 --- a/desktopenvs/hyprlua/hypr/layouts/init.lua +++ b/desktopenvs/hyprlua/hypr/layouts/init.lua @@ -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 diff --git a/desktopenvs/hyprlua/hypr/usr/binds.lua b/desktopenvs/hyprlua/hypr/usr/binds.lua index ff61a8d..503afb1 100644 --- a/desktopenvs/hyprlua/hypr/usr/binds.lua +++ b/desktopenvs/hyprlua/hypr/usr/binds.lua @@ -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. diff --git a/desktopenvs/hyprlua/scripts/hypr-nav b/desktopenvs/hyprlua/scripts/hypr-nav index dfd634c..74e30a2 100755 --- a/desktopenvs/hyprlua/scripts/hypr-nav +++ b/desktopenvs/hyprlua/scripts/hypr-nav @@ -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