From e5f2b466efecd7d61befc002e45a5cd022f5b7ef Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 09:19:44 -0700 Subject: [PATCH] Center the installer screens and move the effects to ttfx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The progress screen and the finish screen now center vertically the way the greeter does, instead of stacking from row one. Both derive their block height from the logo, so the frame the installer sits on for several minutes is composed rather than top-aligned. The progress screen redraws itself if the console resizes, which the framebuffer still does late in VMs. Centering the finish screen turned up a spacing bug: gum confirm paints prompt/blank/button from the cursor and clears all three rows, so anchoring one row below the title left three blank rows under it, not one. Anchoring a row higher closes that to the two rows gum allows, one more than the greeter's frame. Swap python-terminaltexteffects for ttfx. The flags carry over, but two things do not. ttfx resolves even indexed stops to truecolor, so the greeter needs --xterm-colors to keep its palette indexed: the console reduces 256-colour codes well and 24-bit ones badly, and the settle colour has to match the logo drawn underneath with gum style --foreground 2. And ttfx centres its text two columns right of plain centering, so both screens hand it a canvas two columns narrower than the console or the logo jumps sideways when the effect starts. The finish screen uses smoke rather than laseretch, at 60fps rather than 260 — smoke finishes in under a second at the rate laseretch was tuned for, which reads as a flicker instead of a send-off. Smoke also survives the console's 16-colour collapse better, carrying its motion in glyphs rather than gradients. ttfx is in the ISO's package list and timeout is coreutils, so neither needs an availability check. Co-Authored-By: Claude Opus 5 (1M context) --- builder/build-iso.sh | 2 +- configs/airootfs/root/configurator | 60 ++++++++------- .../usr/local/bin/omarchy-install-dashboard | 77 +++++++++++++++---- 3 files changed, 96 insertions(+), 43 deletions(-) diff --git a/builder/build-iso.sh b/builder/build-iso.sh index 6f919d0..78e0b4a 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -117,7 +117,7 @@ cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/" # The selected omarchy-settings package is needed here so its post_install hook # drops Omarchy's plymouthd.conf into /etc/plymouth before mkarchiso builds the # live initramfs. -arch_packages=(linux-t2 git gum jq openssl plymouth python-terminaltexteffects tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted) +arch_packages=(linux-t2 git gum jq openssl plymouth ttfx tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted) printf '%s\n' "${arch_packages[@]}" >> "$build_cache_dir/packages.x86_64" # The live ISO boots linux-t2 (see airootfs/etc/mkinitcpio.d/linux-t2.preset), so diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 44e428b..6a53d0a 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -121,36 +121,42 @@ greeter() { # ColorShift the logo: a green base (indexed color 2) with a cyan accent (6) # drifting through, settling on green. Indexed ANSI colors, not hex — the - # framebuffer console can't render tte's truecolor faithfully (it crushes the + # framebuffer console can't render truecolor faithfully (it crushes the # palette to a muddy lavender), but the 16 indexed colors map to the Tokyo - # Night palette and render true. One long-running invocation (many cycles) so - # it never restarts — a restart is what flashed. The effect reads from - # /dev/null so it never swallows the Return; --reuse-canvas paints upward from - # the saved cursor, anchored one row below the logo. - if command -v tte >/dev/null 2>&1; then - printf '\033[%d;1H\0337' "$((logo_row + logo_h))" - # Run tte directly (not inside a `while` subshell) so $anim is tte's own - # PID: killing a wrapping subshell would orphan tte, which then keeps - # painting the logo over the keyboard step. --cycles is high enough that it - # never ends on its own before Return. - tte -i "$LOGO_PATH" \ - --canvas-width "$cols" \ - --anchor-text c \ - --frame-rate 60 \ - --reuse-canvas \ - colorshift \ - --gradient-stops 2 10 6 10 \ - --gradient-frames 3 \ - --cycles 1000 \ - --final-gradient-stops 2 \ - /dev/tty 2>/dev/null & - anim=$! - fi + # Night palette and render true. --xterm-colors is what keeps them indexed: + # ttfx resolves even indexed stops to truecolor otherwise, which would put the + # settle color out of step with the `gum style --foreground 2` logo underneath. + # One long-running invocation (many cycles) so it never restarts — a restart is + # what flashed. The effect reads from /dev/null so it never swallows the + # Return; --reuse-canvas paints upward from the saved cursor, anchored one row + # below the logo. + printf '\033[%d;1H\0337' "$((logo_row + logo_h))" + # Run ttfx directly (not inside a `while` subshell) so $anim is ttfx's own + # PID: killing a wrapping subshell would orphan ttfx, which then keeps + # painting the logo over the keyboard step. --cycles is high enough that it + # never ends on its own before Return. + # Two columns narrower than the console: ttfx centers its text two columns + # right of plain centering, so a full-width canvas would sit the animated + # logo a column off the gum-drawn one underneath it. + ttfx -i "$LOGO_PATH" \ + --canvas-width "$((cols > 2 ? cols - 2 : cols))" \ + --anchor-text c \ + --frame-rate 60 \ + --reuse-canvas \ + --xterm-colors \ + colorshift \ + --gradient-stops 2 10 6 10 \ + --gradient-frames 3 \ + --cycles 1000 \ + --final-gradient-stops 2 \ + /dev/tty 2>/dev/null & + anim=$! IFS= read -r _ /dev/null; wait "$anim" 2>/dev/null || true; } - # tte leaves the tty in raw/no-echo mode when killed; restore it or the gum + # Tolerate ttfx's kill signal in `wait` so a future `set -e` here can't abort. + kill "$anim" 2>/dev/null + wait "$anim" 2>/dev/null || true + # ttfx leaves the tty in raw/no-echo mode when killed; restore it or the gum # prompts in the keyboard step that follows silently die. stty sane /dev/null || true # Reset the screen so the leftover animation frame doesn't linger under the diff --git a/configs/airootfs/usr/local/bin/omarchy-install-dashboard b/configs/airootfs/usr/local/bin/omarchy-install-dashboard index bf6da9d..82e1f34 100755 --- a/configs/airootfs/usr/local/bin/omarchy-install-dashboard +++ b/configs/airootfs/usr/local/bin/omarchy-install-dashboard @@ -142,8 +142,18 @@ LOGO_HEIGHT="$(logo_height)" (( LOGO_WIDTH > 0 )) || LOGO_WIDTH=81 (( LOGO_HEIGHT > 0 )) || LOGO_HEIGHT=1 CONTENT_WIDTH="$LOGO_WIDTH" +# Rows the progress screen occupies: logo, blank, title, blank, bar, blank, tip. +PROGRESS_HEIGHT=$((LOGO_HEIGHT + 6)) +TOP_ROW=1 DYNAMIC_ROW=$((LOGO_HEIGHT + 3)) +LAST_SIZE="" LAST_PM=10 +# The finish screen mirrors the greeter's frame — logo, blank, one line, gap, +# one button — so the last thing the installer shows matches the first. It runs +# one row taller: `gum confirm` always paints prompt/blank/button and clears all +# three, so the button cannot sit closer than two rows under a title we drew. +FINISH_HEIGHT=$((LOGO_HEIGHT + 5)) +FINISH_TOP_ROW=2 # ── Install progress ───────────────────────────────────────────────────────── # @@ -389,15 +399,40 @@ current_tip() { printf '%s' "${tips[$(( (NOW - DASH_T0) / 8 % ${#tips[@]} ))]}" } +# Vertically center a block of the given height, echoing the row it starts on. +# Both screens here center the way the configurator's greeter centers its +# welcome, so the install opens, runs, and closes on the same composition +# rather than stacking each frame from row one. +centered_top_row() { + local height="$1" rows top + rows="$(term_size)" + rows="${rows%% *}" + [[ $rows =~ ^[0-9]+$ ]] || rows=24 + top=$(( (rows - height) / 2 )) + (( top < 0 )) && top=0 + printf '%d' "$(( top + 1 ))" +} + +measure_progress_layout() { + LAST_SIZE="$(term_size)" + TOP_ROW="$(centered_top_row "$PROGRESS_HEIGHT")" + DYNAMIC_ROW=$(( TOP_ROW + LOGO_HEIGHT + 1 )) +} + render_static() { - printf '%s' "$CLEAR" - blank_line + measure_progress_layout + printf '%s%s%d;1H' "$CLEAR" "$CSI" "$TOP_ROW" render_logo blank_line } render_dynamic() { local pm + # The console can still resize under us (the framebuffer settles late in VMs). + # Horizontal padding is recomputed every frame anyway, but the logo is drawn + # once, so redraw the static half at the new center rather than leave the + # centered layout stranded against a stale geometry. + [[ "$(term_size)" == "$LAST_SIZE" ]] || render_static set_now install_progress pm=$PROGRESS_PM @@ -452,30 +487,40 @@ render_finish() { local duration effect_canvas_width duration="$(install_duration || true)" duration="${duration:-Complete}" + # ttfx centers its text two columns right of plain centering, so hand it a + # canvas two columns narrower and the animated logo lands exactly on the + # static one left_padding already drew. Otherwise the logo jumps a column + # sideways when the effect starts and again when it ends. effect_canvas_width="$(term_cols)" - (( effect_canvas_width > 1 )) && effect_canvas_width=$((effect_canvas_width - 1)) + (( effect_canvas_width > 2 )) && effect_canvas_width=$((effect_canvas_width - 2)) + + # Set before anything that can fail, so reboot_prompt still lands on the + # button row even if the logo or the effect below never renders. + FINISH_TOP_ROW="$(centered_top_row "$FINISH_HEIGHT")" { - printf '%s%s' "$SHOW_CURSOR" "$CLEAR" - blank_line + printf '%s%s%s%d;1H' "$SHOW_CURSOR" "$CLEAR" "$CSI" "$FINISH_TOP_ROW" render_logo blank_line center "Installed Omarchy in ${duration}" "$CONTENT_WIDTH" blank_line } >"$TTY_PATH" - if command -v tte >/dev/null 2>&1 && command -v timeout >/dev/null 2>&1 && [[ -f $LOGO_PATH ]]; then - # TTE reuse mode restores a saved cursor before moving up over its input. - # Save this anchor so the effect redraws the same logo rows shown above. - printf '%s%d;1H\0337' "$CSI" "$((LOGO_HEIGHT + 2))" >"$TTY_PATH" - timeout 8s tte -i "$LOGO_PATH" \ + if [[ -f $LOGO_PATH ]]; then + # Reuse mode restores a saved cursor before moving up over its input. Save + # this anchor so the effect redraws the same logo rows shown above. + printf '%s%d;1H\0337' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT))" >"$TTY_PATH" + # Smoke runs ~4s at 60fps, so the 8s cap is a stuck-process guard rather than + # a trim. The rate is the effect's pacing: the 260 this carried for laseretch + # finishes smoke in under a second, which reads as a flicker, not a send-off. + timeout 8s ttfx -i "$LOGO_PATH" \ --canvas-width "$effect_canvas_width" \ --anchor-text c \ - --frame-rate 260 \ + --frame-rate 60 \ --reuse-canvas \ - laseretch \ + smoke \ >"$TTY_PATH" 2>/dev/null || true - printf '%s%d;1H' "$CSI" "$((LOGO_HEIGHT + 8))" >"$TTY_PATH" + printf '%s%d;1H' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 5))" >"$TTY_PATH" fi } @@ -486,8 +531,10 @@ reboot_prompt() { # applies, so setting both stops on the finish screen without a keyboard. interactive || return 0 - # Position the cursor one blank line below the title so the button sits right there. - printf '%s%d;1H%s' "$CSI" "$((LOGO_HEIGHT + 5))" "$CLEAR_LINE" >"$TTY_PATH" + # gum paints its prompt row at the cursor and the button two rows under it, so + # anchor on the row right below the title to put the button as close to it as + # gum allows. FINISH_HEIGHT budgets for the same three rows. + printf '%s%d;1H%s' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 2))" "$CLEAR_LINE" >"$TTY_PATH" prompt_pad=$(( ( $(term_cols) - 18 ) / 2 )) (( prompt_pad < 0 )) && prompt_pad=0