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
104 changes: 104 additions & 0 deletions bin/omarchy-branding-about-animation
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/bin/bash

# omarchy:summary=Shared helpers for animating the About branding (source this, don't run it).
# omarchy:group=branding
# omarchy:name=about-animation
# omarchy:hidden=true

# The About branding's animation: a sheen, and the frames that make it. *When*
# those frames are written stays with the caller, because that is inseparable
# from how the caller's window closes and resizes; this only says what to write,
# and where — it is handed a logo and knows nothing about About beyond that.

# A band of light leans across the logo, rests, and leans across again. Two
# columns per row puts it at 45 degrees on screen, where a cell is twice as tall
# as it is wide.
ESC=$'\e'
# Bright white, so the band does not depend on how the terminal reads bold. Foot's
# bold-text-in-bright brightens a bold regular colour into its bright counterpart,
# which turns a bold green logo bright green — exactly the colour a bright green
# band would have used, leaving a glint nobody can see. No regular colour
# brightens into bright white, so this one shows either way.
SHEEN_BAND="${ESC}[1m${ESC}[97m"
SHEEN_SLANT=2
SHEEN_HALF=2
SHEEN_FRAME_SECONDS=0.025
SHEEN_REST_TICKS=8
# Re-measure the grid every half second, the cadence a still logo already cost,
# rather than spawning a process per frame.
SHEEN_POLL_FRAMES=20

# A frame is one string holding every row of the logo, positioned and coloured,
# so a tick writes the whole logo at once and never shows it half drawn.
compose_frame() {
local centre=$1
local row line length at from to frame=""

for (( row = 0; row < SHEEN_ROWS; row++ )); do
line=${SHEEN_LINES[row]}
length=${#line}

# Where the band crosses this row. Running off the right needs no clamp,
# because a slice that starts past the end of a line is already empty, but a
# negative offset would count from the end of it instead of off the left.
at=$(( centre - row * SHEEN_SLANT ))
from=$(( at - SHEEN_HALF ))
from=$(( from < 0 ? 0 : from ))
to=$(( at + SHEEN_HALF + 1 ))
to=$(( to < 0 ? 0 : to ))

frame+="${ESC}[$((SHEEN_TOP + row));${SHEEN_LEFT}H"
frame+="${SHEEN_BASE}${line:0:from}${SHEEN_BAND}${line:from:to - from}${SHEEN_BASE}${line:to}"
done

SHEEN_COMPOSED=$frame
}

# Builds every frame up front: a tick that recomputed a logo's worth of colour
# spans in bash would not hold the frame rate, and the sweep is the same every
# time. Leaves them in SHEEN_FRAMES, and answers whether this logo can be
# animated at all — one it cannot put back exactly as it found it is one to leave
# alone, because nothing on screen would say the difference.
#
# sheen_build <logo-file> <top-row> <left-column> <base-colour> <columns-available>
sheen_build() {
local file=$1 columns=$5
SHEEN_TOP=$2
SHEEN_LEFT=$3
SHEEN_BASE=$4

SHEEN_LINES=()
# A failing redirection reports itself before 2>/dev/null would apply, so order
# it first: the caller's window must not get a shell error painted across it.
mapfile -t SHEEN_LINES 2>/dev/null <"$file" || return 1
SHEEN_ROWS=${#SHEEN_LINES[@]}
(( SHEEN_ROWS > 0 )) || return 1

local row line width=0
for (( row = 0; row < SHEEN_ROWS; row++ )); do
line=${SHEEN_LINES[row]}

# A renderer substitutes $1 to $9 for colours, so a logo written with those is
# not the text that reached the screen.
[[ $line == *'$'* ]] && return 1

# These frames slice the row by character and a terminal draws it by column,
# so one character has to be one cell — and everything that breaks that breaks
# it here. A wide glyph, a combining mark or a joined emoji is not one cell; a
# tab or an escape is one the renderer expanded itself; and a shell whose
# locale is counting bytes is not counting characters at all.
(( ${#line} == $(printf '%s' "$line" | LC_ALL=C.UTF-8 wc -L) )) || return 1

if (( ${#line} > width )); then
width=${#line}
fi
done
(( width > 0 && width <= columns )) || return 1

SHEEN_FRAMES=()
local centre last=$(( width + SHEEN_ROWS * SHEEN_SLANT + SHEEN_HALF ))
for (( centre = -SHEEN_HALF; centre <= last; centre++ )); do
compose_frame "$centre"
SHEEN_FRAMES+=("$SHEEN_COMPOSED")
done
}
170 changes: 157 additions & 13 deletions bin/omarchy-launch-about
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,41 @@

LOGO_FILE="$HOME/.config/omarchy/branding/about.txt"
FIT_FILE="$HOME/.local/state/omarchy/windows/about.fit"
OMARCHY_FASTFETCH_DIR=/etc/fastfetch

# A user-level fastfetch config can relocate or restyle the logo in ways this
# measurement cannot see, so leave sizing to the float rule in that case.
# The logo block in the fastfetch config. The fit below reproduces this layout to
# size the window, and the sheen has to repaint the very cells fastfetch drew the
# logo on, so both read the padding from here.
LOGO_PAD_LEFT=2
LOGO_PAD_TOP=2
LOGO_PAD_RIGHT=6

POLL_SECONDS=0.5

# fastfetch has no animation of its own, so the sheen is ours. It knows about a
# logo and nothing about About, which is why it is a file of its own.
source omarchy-branding-about-animation

# A user's own fastfetch config can relocate or restyle the logo in ways this
# measurement cannot see, so leave sizing to the float rule in that case. It can
# sit in any of several directories fastfetch searches ahead of Omarchy's own, so
# ask fastfetch for that order rather than keep a copy here for its next release
# to outdate.
custom_fastfetch_config() {
local directory listed=false

# A whole line at a time, because a home directory may contain a space, and the
# marker fastfetch puts beside the config it settled on is not part of the path.
while IFS= read -r directory; do
listed=true
directory=${directory% (\*)}
[[ ${directory%/} == "$OMARCHY_FASTFETCH_DIR" ]] && return 1
[[ -f ${directory%/}/config.jsonc ]] && return 0
done < <(fastfetch --list-config-paths 2>/dev/null)

# Silence is not the same answer as "none of them", so fall back to the
# directory fastfetch has always looked in first rather than read it as one.
[[ $listed == true ]] && return 1
[[ -f $HOME/.config/fastfetch/config.jsonc ]]
}

Expand Down Expand Up @@ -100,17 +131,22 @@ fit_window() {

# The guard character keeps command substitution from eating the trailing
# break line, which provides the bottom padding row.
local modules module_w module_h
local modules module_w
modules=$(fastfetch --logo none | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g'; printf X)
modules=${modules%X}
module_w=$(printf '%s' "$modules" | display_columns)
module_h=$(printf '%s' "$modules" | wc -l)

# Ask fastfetch how tall its layout came out rather than predicting it from the
# logo and the module column: once the logo is the taller of the two, fastfetch
# writes a row more than that arithmetic expects, and a window sized by it
# scrolls the top padding away.
measure_layout || return 1

# Mirror the logo block in the fastfetch config: 2 columns of padding left of
# the logo, 6 between logo and modules, 2 rows above it. Then 2 columns of
# right padding to match, and a row for the cursor so the trailing break shows.
local target_c=$(( 2 + logo_w + 6 + module_w + 2 ))
local target_r=$(( (logo_h + 2 > module_h ? logo_h + 2 : module_h) + 1 ))
# the logo, 6 between logo and modules. Then 2 columns of right padding to
# match, and a row for the cursor so the trailing break shows.
local target_c=$(( LOGO_PAD_LEFT + logo_w + LOGO_PAD_RIGHT + module_w + LOGO_PAD_LEFT ))
local target_r=$(( LAYOUT_ROWS + 1 ))

local nudges=0 rows cols address width height shift_w shift_h target_w target_h
while :; do
Expand Down Expand Up @@ -146,17 +182,125 @@ fit_window() {
return 1
}

# One run answers both questions the sheen has to ask first. How tall the layout
# is, because a window too small for it scrolls, which moves the logo off the rows
# the frames address. And what colour fastfetch drew the logo in, because the
# glint has to hand every cell back in the colour it arrived in — assume it, and
# a logo fastfetch colours differently comes out of the first glint a new one.
measure_layout() {
[[ -n ${LAYOUT_ROWS:-} ]] && return 0

local rendered row

# --pipe false because fastfetch drops its colours when it is not writing to a
# terminal, and it is writing to this substitution.
rendered=$(fastfetch --pipe false 2>/dev/null; printf X)
rendered=${rendered%X}
LAYOUT_ROWS=$(printf '%s' "$rendered" | wc -l)
(( LAYOUT_ROWS > LOGO_PAD_TOP )) || return 1

# Whatever fastfetch set before the first row of the logo is what the sheen has
# to give those cells back.
row=$(printf '%s' "$rendered" | sed -n "$((LOGO_PAD_TOP + 1))p")
LOGO_COLOR=""
[[ $row =~ ^(($ESC\[[0-9;]*m)+) ]] && LOGO_COLOR=${BASH_REMATCH[1]}

return 0
}

# The About screen's own reasons the logo might not be where these frames would
# draw it. Whether the logo itself can be animated is the sheen's own question.
build_sheen() {
# Whatever the last build left is not this window's, and the loop below plays
# whatever is here — so a build that fails has to leave nothing to play.
SHEEN_FRAMES=()

custom_fastfetch_config && return 1

# fastfetch honours NO_COLOR when it writes to a terminal but not when it writes
# to the measurement below, so a logo drawn without colour would be measured as
# green and left green by the first glint. A glint is colour anyway, which is
# the thing NO_COLOR asks for none of.
[[ -n ${NO_COLOR:-} ]] && return 1

measure_layout || return 1

# The layout needs a row for the cursor past its last line. Without one it has
# scrolled, and the logo is no longer on the rows the frames address.
local rows cols
read -r rows cols <<<"$(stty size)"
(( rows > LAYOUT_ROWS )) || return 1

# The cell the logo's first row starts on, every attribute fastfetch left on
# those cells so a glint that has passed leaves them as it found them, and the
# room it has to work in left of the module column.
sheen_build "$LOGO_FILE" "$(( LOGO_PAD_TOP + 1 ))" "$(( LOGO_PAD_LEFT + 1 ))" "${ESC}[0m${LOGO_COLOR}" "$(( cols - LOGO_PAD_LEFT ))"
}

# What the frames were built against. A window that resized, or a logo that was
# rebranded, needs fastfetch run again before anything is drawn over it.
content_changed() {
[[ $resized == true ]] && return 0

[[ $(stty size) != "$grid" || $(stat -c %Y "$LOGO_FILE" 2>/dev/null) != "$logo_stamp" ]]
}

# A tick either times out, which is the delay, or a key arrives and About closes.
# Anything else on stdin is a terminal that went away, which closes it too.
tick() {
read -t "$1" -n 1 -s && exit
(( $? > 128 )) || exit
}

play_sheen() {
local index

for (( index = 0; index < ${#SHEEN_FRAMES[@]}; index++ )); do
# Stop before painting a frame rather than after: a resize has already moved
# the cells these address, and the rest of a sweep would land across them.
# The grid costs a process, so it stays on the poll interval. The trap costs
# nothing, so it is read last — a signal that arrived while the grid was being
# read would otherwise be seen only after another frame had gone out.
(( index % SHEEN_POLL_FRAMES == 0 )) && content_changed && return 1
[[ $resized == true ]] && return 1

printf '%s' "${SHEEN_FRAMES[index]}"
tick "$SHEEN_FRAME_SECONDS"
done

return 0
}

# The logo is still between glints, so About is a quiet window to leave open.
rest_sheen() {
local ticks

for (( ticks = 0; ticks < SHEEN_REST_TICKS; ticks++ )); do
tick "$POLL_SECONDS"
content_changed && return 1
done

return 0
}

if [[ ${1:-} == "--render" ]]; then
printf '\e[?25l'

# A sweep runs for seconds between polls, so it reads this instead. The polling
# stays as the backstop, for a signal that arrived while it could not be taken.
resized=false
trap 'resized=true' WINCH

# Give the compositor a moment to apply the window rules before measuring cells.
settle_grid

fitted=false
passes=0
while :; do
size=$(stty size)
grid=$(stty size)
logo_stamp=$(stat -c %Y "$LOGO_FILE" 2>/dev/null)
resized=false
LAYOUT_ROWS=""
clear
fastfetch
# A second pass picks up a fit that could not measure the window the first
Expand All @@ -165,10 +309,10 @@ if [[ ${1:-} == "--render" ]]; then
if [[ $fitted == false ]] && (( ++passes <= 2 )); then
fit_window && fitted=true
fi
while [[ $(stty size) == $size && $(stat -c %Y "$LOGO_FILE" 2>/dev/null) == $logo_stamp ]]; do
read -t 0.5 -n 1 -s && exit
(( $? > 128 )) || exit
done
# An empty frame list plays nothing, so a logo that cannot be animated waits
# here exactly as the still one did, and there is one loop rather than two.
build_sheen
while play_sheen && rest_sheen; do :; done
# A rebranded logo changes the content dimensions, so measure again.
if [[ $(stat -c %Y "$LOGO_FILE" 2>/dev/null) != $logo_stamp ]]; then
fitted=false
Expand Down
2 changes: 2 additions & 0 deletions manual/41-branding.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ There are three entries in that menu:

The same three options are under _Style > About_ for the _About_ screen you get from the Omarchy menu, and they work identically — the file is `~/.config/omarchy/branding/about.txt`, and the About window pops up after each change. The About art is converted to a smaller size than the screensaver's, since it has to fit in a window rather than fill your display.

While the window is open a glint of green leans across the art every few seconds and then leaves it still again. Your own art gets it too, as long as every character in it is one column wide — anything _Set From Image_ produces is. Art built from emoji or double-width characters stays still instead, and so does the screen if you keep a fastfetch config of your own: a still logo in those cases is the animation keeping out of the way rather than failing, since sliding a glint across them would land the rest of the line in the wrong place.

![branding-about](images/branding-about.webp)

### Converting images yourself
Expand Down
Loading