Compare commits

...

2 Commits

Author SHA1 Message Date
Amir Alexander Abdelbaki d680730d30 feat(sysupdate): Plymouth/ReGreet migration, astal-menu deps, hyprshutdown, usr redeploy
sysupdate.sh's config sync now closes several gaps where a fresh or drifted
machine would silently be missing pieces that config-updater can't reach
(it only copies files, never installs packages or touches /etc):

- _ensure_plymouth_regreet: detects whether the machine is still on
  greetd+tuigreet (or no greeter at all) and offers to run the existing
  migrate-to-greetd.sh tool rather than duplicating its logic.
- _ensure_astal_menu_deps / _ensure_hyprshutdown: detect and offer to install
  the AUR/pacman packages astal-menu and the new power binds need but that
  config-updater never installs on its own.
- _redeploy_usr_configs: hypr/usr/ is intentionally excluded from the
  automated sync to protect per-device customisation (monitors.lua,
  wallpaper.conf), which left feature/bind updates in the dotfiles template
  with no path onto an already-provisioned machine. Adds an explicit,
  file-by-file dialog checklist (device-specific files default off) so those
  updates can be pulled in deliberately instead of hand-copied.
2026-07-08 13:35:56 +02:00
Amir Alexander Abdelbaki ed44b84afa 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.
2026-07-08 13:35:47 +02:00
3 changed files with 235 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
}

View File

