diff --git a/README.md b/README.md index 1c87e41..e6363f0 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,11 @@ everything else in place, and is the one to run again after every update: overlay; `SUPER+L` cycles the workspace through your layouts and then dwindle, replacing Omarchy's dwindle/scrolling toggle, which cannot return to a Lua layout; `SUPER+SHIFT+L` cycles the other way -- `SUPER+Arrow` focuses and `SUPER+SHIFT+Arrow` swaps with the nearest window in that direction, +- `SUPER+Arrow` focuses the nearest window in that direction. `SUPER+SHIFT+Arrow` + moves to the next layout slot: an empty slot receives the active window, and an + occupied slot swaps windows. Other apps stay in their slots; spacers and scene + slots marked Empty are skipped. Moving into a collapsed slot reveals the full + layout on that workspace until the layout is reset. Every config file it edits is first copied to `.hypertile.bak`. It then reloads Hyprland and checks `hyprctl configerrors`. Update with: diff --git a/hypertile-navigation.lua b/hypertile-navigation.lua index 1b2f40c..2376d25 100644 --- a/hypertile-navigation.lua +++ b/hypertile-navigation.lua @@ -46,6 +46,31 @@ local function navigate(direction, swap) return hl.dispatch(dispatcher({ direction = direction })) end if active.fullscreen ~= 0 then return end + if swap then + local session = require("hypr.hypertile-session") + local source, slots = session.navigation_slots(active) + if source then + -- Retain movement within a stacked slot before leaving that slot. + local stacked = M.neighbor(active, source.windows, direction) + if stacked then + if not session.swap(active, stacked) then + hl.dispatch(hl.dsp.window.swap({ target = "address:" .. stacked.address })) + end + return + end + local destination = M.neighbor(source, slots, direction) + if not destination then return end + if #destination.windows == 0 then + return session.move_to_empty(active, destination.zone) + end + -- Use an actual occupant for swaps, including slots with a stack. + local target = destination.windows[1] + if not session.swap(active, target) then + hl.dispatch(hl.dsp.window.swap({ target = "address:" .. target.address })) + end + return + end + end local target = M.neighbor(active, hl.get_windows({ workspace = active.workspace, floating = false }), direction) if target then if swap then diff --git a/hypertile-session.lua b/hypertile-session.lua index 2b617d0..8d6f761 100644 --- a/hypertile-session.lua +++ b/hypertile-session.lua @@ -29,6 +29,7 @@ function M.snapshot() id = ws.id, selector = selector(ws), layout = ws.tiled_layout, monitor = ws.monitor and ws.monitor.name, visible = ws.visible, special = ws.special, order = json.array(live and live.orders[tostring(ws.id)] or {}), + navigation_keep = live and live.state.navigation_keep and live.state.navigation_keep[tostring(ws.id)] or nil, } if live then out.layouts[name] = { spec = live.spec, sizes = live.state.sizes } @@ -38,7 +39,11 @@ function M.snapshot() -- go away; this query runs every few seconds, so prune here. for _, live in pairs(engine.live) do for id in pairs(live.orders or {}) do - if not existing[id] then live.orders[id] = nil end + if not existing[id] then + live.orders[id] = nil + if live.boxes then live.boxes[id] = nil end + if live.state.navigation_keep then live.state.navigation_keep[id] = nil end + end end end for _, win in ipairs(hl.get_windows()) do @@ -266,10 +271,7 @@ local function swap_windows(request) return found, ws, live end --- Resolve actual engine assignments, including rules, pins and reservations. --- Planning validates both windows without changing either pin. -function M.swap_plan(request) - local windows, ws, live = swap_windows(request) +local function workspace_buckets(ws, live) local by_address, targets = {}, {} for _, w in ipairs(hl.get_windows()) do if w.mapped and w.workspace and w.workspace.id == ws.id and not w.floating then by_address[w.address] = w end @@ -280,10 +282,17 @@ function M.swap_plan(request) assert(next(by_address) == nil, "swap unavailable: waiting for layout order") local reserved = {} for name in pairs((live.state.scene_empty or {})[tostring(ws.id)] or {}) do reserved[name] = true end - local buckets = engine.assign(live.compiled, targets, { + return engine.assign(live.compiled, targets, { pins = live.state.pins, exclusive_pins = live.state.exclusive_pins, reserved = reserved, }) +end + +-- Resolve actual engine assignments, including rules, pins and reservations. +-- Planning validates both windows without changing either pin. +function M.swap_plan(request) + local windows, ws, live = swap_windows(request) + local buckets = workspace_buckets(ws, live) local plan = { workspace = selector(ws), layout = ws.tiled_layout, windows = json.array() } for i, w in ipairs(windows) do local zone @@ -309,6 +318,66 @@ function M.swap_plan(request) return plan end +-- Navigation uses layout boxes rather than window edges, so empty slots and +-- aspect-constrained windows have the same directional destinations. +function M.navigation_slots(active) + local ws = active.workspace + local live = engine.live[ws.tiled_layout:match("^lua:(.+)$")] + local boxes = live and live.boxes and live.boxes[tostring(ws.id)] + if not boxes then return end + local ok, buckets = pcall(workspace_buckets, ws, live) + if not ok then return end -- Wait for the compositor's next layout order. + local slots, source = {}, nil + for index, name in ipairs(live.compiled.leaves) do + local box, bucket = boxes[name], buckets[name] + if box and not live.compiled.leaf_opts[name].spacer and not bucket.reserved then + local slot = { address = string.format("%08d", index), zone = name, workspace = ws, + at = { x = box.x, y = box.y }, size = { x = box.w, y = box.h }, windows = {} } + local unavailable = false + for _, target in ipairs(bucket) do + slot.windows[#slot.windows + 1] = target.window + if target.window.hidden or (target.window.fullscreen or 0) ~= 0 then unavailable = true end + if target.window.address == active.address then source = slot end + end + if not unavailable then slots[#slots + 1] = slot end + end + end + return source, slots +end + +function M.move_to_empty(active, zone) + local ws = active.workspace + local live = engine.live[ws.tiled_layout:match("^lua:(.+)$")] + local buckets = workspace_buckets(ws, live) + assert(live.compiled.leaf_set[zone] and not live.compiled.leaf_opts[zone].spacer, + "move unavailable: zone changed") + assert(#buckets[zone] == 0 and not buckets[zone].reserved, "move unavailable: zone is not empty") + local found = false + for _, bucket in pairs(buckets) do + for _, target in ipairs(bucket) do + local w = target.window + if w.address == active.address and w.stable_id == active.stable_id and w.pid == active.pid + and not w.hidden and (w.fullscreen or 0) == 0 then found = true end + end + end + assert(found, "move unavailable: window changed") + -- Explicit placement reveals configured slots even in collapsing layouts. + live.state.navigation_keep = live.state.navigation_keep or {} + live.state.navigation_keep[tostring(ws.id)] = true + -- Preserve the other windows' assignments before moving the active one; + -- otherwise fill order would pull them into the newly vacated slot. + live.state.exclusive_pins = live.state.exclusive_pins or {} + for name, bucket in pairs(buckets) do + for _, target in ipairs(bucket) do + local address = target.window.address + live.state.pins[address] = address == active.address and zone or name + live.state.exclusive_pins[address] = true + end + end + refresh_workspace(ws) + return true +end + -- Absolute assignments make retry after a lost IPC reply safe. Validate the -- entire exchange before changing either pin; never focus or relaunch. function M.swap_apply(plan) @@ -453,6 +522,12 @@ function M.finish(request) if matches[old] then wanted[#wanted + 1] = matches[old] end end if live and wanted[1] then + for _, current in ipairs(hl.get_workspaces()) do + if selector(current) == ws.selector then + live.state.navigation_keep = live.state.navigation_keep or {} + live.state.navigation_keep[tostring(current.id)] = ws.navigation_keep or nil + end + end reorder(live, wanted, warnings, ws.selector) end local populated = false diff --git a/hypertile.lua b/hypertile.lua index 461434b..45815d1 100644 --- a/hypertile.lua +++ b/hypertile.lua @@ -437,6 +437,15 @@ local function walk(node, box, compiled, buckets, overrides, out, empty) end end +-- The configured slots, including ones currently collapsed out of view. +function M.slot_boxes(compiled, area, sizes) + local buckets, boxes = {}, {} + for _, name in ipairs(compiled.leaves) do buckets[name] = { reserved = true } end + walk(compiled.tree, { x = area.x, y = area.y, w = area.w, h = area.h }, + compiled, buckets, sizes, boxes, "keep") + return boxes +end + -- Shrink `box` to `aspect` (w/h) and/or `scale`, centered. Returns the -- original box when neither is set. local function fit_box(box, opts) @@ -492,6 +501,7 @@ function M.recalculate(compiled, ctx, state) -- The external controller owns every process and network operation. local win = targets[1].window local workspace = win and win.workspace and tostring(win.workspace.id) + local keep_slots = state and state.navigation_keep and state.navigation_keep[workspace] local reserved = {} for name in pairs(state and state.scene_empty and state.scene_empty[workspace] or {}) do reserved[name] = true end state = setmetatable({ reserved = reserved }, { __index = state or {} }) @@ -506,7 +516,7 @@ function M.recalculate(compiled, ctx, state) end state = state or { pins = {}, sizes = {} } local jstate = { pins = state.pins, sizes = state.sizes, jiggle = jiggle and true or false } - if n == 1 and compiled.single == "collapse" and next(reserved) == nil then + if n == 1 and compiled.single == "collapse" and next(reserved) == nil and not keep_slots then -- The lone window takes the whole area, but keeps its slot's shape. local slot for _, name in ipairs(compiled.leaves) do @@ -521,7 +531,11 @@ function M.recalculate(compiled, ctx, state) return { ["*"] = full }, buckets end local boxes = {} - walk(compiled.tree, { x = area.x, y = area.y, w = area.w, h = area.h }, compiled, buckets, jstate.sizes, boxes, compiled.empty) + if keep_slots then + boxes = M.slot_boxes(compiled, area, jstate.sizes) + else + walk(compiled.tree, { x = area.x, y = area.y, w = area.w, h = area.h }, compiled, buckets, jstate.sizes, boxes, compiled.empty) + end for _, name in ipairs(compiled.leaves) do local box = boxes[name] if box and #buckets[name] > 0 then @@ -600,6 +614,7 @@ function M.handle_msg(compiled, state, msg, active_window) elseif cmd == "reset" then state.pins = {} state.exclusive_pins = {} + state.navigation_keep = {} state.sizes = {} return true elseif cmd == "relayout" then @@ -627,6 +642,7 @@ function M.provider(name, spec) live.spec = spec live.state = state live.orders = live.orders or {} + live.boxes = {} -- Plain geometry only; never retain compositor targets. M.live[name] = live return { recalculate = function(ctx) @@ -642,6 +658,9 @@ function M.provider(name, spec) end if workspace then live.orders[workspace] = order end local ok, err = pcall(M.recalculate, live.compiled, ctx, live.state) + if workspace then + live.boxes[workspace] = ok and M.slot_boxes(live.compiled, ctx.area, live.state.sizes) or nil + end if not ok then print("hypertile[" .. name .. "]: " .. tostring(err)) end diff --git a/test/navigation.lua b/test/navigation.lua index e6ac5bd..ad06a39 100644 --- a/test/navigation.lua +++ b/test/navigation.lua @@ -27,7 +27,7 @@ hl = { } package.loaded["hypr.hypertile"] = { live = { test = {} } } local routed -package.loaded["hypr.hypertile-session"] = { swap = function(first, second) +package.loaded["hypr.hypertile-session"] = { navigation_slots = function() end, swap = function(first, second) if routed then routed.first, routed.second = first, second; return true end return false end } @@ -52,3 +52,112 @@ assert(dispatched.direction == "l", "standard layouts use stock swap") nav.focus("r") assert(dispatched.focus and dispatched.direction == "r", "standard layouts use stock focus") print("navigation: all checks passed") + +-- Exercise the real provider/session path, including recalculation after keys. +local engine = require("hypertile") +local session = require("hypertile-session") +package.loaded["hypr.hypertile"] = engine +package.loaded["hypr.hypertile-session"] = session +local provider +hl.layout = { register = function(name, value) provider = value end } +dofile("layouts/quad.lua") +local ws = {id=7, name="7", tiled_layout="lua:quad"} +local live = engine.live.quad +local app = window("app", 0, 0, 1, 1, {workspace=ws, mapped=true, stable_id=1, pid=11}) +local apps = {app} +local function recalculate() + local ctx = {area={x=10, y=30, w=1000, h=600}, targets={}} + for _, w in ipairs(apps) do + ctx.targets[#ctx.targets+1] = {window=w, place=function(_, box) + w.at = {x=box.x+2000, y=box.y-800} -- Offset monitor, not the origin. + w.size = {x=box.w, y=box.h} + end} + end + provider.recalculate(ctx) +end +hl.get_active_window = function() return app end +hl.get_windows = function() return apps end +hl.get_workspaces = function() return {ws} end +hl.dsp.window.resize = function(args) args.resize=true; return args end +hl.dispatch = function(args) + dispatched = args + if args.resize then recalculate() end +end +recalculate() +local function zone(w) + return session.navigation_slots(w).zone +end +assert(zone(app) == "tl") +for _, step in ipairs({{"r","tr"}, {"d","br"}, {"l","bl"}, {"u","tl"}, {"l","left"}, + {"r","tl"}, {"r","tr"}, {"r","right"}}) do + nav.swap(step[1]) + assert(zone(app) == step[2], "single app reaches " .. step[2]) +end +dispatched = nil +nav.swap("r") +assert(zone(app) == "right" and not dispatched, "edge does not wrap") +nav.focus("l") +assert(not dispatched, "focus never moves into empty slots") + +-- Moving the first fill window must not pull the second into its old slot. +live.state.pins, live.state.exclusive_pins = {}, {} +local other = window("other", 0, 0, 1, 1, {workspace=ws, mapped=true, stable_id=2, pid=22}) +apps = {app, other} +recalculate() +assert(zone(app) == "tl" and zone(other) == "tr") +nav.swap("d") +assert(zone(app) == "bl" and zone(other) == "tr", "other app stays put") +nav.swap("r") +nav.swap("u") +assert(zone(app) == "tr" and zone(other) == "br", "occupied destination swaps pinned apps") +nav.swap("d") +assert(zone(app) == "br" and zone(other) == "tr", "swap back") + +live.state.scene_empty = {["7"]={bl=true}} +recalculate() +local _, slots = session.navigation_slots(app) +for _, slot in ipairs(slots) do assert(slot.zone ~= "bl", "scene Empty excluded") end +assert(not pcall(session.move_to_empty, app, "bl"), "reservation revalidated") +assert(not pcall(session.move_to_empty, app, "tr"), "occupied slot rejected") +local before = live.state.pins.app +local stale = window("app", 0, 0, 1, 1, {workspace=ws, stable_id=99, pid=11}) +assert(not pcall(session.move_to_empty, stale, "left")) +assert(live.state.pins.app == before, "stale identity leaves pins unchanged") + +provider = engine.provider("custom", {columns={{name="a"}, {name="gap", spacer=true}, + {name="b"}, {name="c"}}, empty="keep", single="slot"}) +ws.tiled_layout = "lua:custom" +apps = {app} +recalculate() +nav.swap("r") +assert(zone(app) == "b", "custom layout skips spacer") +nav.swap("r") +assert(zone(app) == "c", "custom layout empty slot reached") +print("empty-slot navigation: all checks passed") + +provider = engine.provider("collapsed", {columns={{name="a"}, {rows={{name="b"}, {name="c"}}}}}) +ws.tiled_layout = "lua:collapsed" +recalculate() +assert(app.size.x == 1000, "single window initially collapses") +nav.swap("r") +assert(zone(app) == "b" and app.size.x == 500 and app.size.y == 300, + "moving into collapsed slot reveals configured geometry") +nav.swap("d") +assert(zone(app) == "c", "nested collapsed slots remain reachable") +local collapsed = engine.live.collapsed +engine.handle_msg(collapsed.compiled, collapsed.state, "reset", app) +recalculate() +assert(app.size.x == 1000, "reset restores collapse policy") +print("collapsed-slot navigation: all checks passed") + +provider = engine.provider("stacked", {columns={{name="a"}, {name="b"}}, + fill={"a", "a", "b"}, empty="keep", single="slot"}) +ws.tiled_layout = "lua:stacked" +apps = {app, other} +recalculate() +dispatched = nil +nav.swap("d") +assert(dispatched and dispatched.target == "address:other", "retain swaps within a stack") +nav.swap("r") +assert(zone(app) == "b" and zone(other) == "a", "move out of a stack into an empty slot") +print("stack navigation: all checks passed") diff --git a/test/session.lua b/test/session.lua index 3117f58..e8943bf 100644 --- a/test/session.lua +++ b/test/session.lua @@ -61,12 +61,13 @@ local session = require("hypertile-session") -- Order restoration: saved order o1..o4 maps to live c, a, d, b. local snapshot = { - workspaces = { { selector = "1", layout = "lua:test", order = { "o1", "o2", "o3", "o4" }, monitor = "DP-1", visible = true } }, + workspaces = { { selector = "1", layout = "lua:test", order = { "o1", "o2", "o3", "o4" }, monitor = "DP-1", visible = true, navigation_keep = true } }, windows = {}, layouts = {}, active = nil, workspace = "1", } local result = session.finish({ snapshot = snapshot, matches = { o1 = "c", o2 = "a", o3 = "d", o4 = "b" } }) assert(table.concat(order, ",") == "c,a,d,b", "order restored without relying on recalculation: " .. table.concat(order, ",")) assert(#result.warnings == 0, "no warnings") +assert(engine.live.test.state.navigation_keep["1"], "explicit slot geometry restored") local swaps = 0 for _, args in ipairs(dispatched) do if args.kind == "swap" then swaps = swaps + 1 end end assert(swaps <= 3, "at most n-1 swaps") @@ -91,8 +92,12 @@ assert(rules[1].workspace == "1" and rules[1].layout == "lua:test" and rules[3]. -- snapshot prunes cached orders for workspaces that no longer exist. engine.live.test.orders["1"] = { "a", "b", "c", "d" } +engine.live.test.boxes = {["7"] = {}} +engine.live.test.state.navigation_keep["7"] = true local snap = session.snapshot() assert(engine.live.test.orders["7"] == nil and engine.live.test.orders["1"], "stale workspace order pruned") assert(#snap.workspaces == 1 and table.concat(snap.workspaces[1].order, ",") == "a,b,c,d", "snapshot order follows the engine cache") +assert(snap.workspaces[1].navigation_keep, "snapshot preserves explicit slot geometry") +assert(not engine.live.test.boxes["7"] and not engine.live.test.state.navigation_keep["7"], "stale navigation state pruned") print("session adapter: all checks passed")