feat(hyprlua/columns): center-focused toggle, mirroring scrolling's fit method

Adds a "center-focused" switch to the Columns layout's menu tab: when a column
overflows and scrolls, it can now center the focused window in the column
instead of only nudging the pan the minimum amount to keep it in view. Off by
default so existing behavior is unchanged unless explicitly enabled.

The astal-menu switch widget already existed generically (built for scrolling's
native focus_fit_method) but was hard-wired to that one Hyprland option; it's
now layout-aware and reads/writes columns.lua's own persisted state instead.
main
Amir Alexander Abdelbaki 2026-07-08 13:35:47 +02:00
parent 964ce103a0
commit ed44b84afa
2 changed files with 84 additions and 15 deletions

View File

@ -279,9 +279,13 @@ class Taskbar(Gtk.Box):
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.append(Gtk.Label(label="center-focused", xalign=0.0, hexpand=True))
sw = Gtk.Switch(valign=Gtk.Align.CENTER)
if ly["name"] == "columns":
sw.set_tooltip_text("Keep the focused window centred in its column as "
"it scrolls, instead of the minimal-movement default")
else:
sw.set_tooltip_text("Keep the focused column centred so the prev/next "
"columns stay on-screen and tappable")
sw.connect("state-set", self._on_fit_toggle)
sw.connect("state-set", lambda s, state, name=ly["name"]: self._on_fit_toggle(s, state, name))
row.append(sw)
page.append(row)
self._fit_sws[ly["name"]] = sw
@ -325,6 +329,15 @@ class Taskbar(Gtk.Box):
self._cols_lbl.set_label(str(new))
_eval(f'hl.dispatch(hl.dsp.layout("cols {"+1" if delta > 0 else "-1"}"))')
def _read_center(self) -> bool:
# columns.lua's own "center-focused" switch — global, published in the same
# cache file as the columns stepper's count (see columns.lua's publish()).
try:
data = json.loads(_COLUMNS_STATE.read_text())
return bool(data.get("_center", False))
except (FileNotFoundError, json.JSONDecodeError, ValueError, TypeError):
return False
# -- sync + handlers ---------------------------------------------------
def _sync_layout_controls(self) -> None:
# reflect the current per-ws layout / direction / focus_fit in the tabs+options
@ -332,6 +345,11 @@ class Taskbar(Gtk.Box):
run_json(["hyprctl", "getoption", "general:layout", "-j"], self._apply_tab_sel)
run_json(["hyprctl", "getoption", "scrolling:direction", "-j"], self._apply_dir_sel)
run_json(["hyprctl", "getoption", "scrolling:focus_fit_method", "-j"], self._apply_fit_sel)
# columns' own center-focused state lives in columns-state.json, not a hyprctl
# option — read it synchronously rather than round-tripping a subprocess.
columns_sw = self._fit_sws.get("columns")
if columns_sw is not None:
columns_sw.set_active(self._read_center())
def _apply_tab_sel(self, ok, data) -> None:
cur = data.get("str") if ok and isinstance(data, dict) else None
@ -356,9 +374,13 @@ class Taskbar(Gtk.Box):
self._syncing = False
def _apply_fit_sel(self, ok, data) -> None:
# scrolling:focus_fit_method only describes the scrolling layout's own switch;
# columns' switch is synced separately from columns-state.json (see
# _sync_layout_controls) since it isn't backed by a hyprctl option at all.
val = data.get("int") if ok and isinstance(data, dict) else 0
sw = self._fit_sws.get("scrolling")
if sw is not None:
self._syncing = True
for sw in self._fit_sws.values():
sw.set_active(val == 1)
self._syncing = False
@ -384,8 +406,11 @@ class Taskbar(Gtk.Box):
if name:
_eval(f'layouts.set("{self._active_ws}", "{name}", "{self._cur_dir()}")')
def _on_fit_toggle(self, _sw, state) -> bool:
def _on_fit_toggle(self, _sw, state, name) -> bool:
if not getattr(self, "_syncing", False):
if name == "columns":
_eval(f'hl.dispatch(hl.dsp.layout("center {"on" if state else "off"}"))')
else:
_eval(f"layouts.set_fit({1 if state else 0})")
return False

View File

@ -35,6 +35,15 @@
-- 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).
-- center on/off/toggle — "center-focused" switch (menu): when a column is overfull
-- and scrolls, keep the focused window centred in the column
-- instead of the minimal-movement default (whichever edge of the
-- focused window would otherwise go off-screen just barely comes
-- back into view). Global (mirrors the scrolling layout's
-- "Centered follow" toggle), persisted across reloads, OFF by
-- default. Only ever repositions the column holding the focused
-- window — every other column's pan is untouched, since there is
-- only one focused window at a time.
--
-- State is per workspace; a window's column assignment, order, both weight tables and
-- each column's pan offset persist across relayouts.
@ -48,8 +57,22 @@ 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"
-- "center-focused" switch — global (like the scrolling layout's own fit-method
-- toggle), so a single state, not per-workspace. Seeded once at module load from
-- the last-published state, since a reload wipes this module's Lua state (same
-- reason ncols/rows/orient are seeded — see seed() below).
local center_focus = false
do
local f = io.open(STATE_FILE, "r")
if f then
local data = f:read("*a") or ""; f:close()
if data:match('"_center"%s*:%s*true') then center_focus = true end
end
end
local function publish()
local parts = {}
local parts = { string.format('"_center":%s', tostring(center_focus)) }
for wsid, s in pairs(state) do
parts[#parts + 1] = string.format('"%s":{"cols":%d,"rows":%d,"orient":"%s"}',
tostring(wsid), s.ncols, s.rows, s.orient)
@ -258,15 +281,24 @@ local function recalculate(ctx)
for r = 1, #rowlist do rh[r] = rh[r] * scale end
pan = 0
else
-- overfull: keep natural heights and PAN so the focused row stays in view.
-- overfull: keep natural heights and PAN so the focused row stays in view
-- (center_focus: centred in the viewport instead of minimal movement).
-- Only the column holding the focused window (frow ~= nil) is touched here;
-- every other column keeps whatever pan it already had.
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 center_focus then
pan = top + (rh[frow] - secLen) / 2
else
if top - pan < 0 then pan = top end
if bottom - pan > secLen then pan = bottom - secLen end
end
end
-- clamp to the column's actual scroll range either way — this is also what
-- keeps "centred" from showing blank space past the first/last row.
pan = math.max(0, math.min(pan, total - secLen))
end
s.pan[c] = pan
@ -300,11 +332,23 @@ local function recalculate(ctx)
end
local function layout_msg(ctx, cmd)
local verb, arg = tostring(cmd or ""):match("^(%S+)%s*(%S*)$")
if not verb then return end
-- "center-focused" switch: global, doesn't touch any window, so handled before the
-- empty-workspace bail-out below (toggling it from the menu shouldn't require a
-- window to be focused/present).
if verb == "center" then
if arg == "on" then center_focus = true
elseif arg == "off" then center_focus = false
else center_focus = not center_focus end
publish()
return
end
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")
@ -426,6 +470,6 @@ return {
dirs = { "h", "v" },
dir_labels = { "Horizontal", "Vertical" },
default_dir = "h",
fit_method = false,
fit_method = true, -- menu shows the "center-focused" switch (routed to "center on/off")
stepper = true, -- menu shows a "Columns: (-)[N](+)" count stepper
}