@ -597,6 +597,75 @@ _fix_hypr_usr() {
fi
}
# Offer to redeploy hypr/usr/ (device-specific lua) from the dotfiles template,
# file by file. hypr/usr/ is excluded from the normal config-updater sync to
# preserve per-device customisation (monitors.lua, wallpaper.conf), but feature/
# bind updates in autostart.lua, binds.lua, windowrules.lua etc. still land in
# the dotfiles template and need an explicit, reviewable opt-in to reach a
# machine that already has its own hypr/usr/. Skipped entirely on first-time
# setup — _migrate_hypr_usr already seeds usr/ from the template in that case.
_redeploy_usr_configs() {
local de_dir; de_dir=$(_de_source_dir)
local src_usr="${de_dir:+$de_dir/hypr/usr}"
local usr_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hypr/usr"
[[ -n "${src_usr:-}" && -d "$src_usr" ]] || return 0
[[ -d "$usr_dir" ]] || return 0
ask "Redeploy user-local configs (hypr/usr/)?" || { log "Skipped user-local config redeploy"; return 0; }
local -a files=()
while IFS= read -r -d '' item; do
files+=("$(basename "$item")")
done < <(find "$src_usr" -maxdepth 1 -mindepth 1 -type f ! -name '*.old' -print0 | sort -z)
if [[ ${#files[@]} -eq 0 ]]; then
warn "No template files found in $src_usr"
return 0
fi
local -a selected=()
if command -v dialog &>/dev/null; then
local items=()
for f in "${files[@]}"; do
local tag="on"
[[ "$f" == "monitors.lua" || "$f" == "wallpaper.conf" ]] && tag="off"
local status="new"
[[ -f "$usr_dir/$f" ]] && { diff -q "$usr_dir/$f" "$src_usr/$f" &>/dev/null && status="unchanged" || status="differs"; }
items+=("$f" "($status)" "$tag")
done
local chosen
chosen=$(dialog --stdout --separate-output \
--title " Redeploy hypr/usr/ " \
--checklist " SPACE = toggle | ENTER = confirm | ESC = cancel (monitors.lua / wallpaper.conf default OFF — device-specific) " \
0 70 0 "${items[@]}") || { warn "Cancelled — no user-local configs redeployed."; return 0; }
while IFS= read -r line; do
[[ -n "$line" ]] && selected+=("$line")
done <<< "$chosen"
else
warn "'dialog' not installed — redeploying all except monitors.lua/wallpaper.conf"
for f in "${files[@]}"; do
[[ "$f" == "monitors.lua" || "$f" == "wallpaper.conf" ]] && continue
selected+=("$f")
done
fi
if [[ ${#selected[@]} -eq 0 ]]; then
warn "Nothing selected — no user-local configs redeployed."
return 0
fi
for f in "${selected[@]}"; do
if cp -p "$src_usr/$f" "$usr_dir/$f"; then
ok "redeployed hypr/usr/$f"
else
err "failed hypr/usr/$f"
fi
done
_fix_hypr_usr
}
# Deploy root-owned greeter config (/etc/greetd) that config-updater can't reach.
# Only when greetd is the active greeter on this machine. Uses the sudo credential
# primed once at startup, so it doesn't re-prompt. PAM is intentionally left to the
@ -613,13 +682,95 @@ _deploy_greetd() {
ok "greetd config deployed" || warn "greetd config deploy had errors"
}
# Ensure this machine has finished the Plymouth + greetd/ReGreet boot/login
# migration. Delegates entirely to setup/tools/migrate-to-greetd.sh (idempotent,
# already handles old-DM detection/teardown, PAM/FIDO, wallpaper sync, initramfs
# + GRUB) rather than duplicating that logic here. Skips silently once the
# machine is already on the target stack.
_ensure_plymouth_regreet() {
local migrate_tool="$DOTFILES/setup/tools/migrate-to-greetd.sh"
[[ -x "$migrate_tool" ]] || return 0
local greetd_state; greetd_state=$(systemctl is-enabled greetd.service 2>/dev/null || true)
local using_regreet=false
grep -q 'regreet' /etc/greetd/config.toml 2>/dev/null && using_regreet=true
local plymouth_ok=false
pacman -Qq plymouth &>/dev/null && [[ -d /usr/share/plymouth/themes/m-archy ]] && plymouth_ok=true
if [[ "$greetd_state" == "enabled" && "$using_regreet" == true && "$plymouth_ok" == true ]]; then
ok "Plymouth + greetd/ReGreet already active"
return 0
fi
warn "Not yet on Plymouth + greetd/ReGreet (currently: greetd=${greetd_state}, regreet=${using_regreet}, plymouth=${plymouth_ok})"
if ask "Migrate now? (installs plymouth+regreet+cage, rebuilds initramfs; reboot needed after)"; then
"$migrate_tool" --yes
fi
}
# Ensure the Astal GObject typelibs astal-menu's main.py imports at startup are
# installed. config-updater only copies the astal-menu/ config directory — it
# never installs packages — so a machine that's never run the AUR install is
# missing these even after a full config sync.
_ensure_astal_menu_deps() {
local de_dir; de_dir=$(_de_source_dir)
[[ "${de_dir:-}" == */hyprlua ]] || return 0
[[ -d "$de_dir/astal-menu" ]] || return 0
local -a needed=(libastal-network-git libastal-bluetooth-git libastal-apps-git)
local -a missing=()
for pkg in "${needed[@]}"; do
pacman -Qq "$pkg" &>/dev/null || missing+=("$pkg")
done
if [[ ${#missing[@]} -eq 0 ]]; then
ok "astal-menu AUR deps already installed"
return 0
fi
warn "astal-menu is missing AUR deps: ${missing[*]}"
if ask "Install them now?"; then
if yay -S --noconfirm --needed "${missing[@]}"; then
ok "astal-menu deps installed"
else
warn "Failed to install some astal-menu deps"
fi
fi
}
# Ensure hyprshutdown is installed. hypr/usr/binds.lua's power binds
# (Super+Shift/Ctrl/Ctrl+Shift+O) call it directly; without it those binds
# silently no-op. It's in the official extra repo, not AUR.
_ensure_hyprshutdown() {
local de_dir; de_dir=$(_de_source_dir)
[[ "${de_dir:-}" == */hyprlua ]] || return 0
if pacman -Qq hyprshutdown &>/dev/null; then
ok "hyprshutdown already installed"
return 0
fi
warn "hyprshutdown is missing — the power binds in hypr/usr/binds.lua will no-op without it"
if ask "Install it now?"; then
if sudo pacman -S --noconfirm --needed hyprshutdown; then
ok "hyprshutdown installed"
else
warn "Failed to install hyprshutdown"
fi
fi
}
sync_configs() {
section "Config Sync"
# ── Migration: old flat layout → hypr/usr/ ───────────────────────────────
_migrate_hypr_usr
_fix_hypr_usr
_redeploy_usr_configs
_deploy_greetd
_ensure_plymouth_regreet
_ensure_astal_menu_deps
_ensure_hyprshutdown
# ── Preferred: use the installed update-configs.sh ───────────────────────
local cfg_script