diff options
Diffstat (limited to 'bin')
| -rwxr-xr-x | bin/alacritty-mosh | 61 | ||||
| -rw-r--r-- | bin/bash-string-expansion.sh | 12 | ||||
| -rwxr-xr-x | bin/default-browser | bin | 0 -> 168112 bytes | |||
| -rwxr-xr-x | bin/dotfiles-sync | 34 | ||||
| -rwxr-xr-x | bin/ghostty-reload | 6 | ||||
| -rwxr-xr-x | bin/mosh-tmux.sh | 17 | ||||
| -rwxr-xr-x | bin/neovide-fix.sh | 36 | ||||
| -rwxr-xr-x | bin/notify | 12 | ||||
| -rwxr-xr-x | bin/open | 302 | ||||
| -rwxr-xr-x | bin/pyexec | 27 | ||||
| -rwxr-xr-x | bin/random-id.ts | 105 | ||||
| -rwxr-xr-x | bin/smack | 5 | ||||
| -rwxr-xr-x | bin/sz-rm-meta.sh | 477 | ||||
| -rwxr-xr-x | bin/sz-wait-for | 308 | ||||
| -rwxr-xr-x | bin/terminal-set-opacity | 32 | ||||
| -rwxr-xr-x | bin/theme | 554 | ||||
| -rwxr-xr-x | bin/tmux-colors.zsh | 12 | ||||
| -rwxr-xr-x | bin/truecolour-test | 21 | ||||
| -rwxr-xr-x | bin/vi | 27 | ||||
| -rwxr-xr-x | bin/wifi | 33 | ||||
| -rwxr-xr-x | bin/zsh-colors.zsh | 39 |
21 files changed, 2120 insertions, 0 deletions
diff --git a/bin/alacritty-mosh b/bin/alacritty-mosh new file mode 100755 index 0000000..6032828 --- /dev/null +++ b/bin/alacritty-mosh @@ -0,0 +1,61 @@ +#!/bin/bash + +set -euo pipefail + +target="$1" +dedicated_dir='/tmp/alacritty' +dedicated_cfg="${dedicated_dir}/$target.conf" +dedicated_sh="${dedicated_dir}/${target}.sh" +mkdir -p "${dedicated_dir}" + +main_cfg="${HOME}/.config/alacritty/alacritty.toml" + +# Alacritty loads config files in a way that doesn't allow terminal.shell to be +# overriden, so this doesn't work. +# cat > "${dedicated_cfg}" <<EOF +# # [general] +# # import = [ +# # "${main_cfg}", +# # ] +# [terminal.shell] +# args = ["--login", "-ic", "${dedicated_sh}"] +# program = "/bin/zsh" +# [window] +# title = "${target}" +# dynamic_title = false +# EOF + +awk -v r='terminal.shell,window' ' +/^\[/ { + s = $0 + gsub(/^\[|\]$/, "", s) + p = (index(","r",", ","s",") == 0) +} +p +' "${main_cfg}" > "${dedicated_cfg}" +cat >> "${dedicated_cfg}" <<EOF +[terminal.shell] +args = ["--login", "-ic", "${dedicated_sh}"] +program = "/bin/zsh" + +[window] +title = "${target}" +dynamic_title = false +opacity = 0.96 +# blur = true +decorations = "buttonless" +dynamic_padding = true +option_as_alt = "None" +EOF + +cat > "$dedicated_sh" <<EOF +#!/bin/bash +cleanup() { + rm -f "${dedicated_cfg}" + rm -f "${dedicated_sh}" +} +trap cleanup EXIT INT TERM +mosh ${target} -- tmux new-session -As 0 +EOF +chmod +x "$dedicated_sh" +open -n -a Alacritty --args --config-file "${dedicated_cfg}" diff --git a/bin/bash-string-expansion.sh b/bin/bash-string-expansion.sh new file mode 100644 index 0000000..7e6ba28 --- /dev/null +++ b/bin/bash-string-expansion.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -euo pipefail + +cat <<EOF +\${var#substring} = ${1#$2} +\${var##substring} = ${1##$2} +\${var%substring} = ${1%$2} +\${var%%substring} = ${1%%$2} +EOF + + diff --git a/bin/default-browser b/bin/default-browser Binary files differnew file mode 100755 index 0000000..f5a5d4c --- /dev/null +++ b/bin/default-browser diff --git a/bin/dotfiles-sync b/bin/dotfiles-sync new file mode 100755 index 0000000..503a1ed --- /dev/null +++ b/bin/dotfiles-sync @@ -0,0 +1,34 @@ +#!/bin/bash + +# Transitional, added 2026-09-17: the sync job moved to cloud-setup, whose +# svc/setup.sh points each host's unit at its own copy. A host whose unit still +# runs this path -- one that was offline when the units were regenerated -- +# lands here: bring its cloud-setup clone up to date and hand over, so it keeps +# receiving until someone runs `dotfiles-sync-enable` there. Remove once no unit +# points at this file. + +set -euo pipefail + +cloud_setup="$HOME/src/soarez/cloud-setup" +target="$cloud_setup/bin/dotfiles-sync" + +if [[ ! -x "$target" && -d "$cloud_setup/.git" ]] +then + umask 022 + git -C "$cloud_setup" fetch --prune origin && git -C "$cloud_setup" merge --ff-only --quiet origin/master || true +fi + +if [[ -x "$target" ]] +then + exec "$target" "$@" +fi + +>&2 cat <<MSG +dotfiles-sync has moved to cloud-setup, and this host has no usable copy at +$target. To recover: + + git -C $cloud_setup pull --ff-only + $(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)/link.sh yes + dotfiles-sync-enable +MSG +exit 1 diff --git a/bin/ghostty-reload b/bin/ghostty-reload new file mode 100755 index 0000000..b4ce47f --- /dev/null +++ b/bin/ghostty-reload @@ -0,0 +1,6 @@ +#!/bin/bash + +set -euo pipefail + +# Trigger Ghostty config reload +pkill -SIGUSR2 ghostty diff --git a/bin/mosh-tmux.sh b/bin/mosh-tmux.sh new file mode 100755 index 0000000..55bf295 --- /dev/null +++ b/bin/mosh-tmux.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -ne 1 ]]; then + >&2 cat <<EOF +Usage: + + $0 <hostname> +EOF + exit 1 +fi + +target="${1}" + +mosh "sz@$target" -- tmux new-session -As 0 + diff --git a/bin/neovide-fix.sh b/bin/neovide-fix.sh new file mode 100755 index 0000000..f8d92ad --- /dev/null +++ b/bin/neovide-fix.sh @@ -0,0 +1,36 @@ +#!/bin/zsh +# Make Neovide.app start through a wrapper that sources ~/.zshrc, so the GUI +# app sees the same PATH and environment as a terminal, then re-sign the app. +# +# Re-run after every Neovide update: the update replaces the app bundle and +# undoes this. Needs sudo, for the re-signing. + +set -euo pipefail + +app='/Applications/Neovide.app' + +# Create wrapper if it doesn't exist +cat > "${app}/Contents/MacOS/neovide-wrapper.sh" <<'EOF' +#!/bin/zsh +source ~/.zshrc +exec "$(dirname "$0")/neovide" "$@" +EOF + +chmod +x "${app}/Contents/MacOS/neovide-wrapper.sh" + +# Update plist to use wrapper +/usr/libexec/PlistBuddy -c "Set :CFBundleExecutable neovide-wrapper.sh" "${app}/Contents/Info.plist" + +# Clear code signature +sudo xattr -cr "${app}" + +# Re-sign the app +sudo codesign --force --deep --sign - "${app}" + +# Verify signature +codesign -dv "${app}" + +/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "${app}" + +echo "Neovide fixed!" + diff --git a/bin/notify b/bin/notify new file mode 100755 index 0000000..59ae5be --- /dev/null +++ b/bin/notify @@ -0,0 +1,12 @@ +#!/bin/bash + +set -euo pipefail + +title="$1" +message="${2:-}" + +terminal-notifier \ + -title "$title" \ + -message "$message" \ + -activate com.mitchellh.ghostty + diff --git a/bin/open b/bin/open new file mode 100755 index 0000000..65efddb --- /dev/null +++ b/bin/open @@ -0,0 +1,302 @@ +#!/bin/bash +# +# macOS-style `open` for Linux desktops. Linked on every host like the rest of +# bin/, so it steps aside where it does not apply: on macOS it hands over to the +# real /usr/bin/open, and on Linux it refuses without a graphical session. +# +# Everything starts detached, as it does on macOS: a new session (setsid), stdin +# from /dev/null, stdout/stderr to the journal. So the app neither dies with the +# terminal nor holds it open -- an ssh session that ran `open` can still exit. +# Its output: journalctl --user -t <program> + +# First, before anything bash 3.2 (macOS's /bin/bash) might not parse. +if [[ "$OSTYPE" == darwin* ]] +then + exec /usr/bin/open "$@" +fi + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: open [-a APP] [-n] [-W] [FILE|URL ...] [--args ARG ...] + open -t|-e [FILE ...] + open -R FILE ... + + (none) open each FILE or URL with its default handler (xdg-open) + -a APP open with APP: a desktop entry id (org.kde.dolphin or just dolphin), + its Name= (Alacritty), a path to a .desktop file, or a command + -t, -e open with the default text editor + -R reveal in the file manager + -W wait for the app to exit instead of detaching (needs -a, -t or -e) + -n accepted for compatibility; every launch is a new process anyway + --args pass everything after it to the app verbatim +EOF +} + +die() { + >&2 echo "open: $*" + exit 1 +} + +# The value of KEY in the [Desktop Entry] group, ignoring [Desktop Action ...] +# groups, which reuse Name= and Exec=. +entry_key() { + awk -v key="$2=" ' + /^\[/ { in_entry = ($0 == "[Desktop Entry]"); next } + in_entry && index($0, key) == 1 { print substr($0, length(key) + 1); exit } + ' "$1" +} + +# Path of the desktop entry for $1, case-insensitively: its id with or without +# .desktop (org.kde.dolphin), the last part of a reverse-DNS id (dolphin), or +# failing both its Name= (Alacritty). User entries shadow system ones. +find_entry() { + local want="${1%.desktop}" dirs dir f id entries=() + want="${want,,}" + IFS=: read -ra dirs <<< "${XDG_DATA_HOME:-$HOME/.local/share}:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" + for dir in "${dirs[@]}" + do + for f in "$dir"/applications/*.desktop + do + [[ -f "$f" ]] || continue + entries+=("$f") + id="${f##*/}" + id="${id%.desktop}" + id="${id,,}" + if [[ "$id" == "$want" || "${id##*.}" == "$want" ]] + then + echo "$f" + return + fi + done + done + (( ${#entries[@]} )) || return 1 + awk -v want="$want" ' + FNR == 1 { in_entry = 0 } + /^\[/ { in_entry = ($0 == "[Desktop Entry]"); next } + in_entry && /^Name=/ && tolower(substr($0, 6)) == want { print FILENAME; found = 1; exit } + END { exit !found } + ' "${entries[@]}" +} + +# Splits an Exec= value into NUL-terminated words by the desktop entry spec: +# blanks separate, "..." groups, and within quotes a backslash escapes the next +# character. The spec's outer string escapes (\s, \\\\) are not undone. +exec_words() { + local s="$1" word='' have='' quoted='' c i + for (( i = 0; i < ${#s}; i++ )) + do + c="${s:i:1}" + if [[ -n "$quoted" ]] + then + if [[ "$c" == '\' ]] + then + (( ++i )) + word+="${s:i:1}" + elif [[ "$c" == '"' ]] + then + quoted='' + else + word+="$c" + fi + elif [[ "$c" == '"' ]] + then + quoted=1 have=1 + elif [[ "$c" == ' ' || "$c" == $'\t' ]] + then + if [[ -n "$have" ]] + then + printf '%s\0' "$word" + fi + word='' have='' + else + word+="$c" have=1 + fi + done + if [[ -n "$have" ]] + then + printf '%s\0' "$word" + fi +} + +# Fills argv from a desktop entry. The files replace its %f/%F/%u/%U field code, +# or go last if it has none, and --args go just before them. %f and %u mean one +# file per launch in the spec; all of them are passed to a single launch here. +from_entry() { + local entry="$1" exec word t placed='' extras_placed='' + exec="$(entry_key "$entry" Exec)" + [[ -n "$exec" ]] || die "$entry has no Exec=" + argv=() + if [[ "$(entry_key "$entry" Terminal)" == true ]] + then + for t in ${TERMINAL:-} alacritty konsole xterm + do + if command -v "$t" >/dev/null + then + argv=("$t" -e) + break + fi + done + (( ${#argv[@]} )) || die "$entry needs a terminal and none was found" + fi + while IFS= read -r -d '' word + do + case "$word" in + # flatpak's file forwarding markers: app arguments go before them, not inside + @@u|@@f) + [[ -n "$extras_placed" ]] || argv+=("${extra[@]}") + extras_placed=1 + argv+=("$word") + ;; + %[fFuU]) + [[ -n "$extras_placed" ]] || argv+=("${extra[@]}") + argv+=("${files[@]}") + extras_placed=1 placed=1 + ;; + %%) argv+=(%) ;; + %?) ;; + *) argv+=("${word//"%%"/%}") ;; + esac + done < <(exec_words "$exec") + [[ -n "$extras_placed" ]] || argv+=("${extra[@]}") + [[ -n "$placed" ]] || argv+=("${files[@]}") +} + +# Fills argv with the command line that opens the files with app $1. +resolve() { + local app="$1" entry cmd + if [[ "$app" == *.desktop && -f "$app" ]] + then + entry="$app" + else + entry="$(find_entry "$app")" || entry='' + fi + if [[ -n "$entry" ]] + then + from_entry "$entry" + elif cmd="$(command -v -- "$app" || command -v -- "${app,,}")" + then + argv=("$cmd" "${extra[@]}" "${files[@]}") + else + die "no application named $app" + fi + command -v -- "${argv[0]}" >/dev/null || die "$app: ${argv[0]} is not installed" +} + +launch() { + if [[ -n "$wait" ]] + then + "$@" + elif command -v systemd-cat >/dev/null + then + setsid -f systemd-cat -t "${1##*/}" "$@" </dev/null >/dev/null 2>&1 + else + setsid -f "$@" </dev/null >/dev/null 2>&1 + fi +} + +# Percent-encodes byte by byte, hence LC_ALL=C: ${path:i:1} must be one byte, +# not one character, for a UTF-8 name to come out right. +file_uri() { + local LC_ALL=C path out='' c i + path="$(realpath -- "$1")" + for (( i = 0; i < ${#path}; i++ )) + do + c="${path:i:1}" + case "$c" in + [A-Za-z0-9/._~-]) out+="$c" ;; + *) printf -v c '%%%02X' "'$c"; out+="$c" ;; + esac + done + echo "file://$out" +} + +# Through org.freedesktop.FileManager1, which Dolphin, Nautilus, Nemo and Thunar +# all implement; without it, open the containing directories instead. +reveal() { + local f uris=() + (( $# )) || die "-R needs a file" + for f in "$@" + do + [[ -e "$f" ]] || die "the file $f does not exist" + uris+=("$(file_uri "$f")") + done + if dbus-send --session --print-reply --dest=org.freedesktop.FileManager1 \ + /org/freedesktop/FileManager1 org.freedesktop.FileManager1.ShowItems \ + "array:string:$(IFS=,; echo "${uris[*]}")" string:'' >/dev/null 2>&1 + then + return + fi + for f in "$@" + do + launch xdg-open "$(dirname -- "$(realpath -- "$f")")" + done +} + +app='' editor='' reveal='' wait='' +files=() extra=() argv=() +while (( $# )) +do + case "$1" in + -a) (( $# >= 2 )) || die "-a needs an application"; app="$2"; shift 2 ;; + -t|-e) editor=1; shift ;; + -R) reveal=1; shift ;; + -W) wait=1; shift ;; + -n) shift ;; + -h|--help) usage; exit 0 ;; + --args) shift; extra=("$@"); break ;; + --) shift; files+=("$@"); break ;; + # Bundled short flags, as in `open -na Alacritty`. + -[!-]?*) + flags=() + for (( i = 1; i < ${#1}; i++ )) + do + flags+=("-${1:i:1}") + done + shift + set -- "${flags[@]}" "$@" + ;; + -?*) die "unsupported option $1 (see open --help)" ;; + *) files+=("$1"); shift ;; + esac +done + +# A server, or ssh into a desktop without forwarding: nothing could show up. +[[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || die "no graphical session (WAYLAND_DISPLAY and DISPLAY are unset)" + +for f in "${files[@]}" +do + [[ -e "$f" || "$f" =~ ^[A-Za-z][A-Za-z0-9+.-]*: ]] || die "the file $f does not exist" +done + +if [[ -n "$reveal" ]] +then + reveal "${files[@]}" + exit +fi + +if [[ -n "$editor" ]] +then + app="$(xdg-mime query default text/plain)" + [[ -n "$app" ]] || die "no default text editor (xdg-mime query default text/plain)" +fi + +if [[ -z "$app" ]] +then + [[ -z "$wait" ]] || die "-W needs -a, -t or -e: xdg-open returns before the app exits" + (( ${#extra[@]} == 0 )) || die "--args needs -a" + if (( ${#files[@]} == 0 )) + then + >&2 usage + exit 1 + fi + for f in "${files[@]}" + do + launch xdg-open "$f" + done + exit +fi + +resolve "$app" +launch "${argv[@]}" diff --git a/bin/pyexec b/bin/pyexec new file mode 100755 index 0000000..303bc4b --- /dev/null +++ b/bin/pyexec @@ -0,0 +1,27 @@ +#!/bin/bash + +set -euo pipefail + +debug() { + if [[ "${DEBUG:-}" == "pyexec" ]]; then + >&2 echo "[DEBUG=pyexec]" "$@" + fi +} + +if [[ -n "${VIRTUAL_ENV:-}" ]]; then + debug "Detected VIRTUAL_ENV" + i=python +elif [[ -r ".venv/bin/activate" ]]; then + debug "Found .venv, activating" + source .venv/bin/activate + i=python +elif command -v python3 >/dev/null; then + debug "Found python3 in path" + i=python3 +else + debug "Defaulting to python" + i=python +fi + +exec "$i" "$@" + diff --git a/bin/random-id.ts b/bin/random-id.ts new file mode 100755 index 0000000..b8bf364 --- /dev/null +++ b/bin/random-id.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env node --disable-warning=DEP0180 --no-warnings + +const idChars = (function (): string[] { + const chars: string[] = []; + + function code(x: string | number): number { + return typeof x === "string" ? x.charCodeAt(0) : x; + } + + function add(from: string | number, to: string | number): void { + const fromCode = code(from); + const toCode = code(to); + for (let c = fromCode; c <= toCode; ++c) { + chars.push(String.fromCharCode(c)); + } + } + + add("0", "9"); + add("a", "z"); + add("A", "Z"); + return chars; +})(); + + +/* +Alphabet size: 62 characters (A–Z, a–z, 0–9) +Total possible IDs of length n: TOTAL_IDS = 62^n +To compute the probability that two randomly generated IDs collide (i.e., are identical): P_COLLISION = 1 / TOTAL_IDS + +========================================================== + ID Length | ID space | P(Collision) | Intuition +-----------|----------------|----------------|------------ + 5 | 916,132,832 | ~1.09 × 10⁻⁹ | ~1 in a billion (like lottery odds) + 6 | 56,800,235,584 | ~1.76 × 10⁻¹¹ | ~1 in 50 billion (world population × 6) + 17 | 2.96 × 10³⁰ | ~3.38 × 10⁻³¹ | ~1 in a nonillion (grains of sand × 10¹⁹) +========================================================== + +Interpretation: +- At **length 5**, collisions are rare but not implausible if generating millions of IDs. +- At **length 6**, collisions are extremely rare - safe for most casual uses. +- At **length 17**, collisions are cosmically unlikely—about as probable as picking the same atom out of all atoms in the solar system. + +========================================================== +== Out of a pool of 100,000 === + +Alphabet size: 62 characters (A–Z, a–z, 0–9) +Existing IDs: N = 100 000 + +Formulas: + TOTAL_IDS(n) = 62^n + P_match_one = 1 / TOTAL_IDS(n) + P_collision_any = 1 − (1 − P_match_one)^N + +-------------------------------------------------------------------------------- + n | TOTAL_IDS(n) | P_collision_any | Intuition +---+----------------------+--------------------------------+------------------------------------------- + 5 | 62^5 = 916 132 832 | 1 − (1 − 1/916 132 832)^100 000 + | | ≈ 1.09 × 10⁻⁴ | ~0.01% → ~1 in 9 200 + | | | (about as rare as drawing the same + | | | lottery number in ~9 200 draws) + + 6 | 62^6 = 56 800 235 584| ≈ 1.76 × 10⁻⁶ | ~0.000176% → ~1 in 568 000 + | | | (like finding a specific LEGO + | | | brick in a pile of half a million) + + 8 | 62^8 ≈ 2.18 × 10^14 | ≈ 4.58 × 10⁻¹⁰ | ~4.6 × 10⁻⁸% → ~1 in 2.2 billion + | | | (on par with winning a major lottery) + +17 | 62^17 ≈ 2.96 × 10^30 | ≈ 3.38 × 10⁻²⁶ | ~3.4 × 10⁻²⁴% → ~1 in 3 × 10^25 + | | | (effectively zero—astronomically + | | | unlikely, far beyond grains of sand + | | | on every beach on Earth) +-------------------------------------------------------------------------------- + +Reasoning: +- TOTAL_IDS(n) counts all possible length-n strings. +- P_match_one = chance it matches one given ID. +- With N existing, P_collision_any = 1 minus the chance it misses all N. +- For n=5 collisions are possible if you generate many IDs; for n≥8 they're vanishingly rare; for n=17 they're effectively impossible in any realistic system. + + */ + +// With 62 possible chars, a 17 char long ID has about 10^30 possible values, +// the chance of finding any of 8 billion of them is about the same as the +// chance of finding a single specific grain of sand in on earth. +// +const idLength = process.env.ID_LENGTH ? parseInt(process.env.ID_LENGTH) :17; + +function randomId(length: number = idLength): string { + const chars: string[] = []; + for (let i = 0; i < length; ++i) { + const c = Math.floor(Math.random() * idChars.length); + chars.push(idChars[c]!); + } + return chars.join(""); +} + +// take count from from first argument, default to 1 +const count = parseInt(process.argv[2] || "1"); + +// execute and print +for (let i = 0; i < count; ++i) { + console.log(randomId()); +} + diff --git a/bin/smack b/bin/smack new file mode 100755 index 0000000..667ab2a --- /dev/null +++ b/bin/smack @@ -0,0 +1,5 @@ +#!/bin/bash + +git add -A . \ + && git commit -m 'Update' +git push diff --git a/bin/sz-rm-meta.sh b/bin/sz-rm-meta.sh new file mode 100755 index 0000000..5618dc2 --- /dev/null +++ b/bin/sz-rm-meta.sh @@ -0,0 +1,477 @@ +#!/usr/bin/env bash +set -euo pipefail + +# sz-rm-meta.sh — strip metadata from image files. +# +# sz-rm-meta.sh [options] FILE... +# +# -l, --list report what would be removed; change nothing +# -o, --out DIR write cleaned copies into DIR instead of editing in place +# --keep-icc keep the embedded ICC colour profile (stripped by default) +# --keep-xattr leave extended attributes alone (macOS: kMDItemWhereFroms, +# com.apple.quarantine — these record the download URL) +# -d, --destructive re-encode the pixels as well (see "Two modes" below) +# --quality N JPEG quality for --destructive (default 85) +# --scale PCT resize percentage for --destructive (default 97) +# --rename rename the file to a random name, keeping the extension +# -q, --quiet only report failures +# +# Two modes, because "no metadata" and "not traceable" are different jobs: +# +# default Lossless. Rewrites the container, copies the compressed image +# data byte for byte. Pixels stay bit-identical, so the file is +# still matchable against the source by perceptual hash, and the +# quantization/Huffman tables still fingerprint the encoder that +# produced it. Right for sanitising an archive you keep. +# +# --destructive Re-encodes: resize by --scale, re-compress at --quality, fresh +# quantization tables, then run the lossless pass over the +# result. Breaks bitstream-level linkage to the source copy. +# Does NOT defeat reverse-image search or perceptual hashing — +# nothing that preserves what the picture looks like can. Needs +# ImageMagick. +# +# What the lossless pass removes, per format: +# JPEG every APPn segment except JFIF (APP0) and Adobe (APP14), all COM +# comments, and any trailer appended after the EOI marker. That covers +# EXIF (camera, GPS, serial, timestamps), XMP, IPTC and the Photoshop +# 8BIM block (by-line, credit, document ancestry). +# PNG everything outside an allowlist of image and colour chunks. An +# allowlist rather than a denylist because private chunks are the worst +# offenders: Fireworks' mkBF/mkBS/mkBT embed the entire editable source +# document, and prVw carries a full preview image. +# +# Why APP14 "Adobe" survives: it is not metadata, it declares the colour +# transform (YCbCr vs RGB vs YCCK). Dropping it turns some CMYK and RGB JPEGs +# into colour-inverted garbage. It is a 12-byte codec record written by many +# encoders, not a Photoshop watermark. +# +# Symlinks are resolved and the real file is rewritten, so cleaning a link no +# longer leaves the target dirty. A file with more than one hard link is +# rewritten in place rather than replaced, so every name sees the clean data — +# that write is not atomic, which is the honest trade for not silently breaking +# the link. +# +# Files are otherwise rewritten via a temp file in the same directory and moved +# into place, so an interrupted run leaves the original intact. A file that +# fails to parse is skipped, never half-written. +# +# Deliberately not handled: GIF, TIFF, HEIC, WebP, video. They need a real +# metadata library; this script would rather refuse than corrupt them. Install +# exiftool for those. + +die() { >&2 echo "sz-rm-meta: $*"; exit 1; } +warn() { >&2 echo "sz-rm-meta: warning: $*"; } + +list_only=false +out_dir="" +keep_icc=false +keep_xattr=false +destructive=false +rename=false +quality=85 +scale=97 +quiet=false +files=() + +usage() { + cat <<'USAGE' +sz-rm-meta.sh [options] FILE... strip metadata from JPEG/PNG + + -l, --list report what would be removed; change nothing + -o, --out DIR write cleaned copies into DIR instead of editing in place + --keep-icc keep the embedded ICC colour profile (stripped by default) + --keep-xattr leave extended attributes alone (macOS keeps the download + URL in kMDItemWhereFroms) + -d, --destructive also re-encode the pixels: breaks the bitstream fingerprint + linking this copy to the source. Needs ImageMagick. + --quality N JPEG quality for --destructive (default 85) + --scale PCT resize percentage for --destructive (default 97) + --rename rename to a random name, keeping the extension + -q, --quiet only report failures + +Default mode is lossless: pixels stay bit-identical, which also means the file +still matches the source under reverse-image search or perceptual hashing. +Use --destructive when the goal is that a copy cannot be linked to its origin. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + -l|--list) list_only=true; shift ;; + -o|--out) [ "$#" -ge 2 ] || die "--out needs a directory"; out_dir="$2"; shift 2 ;; + --keep-icc) keep_icc=true; shift ;; + --keep-xattr) keep_xattr=true; shift ;; + -d|--destructive) destructive=true; shift ;; + --quality) [ "$#" -ge 2 ] || die "--quality needs a number"; quality="$2"; shift 2 ;; + --scale) [ "$#" -ge 2 ] || die "--scale needs a number"; scale="$2"; shift 2 ;; + --rename) rename=true; shift ;; + -q|--quiet) quiet=true; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; files+=("$@"); break ;; + -*) die "unknown option '$1' (try --help)" ;; + *) files+=("$1"); shift ;; + esac +done + +[ "${#files[@]}" -gt 0 ] || die "no files given (try --help)" +command -v python3 >/dev/null || die "python3 not found" + +case "${quality}" in ''|*[!0-9]*) die "--quality must be a number" ;; esac +case "${scale}" in ''|*[!0-9]*) die "--scale must be a number" ;; esac +if [ "${quality}" -lt 1 ] || [ "${quality}" -gt 100 ]; then die "--quality must be 1-100"; fi +if [ "${scale}" -lt 1 ] || [ "${scale}" -gt 100 ]; then die "--scale must be 1-100"; fi + +if [ -n "${out_dir}" ]; then + ${list_only} && die "--list and --out are mutually exclusive" + mkdir -p "${out_dir}" +fi + +magick_cmd="" +if ${destructive}; then + ${list_only} && die "--list and --destructive are mutually exclusive" + if command -v magick >/dev/null 2>&1; then + magick_cmd="magick" + elif command -v convert >/dev/null 2>&1; then + magick_cmd="convert" + else + die "--destructive needs ImageMagick (brew install imagemagick / apt install imagemagick)" + fi +fi + +if ${rename} && ${list_only}; then + die "--list and --rename are mutually exclusive" +fi + +# Whether this host can inspect xattrs at all. Without a tool we must say so +# rather than report a reassuring zero: Linux browsers write the download URL to +# user.xdg.origin.url, the direct analogue of macOS kMDItemWhereFroms. +xattr_tool="" +if command -v xattr >/dev/null 2>&1; then + xattr_tool="macos" +elif command -v getfattr >/dev/null 2>&1 && command -v setfattr >/dev/null 2>&1; then + xattr_tool="linux" +fi +if [ -z "${xattr_tool}" ] && ! ${keep_xattr} && [ -z "${out_dir}" ]; then + warn "no xattr/setfattr on this host; extended attributes are NOT being checked or cleared" +fi + +# Clear the extended attributes that carry provenance. com.apple.provenance is +# system-managed and cannot be removed - it holds no information about the +# image, so it is excluded from the count and left alone. +clear_xattrs() { + local f="$1" n=0 a + case "${xattr_tool}" in + macos) + n=$(xattr "${f}" 2>/dev/null | grep -cv '^com\.apple\.provenance$' || true) + if [ "${n}" -gt 0 ]; then + xattr -c "${f}" 2>/dev/null || true + fi + ;; + linux) + while read -r a; do + [ -n "${a}" ] || continue + if setfattr -x "${a}" "${f}" 2>/dev/null; then + n=$((n + 1)) + fi + done < <(getfattr --absolute-names -m '^user\.' "${f}" 2>/dev/null | + grep '^user\.' || true) + ;; + esac + echo "${n}" +} + +count_xattrs() { + case "${xattr_tool}" in + macos) xattr "$1" 2>/dev/null | grep -cv '^com\.apple\.provenance$' || true ;; + linux) getfattr -m '^user\.' "$1" 2>/dev/null | grep -c '^user\.' || true ;; + *) echo 0 ;; + esac +} + +strip_one() { + python3 - "$1" "$2" "$3" "$4" <<'PY' +import os, shutil, struct, sys, tempfile + +src, dst, mode, keep_icc = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == "true" +listing = mode == "list" + +def fail(msg): + sys.stderr.write("sz-rm-meta: %s: %s\n" % (os.path.basename(src), msg)) + sys.exit(2) + +# Follow symlinks: replacing the link node would leave the real file dirty while +# reporting success, and would silently turn the link into a regular file. +src = os.path.realpath(src) +in_place = os.path.realpath(dst) == src +if in_place: + dst = src + +d = open(src, "rb").read() +removed = {} # label -> [count, bytes], insertion-ordered + +def note(label, n): + slot = removed.setdefault(label, [0, 0]) + slot[0] += 1 + slot[1] += n + +def summary(): + parts = [] + for label, (count, size) in removed.items(): + parts.append("%s%s %dB" % (label, " x%d" % count if count > 1 else "", size)) + return ", ".join(parts) + +def jpeg_label(marker, body): + if marker == 0xFE: + return "comment" + if body.startswith(b"Exif\x00"): + return "EXIF" + if body.startswith(b"http://ns.adobe.com/xap"): + return "XMP" + if body.startswith(b"http://ns.adobe.com/xmp/extension"): + return "XMP-extended" + if body.startswith(b"Photoshop 3.0"): + return "IPTC/8BIM" + if body.startswith(b"ICC_PROFILE"): + return "ICC" + if body.startswith(b"Ducky"): + return "Ducky" + if body.startswith(b"http://ns.google.com"): + return "Google-XMP" + if body.startswith(b"MPF\x00"): + return "MPF" + return "APP%d" % (marker - 0xE0) + +def strip_jpeg(d): + out = bytearray(d[:2]) + i, n = 2, len(d) + saw_eoi = False + while i < n - 1: + if d[i] != 0xFF: + fail("malformed JPEG: expected a marker at offset %d" % i) + m = d[i + 1] + if m == 0xFF: # fill byte + out += d[i:i + 1]; i += 1; continue + if m == 0xD9: # EOI + out += d[i:i + 2]; i += 2; saw_eoi = True; break + if m == 0x01 or 0xD0 <= m <= 0xD7: # TEM / RSTn: no payload + out += d[i:i + 2]; i += 2; continue + if i + 4 > n: + fail("truncated JPEG segment header") + ln = struct.unpack(">H", d[i + 2:i + 4])[0] + if ln < 2 or i + 2 + ln > n: + fail("truncated JPEG segment (marker FF%02X)" % m) + body = d[i + 4:i + 2 + ln] + keep = True + if 0xE0 <= m <= 0xEF or m == 0xFE: + label = jpeg_label(m, body) + if m == 0xE0 and body.startswith(b"JFIF\x00"): + # Density header. Its last two fields are thumbnail dimensions; + # a non-zero pair means an embedded RGB preview of the image + # rides along, so drop the segment rather than keep the preview. + keep = not (len(body) >= 14 and body[12] and body[13]) + if not keep: + note("JFIF-thumbnail", ln + 2) + elif m == 0xE0 and body.startswith(b"JFXX\x00"): + keep = False # exists only to carry a thumbnail + note("JFXX-thumbnail", ln + 2) + elif m == 0xEE and body.startswith(b"Adobe"): + keep = True # colour transform: a codec parameter + elif label == "ICC" and keep_icc: + keep = True + else: + keep = False + note(label, ln + 2) + if keep: + out += d[i:i + 2 + ln] + i += 2 + ln + if m == 0xDA: # entropy-coded scan follows + j = i + while j < n - 1: + if d[j] != 0xFF: + j += 1; continue + nx = d[j + 1] + if nx == 0x00 or 0xD0 <= nx <= 0xD7: + j += 2; continue # byte stuffing / restart marker + if nx == 0xFF: + j += 1; continue # fill byte + break + out += d[i:j] + i = j + if not saw_eoi: + fail("no EOI marker found; refusing to write a truncated file") + if i < n: + note("trailer", n - i) # appended thumbnails, depth maps, junk + return bytes(out) + +# Allowlist, not denylist: anything not named here is dropped. Covers the image +# itself, transparency, colour rendering, and APNG animation. Private chunks +# (mkBF/prVw/cmOD and friends) fall through to the drop path by construction. +PNG_KEEP = { + b"IHDR", b"PLTE", b"IDAT", b"IEND", # critical + b"tRNS", b"gAMA", b"cHRM", b"sRGB", b"sBIT", # colour / transparency + b"bKGD", b"pHYs", b"hIST", b"sPLT", # rendering hints + b"acTL", b"fcTL", b"fdAT", # APNG: dropping these +} # would flatten the animation + +def strip_png(d): + out = bytearray(d[:8]) + i, n = 8, len(d) + saw_end = False + while i + 8 <= n: + ln = struct.unpack(">I", d[i:i + 4])[0] + typ = d[i + 4:i + 8] + end = i + 12 + ln + if end > n: + fail("truncated PNG chunk '%s'" % typ.decode("ascii", "replace")) + keep = typ in PNG_KEEP or (typ == b"iCCP" and keep_icc) + if keep: + out += d[i:end] + else: + note(typ.decode("ascii", "replace"), ln + 12) + i = end + if typ == b"IEND": + saw_end = True + break + if not saw_end: + fail("no IEND chunk found; refusing to write a truncated file") + if i < n: + note("trailer", n - i) + return bytes(out) + +if d[:2] == b"\xff\xd8": + cleaned = strip_jpeg(d) +elif d[:8] == b"\x89PNG\r\n\x1a\n": + cleaned = strip_png(d) +else: + fail("unsupported format (only JPEG and PNG; use exiftool for others)") + +links = os.stat(src).st_nlink if in_place else 1 + +if not listing and (removed or not in_place): + if in_place and links > 1: + # Replacing the inode would clean this name and leave every other hard + # link holding the original metadata. Rewrite the inode instead so all + # names see the clean data, accepting a non-atomic write. + with open(dst, "r+b") as fh: + fh.write(cleaned) + fh.truncate() + else: + # Write beside the destination so the rename is atomic on the same volume. + dst_dir = os.path.dirname(os.path.abspath(dst)) or "." + fd, tmp = tempfile.mkstemp(dir=dst_dir, prefix=".sz-rm-meta.") + try: + with os.fdopen(fd, "wb") as fh: + fh.write(cleaned) + shutil.copymode(src, tmp) + os.replace(tmp, dst) + except BaseException: + if os.path.exists(tmp): + os.unlink(tmp) + raise + +print("%d\t%d\t%d\t%s" % (len(d), len(cleaned), links, summary())) +PY +} + +# Re-encode into a temp file: new quantization tables, new bitstream, slightly +# different dimensions. -strip drops what ImageMagick knows about; the lossless +# pass afterwards catches what it leaves behind (it writes its own tEXt/date +# chunks into PNG, for one). +reencode() { + local src="$1" tmp="$2" + "${magick_cmd}" "${src}" -strip -resize "${scale}%" -quality "${quality}" \ + -interlace none "${tmp}" 2>/dev/null +} + +random_name() { + local ext="${1##*.}" + local stem + stem=$(LC_ALL=C tr -dc 'a-z0-9' </dev/urandom | head -c 12) + if [ "${ext}" = "$1" ]; then + echo "${stem}" + else + echo "${stem}.${ext}" + fi +} + +status=0 +for f in "${files[@]}"; do + [ -f "${f}" ] || { >&2 echo "sz-rm-meta: not a file: ${f}"; status=1; continue; } + + orig_size=$(python3 -c 'import os,sys; print(os.path.getsize(sys.argv[1]))' "${f}") + + dst="${f}" + [ -n "${out_dir}" ] && dst="${out_dir%/}/$(basename "${f}")" + + # Count before rewriting: the rewrite replaces the inode, which drops the + # xattrs on its own, so counting afterwards would always report zero. In + # --out mode the source is never touched and the fresh copy starts with no + # xattrs, so there is nothing to clear or report. + xa=0 + if ! ${keep_xattr} && [ -z "${out_dir}" ]; then + xa=$(count_xattrs "${f}") + fi + + src="${f}" + tmp_reenc="" + if ${destructive}; then + tmp_reenc="$(dirname "${dst}")/.sz-rm-meta-reenc.$$.$(basename "${f}")" + if ! reencode "${f}" "${tmp_reenc}"; then + rm -f "${tmp_reenc}" + >&2 echo "sz-rm-meta: ${f}: re-encode failed" + status=1; continue + fi + src="${tmp_reenc}" + fi + + mode="strip"; ${list_only} && mode="list" + if ! result=$(strip_one "${src}" "${dst}" "${mode}" "${keep_icc}"); then + [ -n "${tmp_reenc}" ] && rm -f "${tmp_reenc}" + status=1; continue + fi + if [ -n "${tmp_reenc}" ]; then + rm -f "${tmp_reenc}" + fi + + # Still needed for a file that was already clean, and for the hard-link path + # where the inode survives the rewrite along with its xattrs. + if [ "${xa}" -gt 0 ] && ! ${list_only}; then + clear_xattrs "${dst}" >/dev/null + fi + + after=$(echo "${result}" | cut -f2) + links=$(echo "${result}" | cut -f3) + removed=$(echo "${result}" | cut -f4) + + final="${dst}" + if ${rename} && ! ${list_only}; then + final="$(dirname "${dst}")/$(random_name "$(basename "${dst}")")" + mv -n "${dst}" "${final}" || { >&2 echo "sz-rm-meta: rename failed: ${dst}"; status=1; final="${dst}"; } + fi + + ${quiet} && continue + + detail="${removed}" + if [ "${xa}" -gt 0 ]; then + plural=s; [ "${xa}" -eq 1 ] && plural= + detail="${detail:+${detail}, }${xa} xattr${plural}" + fi + if [ -z "${detail}" ] && ! ${destructive}; then + echo "${f}: already clean" + elif ${list_only}; then + echo "${f}: would remove ${detail} (${orig_size} -> ${after} bytes)" + else + if ${destructive}; then + action="re-encoded q${quality} @${scale}%${detail:+, removed ${detail}}" + else + action="removed ${detail}" + fi + line="${f}: ${action} (${orig_size} -> ${after} bytes)" + [ "${links}" -gt 1 ] && line="${line} [${links} hard links, rewritten in place]" + [ "${final}" != "${dst}" ] && line="${line} -> $(basename "${final}")" + echo "${line}" + fi +done + +exit "${status}" diff --git a/bin/sz-wait-for b/bin/sz-wait-for new file mode 100755 index 0000000..96e32a5 --- /dev/null +++ b/bin/sz-wait-for @@ -0,0 +1,308 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Single-shot waiter: block until the watched target changes, debounce edits for +# 5 seconds, confirm the change is real, then exit 0. Meant to be re-invoked in a +# loop, not to stream continuous events. +# +# Takes one argument: the file or directory to watch. A directory fires when +# anything under it changes -- a file added, removed, or modified, at any depth. +# +# Behavior and design choices: +# - resolve the target to an absolute path so the caller's cwd does not matter +# - capture the baseline in memory at startup; only changes that happen while +# this process is running are detected (no cross-run state is kept, by design) +# - debounce bursts of edits (5 seconds of quiet after the last event) +# +# Change detection uses a fingerprint rather than a single mtime: a recursive +# walk produces a sorted "path<TAB>mtime" listing that is hashed into one value, +# so any add/remove/modify moves the hash. The walk is recursive to match the +# watcher scope (fswatch is recursive on macOS via FSEvents, and we pass -r on +# Linux), so a deep change can never wake us without also moving the fingerprint. +# +# On a clean exit it prints "Changed: <path>"; for a directory it also lists the +# entries it noticed were added, removed, or modified (capped for readability). +# +# Known limitations (deliberate, to stay simple): +# - mtime-only: a change that does not move an entry's mtime is not detected, +# even though fswatch may wake us (we treat it as a spurious event and +# re-arm). This covers metadata-only edits (chmod/chown/xattr) and two edits +# to the same file within one wall-clock second on the seconds-only stat +# fallback. Closing this would require content hashing, not worth the cost. +# - symlinks are not followed: find lists the link node and stat reports the +# link itself, so a change to a symlink's *target* does not move the +# fingerprint. Watching the link node is the intended behavior. +# - cost: the walk runs one stat (or date/stat -c) per entry, recomputed on +# each wake and once per second while settling. Fine for small trees; on +# very large/deep trees a sample may exceed the 1s settle tick. + +if [ "$#" -ne 1 ]; then + echo "Usage: $0 <file-or-directory>" >&2 + exit 1 +fi + +TARGET="$1" + +if [ ! -e "$TARGET" ]; then + echo "Error: path does not exist: $TARGET" >&2 + exit 1 +fi + +# Resolve to an absolute path so the watch target is stable even if the caller's +# cwd changes. Directories resolve by cd'ing in; a file resolves via its parent +# so a symlinked file resolves predictably. +if [ -d "$TARGET" ]; then + ABS_TARGET="$(cd "$TARGET" && pwd)" +else + case "$TARGET" in + /*) ABS_TARGET="$TARGET" ;; + *) ABS_TARGET="$(cd "$(dirname "$TARGET")" && pwd)/$(basename "$TARGET")" ;; + esac +fi + +if [ ! -e "$ABS_TARGET" ]; then + echo "Error: path does not exist: $ABS_TARGET" >&2 + exit 1 +fi + +# Accept only a regular file or a directory. Other node types (sockets, FIFOs, +# devices) are rejected -- the fingerprint/watch logic is not meant for them. +if [ ! -d "$ABS_TARGET" ] && [ ! -f "$ABS_TARGET" ]; then + echo "Error: not a regular file or directory: $ABS_TARGET" >&2 + exit 1 +fi + +# Build the fswatch argument list once. +# - Directory: watch it recursively (-r forces recursion on Linux/inotify; it +# is harmless on macOS, where FSEvents is already recursive). +# - File: watch the file and its parent directory so atomic editor saves +# (write temp + rename over the target, which swaps the inode) are detected. +if [ -d "$ABS_TARGET" ]; then + WATCH_ARGS=(-r "$ABS_TARGET") + # The directory listing relies on `find`. Fail loudly here rather than let a + # missing `find` collapse the walk to an empty listing (which would hash to a + # constant and make the watcher arm but never fire). + if ! command -v find >/dev/null 2>&1; then + echo "Error: find is required to watch a directory" >&2 + exit 1 + fi +else + WATCH_ARGS=("$ABS_TARGET" "$(dirname "$ABS_TARGET")") +fi + +# Hash whatever arrives on stdin to a stable hex digest. +hash_stdin() { + if command -v shasum >/dev/null 2>&1; then + shasum | awk '{print $1}' + else + sha1sum | awk '{print $1}' + fi +} + +# Normalize an mtime value to seconds.nanoseconds with exactly 9 fractional +# digits so values are stable across different stat/date outputs. +normalize_mtime() { + local mtime="$1" + if [[ "$mtime" =~ ^([0-9]+)\.([0-9]+)$ ]]; then + local secs="${BASH_REMATCH[1]}" + local frac="${BASH_REMATCH[2]}" + while [ "${#frac}" -lt 9 ]; do frac="${frac}0"; done + frac="${frac:0:9}" + printf '%s.%s' "$secs" "$frac" + else + printf '%s.000000000' "$mtime" + fi +} + +# Cross-platform mtime with sub-second precision where available. +# Try the macOS/BSD high-precision stat first (avoids a failing `date` exec on +# every poll on stock macOS, where BSD `date -r` only accepts epoch seconds). +# GNU `date -r <file>` gives nanoseconds on Linux. The seconds-only GNU stat +# fallback is the last resort; rapid same-second edits may be invisible there. +# Prints the normalized mtime on stdout and returns 0; returns 1 on failure. +mtime_of() { + local f="$1" + local out + if out=$(stat -f '%Fm' "$f" 2>/dev/null); then + normalize_mtime "$out" + return 0 + elif out=$(date -r "$f" +%s.%N 2>/dev/null) && [[ "$out" =~ ^[0-9]+\.[0-9]{9}$ ]]; then + printf '%s' "$out" + return 0 + elif out=$(stat -c '%Y' "$f" 2>/dev/null); then + normalize_mtime "$out" + return 0 + fi + return 1 +} + +# Emit the target's "path<TAB>mtime" listing on stdout, one line per entry: +# - File: a single line. +# - Directory: the directory itself and every descendant, sorted by path for +# determinism. +# Entries that vanish mid-walk are skipped: their absence simply changes the +# listing on the next sample, which is the correct signal. A directory always +# yields at least itself, so an *empty* listing while it still exists means the +# walk tooling failed (find/sort/stat); we fail loudly there rather than emit +# nothing, which would hide a change behind the constant empty-string hash. A +# truly vanished target also yields nothing, but the main loop's existence check +# reports that with a clear message. +listing_of() { + local target="$1" + if [ -d "$target" ]; then + local listing + # Requires `sort -z` (GNU sort and Apple's sort 2.3+ both support it). If a + # platform's sort lacks it, the pipeline yields no output and the empty + # listing is caught loudly below rather than degrading to a constant hash. + listing=$(find "$target" -print0 2>/dev/null \ + | LC_ALL=C sort -z \ + | while IFS= read -r -d '' entry; do + m=$(mtime_of "$entry") || continue + printf '%s\t%s\n' "$entry" "$m" + done) || true + if [ -z "$listing" ] && [ -d "$target" ]; then + echo "Error: directory walk produced no entries for $target (find/sort/stat failure?)" >&2 + return 1 + fi + printf '%s' "$listing" + else + local m + if m=$(mtime_of "$target"); then + printf '%s\t%s' "$target" "$m" + else + # Fail loudly rather than emit nothing and hide the change behind a hash. + echo "Error: unable to read mtime of $target" >&2 + return 1 + fi + fi +} + +# Hash a target's listing into one digest -- cheap to compare, used only to +# detect that *something* changed. +fingerprint_of() { + listing_of "$1" | hash_stdin +} + +# Summarize what changed between two listings (the "path<TAB>mtime" output of +# listing_of) to stdout, as added/removed/modified entries with paths relative +# to the watched root. A directory's own mtime bump is always a side effect of a +# child being added or removed -- already reported via that child -- so those +# "modified" lines are dropped as noise. Output is capped to stay readable. +report_changes() { + local base="$1" final="$2" root="$3" + local max=10 shown=0 total=0 status path rel + while IFS=$'\t' read -r status path; do + # Drop pure directory mtime bumps; the child add/remove is reported too. + if [ "$status" = M ] && [ -d "$path" ]; then + continue + fi + total=$((total + 1)) + if [ "$shown" -lt "$max" ]; then + case "$path" in + "$root") rel="." ;; + "$root"/*) rel="${path#"$root"/}" ;; + *) rel="$path" ;; + esac + case "$status" in + A) printf ' added: %s\n' "$rel" ;; + R) printf ' removed: %s\n' "$rel" ;; + M) printf ' modified: %s\n' "$rel" ;; + esac + shown=$((shown + 1)) + fi + done < <( + awk -F'\t' ' + NR==FNR { bm[$1]=$2; b[$1]=1; next } + { f[$1]=1 + if (!($1 in b)) print "A\t" $1 + else if (bm[$1] != $2) print "M\t" $1 } + END { for (p in b) if (!(p in f)) print "R\t" p } + ' <(printf '%s\n' "$base") <(printf '%s\n' "$final") | LC_ALL=C sort + ) + if [ "$total" -gt "$shown" ]; then + printf ' ... and %d more\n' "$((total - shown))" + fi +} + +SETTLE=5 + +if ! command -v fswatch >/dev/null 2>&1; then + echo "Error: fswatch is required (https://github.com/emcrisostomo/fswatch)" >&2 + exit 1 +fi + +# Capture the baseline in memory at startup. No state is kept across runs: only +# changes that happen while this process is running are detected. Keep the raw +# listing too so a directory change can be described, not just detected. +BASE_LISTING="$(listing_of "$ABS_TARGET")" +BASE_FP="$(printf '%s' "$BASE_LISTING" | hash_stdin)" + +while :; do + CURRENT_FP="$(fingerprint_of "$ABS_TARGET")" + + if [ "$CURRENT_FP" = "$BASE_FP" ]; then + # No change yet; block on a filesystem notification. + # NOTE: there is a tiny check-then-arm window between the fingerprint read + # above and fswatch starting; a change in that gap may not be noticed until + # the next event wakes us. + # NOTE: watching a directory (or a file's parent) means unrelated entries can + # wake us up; the immediate fingerprint check below keeps that cheap. + if FS_OUT=$(fswatch -1 "${WATCH_ARGS[@]}" 2>&1); then + FS_RC=0 + else + FS_RC=$? + fi + if [ "$FS_RC" -ne 0 ]; then + if [ ! -e "$ABS_TARGET" ]; then + echo "Error: watched path disappeared: $ABS_TARGET" >&2 + else + echo "Error: fswatch failed (exit $FS_RC): $FS_OUT" >&2 + fi + exit "$FS_RC" + fi + if [ ! -e "$ABS_TARGET" ]; then + echo "Error: watched path disappeared: $ABS_TARGET" >&2 + exit 1 + fi + fi + + # Unrelated events can wake us up. Recompute the fingerprint immediately; if it + # has not moved, re-arm without paying the full stabilization penalty. + EVENT_FP="$(fingerprint_of "$ABS_TARGET")" + if [ "$EVENT_FP" = "$BASE_FP" ]; then + continue + fi + + # Stabilize: wait until the target has been quiet for SETTLE seconds. If it + # changes again during that window, reset the timer. For a directory this + # naturally absorbs bursts like a multi-file save or a checkout. + LAST_FP="$EVENT_FP" + QUIET=0 + while [ "$QUIET" -lt "$SETTLE" ]; do + sleep 1 + if [ ! -e "$ABS_TARGET" ]; then + echo "Error: watched path disappeared: $ABS_TARGET" >&2 + exit 1 + fi + NOW_FP="$(fingerprint_of "$ABS_TARGET")" + if [ "$NOW_FP" != "$LAST_FP" ]; then + LAST_FP="$NOW_FP" + QUIET=0 + else + QUIET=$((QUIET + 1)) + fi + done + + # Confirm the listing actually changed from the baseline. fswatch can report + # spurious or duplicate events, so we only fire on a real delta. + FINAL_LISTING="$(listing_of "$ABS_TARGET")" + FINAL_FP="$(printf '%s' "$FINAL_LISTING" | hash_stdin)" + if [ "$FINAL_FP" != "$BASE_FP" ]; then + echo "Changed: $ABS_TARGET" >&2 + if [ -d "$ABS_TARGET" ]; then + report_changes "$BASE_LISTING" "$FINAL_LISTING" "$ABS_TARGET" >&2 + fi + exit 0 + fi + +done diff --git a/bin/terminal-set-opacity b/bin/terminal-set-opacity new file mode 100755 index 0000000..2fce324 --- /dev/null +++ b/bin/terminal-set-opacity @@ -0,0 +1,32 @@ +#!/bin/bash + +set -euo pipefail + +increment="$1" + +_sed_i() { + if [[ "$(uname)" == "Darwin" ]]; then + sed -i '' "$@" + else + sed -i'' "$@" + fi +} + +# Update Alacritty +alacritty_config="$HOME/.config/alacritty/alacritty.toml" +current="$(grep opacity "$alacritty_config" | awk '{ print $3 }' || echo 1)" +changed="$(perl -MList::Util=min,max -e "print max(0, min(1, $current + $increment))")" +_sed_i 's/^opacity = .*$/opacity = '"$changed"'/' "$alacritty_config" + +# Update Ghostty +ghostty_config="$HOME/.config/ghostty/config" +if [[ -f "$ghostty_config" ]]; then + _sed_i 's/^background-opacity = .*$/background-opacity = '"$changed"'/' "$ghostty_config" + + # Trigger Ghostty reload + script_dir="$(cd "$(dirname "$0")" && pwd)" + "$script_dir/ghostty-reload" +fi + +echo "$changed" + diff --git a/bin/theme b/bin/theme new file mode 100755 index 0000000..39f25d1 --- /dev/null +++ b/bin/theme @@ -0,0 +1,554 @@ +#!/bin/bash + +set -euo pipefail + +: ${HOME:="/Users/$(whoami)"} +: ${XDG_CONFIG_HOME:=$HOME/.config} +: ${XDG_DATA_HOME:=$HOME/.local/share} + + +_sed_i() { + if [[ "$(uname)" == "Darwin" ]]; then + sed -i '' "$@" + else + sed -i'' "$@" + fi +} + +is_auto_dark_mode() { + if [[ "$OSTYPE" == "darwin"* ]]; then + defaults read -g AppleInterfaceStyleSwitchesAutomatically >/dev/null 2>&1 + else + true + fi +} +is_ubuntu_desktop() { + [[ "$(lsb_release -is)" == "Ubuntu" ]] \ + && dpkg -l | grep -q desktop +} +is_dark_mode() { + if [[ "$OSTYPE" == "darwin"* ]]; then + defaults read -g AppleInterfaceStyle >/dev/null 2>&1 + elif [[ -n "${SSH_CONNECTION:-}" ]]; then + true + elif is_ubuntu_desktop ; then + [[ "$(gsettings get org.gnome.desktop.interface color-scheme 2>&1)" != *'light'* ]] + else + true + fi +} +set_macos_dark_mode_to() { + osascript -e 'tell application "System Events" to tell appearance preferences to set dark mode to '"$1" +} +set_dark_mode() { + if [[ "$OSTYPE" == "darwin"* ]]; then + set_macos_dark_mode_to "true" + elif is_ubuntu_desktop ; then + gsettings set org.gnome.desktop.interface color-scheme prefer-dark > /dev/null || true + fi +} +unset_dark_mode() { + if [[ "$OSTYPE" == "darwin"* ]]; then + set_macos_dark_mode_to "false" + elif is_ubuntu_desktop ; then + gsettings set org.gnome.desktop.interface color-scheme prefer-light > /dev/null || true + fi +} +get_mode() { + local mode="${1:-}" + if [[ -z "$mode" ]]; then + if is_dark_mode ; then + mode="dark" + else + mode="light" + fi + fi + if [[ "$mode" != "dark" && "$mode" != "light" ]] + then + >&2 echo "ERROR: unsupported mode '$mode'" + exit 1 + fi + if [[ "${mode}" == 'dark' ]] ; then + echo "dark" + else + echo "light" + fi +} +set_mode() { + local mode="${1:-}" + if [[ "$(get_mode "$mode")" == 'dark' ]] + then + set_dark_mode + else + unset_dark_mode + fi +} + + + +# List the available color config options +list_colors() { + local ext="$1" + local dir="$2" + if [ ! -e "$dir" ] ; then + echo "(none)" + return + fi + find "${dir}/" -mindepth 1 -type f -print0 \ + | xargs -r -0 -n 1 basename \ + | sed 's/'"$ext"'$//' \ + | sort +} +# Select the first readable file from a list of filepaths +# specified as arguments. +first_file_in() { + cd "$1" + shift + while [[ $# -ne 0 ]] + do + if [[ -r "$1" ]] + then + echo "$(pwd)/$1" + return 0 + else + shift + fi + done + return 1 +} +color_file() { + local dir="$1" + local ext="$2" + local colors="$3" + local mode="$4" + if ! first_file_in "$dir" \ + "${colors}-${mode}${ext}" \ + "${colors}${mode}${ext}" \ + "${colors}${ext}" \ + "${mode}${ext}" + then + >&2 echo "ERROR: unable to load $ext colors for $colors $mode from $dir" + return 1 + fi +} +# Create or replace colors in a given config file from another +# file specifying the color config content. +set_colors_in_config() { + config_file="$1" + colors_file="$2" + line_comment_prefix="${3:-## }" + if [[ -L "$config_file" ]] + then + config_file="$(readlink "$config_file")" + fi + mkdir -p "$(dirname "$config_file")" + if [[ -r "$config_file" ]] + then + _sed_i '/GEN COLORS START/,/GEN COLORS END/d' "$config_file" + fi + cat >> "$config_file" <<EOF +$line_comment_prefix GEN COLORS START +$line_comment_prefix from: $colors_file +$(cat "$colors_file") +$line_comment_prefix GEN COLORS END +EOF +} + +alacritty_ext='.toml' +alacritty_cfg="$XDG_DATA_HOME/alacritty/color${alacritty_ext}" +alacritty_colors_dir1="$XDG_CONFIG_HOME/alacritty/colors" +alacritty_colors_dir2="$HOME/src/alacritty/alacritty-theme/themes" +alacritty_colors() { + { + list_colors "${alacritty_ext}" "$alacritty_colors_dir1" + list_colors "${alacritty_ext}" "$alacritty_colors_dir2" + } | sort +} +alacritty_color_file() { + color_file "${alacritty_colors_dir1}" "${alacritty_ext}" "$1" "$2" 2>/dev/null || \ + color_file "${alacritty_colors_dir2}" "${alacritty_ext}" "$1" "$2" 2>/dev/null || \ + color_file "${alacritty_colors_dir1}" "${alacritty_ext}" "$1" "$2" +} +alacritty_color_check_exists() { + alacritty_color_file "$1" "$2" > /dev/null +} +alacritty_colors_set() { + set_colors_in_config "${alacritty_cfg}" "$(alacritty_color_file "$1" "$2")" + touch "$XDG_CONFIG_HOME/alacritty/alacritty${alacritty_ext}" +} + +bat_ext='.sh' +bat_cfg="$XDG_DATA_HOME/bat/color.sh" +bat_colors_dir="$XDG_CONFIG_HOME/bat/colors" +bat_colors() { + # list_colors "${bat_ext}" "$bat_colors_dir" + bat --list-themes | cat +} +bat_color_file() { + color_file "${bat_colors_dir}" "${bat_ext}" "$1" "$2" +} +bat_color_check_exists() { + bat_color_file "$1" "$2" > /dev/null +} +bat_colors_set() { + local mode="$2" + + # best for light: Coldark-Cold, Github, Monokai Extended Light, OneHalfLight, gruvbox-light + # best for dark: 1337, Coldark-Dark, Monokai Extended, OneHalfDark, gruvbox-dark + # works well with both: ansi + local theme="1337" + if [[ "$mode" == "light" ]] + then + theme="gruvbox-light" + fi + + [[ -d "$(dirname "${bat_cfg}")" ]] || mkdir -p "$(dirname "${bat_cfg}")" + echo "--theme='$theme'" > "${bat_cfg}" +} + +tmux_ext='.conf' +tmux_cfg="$XDG_DATA_HOME/tmux/color.conf" +tmux_colors_dir="$XDG_CONFIG_HOME/tmux/colors" +tmux_colors() { + list_colors "${tmux_ext}" "$tmux_colors_dir" +} +tmux_color_file() { + color_file "${tmux_colors_dir}" "${tmux_ext}" "$1" "$2" +} +tmux_color_check_exists() { + tmux_color_file "$1" "$2" > /dev/null +} +tmux_colors_set() { + set_colors_in_config "${tmux_cfg}" "$(tmux_color_file "$1" "$2")" + tmux source-file "${tmux_cfg}" +} + +neovim_ext='.lua' +neovim_cfg="${XDG_DATA_HOME}/nvim/colors.lua" +neovim_colors_dir="$XDG_CONFIG_HOME/nvim/colors" +neovim_colors() { + list_colors "${neovim_ext}" "$neovim_colors_dir" +} +neovim_color_file() { + color_file "${neovim_colors_dir}" "${neovim_ext}" "$1" "$2" +} +neovim_color_check_exists() { + neovim_color_file "$1" "$2" 2>/dev/null > /dev/null +} +neovim_colors_set() { + colors="$1" + mode="$2" + if neovim_color_check_exists "${colors}" "${mode}" + then + set_colors_in_config "${neovim_cfg}" "$(neovim_color_file "$1" "$2")" '-- ' + else + cat <<EOF > "${neovim_cfg}" +-- GEN COLORS START +local theme_mode = "$mode" +local theme_colors = "$colors" +$(cat "${neovim_colors_dir}/default.lua") +-- GEN COLORS END +EOF + fi +} + +ghostty_cfg="$XDG_DATA_HOME/ghostty/color" +ghostty_colors_dir="$XDG_CONFIG_HOME/ghostty/colors" +# Check if a custom Ghostty color file exists +ghostty_custom_color_file() { + local colors="$1" + local mode="$2" + + # Try to find custom color file (similar to other tools) + if ! first_file_in "$ghostty_colors_dir" \ + "${colors}-${mode}" \ + "${colors}${mode}" \ + "${colors}" + then + return 1 + fi +} +# Map colorscheme names to Ghostty built-in theme names +ghostty_theme_name() { + local colors="$1" + local mode="$2" + + # Direct mappings for common colorschemes + case "$colors" in + molokai) + # echo "Monokai Soda" + # echo "Monokai Pro Spectrum" + echo "Brogrammer" + ;; + gruvbox) + if [[ "$mode" == "dark" ]]; then + echo "Gruvbox Dark" + else + echo "Gruvbox Light" + fi + ;; + papercolor) + if [[ "$mode" == "dark" ]]; then + echo "GitHub Dark Default" + else + echo "GitHub Light Default" + fi + ;; + solarized) + if [[ "$mode" == "dark" ]]; then + echo "Solarized Dark Patched" + else + echo "Builtin Solarized Light" + fi + ;; + nord) + echo "Nord" + ;; + dracula) + echo "Dracula" + ;; + *) + # Fallback: try to find a matching theme name (case-insensitive) + local search_term="$colors" + ghostty +list-themes --plain 2>/dev/null | sed 's/ (resources)//' | grep -i "^${search_term}$" | head -n 1 || echo "" + ;; + esac +} +ghostty_colors() { + { + # List custom color files first + if [[ -d "$ghostty_colors_dir" ]]; then + find "$ghostty_colors_dir" -mindepth 1 -type f -print0 \ + | xargs -r -0 -n 1 basename \ + | sort + fi + # Then list built-in themes + ghostty +list-themes --plain 2>/dev/null | sed 's/ (resources)//' + } | sort -u || echo "(ghostty not found)" +} +ghostty_color_check_exists() { + local colors="$1" + local mode="$2" + + # # First check for custom color file + # if ghostty_custom_color_file "$colors" "$mode" >/dev/null 2>&1; then + # return 0 + # fi + + # Then check for built-in theme + local theme_name + theme_name="$(ghostty_theme_name "$colors" "$mode")" + if [[ -n "$theme_name" ]]; then + return 0 + else + return 1 + fi +} +ghostty_colors_set() { + local colors="$1" + local mode="$2" + local custom_file + local theme_name + + mkdir -p "$(dirname "$ghostty_cfg")" + + # First priority: use custom color file if it exists + if custom_file="$(ghostty_custom_color_file "$colors" "$mode" 2>/dev/null)"; then + cat > "$ghostty_cfg" <<EOF +# Generated by theme script +# Colorscheme: $colors ($mode) +# Using custom color file: $custom_file +$(cat "$custom_file") +EOF + else + # Second priority: use built-in theme + theme_name="$(ghostty_theme_name "$colors" "$mode")" + + if [[ -z "$theme_name" ]]; then + >&2 echo "WARNING: No Ghostty theme found for '$colors' in $mode mode, skipping Ghostty theme update" + return 0 + fi + + cat > "$ghostty_cfg" <<EOF +# Generated by theme script +# Colorscheme: $colors ($mode) +# Using built-in theme: $theme_name +theme = $theme_name +EOF + fi + + # Trigger Ghostty reload + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + "$script_dir/ghostty-reload" +} + + + +# default colors file saves the local colors preference for each mode +default_colors_file_dark="${XDG_DATA_HOME}/dark-colors" +default_colors_file_light="${XDG_DATA_HOME}/light-colors" +get_default_colors_file() { + local mode="${1-:}" + if [[ "$(get_mode "$mode")" == "dark" ]] ; then + echo "${default_colors_file_dark}" + else + echo "${default_colors_file_light}" + fi +} +get_colors() { + local mode="${1:-}" + local file + file="$(get_default_colors_file "$mode" )" + if [[ -r "$file" ]] + then + cat "$file" + else + is_dark_mode && echo "molokai" || echo "papercolor" + fi +} +common_colors() { + local col com aux types + col="$(mktemp)" + com="$(mktemp)" + aux="$(mktemp)" + types=( + bat + tmux + neovim + alacritty + ghostty + ) + for typ in "${types[@]}"; do + echo "[[ $typ ]]" + "${typ}_colors" | sort | tee "${col}" + if [[ "$(wc -l < "${com}")" -gt 0 ]]; then + comm -12 "${com}" "${col}" > "${aux}" + cp "${aux}" "${com}" + else + cp "${col}" "${com}" + fi + echo "" + done + echo "[[ common ]]" + cat "${com}" + rm "${com}" "${aux}" +} +color_check_exists() { + local colors="$1" + local mode="$2" + if ! tmux_color_check_exists "$colors" "$mode"; then + >&2 echo "ERROR: tmux colors '$colors' not found for mode '$mode'" + return 1 + fi + if ! alacritty_color_check_exists "$colors" "$mode"; then + >&2 echo "ERROR: alacritty colors '$colors' not found for mode '$mode'" + return 1 + fi + # neovim and bat have fallback mechanisms, so don't require strict checking + return 0 +} +set_colors() { + local mode="$1" + local colors="$2" + if [[ -z "$mode" ]]; then + mode="$(get_mode)" + fi + if [[ -z "$colors" ]]; then + colors="$(get_colors "$mode")" + fi + color_check_exists "$colors" "$mode" + + bat_colors_set "$colors" "$mode" + tmux_colors_set "$colors" "$mode" + neovim_colors_set "$colors" "$mode" + alacritty_colors_set "$colors" "$mode" + ghostty_colors_set "$colors" "$mode" + + local colors_file + colors_file="$(get_default_colors_file "$mode")" + mkdir -p "$(dirname "$colors_file")" + echo "$colors" > "$colors_file" 2>/dev/null || true +} + +set_theme() { + local mode=$1 + local colors=$2 + + set_mode "$mode" + set_colors "$mode" "$colors" + + echo "Set theme to $(get_mode "$mode") $(get_colors "$mode")" +} + +print_help() { + local s_name + s_name="$(basename $0)" + cat <<EOF +Help + +Current set theme: + + $s_name + +Current mode + + $s_name --mode + +Current colorscheme + + $s_name --colors + +Set theme + + $s_name --set [light|dark] [colorscheme] + +List available colorschemes + + $s_name --list + $s_name --list-bat + $s_name --list-tmux + $s_name --list-neovim + $s_name --list-alacritty + $s_name --list-ghostty +EOF +} + +if test $# -eq 0 +then + echo "$(get_mode) $(get_colors)" + exit 0 +fi + +while test $# -gt 0 +do + case $1 in + --mode) + get_mode; exit 0; ;; + --colors) + get_colors; exit 0; ;; + --set) + set_theme "${2:-}" "${3:-}"; exit 0; ;; + --list-alacritty) + alacritty_colors; exit 0; ;; + --list-tmux) + tmux_colors; exit 0; ;; + --list-neovim) + neovim_colors; exit 0; ;; + --list-bat) + bat_colors; exit 0; ;; + --list-ghostty) + ghostty_colors; exit 0; ;; + --list) + common_colors; exit 0; ;; + --help|-h) + print_help; exit 0; ;; + *) + print_help; exit 1; ;; + esac + shift +done + +>&2 echo "ERROR: Unexpected state!" +exit 1 + diff --git a/bin/tmux-colors.zsh b/bin/tmux-colors.zsh new file mode 100755 index 0000000..b4ba152 --- /dev/null +++ b/bin/tmux-colors.zsh @@ -0,0 +1,12 @@ +#!/bin/bash + + +for i in {0..255} +do + printf "\x1b[38;5;${i}mcolor%-5i\x1b[0m" $i + # if ! (( ($i + 1 ) % 8 )) + # then + # echo + # fi +done + diff --git a/bin/truecolour-test b/bin/truecolour-test new file mode 100755 index 0000000..11b307a --- /dev/null +++ b/bin/truecolour-test @@ -0,0 +1,21 @@ +#!/bin/bash + +# Based on: https://gist.github.com/XVilka/8346728 +# From: https://unix.stackexchange.com/questions/404414/print-truecolor-24-bit-test-pattern +# You can also call it as: +# width=1000 truecolor-test +# for a wider pattern. + +awk -v term_cols="${width:-$(tput cols || echo 80)}" 'BEGIN{ + s="/\\"; + for (colnum = 0; colnum<term_cols; colnum++) { + r = 255-(colnum*255/term_cols); + g = (colnum*510/term_cols); + b = (colnum*255/term_cols); + if (g>255) g = 510-g; + printf "\033[48;2;%d;%d;%dm", r,g,b; + printf "\033[38;2;%d;%d;%dm", 255-r,255-g,255-b; + printf "%s\033[0m", substr(s,colnum%2+1,1); + } + printf "\n"; +}' @@ -0,0 +1,27 @@ +#!/bin/bash + +set -euo pipefail + +# remove self from path +PATH_save="$PATH" +PATH=$( \ + echo "$PATH" \ + | tr ':' '\n' \ + | grep -vE "$(sed 's/\//\\\//g' <<<"$HOME")"'\/\.?bin' \ + | paste -sd ':' - \ +) +export PATH + +path-of() { + command -v "$1" +} + +editor() { + path-of "nvim" \ + || path-of "vim" \ + || path-of "vi" +} + +export PATH="$PATH_save" +exec "$(editor)" "$@" + diff --git a/bin/wifi b/bin/wifi new file mode 100755 index 0000000..141210a --- /dev/null +++ b/bin/wifi @@ -0,0 +1,33 @@ +#!/bin/bash -l + +set -euo pipefail + +is_on() { + networksetup -getairportpower en0 \ + | grep -q ' On' +} + +current() { + /System/Library/PrivateFrameworks/Apple80211.framework/Resources/airport -I \ + | awk -F' SSID: ' '/ SSID: / {print $2}' +} + +action() { + if [[ "$1" == "on" ]] && is_on + then + notify "WiFi status" "Connected to: $(current)" + else + networksetup -setairportpower en0 "$1" + notify "WiFi status" "On, searching for networks..." + fi +} + +arg="${1:-''}" +if [[ "$arg" == "on" ]]; then + action on +elif [[ "$arg" == "off" ]]; then + action off +else + >&2 echo "ERROR. Need \"on\" or \"off\" arg." +fi + diff --git a/bin/zsh-colors.zsh b/bin/zsh-colors.zsh new file mode 100755 index 0000000..e1e9596 --- /dev/null +++ b/bin/zsh-colors.zsh @@ -0,0 +1,39 @@ +#! /bin/zsh +# A script to make using 256 colors in zsh less painful. +# P.C. Shyamshankar <sykora@lucentbeing.com> +# Copied from https://github.com/sykora/etc/blob/master/zsh/functions/spectrum/ + +typeset -AHg FX FG BG + +FX=( + reset "%{[00m%}" + bold "%{[01m%}" no-bold "%{[22m%}" + italic "%{[03m%}" no-italic "%{[23m%}" + underline "%{[04m%}" no-underline "%{[24m%}" + blink "%{[05m%}" no-blink "%{[25m%}" + reverse "%{[07m%}" no-reverse "%{[27m%}" +) + +for color in {000..255}; do + FG[$color]="%{[38;5;${color}m%}" + BG[$color]="%{[48;5;${color}m%}" +done + + +ZSH_SPECTRUM_TEXT=${ZSH_SPECTRUM_TEXT:-Arma virumque cano Troiae qui primus ab oris} + +# Show all 256 colors with color number +function spectrum_ls() { + for code in {000..255}; do + print -P -- "$code: %{$FG[$code]%}$ZSH_SPECTRUM_TEXT%{$reset_color%}" + done +} + +# Show all 256 colors where the background is set to specific color +function spectrum_bls() { + for code in {000..255}; do + print -P -- "$code: %{$BG[$code]%}$ZSH_SPECTRUM_TEXT%{$reset_color%}" + done +} + +spectrum_ls |
