Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<file>.hypertile.bak`. It
then reloads Hyprland and checks `hyprctl configerrors`. Update with:
Expand Down
25 changes: 25 additions & 0 deletions hypertile-navigation.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 81 additions & 6 deletions hypertile-session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions hypertile.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {} })
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
111 changes: 110 additions & 1 deletion test/navigation.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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")
7 changes: 6 additions & 1 deletion test/session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Loading