diff --git a/desktopenvs/hyprlua/hypr/hyprland.lua b/desktopenvs/hyprlua/hypr/hyprland.lua index 4e0331b..f3f6161 100644 --- a/desktopenvs/hyprlua/hypr/hyprland.lua +++ b/desktopenvs/hyprlua/hypr/hyprland.lua @@ -220,3 +220,4 @@ hl.device({ -- popup via `hyprctl eval 'layouts.set(ws, name, dir)'`. require("layouts.init") layouts.set_default("scrolling") +layouts.restore() -- re-apply per-workspace layout choices so a reload doesn't drop them diff --git a/desktopenvs/hyprlua/hypr/layouts/columns.lua b/desktopenvs/hyprlua/hypr/layouts/columns.lua index dc333a4..cbb6d82 100644 --- a/desktopenvs/hyprlua/hypr/layouts/columns.lua +++ b/desktopenvs/hyprlua/hypr/layouts/columns.lua @@ -1,25 +1,52 @@ --- 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. +-- Columns: a custom (Lua) tiling layout — a row of vertical columns. Think of it as +-- one long horizontal strip of columns that spans every monitor: focus/move flow off +-- the edge of one monitor onto the next, so it behaves like a huge row of scrolling +-- workspaces rather than a set of isolated boxes. -- --- 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. +-- Each column is an INDEPENDENTLY-SCROLLING vertical strip (the whole point): when a +-- 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("")` → 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. +-- focus l/r/u/d — l/r step between columns (off the edge → next/prev monitor), +-- u/d step within the focused column (which pans to it). Swap vertical. +-- 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. -- 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). -- --- State is per workspace; a window's column assignment and each column's pan offset --- persist across relayouts. +-- State is per workspace; a window's column assignment, order, both weight tables 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 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 function publish() local parts = {} @@ -33,10 +60,28 @@ local function publish() 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 s = state[wsid] 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 publish() end @@ -52,6 +97,7 @@ local function reflow(s, targets) 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 k in pairs(s.pair) do s.pair[k] = nil end -- a redistribute breaks parked pairs end local function win_ws_id(win) @@ -73,7 +119,8 @@ local function focused_addr() 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). +-- 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 present, tgt_of = {}, {} 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 end 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 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 + local fresh = {} + for addr in pairs(present) do if not s.assign[addr] then fresh[#fresh + 1] = addr end end + local function least_col() + local best, bestn = 1, math.huge + 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 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 + -- 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 = {} for c = 1, s.ncols do cols[c] = {} 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 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 + local colLen = primLen * ((s.colw[c] or 1) / csum) 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 + -- group the column into rows: two mutually-paired, adjacent windows share one + -- row side by side (a "parked" pair); every other window is its own row. Pairing + -- removes a row, so the column closes up around a park — no gap. + local rowlist = {} + local i = 1 + 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 - colSpan, colStart = A.h / s.ncols, A.y + (c - 1) * (A.h / s.ncols) - winSec, secStart, secTotal = A.w / rows, A.x, A.w + -- overfull: keep natural heights and PAN so the focused row stays in view. + 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 - -- 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 + + -- 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 - and { x = colStart, y = secStart + off, w = colSpan, h = winSec } - or { x = secStart + off, y = colStart, w = winSec, h = colSpan } + and { x = ppos, y = spos, w = plen, h = slen } + or { x = spos, y = ppos, w = slen, h = plen } local tg = tgt_of[addr] if tg then pcall(function() tg:place(box) 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 + cpos = cpos + colLen end end @@ -170,14 +312,23 @@ local function layout_msg(ctx, cmd) 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" + -- 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 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 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 + 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]) hl.dispatch(hl.dsp.focus({ window = "address:" .. cols[nc][ni] })) end @@ -190,22 +341,56 @@ local function layout_msg(ctx, cmd) 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] + unpair(foc) -- leaving the column un-parks it + local nc = fc + (decrease(arg) and -1 or 1) + if nc < 1 then + hl.dispatch(hl.dsp.window.move({ monitor = "-1" })) -- carry to prev monitor + elseif nc > s.ncols then + hl.dispatch(hl.dsp.window.move({ monitor = "+1" })) -- carry to next monitor + else + s.assign[foc] = nc 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 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 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 diff --git a/desktopenvs/hyprlua/hypr/layouts/init.lua b/desktopenvs/hyprlua/hypr/layouts/init.lua index 2210a9c..fa912e0 100644 --- a/desktopenvs/hyprlua/hypr/layouts/init.lua +++ b/desktopenvs/hyprlua/hypr/layouts/init.lua @@ -105,5 +105,24 @@ function M.set_fit(method) hl.config({ scrolling = { focus_fit_method = tonumber(method) or 0 } }) 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() return M diff --git a/desktopenvs/hyprlua/hypr/usr/binds.lua b/desktopenvs/hyprlua/hypr/usr/binds.lua index beeac61..f62eefa 100644 --- a/desktopenvs/hyprlua/hypr/usr/binds.lua +++ b/desktopenvs/hyprlua/hypr/usr/binds.lua @@ -148,21 +148,42 @@ hl.bind(mainMod .. " + SHIFT + period", hl.dsp.window.move({ monitor = "+1" })) -- mouse drag / resize 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 }) -------------------- ---- RESIZE -------- -------------------- -hl.bind(mainMod .. " + ALT + right", hl.dsp.window.resize({ x = 10, y = 0, relative = true }), { repeating = true }) -hl.bind(mainMod .. " + ALT + left", hl.dsp.window.resize({ x = -10, y = 0, relative = true }), { repeating = true }) -hl.bind(mainMod .. " + ALT + up", hl.dsp.window.resize({ x = 0, y = -10, relative = true }), { repeating = true }) -hl.bind(mainMod .. " + ALT + down", hl.dsp.window.resize({ x = 0, y = 10, relative = true }), { repeating = true }) -hl.bind(mainMod .. " + ALT + l", hl.dsp.window.resize({ x = 10, y = 0, relative = true }), { repeating = true }) -hl.bind(mainMod .. " + ALT + h", hl.dsp.window.resize({ x = -10, y = 0, relative = true }), { repeating = true }) -hl.bind(mainMod .. " + ALT + k", hl.dsp.window.resize({ x = 0, y = -10, relative = true }), { repeating = true }) -hl.bind(mainMod .. " + ALT + j", hl.dsp.window.resize({ x = 0, y = 10, relative = true }), { repeating = true }) +-- Routed through hypr-nav: in the Columns layout ALT-resize retunes the focused +-- column's width / window's height (weights); everywhere else it's a plain screen- +-- relative resize, exactly as before. l/h narrower, r/l wider, k/up shorter, j/down taller. +hl.bind(mainMod .. " + ALT + right", hl.dsp.exec_cmd(nav .. " resize r"), { repeating = true }) +hl.bind(mainMod .. " + ALT + left", hl.dsp.exec_cmd(nav .. " resize l"), { repeating = true }) +hl.bind(mainMod .. " + ALT + up", hl.dsp.exec_cmd(nav .. " resize u"), { repeating = true }) +hl.bind(mainMod .. " + ALT + down", hl.dsp.exec_cmd(nav .. " resize d"), { 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 ---- @@ -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 + h", hl.dsp.window.move({ workspace = "r-1" })) --- scroll through workspaces -hl.bind(mainMod .. " + mouse_down", hl.dsp.focus({ workspace = "r+1" })) -hl.bind(mainMod .. " + mouse_up", hl.dsp.focus({ workspace = "r-1" })) +-- Super + wheel: travel along the focused strip in the scrolling/columns layouts +-- (next/prev window in the tape or focused column); falls back to switching workspaces +-- 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) hl.bind(mainMod .. " + XF86AudioRaiseVolume", hl.dsp.focus({ workspace = "r+1" }), { repeating = true }) diff --git a/desktopenvs/hyprlua/scripts/hypr-nav b/desktopenvs/hyprlua/scripts/hypr-nav index 74e30a2..bf1a9d4 100755 --- a/desktopenvs/hyprlua/scripts/hypr-nav +++ b/desktopenvs/hyprlua/scripts/hypr-nav @@ -16,13 +16,20 @@ mapped to its literal on-screen direction: 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 -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. +handler (focus/move drive its columns and flow across monitors at the edges, resize +adjusts column-width / window-height weights, count changes the column count). For any +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 - hypr-nav move l|r|u|d - hypr-nav count h|j|k|l (columns layout only) +scroll travels along the focused strip: within the focused column for "columns", along +the tape for the built-in scroller, and (unchanged) between workspaces everywhere else — +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 @@ -55,8 +62,13 @@ def dispatch(lua: str) -> None: def plain(kind: str, dir_word: str) -> None: if kind == "focus": dispatch(f'hl.dsp.focus({{ direction = "{dir_word}" }})') - else: + elif kind == "move": 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: @@ -96,12 +108,17 @@ def _overlap(a, b): def main() -> int: - # 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"): + # 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", "resize", "count", "scroll"): print(__doc__) return 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: print(__doc__) return 2 @@ -109,13 +126,33 @@ def main() -> int: ws = hypr_json("activeworkspace") or {} 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: - 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 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] if layout not in SCROLLING_LAYOUTS: