Render theme templates in one awk pass (#13406)

A theme switch waits on omarchy-theme-set-templates before the shell
transition starts, and it spent ~670ms spawning processes: a subshell
per color for its rgb form, a grep per template per function family, an
awk per mix, and a sed per template matching every line against
hundreds of patterns.

Helpers now return through REPLY, one grep finds the function tokens
across all templates, and one awk computes the mixes with the same
floating point rounding and renders every template. Output is byte for
byte identical across all shipped themes. Generation takes ~65ms, and
the transition starts ~130ms after omarchy-theme-set begins instead of
~740ms.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier HanssonandClaude Opus 5.5 authored and GitHub committed 2026-09-27 15:29:34 +02:00
1 parent 731fc8d051
commit 7e8d35efb3
2 files changed
+273 -146

No files matched your search

+195 -146
View File
@@ -8,55 +8,19 @@ USER_TEMPLATES_DIR="$HOME/.config/omarchy/themed"
NEXT_THEME_DIR="$HOME/.local/state/omarchy/current/next-theme"
COLORS_FILE="$NEXT_THEME_DIR/colors.toml"
# Theme switching waits on this script before the transition starts, so it
# avoids a process per value: helpers return through REPLY rather than $(...),
# and one awk pass computes the mixes and renders every template.
declare -A THEME_COLORS
MIX_TOKEN_PATTERN='\{\{[[:space:]]*mix(_strip|_rgb)?[[:space:]]+[A-Za-z0-9_]+[[:space:]]+[A-Za-z0-9_]+[[:space:]]+[0-9]+([.][0-9]+)?%?[[:space:]]*\}\}'
GRADIENT_TOKEN_PATTERN='\{\{[[:space:]]*(hypr_gradient|gradient_start|shell_gradient)[[:space:]]+[^}]+[[:space:]]*\}\}'
# Convert hex color to decimal RGB (e.g., "#1e1e2e" -> "30,30,46")
hex_to_rgb() {
local hex="${1#\#}"
printf "%d,%d,%d" "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}"
}
# Mix two hex colors. Amount may be a fraction (0.30) or percentage (30%).
mix_color() {
local start="${1#\#}"
local end="${2#\#}"
local amount="$3"
awk -v start="$start" -v end="$end" -v amount="$amount" '
function hex_value(char) {
return index("0123456789abcdef", tolower(char)) - 1
}
function hex_pair_to_int(hex, idx) {
return hex_value(substr(hex, idx, 1)) * 16 + hex_value(substr(hex, idx + 1, 1))
}
BEGIN {
if (amount ~ /%$/) {
sub(/%$/, "", amount)
amount = amount / 100
} else {
amount += 0
if (amount > 1) amount = amount / 100
}
if (amount < 0) amount = 0
if (amount > 1) amount = 1
start_r = hex_pair_to_int(start, 1)
start_g = hex_pair_to_int(start, 3)
start_b = hex_pair_to_int(start, 5)
end_r = hex_pair_to_int(end, 1)
end_g = hex_pair_to_int(end, 3)
end_b = hex_pair_to_int(end, 5)
red = int(start_r * (1 - amount) + end_r * amount + 0.5)
green = int(start_g * (1 - amount) + end_g * amount + 0.5)
blue = int(start_b * (1 - amount) + end_b * amount + 0.5)
printf "#%02x%02x%02x\n", red, green, blue
}
'
printf -v REPLY "%d,%d,%d" "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}"
}
trim() {
@@ -64,7 +28,7 @@ trim() {
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf "%s" "$value"
REPLY="$value"
}
resolve_theme_ref() {
@@ -72,30 +36,26 @@ resolve_theme_ref() {
local fallback="${2:-}"
if [[ -n ${THEME_COLORS[$ref]+_} ]]; then
printf "%s" "${THEME_COLORS[$ref]}"
REPLY="${THEME_COLORS[$ref]}"
elif [[ -n $fallback && -n ${THEME_COLORS[$fallback]+_} ]]; then
printf "%s" "${THEME_COLORS[$fallback]}"
REPLY="${THEME_COLORS[$fallback]}"
elif [[ -n $fallback ]]; then
printf "%s" "$fallback"
REPLY="$fallback"
else
printf "%s" "$ref"
REPLY="$ref"
fi
}
resolve_gradient_color() {
local color
color=$(trim "$1")
if [[ -n ${THEME_COLORS[$color]+_} ]]; then
color="${THEME_COLORS[$color]}"
trim "$1"
if [[ -n ${THEME_COLORS[$REPLY]+_} ]]; then
REPLY="${THEME_COLORS[$REPLY]}"
fi
printf "%s" "$color"
}
parse_gradient() {
local spec="$1"
local part color
local part
local -a parts
GRADIENT_COLORS=()
@@ -108,8 +68,8 @@ parse_gradient() {
if [[ $part =~ ^-?[0-9]+([.][0-9]+)?deg$ ]]; then
GRADIENT_ANGLE="${part%deg}"
else
color=$(resolve_gradient_color "$part")
GRADIENT_COLORS+=("$color")
resolve_gradient_color "$part"
GRADIENT_COLORS+=("$REPLY")
fi
done
}
@@ -117,12 +77,13 @@ parse_gradient() {
color_to_shell_hex() {
local color r g b
color=$(resolve_gradient_color "$1")
resolve_gradient_color "$1"
color="$REPLY"
if [[ $color =~ ^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$ ]]; then
printf "#%s" "${color:1:6}"
REPLY="#${color:1:6}"
elif [[ $color =~ ^[Rr][Gg][Bb][Aa]?\(([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?\)$ ]]; then
printf "#%s" "${BASH_REMATCH[1]}"
REPLY="#${BASH_REMATCH[1]}"
elif [[ $color =~ ^[Rr][Gg][Bb][Aa]?\(([0-9]+),([0-9]+),([0-9]+)(,[0-9.]+)?\)$ ]]; then
r=${BASH_REMATCH[1]}
g=${BASH_REMATCH[2]}
@@ -130,40 +91,42 @@ color_to_shell_hex() {
(( r > 255 )) && r=255
(( g > 255 )) && g=255
(( b > 255 )) && b=255
printf "#%02x%02x%02x" "$r" "$g" "$b"
printf -v REPLY "#%02x%02x%02x" "$r" "$g" "$b"
elif [[ $color =~ ^0x[0-9A-Fa-f]{8}$ ]]; then
printf "#%s" "${color:4:6}"
REPLY="#${color:4:6}"
else
printf "%s" "$color"
REPLY="$color"
fi
}
hypr_gradient_value() {
local spec color index
local spec index value
spec=$(resolve_theme_ref "$1" "${2:-}")
resolve_theme_ref "$1" "${2:-}"
spec="$REPLY"
parse_gradient "$spec"
if (( ${#GRADIENT_COLORS[@]} == 0 )); then
printf '"%s"' "$spec"
REPLY="\"$spec\""
elif (( ${#GRADIENT_COLORS[@]} == 1 )); then
printf '"%s"' "${GRADIENT_COLORS[0]}"
REPLY="\"${GRADIENT_COLORS[0]}\""
else
printf '{ colors = {'
value='{ colors = {'
for index in "${!GRADIENT_COLORS[@]}"; do
(( index > 0 )) && printf ','
printf ' "%s"' "${GRADIENT_COLORS[$index]}"
(( index > 0 )) && value+=','
value+=" \"${GRADIENT_COLORS[$index]}\""
done
printf ' }'
[[ -n $GRADIENT_ANGLE ]] && printf ', angle = %s' "$GRADIENT_ANGLE"
printf ' }'
value+=' }'
[[ -n $GRADIENT_ANGLE ]] && value+=", angle = $GRADIENT_ANGLE"
REPLY="$value }"
fi
}
gradient_start_value() {
local spec
spec=$(resolve_theme_ref "$1" "${2:-}")
resolve_theme_ref "$1" "${2:-}"
spec="$REPLY"
parse_gradient "$spec"
if (( ${#GRADIENT_COLORS[@]} == 0 )); then
@@ -174,41 +137,45 @@ gradient_start_value() {
}
shell_gradient_value() {
local spec index
local spec index value=""
spec=$(resolve_theme_ref "$1" "${2:-}")
resolve_theme_ref "$1" "${2:-}"
spec="$REPLY"
parse_gradient "$spec"
if (( ${#GRADIENT_COLORS[@]} == 0 )); then
printf "%s" "$spec"
REPLY="$spec"
return
fi
for index in "${!GRADIENT_COLORS[@]}"; do
(( index > 0 )) && printf " "
printf "%s" "${GRADIENT_COLORS[$index]}"
(( index > 0 )) && value+=" "
value+="${GRADIENT_COLORS[$index]}"
done
[[ -n $GRADIENT_ANGLE ]] && printf " %sdeg" "$GRADIENT_ANGLE"
[[ -n $GRADIENT_ANGLE ]] && value+=" ${GRADIENT_ANGLE}deg"
REPLY="$value"
}
# The value table holds token, unit separator, value lines for the renderer to
# substitute. Tokens may contain tabs, so a tab cannot delimit them.
add_template_value() {
local key="$1"
local value="$2"
local rgb
printf 's|{{ %s }}|%s|g\n' "$key" "$value" >>"$sed_script"
printf 's|{{ %s_strip }}|%s|g\n' "$key" "${value#\#}" >>"$sed_script"
printf '{{ %s }}\037%s\n{{ %s_strip }}\037%s\n' "$key" "$value" "$key" "${value#\#}" >>"$value_table"
if [[ $value =~ ^#[0-9A-Fa-f]{6}$ ]]; then
rgb=$(hex_to_rgb "$value")
printf 's|{{ %s_rgb }}|%s|g\n' "$key" "$rgb" >>"$sed_script"
hex_to_rgb "$value"
printf '{{ %s_rgb }}\037%s\n' "$key" "$REPLY" >>"$value_table"
fi
}
# A mix of two hex colors goes to the renderer as mix, token, function, start,
# end and amount, split by unit separators, since awk computes it.
add_mix_value() {
local token="$1"
local content fn start_key end_key amount start end value
local content fn start_key end_key amount start end
content="${token#\{\{}"
content="${content%\}\}}"
@@ -218,42 +185,14 @@ add_mix_value() {
end="${THEME_COLORS[$end_key]:-}"
[[ $start =~ ^#[0-9A-Fa-f]{6}$ && $end =~ ^#[0-9A-Fa-f]{6}$ ]] || return
[[ $fn == "mix" || $fn == "mix_strip" || $fn == "mix_rgb" ]] || return
value=$(mix_color "$start" "$end" "$amount")
case "$fn" in
mix)
;;
mix_strip)
value="${value#\#}"
;;
mix_rgb)
value=$(hex_to_rgb "$value")
;;
*)
return
;;
esac
printf 's|%s|%s|g\n' "$token" "$value" >>"$sed_script"
}
add_mix_values() {
local tpl token
local -A seen=()
for tpl in "${template_files[@]}"; do
while IFS= read -r token; do
[[ -n ${seen[$token]:-} ]] && continue
seen[$token]=1
add_mix_value "$token"
done < <(grep -hEo '\{\{[[:space:]]*mix(_strip|_rgb)?[[:space:]]+[A-Za-z0-9_]+[[:space:]]+[A-Za-z0-9_]+[[:space:]]+[0-9]+([.][0-9]+)?%?[[:space:]]*\}\}' "$tpl" 2>/dev/null || true)
done
printf 'mix\037%s\037%s\037%s\037%s\037%s\n' "$token" "$fn" "$start" "$end" "$amount" >>"$mix_table"
}
add_gradient_function_value() {
local token="$1"
local content fn key fallback value
local content fn key fallback
content="${token#\{\{}"
content="${content%\}\}}"
@@ -261,33 +200,40 @@ add_gradient_function_value() {
case "$fn" in
hypr_gradient)
value=$(hypr_gradient_value "$key" "$fallback")
hypr_gradient_value "$key" "$fallback"
;;
gradient_start)
value=$(gradient_start_value "$key" "$fallback")
gradient_start_value "$key" "$fallback"
;;
shell_gradient)
value=$(shell_gradient_value "$key" "$fallback")
shell_gradient_value "$key" "$fallback"
;;
*)
return
;;
esac
printf 's|%s|%s|g\n' "$token" "$value" >>"$sed_script"
printf '%s\037%s\n' "$token" "$REPLY" >>"$value_table"
}
add_gradient_function_values() {
local tpl token
# Scan every template once for function tokens, instead of a grep per template
# per function family.
add_function_values() {
local token
local -A seen=()
for tpl in "${template_files[@]}"; do
while IFS= read -r token; do
[[ -n ${seen[$token]:-} ]] && continue
seen[$token]=1
(( ${#template_files[@]} > 0 )) || return
while IFS= read -r token; do
[[ -n ${seen[$token]:-} ]] && continue
seen[$token]=1
if [[ $token =~ ^$MIX_TOKEN_PATTERN$ ]]; then
add_mix_value "$token"
else
add_gradient_function_value "$token"
done < <(grep -hEo '\{\{[[:space:]]*(hypr_gradient|gradient_start|shell_gradient)[[:space:]]+[^}]+[[:space:]]*\}\}' "$tpl" 2>/dev/null || true)
done
fi
done < <(grep -hEo "$MIX_TOKEN_PATTERN|$GRADIENT_TOKEN_PATTERN" "${template_files[@]}" 2>/dev/null || true)
}
strip_shell_section_header() {
@@ -367,9 +313,123 @@ apply_shell_section_overrides() {
done
}
# Substitute every template in one awk process. Each {{ ... }} span is looked
# up whole, as the per-template sed script used to match it. A mix is rounded
# with awk's floating point exactly as before, so no generated color moves.
render_templates() {
local tpl filename
local -a pairs=()
# Process user templates first, then built-in templates (user overrides
# built-in). Don't overwrite configs the theme already ships.
for tpl in "${template_files[@]}"; do
filename=${tpl##*/}
filename=${filename%.tpl}
if [[ ! -f $NEXT_THEME_DIR/$filename && -z ${rendered[$filename]:-} ]]; then
rendered[$filename]=1
pairs+=("$tpl" "$NEXT_THEME_DIR/$filename")
fi
done
(( ${#pairs[@]} > 0 )) || return
awk -v value_table="$value_table" -v mix_table="$mix_table" '
function hex_value(char) {
return index("0123456789abcdef", tolower(char)) - 1
}
function hex_pair_to_int(hex, idx) {
return hex_value(substr(hex, idx, 1)) * 16 + hex_value(substr(hex, idx + 1, 1))
}
function mix_color(start, end, amount, start_r, start_g, start_b, end_r, end_g, end_b, red, green, blue) {
start = substr(start, 2)
end = substr(end, 2)
if (amount ~ /%$/) {
sub(/%$/, "", amount)
amount = amount / 100
} else {
amount += 0
if (amount > 1) amount = amount / 100
}
if (amount < 0) amount = 0
if (amount > 1) amount = 1
start_r = hex_pair_to_int(start, 1)
start_g = hex_pair_to_int(start, 3)
start_b = hex_pair_to_int(start, 5)
end_r = hex_pair_to_int(end, 1)
end_g = hex_pair_to_int(end, 3)
end_b = hex_pair_to_int(end, 5)
red = int(start_r * (1 - amount) + end_r * amount + 0.5)
green = int(start_g * (1 - amount) + end_g * amount + 0.5)
blue = int(start_b * (1 - amount) + end_b * amount + 0.5)
return sprintf("#%02x%02x%02x", red, green, blue)
}
function render(line, out, open, span, token) {
out = ""
while ((open = index(line, "{{")) > 0) {
span = index(substr(line, open + 2), "}}")
if (span == 0) break
token = substr(line, open, span + 3)
if (token in values) {
out = out substr(line, 1, open - 1) values[token]
line = substr(line, open + length(token))
} else {
out = out substr(line, 1, open)
line = substr(line, open + 1)
}
}
return out line
}
BEGIN {
while ((getline row < value_table) > 0) {
sep = index(row, "\037")
values[substr(row, 1, sep - 1)] = substr(row, sep + 1)
}
close(value_table)
while ((getline row < mix_table) > 0) {
split(row, field, "\037")
value = mix_color(field[4], field[5], field[6])
if (field[3] == "mix_strip") {
value = substr(value, 2)
} else if (field[3] == "mix_rgb") {
value = sprintf("%d,%d,%d", hex_pair_to_int(substr(value, 2), 1), hex_pair_to_int(substr(value, 2), 3), hex_pair_to_int(substr(value, 2), 5))
}
values[field[2]] = value
}
close(mix_table)
for (i = 1; i < ARGC; i += 2) {
template = ARGV[i]
output = ARGV[i + 1]
# RT holds the terminator each line was read with, so a template that
# ends without a newline renders without one, as sed left it.
while ((getline line < template) > 0) printf "%s%s", render(line), RT > output
close(template)
close(output)
# A template with no lines still leaves its output file behind.
printf "" >> output
close(output)
}
exit
}
' "${pairs[@]}"
}
# Only generate dynamic templates for themes with a colors.toml definition
if [[ -f $COLORS_FILE ]]; then
sed_script=$(mktemp)
value_table=$(mktemp)
mix_table=$(mktemp)
declare -A rendered=()
shopt -s nullglob
template_files=("$USER_TEMPLATES_DIR"/*.tpl "$TEMPLATES_DIR"/*.tpl)
@@ -384,21 +444,10 @@ if [[ -f $COLORS_FILE ]]; then
add_template_value "$key" "${THEME_COLORS[$key]}"
done
add_mix_values
add_gradient_function_values
add_function_values
render_templates
# Process user templates first, then built-in templates (user overrides built-in)
for tpl in "${template_files[@]}"; do
filename=$(basename "$tpl" .tpl)
output_path="$NEXT_THEME_DIR/$filename"
# Don't overwrite configs already exists in the output directory (copied from theme specific folder)
if [[ ! -f $output_path ]]; then
sed -f "$sed_script" "$tpl" >"$output_path"
fi
done
rm "$sed_script"
rm "$value_table" "$mix_table"
fi
apply_shell_section_overrides
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
home="$test_tmp/home"
next="$home/.local/state/omarchy/current/next-theme"
themed="$home/.config/omarchy/themed"
mkdir -p "$next" "$themed"
cat >"$next/colors.toml" <<'EOF'
accent = "#ff0000"
foreground = "#e0e0e0"
background = "#000000"
red = "#ff0000"
blue = "#0000ff"
hyprland_active_border = "0xffa8b0c0 rgb(10,20,300,0.5) accent -12.5deg"
EOF
cat >"$themed/probe.txt.tpl" <<'EOF'
plain={{ accent }} {{ accent_strip }} {{ accent_rgb }}
round={{ mix background accent 30% }} {{ mix background accent 0.3 }} {{ mix background accent 12.5% }} {{ mix background accent 150 }}
variants={{ mix_strip background accent 30% }} {{ mix_rgb red blue 50% }}
spacing={{ mix background accent 30% }} {{accent}}
untouched={{ mix nope accent 30% }} {{ mix background accent }} {{ unknown_key }} {{{ accent }}}
hypr={{ hypr_gradient hyprland_active_border accent }} {{ hypr_gradient missing_border rgba(00000000) }} {{ hypr_gradient missing_border }}
start={{ gradient_start hyprland_active_border accent }} {{ gradient_start missing_border 0xff112233 }}
shell={{ shell_gradient hyprland_active_border accent }}
EOF
# A user template shadows the built-in of the same name, a file the theme ships
# is never overwritten, and an empty template still leaves its file behind.
printf 'user {{ accent }}\n' >"$themed/kitty.conf.tpl"
printf 'shipped\n' >"$next/foot.ini"
: >"$themed/empty.txt.tpl"
printf 'no newline {{ accent }}' >"$themed/tail.txt.tpl"
# Function arguments may be separated by any whitespace, tabs included.
printf '{{ mix\tbackground accent 30%% }} {{\tmix_rgb red\tblue 50%%\t}} {{ gradient_start\thyprland_active_border accent }}\n' >"$themed/tabs.txt.tpl"
HOME="$home" OMARCHY_PATH="$ROOT" PATH="$ROOT/bin:$PATH" "$ROOT/bin/omarchy-theme-set-templates"
# Expected values were produced by the sed-based renderer this replaced, so
# every theme keeps rendering byte for byte the same.
expected=$(cat <<'EOF'
plain=#ff0000 ff0000 255,0,0
round=#4d0000 #4d0000 #200000 #ff0000
variants=4d0000 128,0,128
spacing=#4d0000 {{accent}}
untouched={{ mix nope accent 30% }} {{ mix background accent }} {{ unknown_key }} {#ff0000}
hypr={ colors = { "0xffa8b0c0", "rgb(10,20,300,0.5)", "#ff0000" }, angle = -12.5 } "rgba(00000000)" "missing_border"
start=#a8b0c0 #112233
shell=0xffa8b0c0 rgb(10,20,300,0.5) #ff0000 -12.5deg
EOF
)
[[ $(<"$next/probe.txt") == "$expected" ]] ||
fail "theme templates render values, mixes and gradients as before: $(diff <(printf '%s\n' "$expected") "$next/probe.txt")"
pass "theme templates render values, mixes and gradients as before"
[[ $(<"$next/tabs.txt") == "#4d0000 128,0,128 #a8b0c0" ]] || fail "theme templates resolve function tokens separated by tabs"
pass "theme templates resolve function tokens separated by tabs"
[[ $(<"$next/kitty.conf") == "user #ff0000" ]] || fail "a user template shadows the built-in of the same name"
[[ $(<"$next/foot.ini") == "shipped" ]] || fail "a file the theme ships is not overwritten"
[[ -f $next/empty.txt && ! -s $next/empty.txt ]] || fail "an empty template still produces its file"
[[ $(od -An -c "$next/tail.txt" | tr -d ' \n') == *"#ff0000" ]] || fail "a template without a final newline renders without one"
pass "theme templates keep user, theme and file-ending precedence"
for tpl in "$ROOT"/default/themed/*.tpl; do
name=${tpl##*/}
name=${name%.tpl}
[[ -f $next/$name ]] || fail "every built-in template renders ($name)"
done
pass "theme templates render every built-in template"