Compare commits

..

4 Commits

Author SHA1 Message Date
Amir Alexander Abdelbaki 4c747221f2 style(astal-menu): unfullscreen glyphs, animated takeover, CyberQueer TUIs
- Expand/collapse controls now use consistent fullscreen ()/unfullscreen ()
  glyphs: the quad "Back" and the taskbar-panel "Back" became  buttons (matching
  the appdrawer's - toggle).
- The taskbar panel takeover now plays the same bouncy quad-pop scale as the quads
  when it expands, so every expansion in the menu is animated.
- monitor-manager (curses) and timer-pick (bash TUI) now use the CyberQueer accent
  palette (red #E40046 + violet #5018dd, true-colour with ANSI fallback) instead of
  cyan/green, matching the rest of the config.
- Added a .stepper-value style for the Columns count stepper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUN7gg6GGfnToghMijLm5Y
2026-07-07 13:43:11 +02:00
Amir Alexander Abdelbaki 48e32e7eea fix(greetd): pin greeter GPU + software cursors + single-output centering
Diagnosed the intermittent "login won't take input / frozen cursor" on this
dual-AMD-GPU box: cage (wlroots) could pick the display-less iGPU, and amdgpu
hardware cursors can freeze. regreet-session.sh now:
- pins WLR_DRM_DEVICES to the first DRM card with a connected output (portable,
  evaluated live — no hard-coded PCI path),
- sets WLR_NO_HARDWARE_CURSORS=1 (fixes the stuck cursor),
- runs cage with `-m last` so the login card centres on a single monitor instead
  of the default "extend", which stretched the canvas across all three monitors
  and left the card floating in the middle of the span.

sysupdate.sh now deploys /etc/greetd config (config/regreet/session, not PAM) when
greetd is the active greeter, using the single primed sudo credential.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUN7gg6GGfnToghMijLm5Y
2026-07-07 13:37:22 +02:00
Amir Alexander Abdelbaki de666acac7 feat(astal-menu): Columns count stepper + expanded taskbar is one module
- Columns layout publishes per-workspace column/row counts to columns-state.json
  and handles a direct "cols +1/-1/N" message. The menu's Columns page now shows a
  "Columns  (−)[N](+)" stepper that reads the live count and drives it.
- Expanding the taskbar now fully collapses the strip (header included) via its
  revealer, so the panel that covers the quads reads as a single module instead of a
  thin strip plus a detached card below it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUN7gg6GGfnToghMijLm5Y
2026-07-07 13:31:45 +02:00
Amir Alexander Abdelbaki 9d1ec69a11 feat: hyprshutdown power binds, 76% scrolling windows, single-sudo sysupdate
- hyprshutdown wired into the graceful power flow (Super+Shift+O logout,
  Super+Ctrl+O power off, Super+Ctrl+Shift+O reboot) so apps close cleanly with a
  UI before Hyprland exits, instead of a hard systemctl call. Added to the hyprlua
  package list.
- Scrolling layout: default window is now 76% of the monitor along the scroll axis
  (column_width 0.5 -> 0.76).
- sysupdate.sh: primes sudo once at startup and keeps the credential alive for the
  whole run, so no later step re-prompts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUN7gg6GGfnToghMijLm5Y
2026-07-07 13:26:48 +02:00
13 changed files with 179 additions and 32 deletions

View File

@ -107,6 +107,14 @@ drawingarea {
.quad-action:active,
button:checked.quad-action { background: @accent; color: @bg; border-color: @accent; }
/* Columns count stepper value (between the / + pills) */
.stepper-value {
min-width: 34px;
font-weight: bold;
color: @accent;
font-feature-settings: "tnum";
}
/* A MenuButton (the settings cog) wraps an inner `button` node that would render a
* second ring inside the outer .quad-action pill flatten it so only one border
* shows. */

View File

@ -68,7 +68,8 @@ class QuadGrid(Gtk.Overlay):
self._takeover_reveal.set_valign(Gtk.Align.FILL)
self._takeover_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self._takeover_holder.add_css_class("quad-expanded")
self._takeover_reveal.set_child(bordered(self._takeover_holder, radius=16, fill_bg=True))
self._takeover_card = bordered(self._takeover_holder, radius=16, fill_bg=True)
self._takeover_reveal.set_child(self._takeover_card)
self.add_overlay(self._takeover_reveal)
self._takeover_reveal.set_visible(False)
@ -80,8 +81,9 @@ class QuadGrid(Gtk.Overlay):
A Back row is prepended so the covered quads can be restored."""
header = Gtk.CenterBox()
header.add_css_class("expanded-header")
back = Gtk.Button(label=" Back")
back = Gtk.Button(label="") # nf-fa-compress (unfullscreen)
back.add_css_class("quad-action")
back.set_tooltip_text("Collapse")
back.connect("clicked", lambda *_: on_back())
header.set_start_widget(back)
self._takeover_holder.append(header)
@ -90,6 +92,9 @@ class QuadGrid(Gtk.Overlay):
def show_takeover(self) -> None:
self._takeover_reveal.set_visible(True)
self._takeover_card.add_css_class("quad-pop") # same bouncy scale as a quad
GLib.timeout_add(340, lambda: (self._takeover_card.remove_css_class("quad-pop"),
GLib.SOURCE_REMOVE)[1])
self._takeover_reveal.set_reveal_child(True)
self.grid.add_css_class("dimmed")
@ -156,8 +161,9 @@ class QuadGrid(Gtk.Overlay):
def _expanded_header(self, title: str) -> Gtk.Widget:
header = Gtk.CenterBox()
header.add_css_class("expanded-header")
back = Gtk.Button(label=" Back") # nf arrow
back = Gtk.Button(label="") # nf-fa-compress (unfullscreen)
back.add_css_class("quad-action")
back.set_tooltip_text("Collapse")
back.connect("clicked", lambda *_: self.request_collapse())
header.set_start_widget(back)
lbl = Gtk.Label(label=title)

View File

@ -25,6 +25,7 @@ from lib.proc import run_json, run_text
from paths import CACHE_DIR
_LAYOUTS_MANIFEST = CACHE_DIR / "layouts.json"
_COLUMNS_STATE = CACHE_DIR / "columns-state.json"
_LAYOUTS_STATE = CACHE_DIR / "layouts-state.json"
@ -284,11 +285,46 @@ class Taskbar(Gtk.Box):
row.append(sw)
page.append(row)
self._fit_sws[ly["name"]] = sw
if not ly.get("dirs") and not ly.get("fit_method"):
if ly.get("stepper"): # Columns: (-)[N](+)
page.append(self._cols_stepper())
if not ly.get("dirs") and not ly.get("fit_method") and not ly.get("stepper"):
page.append(Gtk.Label(label="No adjustable options", xalign=0.0,
css_classes=["net-ip"]))
return page
# -- columns count stepper --------------------------------------------
def _cols_stepper(self) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.append(Gtk.Label(label="Columns", xalign=0.0, hexpand=True))
minus = Gtk.Button(label="")
minus.add_css_class("quad-action")
self._cols_lbl = Gtk.Label(label=str(self._read_cols()))
self._cols_lbl.add_css_class("stepper-value")
plus = Gtk.Button(label="+")
plus.add_css_class("quad-action")
minus.connect("clicked", lambda *_a: self._step_cols(-1))
plus.connect("clicked", lambda *_a: self._step_cols(1))
row.append(minus)
row.append(self._cols_lbl)
row.append(plus)
return row
def _read_cols(self) -> int:
try:
data = json.loads(_COLUMNS_STATE.read_text())
return int(data.get(str(self._active_ws), {}).get("cols", 2))
except (FileNotFoundError, json.JSONDecodeError, ValueError, TypeError):
return 2
def _step_cols(self, delta: int) -> None:
try:
cur = int(self._cols_lbl.get_label())
except ValueError:
cur = self._read_cols()
new = max(1, cur + delta)
self._cols_lbl.set_label(str(new))
_eval(f'hl.dispatch(hl.dsp.layout("cols {"+1" if delta > 0 else "-1"}"))')
# -- sync + handlers ---------------------------------------------------
def _sync_layout_controls(self) -> None:
# reflect the current per-ws layout / direction / focus_fit in the tabs+options

View File

@ -159,10 +159,13 @@ class MenuWindow(Gtk.ApplicationWindow):
# -- taskbar panel expansion ------------------------------------------
def _on_taskbar_panel(self, expanded: bool) -> None:
# Cover the quads with the taskbar's workspace/window panel (the strip itself
# collapses inside the Taskbar). Collapse any expanded quad first.
# Cover the quads with the taskbar's workspace/window panel. Fully collapse the
# taskbar strip (header included) so the expanded panel reads as ONE module
# rather than a thin strip on top plus a detached card. Collapse any quad first.
if expanded and self.grid.is_expanded:
self.grid.request_collapse()
self.taskbar_reveal.set_reveal_child(not expanded)
self.taskbar_reveal.set_visible(not expanded)
self.grid.show_takeover() if expanded else self.grid.hide_takeover()
# -- appdrawer expansion ----------------------------------------------

View File

@ -19,15 +19,41 @@
local state = {} -- [wsid] = { ncols, rows, orient, assign={addr=col}, seq={addr=n}, nextseq, pan={col=px} }
-- Publish per-workspace column/row counts 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 = {}
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)
end
pcall(function()
local f = io.open(STATE_FILE, "w")
if f then f:write("{" .. table.concat(parts, ",") .. "}\n"); f:close() end
end)
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 = {} }
state[wsid] = s
publish()
end
return s
end
-- Reflow every window evenly across the current column count, by insertion order.
local function reflow(s, targets)
local addrs = {}
for _, tg in ipairs(targets) do
local ok, addr = pcall(function() return tg.window.address end)
if ok and addr then addrs[#addrs + 1] = addr end
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
end
local function win_ws_id(win)
local ok, ws = pcall(function() return win.workspace end)
if ok and ws ~= nil then
@ -185,18 +211,18 @@ local function layout_msg(ctx, cmd)
elseif arg == less_cols then s.ncols = math.max(1, s.ncols - 1); changed_cols = true
elseif arg == more_rows then s.rows = s.rows + 1
elseif arg == less_rows then s.rows = math.max(1, s.rows - 1) end
if changed_cols then
-- reflow all windows evenly across the new column count (by insertion order)
local addrs = {}
for _, tg in ipairs(t) do
local ok, addr = pcall(function() return tg.window.address end)
if ok and addr then addrs[#addrs + 1] = addr end
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
end
if changed_cols then reflow(s, t) end
publish()
elseif verb == "cols" then
-- direct column-count change (the menu's (-)[N](+) stepper).
if arg == "+" or arg == "+1" then s.ncols = s.ncols + 1
elseif arg == "-" or arg == "-1" then s.ncols = math.max(1, s.ncols - 1)
else local n = tonumber(arg); if n then s.ncols = math.max(1, math.floor(n)) end end
reflow(s, t)
publish()
elseif verb == "orient" then
s.orient = (arg == "v") and "v" or "h"
publish()
end
end
@ -216,4 +242,5 @@ return {
dir_labels = { "Horizontal", "Vertical" },
default_dir = "h",
fit_method = false,
stepper = true, -- menu shows a "Columns: (-)[N](+)" count stepper
}

View File

@ -44,7 +44,8 @@ function M.write_manifest()
',"dirs":[', table.concat(dirs, ","), "]",
',"dir_labels":[', table.concat(dlabels, ","), "]",
',"default_dir":', jstr(s.default_dir or ""),
',"fit_method":', tostring(s.fit_method or false), "}",
',"fit_method":', tostring(s.fit_method or false),
',"stepper":', tostring(s.stepper or false), "}",
})
end
os.execute("mkdir -p '" .. MANIFEST:match("(.+)/[^/]+$") .. "' 2>/dev/null")

View File

@ -4,9 +4,12 @@
-- * focus_fit_method 0/1 ("Centered follow") — see M.set_fit(); when centered the
-- focused column is centred and the previous/next columns peek in from the sides.
--
-- column_width is kept < 1.0 (0.5) so the active column is narrower than the viewport
-- and the prev/next columns stay on-screen and clickable. fullscreen_on_one_column
-- lets a lone window still fill the screen, so the narrow width costs nothing alone.
-- column_width is the default window size along the scroll axis, as a fraction of the
-- monitor (0.76 = 76% of the monitor's width for a horizontal tape, or its height for a
-- vertical one — the layout applies it to whichever axis `direction` scrolls). Keeping it
-- < 1.0 leaves the prev/next windows peeking in on-screen and clickable;
-- fullscreen_on_one_column lets a lone window still fill the screen, so it costs nothing
-- when a window is alone.
return {
name = "scrolling",
label = "Scrolling",
@ -18,7 +21,7 @@ return {
config = {
scrolling = {
direction = "down",
column_width = 0.5,
column_width = 0.76, -- 76% of the monitor along the scroll axis
focus_fit_method = 1,
follow_focus = true,
fullscreen_on_one_column = true,

View File

@ -88,10 +88,12 @@ hl.bind(mainMod .. " + SHIFT + Q", hl.dsp.exec_cmd("hyprctl kill"))
hl.bind(mainMod .. " + CTRL + M", hl.dsp.exec_cmd("~/.config/scripts/toggle-layout.sh"))
-- lock/exit
-- lock/exit — hyprshutdown gracefully closes every app (with a UI) before it exits
-- Hyprland and runs its --post-cmd, instead of yanking the session out from under them.
hl.bind(mainMod .. " + O", hl.dsp.exec_cmd("hyprlock"))
hl.bind(mainMod .. " + SHIFT + O", hl.dsp.exec_cmd("hyprctl dispatch exit"))
hl.bind(mainMod .. " + CTRL + O", hl.dsp.exec_cmd("systemctl poweroff"))
hl.bind(mainMod .. " + SHIFT + O", hl.dsp.exec_cmd("hyprshutdown")) -- graceful logout
hl.bind(mainMod .. " + CTRL + O", hl.dsp.exec_cmd('hyprshutdown -p "systemctl poweroff"')) -- graceful power off
hl.bind(mainMod .. " + CTRL + SHIFT + O",hl.dsp.exec_cmd('hyprshutdown -p "systemctl reboot"')) -- graceful reboot
hl.bind(mainMod .. " + ALT + O", hl.dsp.exec_cmd("~/.config/scripts/pwr-dmenu.sh"))
hl.bind(mainMod .. " + ALT + CTRL + SHIFT + END", hl.dsp.exit())

View File

@ -347,8 +347,20 @@ class App:
def _init_colors(self):
curses.start_color()
curses.use_default_colors()
# 1 = selected (cyan bold)
curses.init_pair(1, curses.COLOR_CYAN, -1)
# Match the rest of the config (CyberQueer): accent red #E40046 for the
# selection, electric violet #5018dd for headers/help. Define them as true
# RGB when the terminal allows (kitty does); otherwise fall back to the
# nearest ANSI colour so the tool still works on a basic console.
accent, violet = curses.COLOR_CYAN, curses.COLOR_GREEN
if curses.COLORS >= 256 and curses.can_change_color():
try:
curses.init_color(16, 894, 0, 275) # #E40046
curses.init_color(17, 314, 94, 867) # #5018dd
accent, violet = 16, 17
except curses.error:
pass
# 1 = selected (accent red, bold)
curses.init_pair(1, accent, -1)
# 2 = normal (white)
curses.init_pair(2, curses.COLOR_WHITE, -1)
# 3 = mirror target (yellow)
@ -357,8 +369,8 @@ class App:
curses.init_pair(4, curses.COLOR_BLACK + 8 if curses.COLORS >= 16 else curses.COLOR_WHITE, -1)
# 5 = status bar (reversed)
curses.init_pair(5, -1, -1)
# 6 = help (green)
curses.init_pair(6, curses.COLOR_GREEN, -1)
# 6 = help / headers (violet)
curses.init_pair(6, violet, -1)
def _get_scale(self, pane_cols: int, pane_rows: int) -> float:
"""Return cached scale; recompute only on resize or when a monitor escapes the viewport."""

View File

@ -13,7 +13,9 @@ hide() { printf "${CSI}?25l"; }
show() { printf "${CSI}?25h"; }
b="${CSI}1m" ; d="${CSI}2m" ; r="${CSI}0m"
cy="${CSI}96m"; gn="${CSI}92m"
# CyberQueer accents to match the rest of the config: red #E40046, violet #5018dd
# (24-bit truecolor; kitty renders these exactly).
cy="${CSI}38;2;228;0;70m" ; gn="${CSI}38;2;80;24;221m"
yl="${CSI}93m"; wh="${CSI}97m"; gy="${CSI}90m"
rv="${CSI}7m"

View File

@ -3,7 +3,7 @@
#
# greetd runs this as the unprivileged `greeter` user. cage is a minimal
# wlroots/Wayland compositor; `-s` allows VT switching (so Ctrl+Alt+F-keys still
# reach a console). Env here tunes the greeter's look before ReGreet starts.
# reach a console). Env here tunes the greeter's look/behaviour before ReGreet starts.
#
# Scaling: cage renders at output scale 1. Bump XCURSOR_SIZE for a larger cursor;
# uncomment GDK_SCALE to integer-scale the whole greeter (careful on mixed-DPI
@ -13,4 +13,22 @@
export XCURSOR_SIZE=32
# export GDK_SCALE=2
exec cage -s -- regreet
# ── Multi-GPU: pin the greeter to a DRM card that actually drives a monitor ─────
# On a dual-GPU box (dGPU + iGPU) wlroots enumerates both cards and may pick the
# display-less one, giving a blank or input-frozen greeter. Hand it only the first
# card that has a connected output. Evaluated live, so it stays portable across
# boots and machines (no hard-coded PCI path).
for _status in /sys/class/drm/card[0-9]*-*/status; do
[ "$(cat "$_status" 2>/dev/null)" = "connected" ] || continue
_card="/dev/dri/$(basename "$(dirname "$_status")" | cut -d- -f1)"
[ -e "$_card" ] && { WLR_DRM_DEVICES="$_card"; export WLR_DRM_DEVICES; break; }
done
# amdgpu hardware cursors can freeze on this hardware (the cursor sticks and input
# feels dead); software cursors are reliable.
export WLR_NO_HARDWARE_CURSORS=1
# cage's default is "-m extend", which stretches one canvas across every monitor and
# leaves ReGreet's login card floating in the middle of the whole multi-monitor span.
# "-m last" renders on a single output instead, so the card is centred on one screen.
exec cage -s -m last -- regreet

View File

@ -89,6 +89,7 @@ HYPRLUA_PACKAGES=(
hyprsunset # blue-light filter / night-mode daemon
hypridle # idle daemon triggering lock/suspend after inactivity
hyprshutdown # hyprtoolkit graphical power/shutdown dialog (Super+Alt+O)
ksshaskpass # Qt SSH passphrase dialog registered as ssh-askpass
nm-connection-editor # GTK editor for NetworkManager connection profiles

View File

@ -96,7 +96,17 @@ spin_stop() {
printf '\r%*s\r' "$(_cols)" ''
}
trap 'spin_stop; tput cnorm 2>/dev/null || true' EXIT
_SUDO_KEEPALIVE=""
trap 'spin_stop; [[ -n "${_SUDO_KEEPALIVE:-}" ]] && kill "$_SUDO_KEEPALIVE" 2>/dev/null; tput cnorm 2>/dev/null || true' EXIT
# Prime sudo ONCE at startup and keep the credential fresh for the whole run, so no
# later step (config deploy, pacman -S per package, state-file write) re-prompts.
sudo_prime() {
printf " ${Y}?${RS} ${BO}Elevated access${RS} — enter your password once for this run.\n"
sudo -v || { err "sudo is required."; exit 1; }
( while true; do sudo -n true 2>/dev/null; sleep 50; kill -0 "$$" 2>/dev/null || exit; done ) &
_SUDO_KEEPALIVE=$!
}
# ═══════════════════════════════════════════════════════════════════════════════
# STATE FILE
@ -587,12 +597,29 @@ _fix_hypr_usr() {
fi
}
# 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
# setup module — a bad PAM file could lock out login.
_deploy_greetd() {
local src="$DOTFILES/etc-greetd"
[[ -d "$src" ]] || return 0
systemctl is-enabled greetd.service &>/dev/null || return 0
log "Deploying greetd / ReGreet config to ${BO}/etc/greetd${RS}..."
sudo install -Dm644 "$src/config.toml" /etc/greetd/config.toml && \
sudo install -Dm644 "$src/regreet.toml" /etc/greetd/regreet.toml && \
sudo install -Dm644 "$src/regreet.css" /etc/greetd/regreet.css && \
sudo install -Dm755 "$src/regreet-session.sh" /etc/greetd/regreet-session.sh && \
ok "greetd config deployed" || warn "greetd config deploy had errors"
}
sync_configs() {
section "Config Sync"
# ── Migration: old flat layout → hypr/usr/ ───────────────────────────────
_migrate_hypr_usr
_fix_hypr_usr
_deploy_greetd
# ── Preferred: use the installed update-configs.sh ───────────────────────
local cfg_script
@ -694,6 +721,7 @@ declare -a FLATPAK_PKGS=()
main() {
header
sudo_prime # single password prompt up front; kept alive for the whole run
# ── Configs-only mode ────────────────────────────────────────────────────
if [[ "$UPDATE_MODE" == "configs" ]]; then