Fork the desktop off Omarchy as a self-contained system
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
blob_bin_dir="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
|
|
||||||
|
command_file() {
|
||||||
|
local name="$1"
|
||||||
|
if [[ -x "$blob_bin_dir/$name" ]]; then
|
||||||
|
printf '%s\n' "$blob_bin_dir/$name"
|
||||||
|
else
|
||||||
|
command -v "$name" 2>/dev/null
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_command() {
|
||||||
|
local words=("$@")
|
||||||
|
local word_count=${#words[@]}
|
||||||
|
local words_used name joined
|
||||||
|
for (( words_used = word_count; words_used > 0; words_used-- )); do
|
||||||
|
joined=$(printf '%s-' "${words[@]:0:words_used}")
|
||||||
|
name="blob-${joined%-}"
|
||||||
|
if [[ -n $(command_file "$name") ]]; then
|
||||||
|
printf '%s %s\n' "$words_used" "$name"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata_value() {
|
||||||
|
sed -n "s/^# blob:$2=//p" "$1" | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
is_hidden() {
|
||||||
|
grep -q '^# blob:hidden=true' "$1" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
list_commands() {
|
||||||
|
local file base summary
|
||||||
|
for file in "$blob_bin_dir"/blob-*; do
|
||||||
|
[[ -f $file ]] || continue
|
||||||
|
is_hidden "$file" && continue
|
||||||
|
base=$(basename "$file")
|
||||||
|
summary=$(metadata_value "$file" summary)
|
||||||
|
printf ' %-28s %s\n' "${base#blob-}" "$summary"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
show_command_help() {
|
||||||
|
local name="$1"
|
||||||
|
local file
|
||||||
|
file=$(command_file "$name")
|
||||||
|
printf '%s\n' "$(metadata_value "$file" summary)"
|
||||||
|
local args
|
||||||
|
args=$(metadata_value "$file" args)
|
||||||
|
printf '\nUsage: %s %s\n' "${name//-/ }" "$args"
|
||||||
|
local examples
|
||||||
|
examples=$(metadata_value "$file" examples)
|
||||||
|
[[ -n $examples ]] && printf '\nExample: %s\n' "$examples"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
show_usage() {
|
||||||
|
cat <<USAGE
|
||||||
|
Usage: blob <command> [args...]
|
||||||
|
|
||||||
|
Commands are files named blob-<area>-<verb>. Both forms work:
|
||||||
|
|
||||||
|
blob theme set tokyo-night
|
||||||
|
blob-theme-set tokyo-night
|
||||||
|
|
||||||
|
Available commands:
|
||||||
|
USAGE
|
||||||
|
list_commands
|
||||||
|
printf '\nRun "blob help <command>" for details on one.\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1-}" in
|
||||||
|
"" | help | --help | -h)
|
||||||
|
shift || true
|
||||||
|
if (( $# == 0 )); then
|
||||||
|
show_usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if resolution=$(resolve_command "$@"); then
|
||||||
|
show_command_help "${resolution#* }"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Unknown command: $*" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if ! resolution=$(resolve_command "$@"); then
|
||||||
|
echo "Unknown command: $*" >&2
|
||||||
|
echo 'Run "blob" to list commands.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
words_used="${resolution%% *}"
|
||||||
|
command_name="${resolution#* }"
|
||||||
|
shift "$words_used"
|
||||||
|
|
||||||
|
exec "$(command_file "$command_name")" "$@"
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Cache background switcher thumbnails for the current theme
|
||||||
|
|
||||||
|
theme_name=$(cat "$HOME/.local/state/blob/current/theme.name" 2>/dev/null)
|
||||||
|
|
||||||
|
blob-menu-images \
|
||||||
|
--cache-only \
|
||||||
|
"$HOME/.local/state/blob/current/theme/backgrounds" \
|
||||||
|
"$HOME/.config/blob/backgrounds/$theme_name"
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Show current background
|
||||||
|
# blob:examples=blob theme bg current
|
||||||
|
|
||||||
|
BACKGROUND_PATH=$(readlink -f "$HOME/.local/state/blob/current/background" 2>/dev/null)
|
||||||
|
|
||||||
|
if [[ -n $BACKGROUND_PATH ]]; then
|
||||||
|
basename -- "$BACKGROUND_PATH" | perl -pe 's/\.[^.]+$//; s/^\d+-//; s/-/ /g; s/\b(\w)/\U$1/g'
|
||||||
|
else
|
||||||
|
echo "Unknown"
|
||||||
|
fi
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Open the current theme's user background folder
|
||||||
|
|
||||||
|
CURRENT_THEME_NAME=$(cat "$HOME/.local/state/blob/current/theme.name")
|
||||||
|
THEME_USER_BACKGROUNDS="$HOME/.config/blob/backgrounds/$CURRENT_THEME_NAME"
|
||||||
|
|
||||||
|
mkdir -p "$THEME_USER_BACKGROUNDS"
|
||||||
|
nautilus "$THEME_USER_BACKGROUNDS"
|
||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Cycle to the next background for the current theme
|
||||||
|
# blob:examples=blob theme bg next
|
||||||
|
|
||||||
|
THEME_NAME=$(cat "$HOME/.local/state/blob/current/theme.name" 2>/dev/null)
|
||||||
|
THEME_BACKGROUNDS_PATH="$HOME/.local/state/blob/current/theme/backgrounds/"
|
||||||
|
USER_BACKGROUNDS_PATH="$HOME/.config/blob/backgrounds/$THEME_NAME/"
|
||||||
|
CURRENT_BACKGROUND_LINK="$HOME/.local/state/blob/current/background"
|
||||||
|
|
||||||
|
mapfile -d '' -t BACKGROUNDS < <(
|
||||||
|
find -L "$USER_BACKGROUNDS_PATH" "$THEME_BACKGROUNDS_PATH" -maxdepth 1 -type f \
|
||||||
|
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \
|
||||||
|
-print0 2>/dev/null | sort -z
|
||||||
|
)
|
||||||
|
TOTAL=${#BACKGROUNDS[@]}
|
||||||
|
|
||||||
|
if (( TOTAL == 0 )); then
|
||||||
|
blob-notify-send "No background was found for theme" -t 2000
|
||||||
|
else
|
||||||
|
# Get current background from symlink
|
||||||
|
if [[ -L $CURRENT_BACKGROUND_LINK ]]; then
|
||||||
|
CURRENT_BACKGROUND=$(readlink "$CURRENT_BACKGROUND_LINK")
|
||||||
|
else
|
||||||
|
# Default to first background if no symlink exists
|
||||||
|
CURRENT_BACKGROUND=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find current background index
|
||||||
|
INDEX=-1
|
||||||
|
for i in "${!BACKGROUNDS[@]}"; do
|
||||||
|
if [[ ${BACKGROUNDS[$i]} == "$CURRENT_BACKGROUND" ]]; then
|
||||||
|
INDEX=$i
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Get next background (wrap around)
|
||||||
|
if (( INDEX == -1 )); then
|
||||||
|
# Use the first background when no match was found
|
||||||
|
NEW_BACKGROUND="${BACKGROUNDS[0]}"
|
||||||
|
else
|
||||||
|
NEXT_INDEX=$(((INDEX + 1) % TOTAL))
|
||||||
|
NEW_BACKGROUND="${BACKGROUNDS[$NEXT_INDEX]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
blob-bg-set "$NEW_BACKGROUND"
|
||||||
|
fi
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Set the current background image
|
||||||
|
# blob:args=<path-to-image>
|
||||||
|
# blob:examples=blob theme bg set ~/Pictures/background.png
|
||||||
|
|
||||||
|
if [[ -z $1 ]]; then
|
||||||
|
echo "Usage: blob-bg-set <path-to-image>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKGROUND="$(realpath "$1")"
|
||||||
|
CURRENT_BACKGROUND_LINK="$HOME/.local/state/blob/current/background"
|
||||||
|
|
||||||
|
if [[ ! -f $BACKGROUND ]]; then
|
||||||
|
echo "File does not exist: $BACKGROUND" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create symlink to the new background
|
||||||
|
ln -nsf "$BACKGROUND" "$CURRENT_BACKGROUND_LINK"
|
||||||
|
|
||||||
|
# Update the live shell background immediately when it is running. The
|
||||||
|
# background plugin also polls this symlink, but IPC avoids the visible delay.
|
||||||
|
blob-shell -q background set "$BACKGROUND"
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Open the Blob background switcher
|
||||||
|
# blob:group=theme
|
||||||
|
# blob:name=bg-switcher
|
||||||
|
# blob:aliases=blob background
|
||||||
|
|
||||||
|
theme_name=$(cat "$HOME/.local/state/blob/current/theme.name" 2>/dev/null)
|
||||||
|
current_background=$(readlink -f "$HOME/.local/state/blob/current/background" 2>/dev/null)
|
||||||
|
|
||||||
|
blob-menu-images \
|
||||||
|
--selected "$current_background" \
|
||||||
|
"$HOME/.local/state/blob/current/theme/backgrounds" \
|
||||||
|
"$HOME/.config/blob/backgrounds/$theme_name"
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Print the current working directory of the active terminal window
|
||||||
|
# blob:hidden=true
|
||||||
|
|
||||||
|
terminal_pid=$(hyprctl activewindow | awk '/pid:/ {print $2}')
|
||||||
|
kitty_socket="$XDG_RUNTIME_DIR/blob-kitty-$terminal_pid"
|
||||||
|
cwd=""
|
||||||
|
|
||||||
|
if [[ -S $kitty_socket ]]; then
|
||||||
|
cwd=$(kitten @ --to "unix:$kitty_socket" ls --match "state:focused" 2>/dev/null |
|
||||||
|
jq -r '.[].tabs[].windows[].cwd // empty')
|
||||||
|
else
|
||||||
|
shell_pid=$(pgrep -P "$terminal_pid" | tail -n1)
|
||||||
|
|
||||||
|
if [[ -n $shell_pid ]]; then
|
||||||
|
cwd=$(readlink -f "/proc/$shell_pid/cwd" 2>/dev/null)
|
||||||
|
shell=$(readlink -f "/proc/$shell_pid/exe" 2>/dev/null)
|
||||||
|
grep -Fqsx "$shell" /etc/shells || cwd=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d $cwd ]]; then
|
||||||
|
echo "$cwd"
|
||||||
|
else
|
||||||
|
echo "$HOME"
|
||||||
|
fi
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Check whether any required commands are missing
|
||||||
|
|
||||||
|
for cmd in "$@"; do
|
||||||
|
if ! command -v "$cmd" &>/dev/null; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
exit 1
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Check whether all required commands are available
|
||||||
|
|
||||||
|
for cmd in "$@"; do
|
||||||
|
command -v "$cmd" &>/dev/null || exit 1
|
||||||
|
done
|
||||||
|
|
||||||
|
exit 0
|
||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Set the default browser for Blob and XDG handlers
|
||||||
|
# blob:args=[chromium|chrome|brave|brave-origin|edge|firefox|zen]
|
||||||
|
# blob:examples=blob default browser firefox | blob default browser brave
|
||||||
|
|
||||||
|
if (($# == 0)); then
|
||||||
|
case "$(env -u BROWSER xdg-settings get default-web-browser)" in
|
||||||
|
chromium.desktop) echo "chromium" ;;
|
||||||
|
google-chrome.desktop) echo "chrome" ;;
|
||||||
|
brave-browser.desktop) echo "brave" ;;
|
||||||
|
brave-origin.desktop) echo "brave-origin" ;;
|
||||||
|
microsoft-edge.desktop) echo "edge" ;;
|
||||||
|
firefox.desktop) echo "firefox" ;;
|
||||||
|
zen.desktop) echo "zen" ;;
|
||||||
|
*) env -u BROWSER xdg-settings get default-web-browser ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
chromium) desktop_id="chromium.desktop"; name="Chromium"; glyph= ;;
|
||||||
|
chrome) desktop_id="google-chrome.desktop"; name="Chrome"; glyph= ;;
|
||||||
|
brave) desktop_id="brave-browser.desktop"; name="Brave"; glyph= ;;
|
||||||
|
brave-origin) desktop_id="brave-origin.desktop"; name="Brave Origin"; glyph= ;;
|
||||||
|
edge) desktop_id="microsoft-edge.desktop"; name="Edge"; glyph= ;;
|
||||||
|
firefox) desktop_id="firefox.desktop"; name="Firefox"; glyph= ;;
|
||||||
|
zen) desktop_id="zen.desktop"; name="Zen"; glyph= ;;
|
||||||
|
*)
|
||||||
|
echo "Usage: blob-default-browser <chromium|chrome|brave|brave-origin|edge|firefox|zen>"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
env -u BROWSER xdg-settings set default-web-browser "$desktop_id" || exit 1
|
||||||
|
|
||||||
|
blob-notify-send -g $glyph "$name is now the default browser"
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Set the default editor used by blob-launch-editor
|
||||||
|
# blob:args=[code|cursor|zed|sublime_text|helix|vim|emacs|nvim]
|
||||||
|
# blob:examples=blob default editor | blob default editor code | blob default editor helix
|
||||||
|
|
||||||
|
editor_file="$HOME/.local/state/blob/defaults/editor"
|
||||||
|
|
||||||
|
if (($# == 0)); then
|
||||||
|
if [[ -f $editor_file ]]; then
|
||||||
|
read -r editor <"$editor_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n $editor ]] && echo "$editor" || echo "nvim"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
code) editor="code"; name="VSCode"; glyph= ;;
|
||||||
|
cursor) editor="cursor"; name="Cursor"; glyph= ;;
|
||||||
|
zed | zeditor) editor="zeditor"; name="Zed"; glyph= ;;
|
||||||
|
sublime_text) editor="sublime_text"; name="Sublime Text"; glyph= ;;
|
||||||
|
helix) editor="helix"; name="Helix"; glyph= ;;
|
||||||
|
vim) editor="vim"; name="Vim"; glyph= ;;
|
||||||
|
emacs) editor="emacs"; name="Emacs"; glyph= ;;
|
||||||
|
nvim) editor="nvim"; name="Neovim"; glyph= ;;
|
||||||
|
*)
|
||||||
|
echo "Usage: blob-default-editor <code|cursor|zed|sublime_text|helix|vim|emacs|nvim>"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$editor_file")"
|
||||||
|
printf '%s\n' "$editor" >"$editor_file"
|
||||||
|
|
||||||
|
blob-notify-send -g $glyph "$name is now the default editor"
|
||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Set the default terminal used by xdg-terminal-exec
|
||||||
|
# blob:args=[alacritty|foot|ghostty|kitty]
|
||||||
|
# blob:examples=blob default terminal ghostty | blob default terminal kitty
|
||||||
|
|
||||||
|
if (($# == 0)); then
|
||||||
|
desktop_id=$(xdg-terminal-exec --print-id 2>/dev/null || true)
|
||||||
|
desktop_id=${desktop_id%%:*}
|
||||||
|
case "$desktop_id" in
|
||||||
|
Alacritty.desktop) echo "alacritty" ;;
|
||||||
|
foot.desktop) echo "foot" ;;
|
||||||
|
com.mitchellh.ghostty.desktop) echo "ghostty" ;;
|
||||||
|
kitty.desktop) echo "kitty" ;;
|
||||||
|
*) echo "$desktop_id" ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
alacritty) desktop_id="Alacritty.desktop"; name="Alacritty"; glyph= ;;
|
||||||
|
foot) desktop_id="foot.desktop"; name="Foot"; glyph= ;;
|
||||||
|
ghostty) desktop_id="com.mitchellh.ghostty.desktop"; name="Ghostty"; glyph= ;;
|
||||||
|
kitty) desktop_id="kitty.desktop"; name="Kitty"; glyph= ;;
|
||||||
|
*)
|
||||||
|
echo "Usage: blob-default-terminal <alacritty|foot|ghostty|kitty>"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
cat >~/.config/xdg-terminals.list <<EOF
|
||||||
|
# Terminal emulator preference order for xdg-terminal-exec
|
||||||
|
# The first found and valid terminal will be used
|
||||||
|
$desktop_id
|
||||||
|
EOF
|
||||||
|
|
||||||
|
blob-notify-send -g $glyph "$name is now the default terminal"
|
||||||
Executable
+124
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
# blob:summary=Pick files with the desktop file chooser
|
||||||
|
# blob:args=[--title <title>] [--multiple] [--directory] [--extensions "<ext ext...>"]
|
||||||
|
# blob:examples=blob file select --title "Send with Tailscale" --multiple | blob file select --title "Pick image" --extensions "png svg" | blob file select --title "Share folder" --directory
|
||||||
|
|
||||||
|
# Python rather than bash, alone among the commands here, because the portal
|
||||||
|
# answers a request with a Response signal addressed to the connection that
|
||||||
|
# asked, and D-Bus delivers a directed signal only to that connection. Every
|
||||||
|
# shell-callable client — gdbus call, busctl call, dbus-send — opens its own
|
||||||
|
# connection and exits before the answer arrives, and gdbus monitor registers
|
||||||
|
# with AddMatch rather than BecomeMonitor, so it never sees one either. Holding
|
||||||
|
# a single connection across both the call and the wait is the whole job, and
|
||||||
|
# bash has no way to hold one.
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gio", "2.0")
|
||||||
|
from gi.repository import Gio, GLib
|
||||||
|
|
||||||
|
# A dialog nobody ever answers would otherwise keep this process, and whatever
|
||||||
|
# waits on its output, alive forever.
|
||||||
|
ANSWER_TIMEOUT_SEC = 600
|
||||||
|
|
||||||
|
# Callers act on these: nothing picked is a decision, a chooser that never ran
|
||||||
|
# is a fault, and the two want different handling.
|
||||||
|
EXIT_NOTHING_PICKED = 1
|
||||||
|
EXIT_CHOOSER_FAILED = 2
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(add_help=False)
|
||||||
|
parser.add_argument("--title", default="Select file")
|
||||||
|
parser.add_argument("--multiple", action="store_true")
|
||||||
|
parser.add_argument("--directory", action="store_true")
|
||||||
|
parser.add_argument("--extensions", default="")
|
||||||
|
args, unknown = parser.parse_known_args()
|
||||||
|
|
||||||
|
if unknown:
|
||||||
|
print("blob-file-select: unknown option %s" % unknown[0], file=sys.stderr)
|
||||||
|
return EXIT_CHOOSER_FAILED
|
||||||
|
|
||||||
|
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||||
|
loop = GLib.MainLoop()
|
||||||
|
uris = []
|
||||||
|
|
||||||
|
def on_response(connection, sender, path, interface, signal, params):
|
||||||
|
code, results = params.unpack()
|
||||||
|
if code == 0:
|
||||||
|
uris.extend(results.get("uris", []))
|
||||||
|
loop.quit()
|
||||||
|
|
||||||
|
def subscribe(path):
|
||||||
|
bus.signal_subscribe(
|
||||||
|
"org.freedesktop.portal.Desktop",
|
||||||
|
"org.freedesktop.portal.Request",
|
||||||
|
"Response",
|
||||||
|
path,
|
||||||
|
None,
|
||||||
|
Gio.DBusSignalFlags.NONE,
|
||||||
|
on_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The request path is derived from our bus name and the token we pass, so it
|
||||||
|
# can be subscribed to up front. Asking first would race a dialog that gets
|
||||||
|
# answered immediately.
|
||||||
|
token = "blob%d" % os.getpid()
|
||||||
|
sender = bus.get_unique_name()[1:].replace(".", "_")
|
||||||
|
predicted = "/org/freedesktop/portal/desktop/request/%s/%s" % (sender, token)
|
||||||
|
subscribe(predicted)
|
||||||
|
|
||||||
|
options = {
|
||||||
|
"handle_token": GLib.Variant("s", token),
|
||||||
|
"multiple": GLib.Variant("b", args.multiple),
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.directory:
|
||||||
|
options["directory"] = GLib.Variant("b", True)
|
||||||
|
|
||||||
|
# Filters name file formats, which a directory chooser has no use for.
|
||||||
|
if args.extensions and not args.directory:
|
||||||
|
# Glob matching in the chooser is case-sensitive, so cover both cases.
|
||||||
|
exts = [ext.lstrip(".").lower() for ext in args.extensions.split()]
|
||||||
|
patterns = [(0, "*." + ext) for ext in exts] + [(0, "*." + ext.upper()) for ext in exts]
|
||||||
|
label = " ".join("*." + ext for ext in exts)
|
||||||
|
filters = GLib.Variant("a(sa(us))", [(label, patterns)])
|
||||||
|
options["filters"] = filters
|
||||||
|
options["current_filter"] = GLib.Variant("(sa(us))", (label, patterns))
|
||||||
|
|
||||||
|
handle = bus.call_sync(
|
||||||
|
"org.freedesktop.portal.Desktop",
|
||||||
|
"/org/freedesktop/portal/desktop",
|
||||||
|
"org.freedesktop.portal.FileChooser",
|
||||||
|
"OpenFile",
|
||||||
|
GLib.Variant("(ssa{sv})", ("", args.title, options)),
|
||||||
|
None,
|
||||||
|
Gio.DBusCallFlags.NONE,
|
||||||
|
-1,
|
||||||
|
None,
|
||||||
|
).unpack()[0]
|
||||||
|
|
||||||
|
# Portals predating the token convention answer on a path of their choosing.
|
||||||
|
if handle != predicted:
|
||||||
|
subscribe(handle)
|
||||||
|
|
||||||
|
GLib.timeout_add_seconds(ANSWER_TIMEOUT_SEC, loop.quit)
|
||||||
|
loop.run()
|
||||||
|
|
||||||
|
for uri in uris:
|
||||||
|
print(GLib.filename_from_uri(uri)[0])
|
||||||
|
|
||||||
|
return 0 if uris else EXIT_NOTHING_PICKED
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
sys.exit(main())
|
||||||
|
except GLib.Error as error:
|
||||||
|
print("blob-file-select: %s" % error.message, file=sys.stderr)
|
||||||
|
sys.exit(EXIT_CHOOSER_FAILED)
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Run a named hook from ~/.config/blob/hooks/<name> and ~/.config/blob/hooks/<name>.d/.
|
||||||
|
# blob:args=[name] [args...]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if (( $# < 1 )); then
|
||||||
|
echo "Usage: blob-hook [name] [args...]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
HOOK=$1
|
||||||
|
HOOK_PATH="$HOME/.config/blob/hooks/$1"
|
||||||
|
HOOK_DIR="$HOOK_PATH.d"
|
||||||
|
shift
|
||||||
|
|
||||||
|
if [[ -f $HOOK_PATH ]]; then
|
||||||
|
bash "$HOOK_PATH" "$@" || echo "Hook failed: $HOOK_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d $HOOK_DIR ]]; then
|
||||||
|
for hook in "$HOOK_DIR"/*; do
|
||||||
|
[[ -f $hook ]] || continue
|
||||||
|
[[ $hook == *.sample ]] && continue
|
||||||
|
bash "$hook" "$@" || echo "Hook failed: $hook"
|
||||||
|
done
|
||||||
|
fi
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Returns true when clamshell mode is active
|
||||||
|
# blob:hidden=true
|
||||||
|
|
||||||
|
# Clamshell = lid closed while driving one or more external monitors.
|
||||||
|
blob-hw-laptop-closed && blob-hw-external
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Print the most likely display backlight device.
|
||||||
|
# blob:examples=blob-hw-display
|
||||||
|
|
||||||
|
backlight_path="${BLOB_BACKLIGHT_PATH:-/sys/class/backlight}"
|
||||||
|
|
||||||
|
# Start with the first possible output, then refine to the most likely given an order heuristic.
|
||||||
|
# Glob the candidates in the loop list: [[ ]] does not do pathname expansion.
|
||||||
|
# The Touch Bar on T2 Macs registers a backlight that never drives the display panel.
|
||||||
|
device="$(ls -1 "$backlight_path" 2>/dev/null | grep -vx appletb_backlight | head -n1)"
|
||||||
|
# gmux comes first: apple-gmux only registers when the kernel has already picked it, and on
|
||||||
|
# dual-GPU Macs the GPU's own PWM stops driving the panel once that GPU suspends.
|
||||||
|
for candidate in "$backlight_path"/gmux_backlight "$backlight_path"/amdgpu_bl* "$backlight_path"/intel_backlight "$backlight_path"/acpi_video*; do
|
||||||
|
if [[ -e $candidate ]]; then
|
||||||
|
device="${candidate##*/}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n $device ]]; then
|
||||||
|
printf '%s\n' "$device"
|
||||||
|
else
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Returns true when an external monitor is physically connected.
|
||||||
|
|
||||||
|
drm_path="${BLOB_DRM_PATH:-/sys/class/drm}"
|
||||||
|
|
||||||
|
for status in "$drm_path"/card*-*/status; do
|
||||||
|
[[ -e $status ]] || continue
|
||||||
|
[[ $status =~ -(eDP|LVDS|DSI)-[^/]+/status$ ]] && continue
|
||||||
|
[[ $(< $status) == "connected" ]] && exit 0
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Returns true when the laptop lid is closed
|
||||||
|
# blob:hidden=true
|
||||||
|
|
||||||
|
for state in /proc/acpi/button/lid/*/state; do
|
||||||
|
[[ -r $state ]] || continue
|
||||||
|
[[ $(< "$state") == *"closed"* ]] && exit 0
|
||||||
|
done
|
||||||
|
|
||||||
|
exit 1
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Match against the computer's DMI product name or product family (case-insensitive).
|
||||||
|
# blob:args=<pattern>
|
||||||
|
|
||||||
|
grep -qi "$1" /sys/class/dmi/id/product_name 2>/dev/null ||
|
||||||
|
grep -qi "$1" /sys/class/dmi/id/product_family 2>/dev/null
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Focus a Hyprland window by application identity
|
||||||
|
# blob:args=<app-name>
|
||||||
|
# blob:examples=blob hyprland focus app Slack
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: blob-hypr-focus <app-name>" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
app=${1:-}
|
||||||
|
[[ -n $app ]] || usage
|
||||||
|
|
||||||
|
# Agent terminals notify as kitty/foot/etc. while their shared window class is
|
||||||
|
# org.blob.agent, leaving the terminal name only in initialTitle. So match
|
||||||
|
# by class first, then fall back to the launch-time title of agent windows.
|
||||||
|
address=$(
|
||||||
|
hyprctl clients -j 2>/dev/null |
|
||||||
|
jq -r --arg pattern "$app" \
|
||||||
|
'def matches($value): ($value // "") | test($pattern; "i");
|
||||||
|
first(
|
||||||
|
(.[] | select(matches(.class))),
|
||||||
|
(.[] | select(.initialClass == "org.blob.agent" and matches(.initialTitle)))
|
||||||
|
).address // empty'
|
||||||
|
)
|
||||||
|
|
||||||
|
[[ -n $address ]] || exit 1
|
||||||
|
|
||||||
|
hyprctl dispatch "hl.dsp.focus({ window = \"address:$address\" })" >/dev/null 2>&1 || \
|
||||||
|
hyprctl dispatch focuswindow "address:$address" >/dev/null
|
||||||
Executable
+252
@@ -0,0 +1,252 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Apply clamshell display state to Hyprland monitors
|
||||||
|
# blob:hidden=true
|
||||||
|
|
||||||
|
TOGGLES_DIR="$HOME/.local/state/blob/toggles/hypr"
|
||||||
|
CLAMSHELL_FLAG="$TOGGLES_DIR/internal-monitor-clamshell.lua"
|
||||||
|
MANUAL_DISABLE_FLAG="$TOGGLES_DIR/internal-monitor-disable.lua"
|
||||||
|
SCALE_STATE="$TOGGLES_DIR/internal-monitor-scale"
|
||||||
|
MONITOR_LUA="$HOME/.config/hypr/monitors.lua"
|
||||||
|
|
||||||
|
INTERNAL=$(blob-hypr-monitor-laptop)
|
||||||
|
|
||||||
|
# INTERNAL is written into generated Lua and hyprctl eval/dispatch below, so a
|
||||||
|
# name that is not a plain connector string could execute on the next reload.
|
||||||
|
# Names come from hyprctl; a user-created headless output can carry anything.
|
||||||
|
if [[ -n $INTERNAL && ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||||
|
echo "Refusing unsafe internal monitor name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
valid_scale() {
|
||||||
|
[[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
scales_match() {
|
||||||
|
local left="$1"
|
||||||
|
local right="$2"
|
||||||
|
|
||||||
|
valid_scale "$left" && valid_scale "$right" || return 1
|
||||||
|
awk -v left="$left" -v right="$right" 'BEGIN {
|
||||||
|
diff = left - right
|
||||||
|
if (diff < 0) diff = -diff
|
||||||
|
exit(diff < 0.001 ? 0 : 1)
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
|
||||||
|
lua_identifier() {
|
||||||
|
[[ $1 =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Value assigned by `local <name> = ...`, without its quotes or trailing comment.
|
||||||
|
# Only a lone scalar counts: an expression like `1080 / 720` must stay unresolved
|
||||||
|
# so the caller falls back rather than applying the first half of the sum.
|
||||||
|
lua_local_value() {
|
||||||
|
local name="$1" value
|
||||||
|
lua_identifier "$name" && [[ -f $MONITOR_LUA ]] || return 0
|
||||||
|
|
||||||
|
value=$(sed -nE 's/^[[:space:]]*local[[:space:]]+'"$name"'[[:space:]]*=[[:space:]]*("[^"]*"|[^"[:space:]]+)[[:space:]]*(--.*)?$/\1/p' "$MONITOR_LUA" | head -1)
|
||||||
|
[[ $value == \"*\" ]] && value="${value:1:-1}"
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A quoted capture is a Lua string and stands for itself; a bare word may instead
|
||||||
|
# name a local the rule refers to, as the shipped scale = blob_monitor_scale
|
||||||
|
# does. A bare word naming no local is left alone to fail validation.
|
||||||
|
lua_scalar() {
|
||||||
|
local value="$1" resolved
|
||||||
|
|
||||||
|
if [[ $value == \"*\" ]]; then
|
||||||
|
printf '%s\n' "${value:1:-1}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
resolved=$(lua_local_value "$value")
|
||||||
|
printf '%s\n' "${resolved:-$value}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The config with its comments cut away, so commented-out text can pose neither
|
||||||
|
# as a rule nor as one of its keys.
|
||||||
|
monitor_rules() {
|
||||||
|
[[ -f $MONITOR_LUA ]] || return 0
|
||||||
|
|
||||||
|
sed -E -e 's/--\[\[[^]]*\]\]//g' -e 's/--.*$//' "$MONITOR_LUA"
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor_rule_regex() {
|
||||||
|
printf '^[[:space:]]*hl\\.monitor\\(\\{.*output[[:space:]]*=[[:space:]]*"%s"' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A key is preceded by a table separator, so a longer key cannot stand in for it,
|
||||||
|
# and its value is the whole of what sits between the `=` and the next separator.
|
||||||
|
# Anything else is an expression this cannot evaluate.
|
||||||
|
configured_monitor_value() {
|
||||||
|
local output="$1" key="$2" value
|
||||||
|
|
||||||
|
value=$(monitor_rules | sed -nE '/'"$(monitor_rule_regex "$output")"'/s/.*[{,;[:space:]]'"$key"'[[:space:]]*=[[:space:]]*("[^"]*"|[^,;}[:space:]]+)[[:space:]]*([,;}].*)?$/\1/p' | head -1)
|
||||||
|
lua_scalar "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
configured_internal_monitor_value() {
|
||||||
|
[[ -n $INTERNAL ]] || return 0
|
||||||
|
|
||||||
|
configured_monitor_value "$INTERNAL" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
configured_monitor_scale() {
|
||||||
|
local scale
|
||||||
|
scale=$(configured_internal_monitor_value scale)
|
||||||
|
# An internal rule that names a scale settles it, even when the name resolves
|
||||||
|
# to nothing usable. Only a rule that names none at all defers to the catch-all,
|
||||||
|
# which is the scale Blob has always applied for that config.
|
||||||
|
[[ -n $scale ]] || scale=$(configured_monitor_value "" scale)
|
||||||
|
# No rule carries a scale at all: fall back to Blob's own knob.
|
||||||
|
[[ -n $scale ]] || scale=$(lua_local_value blob_monitor_scale)
|
||||||
|
|
||||||
|
printf '%s\n' "$scale"
|
||||||
|
}
|
||||||
|
|
||||||
|
current_internal_scale() {
|
||||||
|
[[ -n $INTERNAL ]] || return 0
|
||||||
|
hyprctl monitors all -j | jq -r --arg internal "$INTERNAL" '.[] | select(.name == $internal and .disabled != true) | .scale' | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
store_internal_scale() {
|
||||||
|
local scale="$1"
|
||||||
|
valid_scale "$scale" || return 0
|
||||||
|
|
||||||
|
mkdir -p "$TOGGLES_DIR"
|
||||||
|
printf '%s\n' "$scale" >"$SCALE_STATE"
|
||||||
|
}
|
||||||
|
|
||||||
|
remember_internal_scale() {
|
||||||
|
local scale
|
||||||
|
scale=$(current_internal_scale)
|
||||||
|
store_internal_scale "$scale"
|
||||||
|
}
|
||||||
|
|
||||||
|
# $1 is the configured scale when the caller has already read it, so one sync
|
||||||
|
# does not parse the config twice.
|
||||||
|
read_monitor_scale() {
|
||||||
|
local scale
|
||||||
|
|
||||||
|
if (( $# )); then
|
||||||
|
scale="$1"
|
||||||
|
else
|
||||||
|
scale=$(configured_monitor_scale)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if valid_scale "$scale"; then
|
||||||
|
echo "$scale"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f $SCALE_STATE ]]; then
|
||||||
|
scale=$(<"$SCALE_STATE")
|
||||||
|
if valid_scale "$scale"; then
|
||||||
|
echo "$scale"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo 2
|
||||||
|
}
|
||||||
|
|
||||||
|
read_monitor_position() {
|
||||||
|
local position
|
||||||
|
position=$(configured_internal_monitor_value position)
|
||||||
|
if [[ $position =~ ^[-[:alnum:]_.+]+$ ]]; then
|
||||||
|
echo "$position"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo auto
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_internal_output() {
|
||||||
|
[[ -n $INTERNAL ]] || return 0
|
||||||
|
local scale="${1:-}"
|
||||||
|
local position
|
||||||
|
[[ -n $scale ]] || scale=$(read_monitor_scale)
|
||||||
|
position=$(read_monitor_position)
|
||||||
|
hyprctl eval "hl.monitor({ output = \"$INTERNAL\", mode = \"preferred\", position = \"$position\", scale = $scale })" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_internal_scale() {
|
||||||
|
[[ -n $INTERNAL ]] || return 0
|
||||||
|
local configured_scale
|
||||||
|
local desired_scale
|
||||||
|
local active_scale
|
||||||
|
|
||||||
|
configured_scale=$(configured_monitor_scale)
|
||||||
|
active_scale=$(current_internal_scale)
|
||||||
|
|
||||||
|
# A config without a usable number -- the default "auto", or an expression
|
||||||
|
# only Hyprland's Lua can evaluate -- delegates the scale to the compositor,
|
||||||
|
# so whatever it resolved for the enabled panel IS the configured scale.
|
||||||
|
# There is no number to correct it toward: substituting one makes the scale
|
||||||
|
# flap between that number and the compositor's own value on every idle-wake.
|
||||||
|
# Only a panel that is off altogether still gets a hand below, from the
|
||||||
|
# remembered scale.
|
||||||
|
if ! valid_scale "$configured_scale" && valid_scale "$active_scale"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
desired_scale=$(read_monitor_scale "$configured_scale")
|
||||||
|
scales_match "$active_scale" "$desired_scale" && return 0
|
||||||
|
|
||||||
|
enable_internal_output "$desired_scale"
|
||||||
|
}
|
||||||
|
|
||||||
|
dpms_internal() {
|
||||||
|
local action="$1"
|
||||||
|
|
||||||
|
[[ -n $INTERNAL ]] || return 0
|
||||||
|
hyprctl dispatch "hl.dsp.dpms({ action = \"$action\", monitor = \"$INTERNAL\" })" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_internal() {
|
||||||
|
local changed=0
|
||||||
|
|
||||||
|
if [[ -f $CLAMSHELL_FLAG ]]; then
|
||||||
|
rm -f "$CLAMSHELL_FLAG"
|
||||||
|
changed=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( changed )); then
|
||||||
|
hyprctl reload >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -f $MANUAL_DISABLE_FLAG ]] && blob-hypr-monitor-external && return 0
|
||||||
|
sync_internal_scale
|
||||||
|
|
||||||
|
if (( changed )); then
|
||||||
|
dpms_internal enable
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
disable_internal() {
|
||||||
|
[[ -n $INTERNAL ]] || exit 0
|
||||||
|
[[ -f $MANUAL_DISABLE_FLAG ]] && return 0
|
||||||
|
|
||||||
|
mkdir -p "$TOGGLES_DIR"
|
||||||
|
remember_internal_scale
|
||||||
|
|
||||||
|
local config
|
||||||
|
config=$(printf 'hl.monitor({ output = "%s", disabled = true })' "$INTERNAL")
|
||||||
|
|
||||||
|
if [[ ! -f $CLAMSHELL_FLAG ]] || [[ $(< "$CLAMSHELL_FLAG") != $config ]]; then
|
||||||
|
printf '%s\n' "$config" >"$CLAMSHELL_FLAG"
|
||||||
|
hyprctl reload >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
blob-hypr-monitor-internal recover >/dev/null 2>&1 || true
|
||||||
|
blob-hypr-monitor-mirror recover >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
if blob-hw-clamshell && blob-hypr-monitor-external; then
|
||||||
|
disable_internal
|
||||||
|
else
|
||||||
|
enable_internal
|
||||||
|
fi
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Returns true when Hyprland has an active external monitor
|
||||||
|
# blob:hidden=true
|
||||||
|
|
||||||
|
hyprctl monitors all -j | jq -e '.[] | select(.name | test("^(eDP|LVDS|DSI)-") | not) | select(.disabled == false)' >/dev/null 2>&1
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Print the name of the currently focused Hyprland monitor.
|
||||||
|
|
||||||
|
hyprctl monitors -j | jq -r '.[] | select(.focused == true).name'
|
||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Enable, disable, toggle, or recover the internal laptop display
|
||||||
|
# blob:args=<on|off|toggle|recover>
|
||||||
|
|
||||||
|
TOGGLE="internal-monitor-disable"
|
||||||
|
TOGGLE_FLAG="$HOME/.local/state/blob/toggles/hypr/$TOGGLE.lua"
|
||||||
|
MIRROR_TOGGLE="internal-monitor-mirror"
|
||||||
|
|
||||||
|
INTERNAL=$(blob-hypr-monitor-laptop)
|
||||||
|
|
||||||
|
wake() {
|
||||||
|
hyprctl dispatch 'hl.dsp.dpms({ action = "enable" })' >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
on() {
|
||||||
|
if blob-hypr-toggle-enabled $TOGGLE; then
|
||||||
|
blob-hypr-toggle $TOGGLE off
|
||||||
|
blob-notify-send -g "Laptop display enabled"
|
||||||
|
fi
|
||||||
|
|
||||||
|
wake
|
||||||
|
}
|
||||||
|
|
||||||
|
off() {
|
||||||
|
if [[ -z $INTERNAL ]]; then
|
||||||
|
blob-notify-send -g "No laptop display found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The name is written into generated Lua below, so only a plain connector
|
||||||
|
# name may pass; anything else could execute on the next reload.
|
||||||
|
if [[ ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||||
|
blob-notify-send -g "Refusing unsafe monitor name"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! blob-hypr-monitor-external; then
|
||||||
|
blob-notify-send -g "Can't disable the only active display"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if blob-hypr-toggle-disabled $TOGGLE && blob-hypr-toggle-disabled $MIRROR_TOGGLE; then
|
||||||
|
printf 'hl.monitor({ output = "%s", disabled = true })\n' "$INTERNAL" >"$TOGGLE_FLAG"
|
||||||
|
blob-notify-send -g "Laptop display disabled"
|
||||||
|
hyprctl reload
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
recover() {
|
||||||
|
# Runs from the clamshell watcher every few seconds, so it must be a no-op
|
||||||
|
# unless it actually re-enables a display: an unconditional wake here undoes
|
||||||
|
# lock-screen blanking and races the resume modeset into a visible flash.
|
||||||
|
blob-hypr-monitor-external && return 0
|
||||||
|
blob-hypr-toggle-enabled $TOGGLE || return 0
|
||||||
|
|
||||||
|
blob-hypr-toggle $TOGGLE off
|
||||||
|
wake
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
if blob-hypr-toggle-enabled $TOGGLE; then
|
||||||
|
on
|
||||||
|
else
|
||||||
|
off
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
on) on ;;
|
||||||
|
off) off ;;
|
||||||
|
toggle) toggle ;;
|
||||||
|
recover) recover ;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $(basename "$0") {on|off|toggle|recover}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Print the name of the built-in laptop display, including disabled outputs.
|
||||||
|
|
||||||
|
hyprctl monitors all -j | jq -r '.[] | select(.name | test("^(eDP|LVDS|DSI)-")).name' | head -n 1
|
||||||
Executable
+73
@@ -0,0 +1,73 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Enable, disable, toggle, or recover mirroring the internal display to an external monitor
|
||||||
|
# blob:args=<on|off|toggle|recover>
|
||||||
|
|
||||||
|
TOGGLE="internal-monitor-mirror"
|
||||||
|
TOGGLE_FLAG="$HOME/.local/state/blob/toggles/hypr/$TOGGLE.lua"
|
||||||
|
DISABLE_TOGGLE="internal-monitor-disable"
|
||||||
|
|
||||||
|
INTERNAL=$(blob-hypr-monitor-laptop)
|
||||||
|
# The first active external monitor
|
||||||
|
EXTERNAL=$(hyprctl monitors -j | jq -r '.[] | select(.name | test("^(eDP|LVDS|DSI)-") | not).name' | head -n 1)
|
||||||
|
|
||||||
|
on() {
|
||||||
|
if [[ -z $EXTERNAL ]]; then
|
||||||
|
blob-notify-send -g "No external monitors found for mirror"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z $INTERNAL ]]; then
|
||||||
|
blob-notify-send -g "No laptop monitor found to mirror"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Both names are written into generated Lua below, so only plain connector
|
||||||
|
# names may pass; a user-created headless output can carry any name.
|
||||||
|
for output in "$INTERNAL" "$EXTERNAL"; do
|
||||||
|
if [[ ! $output =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||||
|
blob-notify-send -g "Refusing unsafe monitor name"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
blob-hypr-toggle $DISABLE_TOGGLE off
|
||||||
|
|
||||||
|
if blob-hypr-toggle-disabled $TOGGLE; then
|
||||||
|
printf 'hl.monitor({ output = "%s", mode = "preferred", position = "auto", scale = 1, mirror = "%s" })\n' "$EXTERNAL" "$INTERNAL" >"$TOGGLE_FLAG"
|
||||||
|
blob-notify-send -g "Mirroring enabled ($EXTERNAL)"
|
||||||
|
hyprctl reload
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
off() {
|
||||||
|
if blob-hypr-toggle-enabled $TOGGLE; then
|
||||||
|
blob-hypr-toggle $TOGGLE off
|
||||||
|
blob-notify-send -g "Extended mode restored"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
if blob-hypr-toggle-enabled $TOGGLE; then
|
||||||
|
off
|
||||||
|
else
|
||||||
|
on
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
recover() {
|
||||||
|
if ! blob-hypr-monitor-external && blob-hypr-toggle-enabled $TOGGLE; then
|
||||||
|
blob-hypr-toggle $TOGGLE off
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
on) on ;;
|
||||||
|
off) off ;;
|
||||||
|
toggle) toggle ;;
|
||||||
|
recover) recover ;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $(basename "$0") {on|off|toggle|recover}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+203
@@ -0,0 +1,203 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Show, set, or adjust focused Hyprland monitor scaling
|
||||||
|
# blob:args=[up|down|SCALE]
|
||||||
|
# blob:examples=blob hyprland monitor scaling | blob hyprland monitor scaling 1.6 | blob hyprland monitor scaling up | blob hyprland monitor scaling down
|
||||||
|
|
||||||
|
SCALES=(1 1.25 1.6 2 3 4)
|
||||||
|
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/blob"
|
||||||
|
SCALE_LOG="$STATE_DIR/monitor-scaling.log"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: blob-hypr-monitor-scaling [up|down|SCALE]"
|
||||||
|
}
|
||||||
|
|
||||||
|
focused_monitor_scale() {
|
||||||
|
hyprctl monitors -j | jq -er '.[] | select(.focused == true) | .scale'
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdline_for_pid() {
|
||||||
|
local pid="$1"
|
||||||
|
|
||||||
|
[[ -r /proc/$pid/cmdline ]] || return 0
|
||||||
|
tr '\0\t\n' ' ' <"/proc/$pid/cmdline" | sed -E 's/[[:space:]]+/ /g; s/[[:space:]]+$//'
|
||||||
|
}
|
||||||
|
|
||||||
|
audit_scale_change() {
|
||||||
|
local requested="$1"
|
||||||
|
local active_monitor="$2"
|
||||||
|
local current_scale="$3"
|
||||||
|
local new_scale="$4"
|
||||||
|
local parent_pid="$PPID"
|
||||||
|
local grandparent_pid
|
||||||
|
local parent_cmd
|
||||||
|
local grandparent_cmd
|
||||||
|
|
||||||
|
mkdir -p "$STATE_DIR" || return 0
|
||||||
|
|
||||||
|
grandparent_pid=$(ps -o ppid= -p "$parent_pid" 2>/dev/null | tr -d ' ')
|
||||||
|
parent_cmd=$(cmdline_for_pid "$parent_pid")
|
||||||
|
grandparent_cmd=$(cmdline_for_pid "$grandparent_pid")
|
||||||
|
|
||||||
|
printf 'at=%s\trequested=%s\tcurrent=%s\tnew=%s\tmonitor=%s\tpid=%s\tppid=%s\tparent=%s\tgppid=%s\tgrandparent=%s\n' \
|
||||||
|
"$(date --iso-8601=seconds)" \
|
||||||
|
"$requested" \
|
||||||
|
"$current_scale" \
|
||||||
|
"$new_scale" \
|
||||||
|
"$active_monitor" \
|
||||||
|
"$$" \
|
||||||
|
"$parent_pid" \
|
||||||
|
"$parent_cmd" \
|
||||||
|
"$grandparent_pid" \
|
||||||
|
"$grandparent_cmd" >>"$SCALE_LOG"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hyprland only accepts scales where the mode divides into whole logical
|
||||||
|
# pixels (in 1/120 steps), so clean scales are divisors of gcd(w*120, h*120).
|
||||||
|
# Round the requested scale up to the nearest clean value.
|
||||||
|
clean_scale() {
|
||||||
|
awk -v scale="$1" -v width="$2" -v height="$3" '
|
||||||
|
function gcd(a, b, t) { while (b) { t = a % b; a = b; b = t } return a }
|
||||||
|
BEGIN {
|
||||||
|
g = gcd(width * 120, height * 120)
|
||||||
|
k = int(scale * 120 + 0.5)
|
||||||
|
if (k > g) k = g
|
||||||
|
while (g % k != 0) k++
|
||||||
|
printf "%g\n", k / 120
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_scale() {
|
||||||
|
awk 'NR == 1 { printf "%g\n", $0 }'
|
||||||
|
}
|
||||||
|
|
||||||
|
set_scale() {
|
||||||
|
local requested_scale="$1"
|
||||||
|
local requested="${2:-$requested_scale}"
|
||||||
|
local monitor_info="$(hyprctl monitors -j | jq -e -c '.[] | select(.focused == true)')"
|
||||||
|
local active_monitor="$(echo "$monitor_info" | jq -r '.name')"
|
||||||
|
local current_scale="$(echo "$monitor_info" | jq -r '.scale')"
|
||||||
|
local width="$(echo "$monitor_info" | jq -r '.width')"
|
||||||
|
local height="$(echo "$monitor_info" | jq -r '.height')"
|
||||||
|
local refresh_rate="$(echo "$monitor_info" | jq -r '.refreshRate')"
|
||||||
|
|
||||||
|
# active_monitor is written into the Lua string eval'd below, so only a plain
|
||||||
|
# connector name may pass; a hostile output name could execute otherwise.
|
||||||
|
if [[ ! $active_monitor =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||||
|
echo "Refusing unsafe monitor name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local new_scale="$(clean_scale "$requested_scale" "$width" "$height")"
|
||||||
|
# GTK only honors integer GDK_SCALE values, so persist the nearest whole
|
||||||
|
# factor even when the monitor scale itself is fractional.
|
||||||
|
local new_gdk_scale="$(awk -v scale="$new_scale" 'BEGIN { printf "%d", int(scale + 0.5) }')"
|
||||||
|
local monitor_lua="$HOME/.config/hypr/monitors.lua"
|
||||||
|
|
||||||
|
hyprctl eval "hl.monitor({ output = \"$active_monitor\", mode = \"${width}x${height}@${refresh_rate}\", position = \"auto\", scale = $new_scale })" >/dev/null
|
||||||
|
audit_scale_change "$requested" "$active_monitor" "$current_scale" "$new_scale"
|
||||||
|
|
||||||
|
# Persist to monitors.lua if the user still has Blob's generic catch-all
|
||||||
|
# defaults, so the scale survives reboots.
|
||||||
|
if [[ -f $monitor_lua ]] && grep -q '^local blob_monitor_scale = ' "$monitor_lua"; then
|
||||||
|
sed -i -E \
|
||||||
|
-e "s|^local blob_monitor_scale = .*|local blob_monitor_scale = ${new_scale}|" \
|
||||||
|
-e "s|^local blob_gdk_scale = .*|local blob_gdk_scale = ${new_gdk_scale}|" \
|
||||||
|
"$monitor_lua"
|
||||||
|
elif [[ -f $monitor_lua ]] && grep -Eq '^hl\.monitor\(\{ output = "", mode = "preferred", position = "auto", scale = ("auto"|[0-9.]+) \}\)' "$monitor_lua"; then
|
||||||
|
sed -i -E \
|
||||||
|
-e "s|^(hl\.monitor\(\{ output = \"\", mode = \"preferred\", position = \"auto\", scale = )([^ ]+)( \}\))|\\1${new_scale}\\3|" \
|
||||||
|
-e 's|^hl\.env\("GDK_SCALE", ".*"\)|hl.env("GDK_SCALE", "'"$new_gdk_scale"'")|' \
|
||||||
|
"$monitor_lua"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
scale_from_current() {
|
||||||
|
local direction="${1:-}"
|
||||||
|
local width="${2:-}"
|
||||||
|
local height="${3:-}"
|
||||||
|
|
||||||
|
awk -v direction="$direction" -v list="${SCALES[*]}" -v width="$width" -v height="$height" '
|
||||||
|
function gcd(a, b, t) { while (b) { t = a % b; a = b; b = t } return a }
|
||||||
|
function clean(scale, g, k) {
|
||||||
|
g = gcd(width * 120, height * 120)
|
||||||
|
k = int(scale * 120 + 0.5)
|
||||||
|
if (k > g) k = g
|
||||||
|
while (g % k != 0) k++
|
||||||
|
return k / 120
|
||||||
|
}
|
||||||
|
NR == 1 { scale = $0; found = 1 }
|
||||||
|
END {
|
||||||
|
if (!found) exit 1
|
||||||
|
|
||||||
|
preset_count = split(list, presets, " ")
|
||||||
|
for (i = 1; i <= preset_count; i++) {
|
||||||
|
effective = clean(presets[i])
|
||||||
|
key = sprintf("%.8f", effective)
|
||||||
|
distance = presets[i] - effective
|
||||||
|
if (distance < 0) distance = -distance
|
||||||
|
|
||||||
|
# Multiple presets can collapse to the same clean scale. Keep only the
|
||||||
|
# closest label so stepping always moves to a distinct effective value.
|
||||||
|
if (!(key in effective_index)) {
|
||||||
|
effective_index[key] = ++n
|
||||||
|
effective_scales[n] = effective
|
||||||
|
scales[n] = presets[i]
|
||||||
|
distances[n] = distance
|
||||||
|
} else {
|
||||||
|
idx = effective_index[key]
|
||||||
|
if (distance < distances[idx]) {
|
||||||
|
scales[idx] = presets[i]
|
||||||
|
distances[idx] = distance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Snap to the nearest effective scale first. Hyprland reports floating
|
||||||
|
# point values, so exact comparisons can otherwise get stuck.
|
||||||
|
best = 1; best_diff = 1e9
|
||||||
|
for (i = 1; i <= n; i++) {
|
||||||
|
diff = scale - effective_scales[i]; if (diff < 0) diff = -diff
|
||||||
|
if (diff < best_diff) { best_diff = diff; best = i }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction == "next") {
|
||||||
|
print scales[(best < n ? best + 1 : n)]
|
||||||
|
} else if (direction == "previous") {
|
||||||
|
print scales[(best > 1 ? best - 1 : 1)]
|
||||||
|
} else {
|
||||||
|
print scales[best]
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
"")
|
||||||
|
focused_monitor_scale | normalize_scale
|
||||||
|
;;
|
||||||
|
-h | --help)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
up)
|
||||||
|
monitor_info=$(hyprctl monitors -j | jq -e -c '.[] | select(.focused == true)')
|
||||||
|
set_scale "$(echo "$monitor_info" | jq -r '.scale' | scale_from_current next \
|
||||||
|
"$(echo "$monitor_info" | jq -r '.width')" "$(echo "$monitor_info" | jq -r '.height')")" "up"
|
||||||
|
;;
|
||||||
|
down)
|
||||||
|
monitor_info=$(hyprctl monitors -j | jq -e -c '.[] | select(.focused == true)')
|
||||||
|
set_scale "$(echo "$monitor_info" | jq -r '.scale' | scale_from_current previous \
|
||||||
|
"$(echo "$monitor_info" | jq -r '.width')" "$(echo "$monitor_info" | jq -r '.height')")" "down"
|
||||||
|
;;
|
||||||
|
1 | 1.25 | 1.6 | 2 | 3 | 4)
|
||||||
|
set_scale "$1" "$1"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]] &&
|
||||||
|
awk -v scale="$1" 'BEGIN { exit !(scale >= 1 && scale <= 4) }'; then
|
||||||
|
set_scale "$1" "$1"
|
||||||
|
else
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Reload hyprland configuration (used by the Blob theme switching).
|
||||||
|
|
||||||
|
hyprctl reload >/dev/null
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Returns true when the compositor holds a session lock
|
||||||
|
# blob:hidden=true
|
||||||
|
|
||||||
|
# Hyprland reports no lock state directly, but an active ext-session-lock is one
|
||||||
|
# of the reasons a monitor cannot go solitary: LOCK in solitaryBlockedBy. It
|
||||||
|
# stays set once the lock's client dies, which is the case worth detecting.
|
||||||
|
#
|
||||||
|
# Exits 0 locked, 1 unlocked, 2 undetermined. Hyprland stops at the first reason
|
||||||
|
# on a monitor with no workspace yet, before it ever reaches the lock, so a
|
||||||
|
# missing LOCK there means nothing was asked. Callers branching only on success
|
||||||
|
# treat 2 as unlocked.
|
||||||
|
monitors=$(hyprctl -j monitors 2>/dev/null) || exit 2
|
||||||
|
|
||||||
|
state=$(jq '
|
||||||
|
def blockers: .solitaryBlockedBy // [];
|
||||||
|
def readable: blockers | index("WORKSPACE") | not;
|
||||||
|
|
||||||
|
if any(.[]; blockers | index("LOCK")) then 0
|
||||||
|
elif any(.[]; readable) then 1
|
||||||
|
else 2
|
||||||
|
end
|
||||||
|
' <<<"$monitors" 2>/dev/null)
|
||||||
|
|
||||||
|
case $state in
|
||||||
|
0 | 1) exit "$state" ;;
|
||||||
|
*) exit 2 ;;
|
||||||
|
esac
|
||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Toggle permanent Hyprland flags by copying them into a directory that's sourced entirely.
|
||||||
|
# blob:args=<flag-name> [on|off|toggle]
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: blob-hypr-toggle <flag-name> [on|off|toggle]" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($# < 1)); then
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
FLAG_NAME="$1"
|
||||||
|
ACTION="${2:-toggle}"
|
||||||
|
FLAG_FILE="$HOME/.local/state/blob/toggles/hypr/$FLAG_NAME.lua"
|
||||||
|
FLAG_SOURCE="$BLOB_PATH/default/hypr/toggles/$FLAG_NAME.lua"
|
||||||
|
|
||||||
|
on() {
|
||||||
|
if [[ -f $FLAG_SOURCE ]]; then
|
||||||
|
mkdir -p "$(dirname "$FLAG_FILE")"
|
||||||
|
cp "$FLAG_SOURCE" "$FLAG_FILE"
|
||||||
|
else
|
||||||
|
echo "Flag not found: $FLAG_NAME" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
off() {
|
||||||
|
rm -f "$FLAG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
if [[ -f $FLAG_FILE ]]; then
|
||||||
|
off
|
||||||
|
echo "off"
|
||||||
|
else
|
||||||
|
on
|
||||||
|
echo "on"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case $ACTION in
|
||||||
|
on) on ;;
|
||||||
|
off) off ;;
|
||||||
|
toggle) toggle ;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
hyprctl reload >/dev/null
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Check if a Hyprland toggle is currently disabled (missing).
|
||||||
|
# blob:args=<flag-name>
|
||||||
|
|
||||||
|
[[ ! -f "$HOME/.local/state/blob/toggles/hypr/$1.lua" ]]
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Check if a Hyprland toggle is currently enabled.
|
||||||
|
# blob:args=<flag-name>
|
||||||
|
|
||||||
|
[[ -f "$HOME/.local/state/blob/toggles/hypr/$1.lua" ]]
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Close all open windows
|
||||||
|
|
||||||
|
hyprctl clients -j | \
|
||||||
|
jq -r ".[].address" | \
|
||||||
|
while read -r addr; do
|
||||||
|
hyprctl dispatch "hl.dsp.window.close({ window = \"address:$addr\" })" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
# Move to first workspace
|
||||||
|
hyprctl dispatch 'hl.dsp.focus({ workspace = "1" })' >/dev/null 2>&1 || hyprctl dispatch workspace 1
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Install a packaged app and launch it once it finishes
|
||||||
|
# blob:args=<display-name> <packages> <desktop-id>
|
||||||
|
# blob:examples=blob install and launch Cursor cursor-bin cursor
|
||||||
|
|
||||||
|
name="${1-}"
|
||||||
|
packages="${2-}"
|
||||||
|
desktop_id="${3-}"
|
||||||
|
|
||||||
|
if [[ -z $name || -z $packages || -z $desktop_id ]]; then
|
||||||
|
echo "Usage: blob-install-launch <display-name> <packages> <desktop-id>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf -v install_message '%q' "Installing ${name}..."
|
||||||
|
printf -v desktop_id_arg '%q' "$desktop_id"
|
||||||
|
|
||||||
|
# The list has to reach blob-pkg-add as several words, so each word is quoted
|
||||||
|
# rather than the whole string; -d '' reads past newlines and always ends at EOF.
|
||||||
|
read -r -d '' -a package_list <<<"$packages" || true
|
||||||
|
printf -v packages_arg '%q ' "${package_list[@]}"
|
||||||
|
packages_arg="${packages_arg% }"
|
||||||
|
|
||||||
|
# The subshell keeps & from backgrounding the package installation too.
|
||||||
|
exec blob-launch-floating \
|
||||||
|
"echo ${install_message}; blob-pkg-add ${packages_arg} && (setsid uwsm-app -- gtk-launch ${desktop_id_arg} >/dev/null 2>&1 &)"
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Launch the default browser as determined by xdg-settings.
|
||||||
|
# blob:args=[url]
|
||||||
|
|
||||||
|
default_browser=$(env -u BROWSER xdg-settings get default-web-browser)
|
||||||
|
if [[ -z $default_browser ]]; then
|
||||||
|
default_browser=$(xdg-mime query default x-scheme-handler/https)
|
||||||
|
fi
|
||||||
|
browser_exec=$(sed -n 's/^Exec=\([^ ]*\).*/\1/p' {~/.local,~/.nix-profile,/usr}/share/applications/$default_browser 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if $browser_exec --help 2>/dev/null | grep -q MOZ_LOG; then
|
||||||
|
private_flag="--private-window"
|
||||||
|
elif [[ $browser_exec =~ edge ]]; then
|
||||||
|
private_flag="--inprivate"
|
||||||
|
else
|
||||||
|
private_flag="--incognito"
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemd-run --user --quiet --collect --unit="blob-browser-$(date +%s%N)" \
|
||||||
|
--property=StandardOutput=null --property=StandardError=null \
|
||||||
|
uwsm-app -- "$browser_exec" "${@/--private/$private_flag}"
|
||||||
|
|
||||||
|
url=""
|
||||||
|
for argument in "$@"; do
|
||||||
|
if [[ $argument != "--private" ]]; then
|
||||||
|
url=$argument
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n $url && -n ${HYPRLAND_INSTANCE_SIGNATURE:-} ]]; then
|
||||||
|
blob-hypr-focus "^$(basename "$browser_exec" -stable).*$" || true
|
||||||
|
fi
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Launch the default editor selected via Blob defaults.
|
||||||
|
# blob:args=[--inline] <path>
|
||||||
|
|
||||||
|
default_editor="$HOME/.local/state/blob/defaults/editor"
|
||||||
|
|
||||||
|
if [[ ${1:-} == "--inline" ]]; then
|
||||||
|
inline=true
|
||||||
|
shift
|
||||||
|
else
|
||||||
|
inline=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f $default_editor ]]; then
|
||||||
|
read -r editor <"$default_editor"
|
||||||
|
else
|
||||||
|
editor="nvim"
|
||||||
|
fi
|
||||||
|
|
||||||
|
blob-cmd-present "$editor" || editor="nvim"
|
||||||
|
|
||||||
|
case "${editor##*/}" in
|
||||||
|
nvim | vim | nano | micro | hx | helix | fresh)
|
||||||
|
if [[ $inline == "true" ]]; then
|
||||||
|
exec "$editor" "$@"
|
||||||
|
else
|
||||||
|
exec blob-launch-tui "$editor" "$@"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
exec setsid uwsm-app -- "$editor" "$@"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Launch a floating terminal with the Blob presentation wrapper
|
||||||
|
# blob:args=<command>
|
||||||
|
|
||||||
|
# Export the current theme's gum styling so gum widgets match the active theme
|
||||||
|
# even after a theme switch (the inherited environment is captured at login).
|
||||||
|
source blob-restart-gum
|
||||||
|
|
||||||
|
cmd="$*"
|
||||||
|
presentation_script="blob-show-logo; $cmd; if (( \$? != 130 )); then blob-show-done; fi"
|
||||||
|
|
||||||
|
exec setsid uwsm-app -- xdg-terminal-exec --app-id=org.blob.terminal --title=Blob -e bash -c "$presentation_script"
|
||||||
Executable
+91
@@ -0,0 +1,91 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Launch the Blob shell with its log kept in the journal
|
||||||
|
# blob:hidden=true
|
||||||
|
|
||||||
|
# Quickshell only logs to its instance runtime dir (tmpfs), so when the shell
|
||||||
|
# dies the idle/lock event trail is gone after a reboot. The journal keeps it
|
||||||
|
# across sessions, bounded and timestamped, under the blob-shell tag.
|
||||||
|
#
|
||||||
|
# Backgrounded because bash defers a trap until a foreground command returns but
|
||||||
|
# interrupts wait. systemd-cat execs, so the job is Quickshell itself.
|
||||||
|
#
|
||||||
|
# Quickshell's own reloading is off; Blob restarts the shell deliberately.
|
||||||
|
# A package upgrade rewriting $BLOB_PATH/shell would otherwise reload it
|
||||||
|
# against a half-written tree, and that failed reload leaves a second engine
|
||||||
|
# generation behind that turns the next restart's IPC kill into a crash.
|
||||||
|
run_shell() {
|
||||||
|
QS_DISABLE_FILE_WATCHER=1 QS_NO_RELOAD_POPUP=1 \
|
||||||
|
systemd-cat -t blob-shell -- quickshell -n -p "$BLOB_PATH/shell" &
|
||||||
|
shell_pid=$!
|
||||||
|
|
||||||
|
local status
|
||||||
|
while true; do
|
||||||
|
wait "$shell_pid"
|
||||||
|
status=$?
|
||||||
|
|
||||||
|
# An interrupted wait and a shell killed by that signal report alike.
|
||||||
|
kill -0 "$shell_pid" 2>/dev/null || break
|
||||||
|
done
|
||||||
|
|
||||||
|
shell_pid=""
|
||||||
|
return $status
|
||||||
|
}
|
||||||
|
|
||||||
|
# A compositor busy reconfiguring outputs can miss a query without being gone,
|
||||||
|
# and that is when the shell dies.
|
||||||
|
compositor_alive() {
|
||||||
|
local attempt
|
||||||
|
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
hyprctl -j monitors >/dev/null 2>&1 && return 0
|
||||||
|
(( attempt < 3 )) && sleep 0.5
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Quickshell relaunches itself from its signal handlers, but Qt leaves through
|
||||||
|
# _exit() when the Wayland connection fails, raising no signal: no crash report,
|
||||||
|
# no relaunch, no bar. Supervise those deaths. A clean exit is a deliberate stop
|
||||||
|
# (blob-shell-restart starts its own replacement); a signal here means the
|
||||||
|
# session is going, and has to reach the shell the launcher used to exec.
|
||||||
|
terminating=0
|
||||||
|
shell_pid=""
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
terminating=1
|
||||||
|
[[ -n $shell_pid ]] && kill -TERM "$shell_pid" 2>/dev/null
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
trap stop HUP INT TERM
|
||||||
|
|
||||||
|
attempts=0
|
||||||
|
window_started=$SECONDS
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
# A signal during the backoff only reaches the trap once the sleep is over.
|
||||||
|
(( terminating )) && exit 0
|
||||||
|
|
||||||
|
run_shell
|
||||||
|
status=$?
|
||||||
|
|
||||||
|
(( terminating )) && exit 0
|
||||||
|
(( status == 0 )) && exit 0
|
||||||
|
|
||||||
|
# Relaunching into a session already tearing down burns the attempt budget.
|
||||||
|
compositor_alive || exit 0
|
||||||
|
|
||||||
|
if (( SECONDS - window_started > 60 )); then
|
||||||
|
attempts=0
|
||||||
|
window_started=$SECONDS
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( ++attempts > 5 )); then
|
||||||
|
logger -t blob-shell "Giving up on the Blob shell after $attempts relaunches in under a minute."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
logger -t blob-shell "Blob shell exited with status $status; relaunching."
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Launch a TUI command in the default terminal with Blob styling
|
||||||
|
# blob:args=[--app-id=<app-id>] <command> [args...]
|
||||||
|
|
||||||
|
if [[ ${1:-} == --app-id=* ]]; then
|
||||||
|
APP_ID="${1#--app-id=}"
|
||||||
|
shift
|
||||||
|
else
|
||||||
|
APP_ID="org.blob.$(basename $1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec setsid uwsm-app -- xdg-terminal-exec --app-id=$APP_ID -e "$1" "${@:2}"
|
||||||
Executable
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Remove or uninstall the selected launcher entry
|
||||||
|
# blob:args=<desktop-id> <name>
|
||||||
|
# blob:hidden=true
|
||||||
|
# blob:requires-sudo=true
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
desktop_id="${1-}"
|
||||||
|
entry_name="${2-}"
|
||||||
|
|
||||||
|
if [[ -z $desktop_id ]]; then
|
||||||
|
echo "Usage: blob-launcher-remove <desktop-id> <name>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
desktop_file_name="$desktop_id"
|
||||||
|
if [[ $desktop_file_name != *.desktop ]]; then
|
||||||
|
desktop_file_name="$desktop_file_name.desktop"
|
||||||
|
fi
|
||||||
|
|
||||||
|
user_desktop_dirs=()
|
||||||
|
desktop_dirs=()
|
||||||
|
if [[ -n ${XDG_DATA_HOME-} ]]; then
|
||||||
|
user_desktop_dirs+=("$XDG_DATA_HOME/applications")
|
||||||
|
else
|
||||||
|
user_desktop_dirs+=("$HOME/.local/share/applications")
|
||||||
|
fi
|
||||||
|
desktop_dirs+=("${user_desktop_dirs[@]}")
|
||||||
|
|
||||||
|
if [[ -n ${XDG_DATA_DIRS-} ]]; then
|
||||||
|
IFS=: read -ra data_dirs <<<"$XDG_DATA_DIRS"
|
||||||
|
for data_dir in "${data_dirs[@]}"; do
|
||||||
|
desktop_dirs+=("$data_dir/applications")
|
||||||
|
done
|
||||||
|
else
|
||||||
|
desktop_dirs+=("/usr/local/share/applications" "/usr/share/applications")
|
||||||
|
fi
|
||||||
|
|
||||||
|
desktop_file=""
|
||||||
|
for dir in "${desktop_dirs[@]}"; do
|
||||||
|
candidate="$dir/$desktop_file_name"
|
||||||
|
if [[ -f $candidate ]]; then
|
||||||
|
desktop_file="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z $desktop_file ]]; then
|
||||||
|
echo "Could not find launcher entry: $desktop_file_name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
is_user_desktop_file() {
|
||||||
|
local dir desktop_dir
|
||||||
|
|
||||||
|
desktop_dir="${desktop_file%/*}"
|
||||||
|
for dir in "${user_desktop_dirs[@]}"; do
|
||||||
|
[[ $desktop_dir == $dir ]] && return 0
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
desktop_name="${desktop_file##*/}"
|
||||||
|
desktop_name="${desktop_name%.desktop}"
|
||||||
|
exec_line="$(sed -n 's/^Exec=//p' "$desktop_file" | head -1)"
|
||||||
|
|
||||||
|
if [[ $exec_line =~ blob-launch-webapp|blob-webapp-handler ]]; then
|
||||||
|
BLOB_REMOVE_NOTIFY=false blob-webapp-remove "$desktop_name"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $exec_line =~ (^|[[:space:]])(\$TERMINAL|xdg-terminal-exec)[[:space:]].*-e([[:space:]]|$) ]]; then
|
||||||
|
BLOB_REMOVE_NOTIFY=false blob-tui-remove "$desktop_name"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if is_user_desktop_file; then
|
||||||
|
display_name="${entry_name:-$desktop_name}"
|
||||||
|
rm -f "$desktop_file"
|
||||||
|
update-desktop-database "${desktop_file%/*}" &>/dev/null || true
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if package_name="$(pacman -Qqo "$desktop_file" 2>/dev/null | head -1)" && [[ -n $package_name ]]; then
|
||||||
|
display_name="${entry_name:-$desktop_name}"
|
||||||
|
quoted_package="$(printf '%q' "$package_name")"
|
||||||
|
quoted_display="$(printf '%q' "$display_name")"
|
||||||
|
exec blob-launch-floating "echo Uninstalling $quoted_display...; sudo pacman -Rns $quoted_package"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if blob-cmd-present flatpak && flatpak info "${desktop_file_name%.desktop}" >/dev/null 2>&1; then
|
||||||
|
flatpak_id="${desktop_file_name%.desktop}"
|
||||||
|
quoted_flatpak="$(printf '%q' "$flatpak_id")"
|
||||||
|
exec blob-launch-floating "flatpak uninstall $quoted_flatpak"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Don't know how to uninstall $desktop_file_name" >&2
|
||||||
|
exit 1
|
||||||
Executable
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Control the Blob menu (toggle / summon / close / refresh)
|
||||||
|
# blob:args=[toggle|summon|close|refresh|ping] [route]
|
||||||
|
# blob:examples=blob menu | blob menu toggle system | blob menu summon style.theme | blob menu refresh
|
||||||
|
|
||||||
|
# Thin wrapper around the standard plugin IPC surface. The menu is the
|
||||||
|
# first-party `blob.menu` plugin; routes are passed as JSON payload.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
verb="${1-toggle}"
|
||||||
|
route="${2-root}"
|
||||||
|
|
||||||
|
menu_payload() {
|
||||||
|
jq -nc --arg menu "$1" '{ menu: $menu }'
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$verb" in
|
||||||
|
toggle)
|
||||||
|
exec blob-shell shell toggle blob.menu "$(menu_payload "$route")"
|
||||||
|
;;
|
||||||
|
summon)
|
||||||
|
exec blob-shell shell summon blob.menu "$(menu_payload "$route")"
|
||||||
|
;;
|
||||||
|
close)
|
||||||
|
exec blob-shell shell hide blob.menu
|
||||||
|
;;
|
||||||
|
refresh | ping)
|
||||||
|
exec blob-shell shell call blob.menu "$verb" "{}"
|
||||||
|
;;
|
||||||
|
-h | --help | help)
|
||||||
|
cat <<USAGE
|
||||||
|
Usage: blob menu [verb] [route]
|
||||||
|
|
||||||
|
Verbs:
|
||||||
|
toggle [route] Open the menu at <route>, or close it if already open. Default verb.
|
||||||
|
summon [route] Always open the menu (no close-if-visible toggle).
|
||||||
|
close Close the menu if it is visible.
|
||||||
|
refresh Re-parse the menu JSONC files.
|
||||||
|
ping Health check.
|
||||||
|
|
||||||
|
Route is an item id (e.g. setup.power) or alias (e.g. power). Defaults to
|
||||||
|
"root", which opens the top-level menu.
|
||||||
|
USAGE
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "blob-menu: unknown verb '$verb'. Try 'blob menu --help'." >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+99
@@ -0,0 +1,99 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Pick one option from a menu
|
||||||
|
# blob:group=menu
|
||||||
|
# blob:name=select
|
||||||
|
# blob:args=prompt [option...] [-- menu args...]
|
||||||
|
# blob:examples=blob menu select Format jpg png|blob-menu-select Resolution 4k 1080p 720p -- --width 400
|
||||||
|
|
||||||
|
# An option may lead with an icon, as "<glyph><TAB><label>", and may trail a
|
||||||
|
# subtext shown under the label, as "<glyph><TAB><label><TAB><subtext>". The
|
||||||
|
# menu shows the glyph but never returns it. A plain option returns the label
|
||||||
|
# alone; an option with a subtext returns "<label><TAB><subtext>", so callers
|
||||||
|
# with same-named rows get the subtext back as the stable key.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if (( $# < 1 )); then
|
||||||
|
echo "Usage: blob-menu-select <prompt> [option...] [-- menu args...]" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
prompt="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
options=()
|
||||||
|
menu_width=""
|
||||||
|
menu_maxheight=""
|
||||||
|
|
||||||
|
while (( $# > 0 )); do
|
||||||
|
if [[ $1 == "--" ]]; then
|
||||||
|
shift
|
||||||
|
while (( $# > 0 )); do
|
||||||
|
case "$1" in
|
||||||
|
--width)
|
||||||
|
shift
|
||||||
|
if (( $# == 0 )); then
|
||||||
|
echo "blob-menu-select: --width requires a value" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
menu_width="$1"
|
||||||
|
;;
|
||||||
|
--height|--maxheight)
|
||||||
|
arg="$1"
|
||||||
|
shift
|
||||||
|
if (( $# == 0 )); then
|
||||||
|
echo "blob-menu-select: $arg requires a value" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
menu_maxheight="$1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
options+=("$1")
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
if (( ${#options[@]} == 0 )) && [[ ! -t 0 ]]; then
|
||||||
|
mapfile -t options
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( ${#options[@]} == 0 )); then
|
||||||
|
echo "Usage: blob-menu-select <prompt> [option...] [-- menu args...]" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
selection_file=$(mktemp)
|
||||||
|
done_file=$(mktemp)
|
||||||
|
rm -f "$done_file"
|
||||||
|
trap 'rm -f "$selection_file" "$done_file"' EXIT
|
||||||
|
|
||||||
|
options_json=$(perl -MEncode=decode -MJSON::PP=encode_json -e 'print encode_json([map { decode("UTF-8", $_) } @ARGV])' "${options[@]}")
|
||||||
|
payload=$(perl -MEncode=decode -MJSON::PP=encode_json,decode_json -e '
|
||||||
|
my $payload = {
|
||||||
|
mode => "select",
|
||||||
|
prompt => decode("UTF-8", $ARGV[0]),
|
||||||
|
options => decode_json($ARGV[1]),
|
||||||
|
selectionFile => decode("UTF-8", $ARGV[2]),
|
||||||
|
doneFile => decode("UTF-8", $ARGV[3])
|
||||||
|
};
|
||||||
|
$payload->{width} = int($ARGV[4]) if length($ARGV[4] // "");
|
||||||
|
$payload->{maxHeight} = int($ARGV[5]) if length($ARGV[5] // "");
|
||||||
|
print encode_json($payload)
|
||||||
|
' "$prompt" "$options_json" "$selection_file" "$done_file" "$menu_width" "$menu_maxheight")
|
||||||
|
|
||||||
|
blob-shell shell summon blob.menu "$payload" >/dev/null
|
||||||
|
|
||||||
|
while [[ ! -e $done_file ]]; do
|
||||||
|
sleep 0.05
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -s $selection_file ]]; then
|
||||||
|
cat "$selection_file"
|
||||||
|
else
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Dismiss a notification by summary substring. Used by the first-run notifications to dismiss them after clicking for action.
|
||||||
|
# blob:args=<summary>
|
||||||
|
|
||||||
|
if (($# == 0)); then
|
||||||
|
echo "Usage: blob-notify-dismiss <summary>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
blob-shell -q notifications dismiss "$1"
|
||||||
Executable
+211
@@ -0,0 +1,211 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Send an Blob desktop notification
|
||||||
|
# blob:args=[--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [-i <icon>] [-t <ms>] [-r <id>] [-p] [--image <path-or-uri>] <headline> [description] [--exec <program> [args...]]
|
||||||
|
# blob:examples=blob notification send "Reminder" "5 minutes are up" -g
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
headline=""
|
||||||
|
description=""
|
||||||
|
glyph=
|
||||||
|
urgency="low"
|
||||||
|
app_name="blob-action"
|
||||||
|
app_icon=""
|
||||||
|
image=
|
||||||
|
expire_timeout=-1
|
||||||
|
replaces_id=0
|
||||||
|
print_id=0
|
||||||
|
exec_args=()
|
||||||
|
exec_present=0
|
||||||
|
parsed_option_args=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: blob-notify-send [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [-i <icon>] [-t <ms>] [-r <id>] [-p] [--image <path-or-uri>] <headline> [description] [--exec <program> [args...]]" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# Recognize a known option, in both `--flag value` and `--flag=value` forms.
|
||||||
|
# Returns 1 for anything unrecognized so the caller can decide (headline, or a
|
||||||
|
# hard error in option position).
|
||||||
|
parse_blob_option() {
|
||||||
|
local opt val nargs
|
||||||
|
if [[ $1 == --?*=* ]]; then
|
||||||
|
opt=${1%%=*}
|
||||||
|
val=${1#*=}
|
||||||
|
nargs=1
|
||||||
|
else
|
||||||
|
opt=$1
|
||||||
|
val=${2-}
|
||||||
|
nargs=2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -p/--print-id is a flag; it takes no value.
|
||||||
|
if [[ $opt == -p || $opt == --print-id ]]; then
|
||||||
|
print_id=1
|
||||||
|
parsed_option_args=1
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
case $opt in
|
||||||
|
-g | --glyph | -u | --urgency | --app-name | -i | --icon | --image | -r | --replace-id | -t | --expire-time) ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if ((nargs == 2)) && (($# < 2)); then
|
||||||
|
echo "Missing value for $opt" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case $opt in
|
||||||
|
-g | --glyph) glyph=$val ;;
|
||||||
|
-u | --urgency) urgency=$val ;;
|
||||||
|
--app-name) app_name=$val ;;
|
||||||
|
-i | --icon) app_icon=$val ;;
|
||||||
|
--image) image=$val ;;
|
||||||
|
-r | --replace-id)
|
||||||
|
[[ $val =~ ^[0-9]+$ ]] || {
|
||||||
|
echo "Invalid $opt value (numeric id expected): $val" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
replaces_id=$val
|
||||||
|
;;
|
||||||
|
-t | --expire-time)
|
||||||
|
[[ $val =~ ^-?[0-9]+$ ]] || {
|
||||||
|
echo "Invalid $opt value (milliseconds expected): $val" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
expire_timeout=$val
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
parsed_option_args=$nargs
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
while (($# > 0)); do
|
||||||
|
if parse_blob_option "$@"; then
|
||||||
|
shift "$parsed_option_args"
|
||||||
|
else
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if (($# < 1)); then
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
headline=$1
|
||||||
|
shift
|
||||||
|
|
||||||
|
# The description is the next positional, taken as text even when it begins with
|
||||||
|
# a dash — a body like "-50% off" or a negative number is content, not options.
|
||||||
|
# Only a recognized option flag or --exec in that slot is not the description.
|
||||||
|
known_flag() {
|
||||||
|
case $1 in
|
||||||
|
-g | --glyph | -u | --urgency | --app-name | -i | --icon | -t | --expire-time | --image | -r | --replace-id | -p | --print-id | --exec) return 0 ;;
|
||||||
|
--glyph=* | --urgency=* | --app-name=* | --icon=* | --expire-time=* | --image=* | --replace-id=*) return 0 ;;
|
||||||
|
esac
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($# > 0)) && ! known_flag "$1"; then
|
||||||
|
description=$1
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
|
||||||
|
while (($# > 0)); do
|
||||||
|
if [[ $1 == "--exec" ]]; then
|
||||||
|
# --exec consumes the rest of the line as the click command's argv. The
|
||||||
|
# caller's shell already tokenized those words into discrete arguments, and
|
||||||
|
# the shell runs them as-is (never re-parsed), so untrusted data in an
|
||||||
|
# argument is only ever one argument and can never become a command.
|
||||||
|
# Detected only here, after the headline/description positionals are
|
||||||
|
# captured, so an untrusted headline that is literally "--exec" is taken as
|
||||||
|
# text and can't be mistaken for the delimiter. --exec therefore comes last.
|
||||||
|
shift
|
||||||
|
exec_args=("$@")
|
||||||
|
exec_present=1
|
||||||
|
break
|
||||||
|
elif parse_blob_option "$@"; then
|
||||||
|
shift "$parsed_option_args"
|
||||||
|
else
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
case $urgency in
|
||||||
|
low) urgency_byte=0 ;;
|
||||||
|
normal) urgency_byte=1 ;;
|
||||||
|
critical) urgency_byte=2 ;;
|
||||||
|
*)
|
||||||
|
echo "Unknown urgency: $urgency (use low, normal, or critical)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# a{sv} hints, as busctl triples (key, variant type, value). urgency is a byte;
|
||||||
|
# the rest are strings. The click command rides here as blob-exec, built
|
||||||
|
# only from --exec below.
|
||||||
|
hints=(urgency y "$urgency_byte")
|
||||||
|
|
||||||
|
if [[ -n $glyph ]]; then
|
||||||
|
hints+=(blob-glyph s "$glyph")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n $image ]]; then
|
||||||
|
hints+=(image-path s "$image")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ((exec_present)); then
|
||||||
|
if ((${#exec_args[@]} == 0)) || [[ -z ${exec_args[0]} ]]; then
|
||||||
|
echo "--exec needs a command: --exec <program> [args...]" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# A single word with a space is almost always a whole command passed as one
|
||||||
|
# quoted string — which would run a program literally named that. Splitting it
|
||||||
|
# ourselves is exactly the injection we avoid, so reject it and point at the
|
||||||
|
# unquoted form instead.
|
||||||
|
if ((${#exec_args[@]} == 1)) && [[ ${exec_args[0]} == *[[:space:]]* ]]; then
|
||||||
|
echo "--exec takes the command as separate words, not one quoted string." >&2
|
||||||
|
echo "Write: --exec ${exec_args[0]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# NUL-delimit into jq so every byte survives as data: jq's own --args would eat
|
||||||
|
# a bare "--", and a newline in an arg must not split the vector.
|
||||||
|
exec_argv_json=$(printf '%s\0' "${exec_args[@]}" | jq -Rsc 'split("\u0000")[:-1]')
|
||||||
|
hints+=(blob-exec s "$exec_argv_json")
|
||||||
|
fi
|
||||||
|
|
||||||
|
hint_count=$((${#hints[@]} / 3))
|
||||||
|
|
||||||
|
# Call org.freedesktop.Notifications.Notify directly — never notify-send. Its
|
||||||
|
# argv parsing is the surface that reinterprets a relayed headline like
|
||||||
|
# `--hint=…` or `-rf` as options or hints; busctl takes each value as one typed
|
||||||
|
# D-Bus parameter instead, and the leading `--` keeps a dash-leading value
|
||||||
|
# (headline, description, a negative timeout) positional rather than a busctl
|
||||||
|
# option. So the summary and body are strings that can never become a hint, and
|
||||||
|
# blob-exec is set only from --exec.
|
||||||
|
#
|
||||||
|
# Signature susssasa{sv}i: app_name, replaces_id, app_icon, summary, body,
|
||||||
|
# actions (empty), hints, expire_timeout. replaces_id (from -r) updates a toast
|
||||||
|
# in place; -p prints the returned id so a caller can reuse it.
|
||||||
|
notify_cmd=(
|
||||||
|
busctl --user -- call
|
||||||
|
org.freedesktop.Notifications /org/freedesktop/Notifications
|
||||||
|
org.freedesktop.Notifications Notify susssasa{sv}i
|
||||||
|
"$app_name" "$replaces_id" "$app_icon" "$headline" "$description"
|
||||||
|
0
|
||||||
|
"$hint_count" "${hints[@]}"
|
||||||
|
"$expire_timeout"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ((print_id)); then
|
||||||
|
# busctl prints the UINT32 return as "u <id>"; emit just the id.
|
||||||
|
out=$("${notify_cmd[@]}")
|
||||||
|
printf '%s\n' "${out##* }"
|
||||||
|
else
|
||||||
|
"${notify_cmd[@]}" >/dev/null
|
||||||
|
fi
|
||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Show the Blob Quickshell on-screen display
|
||||||
|
# blob:args=[-i|--icon <icon>] [-m|--message <text>] [-p|--progress <0-100>] [-d|--duration <ms>]
|
||||||
|
# blob:examples=blob osd -i brightness -p 50 | blob osd -m "Hello"
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
icon=""
|
||||||
|
message=""
|
||||||
|
progress=""
|
||||||
|
progress_text=""
|
||||||
|
max="100"
|
||||||
|
duration=""
|
||||||
|
|
||||||
|
while (($#)); do
|
||||||
|
case $1 in
|
||||||
|
-i|--icon) icon="${2:-}"; shift 2 ;;
|
||||||
|
-m|--message) message="${2:-}"; shift 2 ;;
|
||||||
|
-p|--progress) progress="${2:-}"; shift 2 ;;
|
||||||
|
-d|--duration) duration="${2:-}"; shift 2 ;;
|
||||||
|
-h|--help) blob osd --help; exit 0 ;;
|
||||||
|
*) echo "Unknown OSD option: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n $progress ]]; then
|
||||||
|
progress_text="${progress}%"
|
||||||
|
fi
|
||||||
|
|
||||||
|
payload=$(jq -cn \
|
||||||
|
--arg icon "$icon" \
|
||||||
|
--arg message "$message" \
|
||||||
|
--arg value "$progress" \
|
||||||
|
--arg progressText "$progress_text" \
|
||||||
|
--arg max "$max" \
|
||||||
|
--arg duration "$duration" \
|
||||||
|
'{icon:$icon,message:$message,value:$value,progressText:$progressText,max:$max,duration:$duration}')
|
||||||
|
|
||||||
|
blob-shell -q osd show "$payload"
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Install Arch packages if they are missing
|
||||||
|
# blob:args=<packages...>
|
||||||
|
# blob:examples=blob pkg add jq ripgrep
|
||||||
|
# blob:requires-sudo=true
|
||||||
|
|
||||||
|
if blob-pkg-missing "$@"; then
|
||||||
|
if (( EUID == 0 )); then
|
||||||
|
pacman -S --noconfirm --needed "$@" || exit 1
|
||||||
|
else
|
||||||
|
sudo pacman -S --noconfirm --needed "$@" || exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
for pkg in "$@"; do
|
||||||
|
# Secondary check to handle states where pacman doesn't actually register an error
|
||||||
|
if ! pacman -Q "$pkg" &>/dev/null; then
|
||||||
|
echo -e "\033[31mError: Package '$pkg' did not install\033[0m" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
exit 0
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Returns true if the AUR is up and available.
|
||||||
|
|
||||||
|
curl -sf --connect-timeout 30 --retry 3 --retry-delay 3 -A "blob-update" \
|
||||||
|
"https://aur.archlinux.org/rpc/?v=5&type=info&arg=base" >/dev/null
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Returns true if any of the named packages are missing from the system (or false if they're all there).
|
||||||
|
# blob:args=<packages...>
|
||||||
|
|
||||||
|
for pkg in "$@"; do
|
||||||
|
if ! pacman -Q "$pkg" &>/dev/null; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
exit 1
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Returns true if all of the named packages are installed on the system (or false if any of them are missing).
|
||||||
|
# blob:args=<packages...>
|
||||||
|
|
||||||
|
for pkg in "$@"; do
|
||||||
|
pacman -Q "$pkg" &>/dev/null || exit 1
|
||||||
|
done
|
||||||
|
|
||||||
|
exit 0
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Reload btop configuration (used by the Blob theme switching).
|
||||||
|
|
||||||
|
pkill -SIGUSR2 btop
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Reload supported terminal emulators after config changes
|
||||||
|
|
||||||
|
if [[ -f ~/.config/alacritty/alacritty.toml ]]; then
|
||||||
|
touch ~/.config/alacritty/alacritty.toml
|
||||||
|
fi
|
||||||
|
|
||||||
|
if pgrep -x kitty >/dev/null; then
|
||||||
|
killall -SIGUSR1 kitty >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
if pgrep -x ghostty >/dev/null; then
|
||||||
|
killall -SIGUSR2 ghostty
|
||||||
|
fi
|
||||||
Executable
+82
@@ -0,0 +1,82 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Send an IPC call to the running Blob shell
|
||||||
|
# blob:args=[-q] <target> <method> [args...]
|
||||||
|
# blob:examples=blob shell shell ping | blob-shell shell toggle blob.menu '{"menu":"root"}'
|
||||||
|
|
||||||
|
QUIET=0
|
||||||
|
if [[ ${1:-} == "-q" ]]; then
|
||||||
|
QUIET=1
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
(( QUIET )) && exit 0
|
||||||
|
echo "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (( $# == 0 )) || [[ $1 == "-h" || $1 == "--help" ]]; then
|
||||||
|
cat <<USAGE
|
||||||
|
Usage: blob-shell [-q] <target> <method> [args...]
|
||||||
|
|
||||||
|
Forwards an IPC call to the running Blob shell. The shell is expected
|
||||||
|
to already be running; this command does not start it.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-q Quiet best-effort mode. Suppress output and return success even when
|
||||||
|
the shell, target, method, or arguments are unavailable.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
blob-shell shell ping
|
||||||
|
blob-shell -q blob.indicators refresh
|
||||||
|
blob-shell shell listPlugins
|
||||||
|
blob-shell shell toggle blob.menu '{"menu":"root"}'
|
||||||
|
USAGE
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
(( $# >= 2 )) || fail "Usage: blob-shell <target> <method> [args...]"
|
||||||
|
[[ -n ${BLOB_PATH:-} ]] || fail "BLOB_PATH is not set"
|
||||||
|
[[ -f $BLOB_PATH/shell/shell.qml ]] || fail "blob-shell config not found: $BLOB_PATH/shell/shell.qml"
|
||||||
|
|
||||||
|
# qs matches instances by display, and a caller from outside the session (an
|
||||||
|
# ssh or TTY blob-shell-restart, and the migrations it runs for) has none,
|
||||||
|
# so recover it from the compositor socket.
|
||||||
|
if [[ -z ${WAYLAND_DISPLAY:-} ]]; then
|
||||||
|
socket=$(ls -t "${XDG_RUNTIME_DIR:-/run/user/$UID}"/wayland-[0-9]* 2>/dev/null | grep -v '\.lock$' | head -n1)
|
||||||
|
[[ -n $socket ]] && export WAYLAND_DISPLAY=${socket##*/}
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $1 == "shell" && ( $2 == "summon" || $2 == "toggle" ) ]] && (( $# == 3 )); then
|
||||||
|
set -- "$1" "$2" "$3" "{}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The -- keeps function names that shadow qs subcommands (e.g. show) as
|
||||||
|
# positionals. qs reports connection failures with a nonzero exit, but IPC-level
|
||||||
|
# failures (unknown target/function, bad arguments) go to stdout with exit 0.
|
||||||
|
ipc_timeout=${BLOB_SHELL_IPC_TIMEOUT:-2s}
|
||||||
|
output=$(timeout --kill-after=1s "$ipc_timeout" qs ipc -n -p "$BLOB_PATH/shell" call -- "$@" 2>/dev/null)
|
||||||
|
ipc_status=$?
|
||||||
|
|
||||||
|
if (( ipc_status == 124 || ipc_status == 137 )); then
|
||||||
|
fail "blob-shell is not responding"
|
||||||
|
elif (( ipc_status != 0 )); then
|
||||||
|
fail "blob-shell is not running"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case $output in
|
||||||
|
"Target not found." | "Function not found." | "Too few arguments provided"* | "Too many arguments provided"*)
|
||||||
|
fail "$output"
|
||||||
|
;;
|
||||||
|
# A starting shell answers on stdout and exits 0, so a ping reads it as up
|
||||||
|
# and the next call's answer as a result. It is as unreachable as none.
|
||||||
|
"Not ready to accept queries yet"*)
|
||||||
|
fail "blob-shell is not ready"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if (( !QUIET )) && [[ -n $output ]]; then
|
||||||
|
echo "$output"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Shared helpers for editing ~/.config/blob/shell.json (source this, don't run it).
|
||||||
|
# blob:hidden=true
|
||||||
|
|
||||||
|
CONFIG_FILE="$HOME/.config/blob/shell.json"
|
||||||
|
DEFAULTS_FILE="$BLOB_PATH/config/blob/shell.json"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "${0##*/}: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh_shell_config() {
|
||||||
|
if ! blob-shell shell reloadConfig >/dev/null 2>&1; then
|
||||||
|
blob-shell -q shell rescanPlugins >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
source_file() {
|
||||||
|
if [[ -s $CONFIG_FILE ]]; then
|
||||||
|
printf '%s\n' "$CONFIG_FILE"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$DEFAULTS_FILE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# jq pipeline that normalizes shell.json into a well-shaped object with
|
||||||
|
# version=1, bar.layout.{left,center,right} arrays, and plugins array. Every
|
||||||
|
# mutation pipes through this so downstream jq can assume structure.
|
||||||
|
NORMALIZE='
|
||||||
|
def object_or_empty: if type == "object" then . else {} end;
|
||||||
|
def array_or_empty: if type == "array" then . else [] end;
|
||||||
|
object_or_empty
|
||||||
|
| .version = 1
|
||||||
|
| .bar = (.bar | object_or_empty)
|
||||||
|
| .bar.layout = (.bar.layout | object_or_empty)
|
||||||
|
| .bar.layout.left = (.bar.layout.left | array_or_empty)
|
||||||
|
| .bar.layout.center = (.bar.layout.center | array_or_empty)
|
||||||
|
| .bar.layout.right = (.bar.layout.right | array_or_empty)
|
||||||
|
| .plugins = (.plugins | array_or_empty)
|
||||||
|
'
|
||||||
|
|
||||||
|
# Apply a jq program to the source file and atomically write the result to the
|
||||||
|
# user config, then refresh the running shell. Extra args after the program are
|
||||||
|
# forwarded to jq (e.g. --arg/--argjson).
|
||||||
|
_SHELL_CONFIG_TMP=""
|
||||||
|
cleanup_shell_config_tmp() {
|
||||||
|
if [[ -n $_SHELL_CONFIG_TMP ]]; then rm -f "$_SHELL_CONFIG_TMP"; fi
|
||||||
|
}
|
||||||
|
trap cleanup_shell_config_tmp EXIT
|
||||||
|
|
||||||
|
commit() {
|
||||||
|
local program="$1"
|
||||||
|
shift
|
||||||
|
mkdir -p "$(dirname "$CONFIG_FILE")"
|
||||||
|
_SHELL_CONFIG_TMP=$(mktemp)
|
||||||
|
jq -S -e "$@" "$program" "$(source_file)" >"$_SHELL_CONFIG_TMP" || fail "could not update shell config"
|
||||||
|
mv "$_SHELL_CONFIG_TMP" "$CONFIG_FILE"
|
||||||
|
_SHELL_CONFIG_TMP=""
|
||||||
|
refresh_shell_config
|
||||||
|
}
|
||||||
Executable
+93
@@ -0,0 +1,93 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Restart the Blob shell
|
||||||
|
# blob:examples=blob restart shell
|
||||||
|
|
||||||
|
# A caller opened after dev link/unlink may disagree with the still-running
|
||||||
|
# desktop. The user manager receives Hyprland's environment at session start.
|
||||||
|
session_blob_path=$(systemctl --user show-environment 2>/dev/null | sed -n 's/^BLOB_PATH=//p' | tail -n 1)
|
||||||
|
: "${session_blob_path:=$BLOB_PATH}"
|
||||||
|
|
||||||
|
CONFIG_DIR="$session_blob_path/shell"
|
||||||
|
[[ -f $CONFIG_DIR/shell.qml ]] || { echo "Blob shell config not found: $CONFIG_DIR" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Allow running from outside the session (e.g. over ssh) by deriving the
|
||||||
|
# Hyprland instance signature from the newest instance runtime dir.
|
||||||
|
if [[ -z ${HYPRLAND_INSTANCE_SIGNATURE:-} ]]; then
|
||||||
|
hypr_dir=$(find "${XDG_RUNTIME_DIR:-/run/user/$UID}/hypr" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -n 1 | cut -d' ' -f2-)
|
||||||
|
[[ -n $hypr_dir ]] && export HYPRLAND_INSTANCE_SIGNATURE=${hypr_dir##*/}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restarting a live lock client would kill the lock screen and strand the
|
||||||
|
# session behind Hyprland's failsafe. But a LOCK session without an active
|
||||||
|
# locker — the shell died, or its crash handler re-execed a fresh instance
|
||||||
|
# that holds no lock — sits in that failsafe with no way to authenticate,
|
||||||
|
# and a restart plus re-lock is the only way back in without a reboot. So
|
||||||
|
# ask the lock service rather than merely pinging the shell: only a locker
|
||||||
|
# that reports the lock secure or in progress is worth preserving.
|
||||||
|
relock=0
|
||||||
|
if blob-hypr-session-locked; then
|
||||||
|
locking=$(BLOB_PATH="$session_blob_path" BLOB_SHELL_IPC_TIMEOUT=0.5s blob-shell lock status 2>/dev/null |
|
||||||
|
jq -r '.secure or .requested' 2>/dev/null)
|
||||||
|
if [[ $locking == "true" ]]; then
|
||||||
|
echo "Refusing to restart Blob shell while the session is locked." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
relock=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The lock plugin loads asynchronously, so a fresh shell answers ping before
|
||||||
|
# it can lock, and may even refuse early lock requests while its plugins or
|
||||||
|
# PAM config are still loading. Mirror blob-system-sleep: request the
|
||||||
|
# lock and poll until the session reports secure, re-requesting as needed, so
|
||||||
|
# recovery never claims success while the failsafe is still up. The deadline
|
||||||
|
# is generous because slow plugin discovery delays the lock IPC target.
|
||||||
|
relock_session() {
|
||||||
|
local state deadline=$((SECONDS + 30))
|
||||||
|
|
||||||
|
while (( SECONDS < deadline )); do
|
||||||
|
state=$(BLOB_PATH="$session_blob_path" BLOB_SHELL_IPC_TIMEOUT=0.5s blob-shell lock status 2>/dev/null |
|
||||||
|
jq -r 'if .secure == true then "secure" elif .requested == true then "locking" else "idle" end' 2>/dev/null)
|
||||||
|
|
||||||
|
case $state in
|
||||||
|
secure) return 0 ;;
|
||||||
|
locking) ;;
|
||||||
|
*) BLOB_PATH="$session_blob_path" BLOB_SHELL_IPC_TIMEOUT=0.5s blob-shell lock lock >/dev/null 2>&1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Each kill stops the oldest matching instance and only returns once it has
|
||||||
|
# fully exited, so the no-duplicate launch below can't race a dying shell.
|
||||||
|
while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1; do :; done
|
||||||
|
|
||||||
|
# Spawn from Hyprland so the shell inherits the canonical session environment,
|
||||||
|
# not transient variables from a terminal, SSH connection, or development tool.
|
||||||
|
hyprctl dispatch 'hl.dsp.exec_cmd("blob-launch-shell")' >/dev/null
|
||||||
|
|
||||||
|
for (( attempt = 0; attempt < 20; attempt++ )); do
|
||||||
|
if BLOB_PATH="$session_blob_path" BLOB_SHELL_IPC_TIMEOUT=0.5s blob-shell shell ping >/dev/null 2>&1; then
|
||||||
|
# The session stays compositor-locked after the old lock client died, so
|
||||||
|
# re-acquire the lock and let the user authenticate out of it.
|
||||||
|
if (( relock )) && ! relock_session; then
|
||||||
|
echo "Blob shell restarted, but the session lock was not re-secured." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Invitation toasts (like Voxtype/fingerprint setup) die with the old
|
||||||
|
# shell, and their notify-send waiters hang forever: the dying server
|
||||||
|
# never emits NotificationClosed. A still-running blob-*-invitation
|
||||||
|
# unit is therefore an unanswered invitation — re-run it so its toast
|
||||||
|
# reappears on the new shell. Answered invitations have already exited
|
||||||
|
# and been collected, so the glob no longer matches them.
|
||||||
|
systemctl --user try-restart 'blob-*-invitation.service' 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Blob shell did not become ready after restart." >&2
|
||||||
|
exit 1
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Manage persistent state files for Blob toggles and settings.
|
||||||
|
# blob:args=<set|clear> <state-name-or-pattern>
|
||||||
|
# blob:hidden=true
|
||||||
|
|
||||||
|
STATE_DIR="$HOME/.local/state/blob"
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
|
||||||
|
COMMAND="$1"
|
||||||
|
STATE_NAME="$2"
|
||||||
|
|
||||||
|
if [[ -z $COMMAND ]]; then
|
||||||
|
echo "Usage: blob-state <set|clear> <state-name-or-pattern>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z $STATE_NAME ]]; then
|
||||||
|
echo "Usage: blob-state $COMMAND <state-name>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$COMMAND" in
|
||||||
|
set) touch "$STATE_DIR/$STATE_NAME" ;;
|
||||||
|
clear) find "$STATE_DIR" -maxdepth 1 -type f -name "$STATE_NAME" -delete ;;
|
||||||
|
esac
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Apply the current theme color to Chromium, Chrome, Edge, and Brave
|
||||||
|
# blob:hidden=true
|
||||||
|
|
||||||
|
source "$BLOB_PATH/install/helpers/browser-policy.sh"
|
||||||
|
|
||||||
|
CHROMIUM_THEME=$HOME/.local/state/blob/current/theme/chromium.theme
|
||||||
|
THEME_HEX_COLOR=$BROWSER_POLICY_DEFAULT_COLOR
|
||||||
|
|
||||||
|
if [[ -f $CHROMIUM_THEME ]]; then
|
||||||
|
THEME_HEX_COLOR=$(browser_policy_theme_hex "$(<$CHROMIUM_THEME)")
|
||||||
|
fi
|
||||||
|
|
||||||
|
refresh_running_browser() {
|
||||||
|
local process="$1"
|
||||||
|
local command="$2"
|
||||||
|
local pgrep_args="${3:--x}"
|
||||||
|
|
||||||
|
if blob-cmd-present "$command" && pgrep $pgrep_args "$process" >/dev/null; then
|
||||||
|
"$command" --refresh-platform-policy --no-startup-window &>/dev/null
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
failed=0
|
||||||
|
blob-theme-browser-policy "${THEME_HEX_COLOR#\#}" || failed=1
|
||||||
|
|
||||||
|
refresh_running_browser chromium chromium
|
||||||
|
refresh_running_browser chrome google-chrome-stable || refresh_running_browser chrome google-chrome
|
||||||
|
refresh_running_browser msedge microsoft-edge-stable
|
||||||
|
refresh_running_browser brave brave
|
||||||
|
# Match on the binary path: the running process is named plain "brave", and a
|
||||||
|
# bare -f brave-origin pattern would also match the installer's own terminal.
|
||||||
|
refresh_running_browser /opt/brave-origin-bin/ brave-origin -f
|
||||||
|
|
||||||
|
exit "$failed"
|
||||||
Executable
+123
@@ -0,0 +1,123 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Write the current theme color into the browser policy directories
|
||||||
|
# blob:args=<rrggbb>
|
||||||
|
# blob:hidden=true
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Whenever this runs as root — invoked directly through the passwordless
|
||||||
|
# sudoers rule, or re-execed by require_root below — sudo's secure_path decides
|
||||||
|
# where a bare helper resolves, and a dev link (etc/sudoers.d/blob-dev-path)
|
||||||
|
# prepends a user-writable checkout bin/ to it. Every helper this script calls
|
||||||
|
# by bare name (printf's builtin aside: install, mktemp, rm) is a system tool,
|
||||||
|
# never an blob-* command, so pin PATH to trusted system directories and keep
|
||||||
|
# root from resolving one out of that checkout. The unprivileged wrapper phase
|
||||||
|
# keeps the caller's PATH so it can still find sudo/pkexec.
|
||||||
|
if (( EUID == 0 )); then
|
||||||
|
export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Enterprise policy trust roots. The list is fixed here rather than taken from
|
||||||
|
# the caller: the caller chooses a color, never a path.
|
||||||
|
POLICY_DIRS=(
|
||||||
|
/etc/chromium/policies/managed
|
||||||
|
/etc/opt/chrome/policies/managed
|
||||||
|
/etc/opt/edge/policies/managed
|
||||||
|
/etc/brave/policies/managed
|
||||||
|
)
|
||||||
|
|
||||||
|
# The path etc/sudoers.d/blob-theme-browser names. The privileged half always
|
||||||
|
# runs from there rather than from whichever copy was invoked, so the rule
|
||||||
|
# matches even where $BLOB_PATH points at a checkout.
|
||||||
|
PACKAGED_PATH=/usr/bin/blob-theme-browser-policy
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: blob-theme-browser-policy <rrggbb>" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
if (( $# != 1 )); then
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
color="$1"
|
||||||
|
|
||||||
|
# Six lowercase hex digits is the whole of what this accepts. The leading "#"
|
||||||
|
# is added when the JSON is written rather than passed in: "#" opens a comment
|
||||||
|
# in sudoers, and keeping it out of argv lets the sudoers rule spell the
|
||||||
|
# argument as a plain six-character glob.
|
||||||
|
if [[ ! $color =~ ^[0-9a-f]{6}$ ]]; then
|
||||||
|
echo "blob-theme-browser-policy: expected six lowercase hex digits, got '$color'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# True when sudo would run this exact command without stopping for a password.
|
||||||
|
# `sudo -l` on its own reports whether a command is permitted, which the blanket
|
||||||
|
# %wheel rule answers yes to for everything; the long listing prints the matched
|
||||||
|
# entry's tags, so !authenticate is the grant in
|
||||||
|
# etc/sudoers.d/blob-theme-browser and nothing else. Listing runs nothing
|
||||||
|
# and, under -n, prompts for nothing.
|
||||||
|
sudo_grants_passwordless() {
|
||||||
|
sudo -n -l -l "$PACKAGED_PATH" "$@" 2>/dev/null | grep -q '!authenticate'
|
||||||
|
}
|
||||||
|
|
||||||
|
require_root() {
|
||||||
|
if (( EUID == 0 )); then
|
||||||
|
return
|
||||||
|
elif [[ -t 0 ]] || sudo_grants_passwordless "$@"; then
|
||||||
|
exec sudo "$PACKAGED_PATH" "$@"
|
||||||
|
else
|
||||||
|
exec pkexec "$PACKAGED_PATH" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_root "$color"
|
||||||
|
|
||||||
|
failed=0
|
||||||
|
staged=""
|
||||||
|
# Bash 5.3 makes the EXIT trap's last command decide the script's exit status,
|
||||||
|
# so this handler must not end on a false test. Every successful run clears
|
||||||
|
# staged, and a trailing `[[ -n $staged ]] && ...` would report that as failure.
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n $staged ]]; then
|
||||||
|
rm -f "$staged"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
for policy_dir in "${POLICY_DIRS[@]}"; do
|
||||||
|
# Only browsers Blob has installed have a policy directory. Creating one
|
||||||
|
# here would hand a browser a managed-policy root it does not otherwise have.
|
||||||
|
[[ -d $policy_dir && ! -L $policy_dir ]] || continue
|
||||||
|
|
||||||
|
dest=$policy_dir/color.json
|
||||||
|
staged=$(mktemp) || {
|
||||||
|
failed=1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
printf '{"BrowserThemeColor": "#%s", "BrowserColorScheme": "device"}\n' "$color" >"$staged"
|
||||||
|
|
||||||
|
if [[ -L $dest || -d $dest ]]; then
|
||||||
|
if ! rm -rf -- "$dest"; then
|
||||||
|
rm -f "$staged"
|
||||||
|
staged=""
|
||||||
|
echo "blob-theme-browser-policy: cannot replace $dest" >&2
|
||||||
|
failed=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! install -m 0644 -o root -g root -T "$staged" "$dest"; then
|
||||||
|
rm -f "$staged"
|
||||||
|
staged=""
|
||||||
|
echo "blob-theme-browser-policy: cannot write $dest" >&2
|
||||||
|
failed=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$staged"
|
||||||
|
staged=""
|
||||||
|
done
|
||||||
|
|
||||||
|
exit "$failed"
|
||||||
Executable
+304
@@ -0,0 +1,304 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Resolve semantic colors from an Blob theme colors.toml
|
||||||
|
# blob:args=[--file <colors.toml>] (--all | --raw | <key> [fallback])
|
||||||
|
# blob:hidden=true
|
||||||
|
|
||||||
|
# Shared colors.toml parser/resolver. The alias/fallback cascade mirrors what
|
||||||
|
# blob-theme-templates bakes into the generated configs at theme-set
|
||||||
|
# time, so every consumer (templates, OSC sequences, previews)
|
||||||
|
# resolves the exact same palette.
|
||||||
|
#
|
||||||
|
# --all print every resolved key<TAB>value pair (sorted by key)
|
||||||
|
# --raw print only the key<TAB>value pairs defined in the file
|
||||||
|
# <key> [fallback] print one resolved value; the fallback is tried as
|
||||||
|
# another palette key first, then used verbatim
|
||||||
|
#
|
||||||
|
# Theme mode precedence: `mode` key, legacy `theme_type` key, a light.mode
|
||||||
|
# file beside the colors.toml, background luminance auto-detect, dark.
|
||||||
|
|
||||||
|
COLORS_FILE="$HOME/.local/state/blob/current/theme/colors.toml"
|
||||||
|
OUTPUT=""
|
||||||
|
QUERY_KEY=""
|
||||||
|
QUERY_FALLBACK=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: blob-theme-color [--file <colors.toml>] (--all | --raw | <key> [fallback])"
|
||||||
|
}
|
||||||
|
|
||||||
|
while (( $# > 0 )); do
|
||||||
|
case "$1" in
|
||||||
|
--file)
|
||||||
|
COLORS_FILE="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--all)
|
||||||
|
OUTPUT="all"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--raw)
|
||||||
|
OUTPUT="raw"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h | --help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [[ -z $QUERY_KEY ]]; then
|
||||||
|
QUERY_KEY="$1"
|
||||||
|
elif [[ -z $QUERY_FALLBACK ]]; then
|
||||||
|
QUERY_FALLBACK="$1"
|
||||||
|
else
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z $OUTPUT && -z $QUERY_KEY ]] || [[ -n $OUTPUT && -n $QUERY_KEY ]]; then
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
COLORS_DIR=$(dirname "$COLORS_FILE")
|
||||||
|
|
||||||
|
declare -A THEME_COLORS
|
||||||
|
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
alias_theme_color() {
|
||||||
|
local key="$1"
|
||||||
|
local fallback="$2"
|
||||||
|
|
||||||
|
[[ ${THEME_COLORS[$key]} ]] || THEME_COLORS[$key]="${THEME_COLORS[$fallback]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_theme_mode() {
|
||||||
|
local bg_hex lum
|
||||||
|
|
||||||
|
[[ ${THEME_COLORS[mode]} ]] || THEME_COLORS[mode]="${THEME_COLORS[theme_type]}"
|
||||||
|
[[ ${THEME_COLORS[mode]} ]] && return
|
||||||
|
|
||||||
|
if [[ -f $COLORS_DIR/light.mode ]]; then
|
||||||
|
THEME_COLORS[mode]="light"
|
||||||
|
elif [[ ${THEME_COLORS[background]} =~ ^#[0-9A-Fa-f]{6}$ ]]; then
|
||||||
|
bg_hex="${THEME_COLORS[background]#\#}"
|
||||||
|
lum=$(( $(printf "%d" "0x${bg_hex:0:2}") + $(printf "%d" "0x${bg_hex:2:2}") + $(printf "%d" "0x${bg_hex:4:2}") ))
|
||||||
|
(( lum > 382 )) && THEME_COLORS[mode]="light" || THEME_COLORS[mode]="dark"
|
||||||
|
else
|
||||||
|
THEME_COLORS[mode]="dark"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_colors_file() {
|
||||||
|
local key value
|
||||||
|
|
||||||
|
[[ -f $COLORS_FILE ]] || return 0
|
||||||
|
|
||||||
|
while IFS='=' read -r key value; do
|
||||||
|
key="${key//[\"\' ]/}" # strip quotes and spaces from key
|
||||||
|
[[ $key && $key != \#* ]] || continue # skip empty lines and comments
|
||||||
|
|
||||||
|
if [[ $value == *[\"\']* ]]; then
|
||||||
|
value="${value#*[\"\']}"
|
||||||
|
value="${value%%[\"\']*}" # extract value between quotes (ignores inline comments)
|
||||||
|
else
|
||||||
|
value="${value#"${value%%[![:space:]]*}"}"
|
||||||
|
value="${value%"${value##*[![:space:]]}"}" # trim unquoted values
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Values reach consumers as sed replacement text, so the charset excludes
|
||||||
|
# the delimiter, backslash, and & while still covering everything a real
|
||||||
|
# palette holds: hex, rgb()/rgba() lists, gradient angles like -45deg,
|
||||||
|
# decimals, and bare words. Rejections are announced so a third-party theme
|
||||||
|
# doesn't lose a key silently and leave a raw {{ placeholder }} behind.
|
||||||
|
if [[ ! $key =~ ^[abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-]+$ ]]; then
|
||||||
|
printf 'blob-theme-color: skipping key with unsupported characters\n' >&2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! $value =~ ^[abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#(),._+/%\ -]*$ ]]; then
|
||||||
|
printf 'blob-theme-color: skipping %s: unsupported characters in value\n' "$key" >&2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
THEME_COLORS[$key]="$value"
|
||||||
|
[[ $OUTPUT == "raw" ]] && printf '%s\t%s\n' "$key" "$value"
|
||||||
|
done <"$COLORS_FILE"
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_theme_colors() {
|
||||||
|
local key
|
||||||
|
|
||||||
|
# Accept the complete legacy short-name palette before applying ANSI
|
||||||
|
# fallbacks or deriving shades. Canonical names take precedence when a theme
|
||||||
|
# defines both forms.
|
||||||
|
declare -A legacy_palette_alias=(
|
||||||
|
[background]=bg
|
||||||
|
[dark_background]=dark_bg
|
||||||
|
[darker_background]=darker_bg
|
||||||
|
[lighter_background]=lighter_bg
|
||||||
|
[foreground]=fg
|
||||||
|
[dark_foreground]=dark_fg
|
||||||
|
[light_foreground]=light_fg
|
||||||
|
[bright_foreground]=bright_fg
|
||||||
|
)
|
||||||
|
for key in "${!legacy_palette_alias[@]}"; do
|
||||||
|
alias_theme_color "$key" "${legacy_palette_alias[$key]}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Themes generated before the semantic palette may only define ANSI names.
|
||||||
|
[[ ${THEME_COLORS[background]} ]] || THEME_COLORS[background]="${THEME_COLORS[color0]}"
|
||||||
|
[[ ${THEME_COLORS[foreground]} ]] || THEME_COLORS[foreground]="${THEME_COLORS[color7]}"
|
||||||
|
[[ ${THEME_COLORS[background]} ]] && THEME_COLORS[color0]="${THEME_COLORS[background]}"
|
||||||
|
[[ ${THEME_COLORS[foreground]} ]] && THEME_COLORS[color7]="${THEME_COLORS[foreground]}"
|
||||||
|
|
||||||
|
# Legacy compatibility: map ANSI color0..color15 to semantic names.
|
||||||
|
declare -A legacy_alias=(
|
||||||
|
[red]=color1
|
||||||
|
[green]=color2
|
||||||
|
[yellow]=color3
|
||||||
|
[blue]=color4
|
||||||
|
[magenta]=color5
|
||||||
|
[cyan]=color6
|
||||||
|
[bright_red]=color9
|
||||||
|
[bright_green]=color10
|
||||||
|
[bright_yellow]=color11
|
||||||
|
[bright_blue]=color12
|
||||||
|
[bright_magenta]=color13
|
||||||
|
[bright_cyan]=color14
|
||||||
|
)
|
||||||
|
for key in "${!legacy_alias[@]}"; do
|
||||||
|
alias_theme_color "$key" "${legacy_alias[$key]}"
|
||||||
|
done
|
||||||
|
alias_theme_color magenta purple
|
||||||
|
alias_theme_color bright_magenta bright_purple
|
||||||
|
|
||||||
|
[[ ${THEME_COLORS[light_foreground]} ]] || THEME_COLORS[light_foreground]="${THEME_COLORS[color7]:-${THEME_COLORS[foreground]}}"
|
||||||
|
[[ ${THEME_COLORS[bright_foreground]} ]] || THEME_COLORS[bright_foreground]="${THEME_COLORS[color15]:-${THEME_COLORS[foreground]}}"
|
||||||
|
THEME_COLORS[cursor]="${THEME_COLORS[bright_foreground]}"
|
||||||
|
[[ ${THEME_COLORS[lighter_background]} ]] || THEME_COLORS[lighter_background]="${THEME_COLORS[color0]:-${THEME_COLORS[background]}}"
|
||||||
|
[[ ${THEME_COLORS[dark_foreground]} ]] || THEME_COLORS[dark_foreground]="${THEME_COLORS[color8]:-${THEME_COLORS[foreground]}}"
|
||||||
|
[[ ${THEME_COLORS[muted]} ]] || THEME_COLORS[muted]="${THEME_COLORS[color8]:-${THEME_COLORS[dark_foreground]}}"
|
||||||
|
[[ ${THEME_COLORS[selection]} ]] || THEME_COLORS[selection]="${THEME_COLORS[selection_background]:-${THEME_COLORS[color8]:-${THEME_COLORS[color0]:-${THEME_COLORS[background]}}}}"
|
||||||
|
[[ ${THEME_COLORS[selection_background]} ]] || THEME_COLORS[selection_background]="${THEME_COLORS[selection]}"
|
||||||
|
[[ ${THEME_COLORS[selection_foreground]} ]] || THEME_COLORS[selection_foreground]="${THEME_COLORS[bright_foreground]}"
|
||||||
|
[[ ${THEME_COLORS[orange]} ]] || THEME_COLORS[orange]="${THEME_COLORS[yellow]}"
|
||||||
|
[[ ${THEME_COLORS[brown]} ]] || THEME_COLORS[brown]=$(mix_color "${THEME_COLORS[orange]}" "#000000" 50%)
|
||||||
|
|
||||||
|
# Auto-derive shades from base accents when not defined and not aliased from colorN
|
||||||
|
[[ ${THEME_COLORS[dark_background]} ]] || THEME_COLORS[dark_background]=$(mix_color "${THEME_COLORS[background]}" "#000000" 25%)
|
||||||
|
[[ ${THEME_COLORS[darker_background]} ]] || THEME_COLORS[darker_background]=$(mix_color "${THEME_COLORS[background]}" "#000000" 50%)
|
||||||
|
[[ ${THEME_COLORS[bright_red]} ]] || THEME_COLORS[bright_red]=$(mix_color "${THEME_COLORS[red]}" "#ffffff" 20%)
|
||||||
|
[[ ${THEME_COLORS[bright_yellow]} ]] || THEME_COLORS[bright_yellow]=$(mix_color "${THEME_COLORS[yellow]}" "#ffffff" 20%)
|
||||||
|
[[ ${THEME_COLORS[bright_green]} ]] || THEME_COLORS[bright_green]=$(mix_color "${THEME_COLORS[green]}" "#ffffff" 20%)
|
||||||
|
[[ ${THEME_COLORS[bright_cyan]} ]] || THEME_COLORS[bright_cyan]=$(mix_color "${THEME_COLORS[cyan]}" "#ffffff" 20%)
|
||||||
|
[[ ${THEME_COLORS[bright_blue]} ]] || THEME_COLORS[bright_blue]=$(mix_color "${THEME_COLORS[blue]}" "#ffffff" 20%)
|
||||||
|
[[ ${THEME_COLORS[bright_magenta]} ]] || THEME_COLORS[bright_magenta]=$(mix_color "${THEME_COLORS[magenta]}" "#ffffff" 20%)
|
||||||
|
alias_theme_color purple magenta
|
||||||
|
alias_theme_color bright_purple bright_magenta
|
||||||
|
|
||||||
|
# Keep semantic themes compatible with consumers that still reference the
|
||||||
|
# legacy ANSI names directly.
|
||||||
|
declare -A ansi_alias=(
|
||||||
|
[color0]=background
|
||||||
|
[color1]=red
|
||||||
|
[color2]=green
|
||||||
|
[color3]=yellow
|
||||||
|
[color4]=blue
|
||||||
|
[color5]=magenta
|
||||||
|
[color6]=cyan
|
||||||
|
[color7]=foreground
|
||||||
|
[color8]=muted
|
||||||
|
[color9]=bright_red
|
||||||
|
[color10]=bright_green
|
||||||
|
[color11]=bright_yellow
|
||||||
|
[color12]=bright_blue
|
||||||
|
[color13]=bright_magenta
|
||||||
|
[color14]=bright_cyan
|
||||||
|
[color15]=bright_foreground
|
||||||
|
)
|
||||||
|
for key in "${!ansi_alias[@]}"; do
|
||||||
|
alias_theme_color "$key" "${ansi_alias[$key]}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Keep canonical themes compatible with old user templates and consumers
|
||||||
|
# that still query the short palette names directly.
|
||||||
|
for key in "${!legacy_palette_alias[@]}"; do
|
||||||
|
if [[ ${THEME_COLORS[$key]} ]]; then
|
||||||
|
THEME_COLORS["${legacy_palette_alias[$key]}"]="${THEME_COLORS[$key]}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
resolve_theme_mode
|
||||||
|
THEME_COLORS[theme_type]="${THEME_COLORS[mode]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_colors_file
|
||||||
|
|
||||||
|
if [[ $OUTPUT == "raw" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
resolve_theme_colors
|
||||||
|
|
||||||
|
if [[ $OUTPUT == "all" ]]; then
|
||||||
|
while IFS= read -r key; do
|
||||||
|
printf '%s\t%s\n' "$key" "${THEME_COLORS[$key]}"
|
||||||
|
done < <(printf '%s\n' "${!THEME_COLORS[@]}" | LC_ALL=C sort)
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
value="${THEME_COLORS[$QUERY_KEY]:-}"
|
||||||
|
if [[ -z $value && -n $QUERY_FALLBACK ]]; then
|
||||||
|
value="${THEME_COLORS[$QUERY_FALLBACK]:-$QUERY_FALLBACK}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n $value ]] || exit 1
|
||||||
|
printf '%s\n' "$value"
|
||||||
Executable
+133
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
blob-theme-contrast - Detects monotone/bland wal color palettes and replaces
|
||||||
|
accent colors with vibrant, harmonically-spread alternatives.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import math
|
||||||
|
import colorsys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
MIN_SATURATION = 0.65
|
||||||
|
MIN_LIGHTNESS = 0.40
|
||||||
|
MAX_LIGHTNESS = 0.70
|
||||||
|
HUE_THRESHOLD = 0.15
|
||||||
|
SAT_THRESHOLD = 0.35
|
||||||
|
HUE_VARIANCE_THRESHOLD = 0.15
|
||||||
|
ACCENT_SLICE = slice(1, 7)
|
||||||
|
BRIGHT_OFFSET = 8
|
||||||
|
|
||||||
|
HUE_SHIFTS: list[float] = [
|
||||||
|
0.0,
|
||||||
|
0.5,
|
||||||
|
0.083,
|
||||||
|
-0.083,
|
||||||
|
0.416,
|
||||||
|
-0.416,
|
||||||
|
]
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HLS:
|
||||||
|
h: float
|
||||||
|
l: float
|
||||||
|
s: float
|
||||||
|
|
||||||
|
|
||||||
|
def hex_to_rgb(hex_str: str) -> tuple[float, float, float]:
|
||||||
|
hex_str = hex_str.lstrip("#")
|
||||||
|
return tuple(int(hex_str[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def rgb_to_hex(r: float, g: float, b: float) -> str:
|
||||||
|
return "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
|
||||||
|
|
||||||
|
|
||||||
|
def hex_to_hls(hex_str: str) -> HLS:
|
||||||
|
h, l, s = colorsys.rgb_to_hls(*hex_to_rgb(hex_str))
|
||||||
|
return HLS(h, l, s)
|
||||||
|
|
||||||
|
|
||||||
|
def vibrant_shift(hex_str: str, hue_shift: float) -> str:
|
||||||
|
"""Return *hex_str* with its hue rotated by *hue_shift* and vibrancy enforced."""
|
||||||
|
hls = hex_to_hls(hex_str)
|
||||||
|
h = (hls.h + hue_shift) % 1.0
|
||||||
|
s = max(hls.s, MIN_SATURATION)
|
||||||
|
l = min(max(hls.l, MIN_LIGHTNESS), MAX_LIGHTNESS)
|
||||||
|
return rgb_to_hex(*colorsys.hls_to_rgb(h, l, s))
|
||||||
|
|
||||||
|
|
||||||
|
def palette_is_bland(accents: list[str]) -> bool:
|
||||||
|
"""Return True when accent colors are too similar, clustered, or too desaturated."""
|
||||||
|
stats = [hex_to_hls(c) for c in accents]
|
||||||
|
hues = [hls.h for hls in stats]
|
||||||
|
sats = [hls.s for hls in stats]
|
||||||
|
|
||||||
|
raw_spread = max(hues) - min(hues)
|
||||||
|
hue_spread = min(raw_spread, 1.0 - raw_spread)
|
||||||
|
avg_sat = sum(sats) / len(sats)
|
||||||
|
|
||||||
|
if hue_spread < HUE_THRESHOLD or avg_sat < SAT_THRESHOLD:
|
||||||
|
return True
|
||||||
|
|
||||||
|
mean_sin = sum(math.sin(2 * math.pi * h) for h in hues) / len(hues)
|
||||||
|
mean_cos = sum(math.cos(2 * math.pi * h) for h in hues) / len(hues)
|
||||||
|
hue_variance = 1.0 - math.sqrt(mean_sin ** 2 + mean_cos ** 2)
|
||||||
|
if hue_variance < HUE_VARIANCE_THRESHOLD:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def most_saturated(accents: list[str]) -> str:
|
||||||
|
return max(accents, key=lambda c: hex_to_hls(c).s)
|
||||||
|
|
||||||
|
|
||||||
|
def load_colors(filepath: str) -> list[str]:
|
||||||
|
try:
|
||||||
|
with open(filepath) as f:
|
||||||
|
colors = [line.strip() for line in f if line.strip()]
|
||||||
|
except OSError as exc:
|
||||||
|
sys.exit(f"Error reading '{filepath}': {exc}")
|
||||||
|
|
||||||
|
if len(colors) < 16:
|
||||||
|
sys.exit(f"Expected ≥ 16 colors in '{filepath}', found {len(colors)}.")
|
||||||
|
|
||||||
|
return colors
|
||||||
|
|
||||||
|
|
||||||
|
def save_colors(filepath: str, colors: list[str]) -> None:
|
||||||
|
try:
|
||||||
|
with open(filepath, "w") as f:
|
||||||
|
f.write("\n".join(colors) + "\n")
|
||||||
|
except OSError as exc:
|
||||||
|
sys.exit(f"Error writing '{filepath}': {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
sys.exit("Usage: blob_color_fixer.py <path_to_wal_colors>")
|
||||||
|
|
||||||
|
filepath = sys.argv[1]
|
||||||
|
colors = load_colors(filepath)
|
||||||
|
accents = colors[ACCENT_SLICE]
|
||||||
|
|
||||||
|
if not palette_is_bland(accents):
|
||||||
|
print("Palette is already vibrant and diverse — nothing to do.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Monotone / bland palette detected. Generating vibrant harmony…")
|
||||||
|
|
||||||
|
base = most_saturated(accents)
|
||||||
|
new_accents = [vibrant_shift(base, shift) for shift in HUE_SHIFTS]
|
||||||
|
|
||||||
|
for i, color in enumerate(new_accents):
|
||||||
|
colors[i + 1] = color
|
||||||
|
colors[i + 1 + BRIGHT_OFFSET] = color
|
||||||
|
|
||||||
|
save_colors(filepath, colors)
|
||||||
|
print("Done — colors enhanced and written back to file.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Show current theme
|
||||||
|
# blob:examples=blob theme current
|
||||||
|
|
||||||
|
THEME_NAME_PATH="$HOME/.local/state/blob/current/theme.name"
|
||||||
|
|
||||||
|
if [[ -f $THEME_NAME_PATH ]]; then
|
||||||
|
sed -E 's/(^|-)([a-z])/\1\u\2/g; s/-/ /g' "$THEME_NAME_PATH"
|
||||||
|
else
|
||||||
|
echo "Unknown"
|
||||||
|
fi
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Print the directory holding a theme, preferring a user-installed copy
|
||||||
|
# blob:args=<theme-name>
|
||||||
|
# blob:examples=blob theme dir tokyo-night
|
||||||
|
|
||||||
|
theme="${1:-}"
|
||||||
|
|
||||||
|
if [[ -z $theme ]]; then
|
||||||
|
echo "Usage: blob-theme-dir <theme-name>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d $HOME/.config/blob/themes/$theme ]]; then
|
||||||
|
echo "$HOME/.config/blob/themes/$theme"
|
||||||
|
else
|
||||||
|
echo "$BLOB_PATH/themes/$theme"
|
||||||
|
fi
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Apply the pywal-generated theme built from the current wallpaper
|
||||||
|
|
||||||
|
dynamic_theme_dir="$HOME/.config/blob/themes/blob-dynamic"
|
||||||
|
|
||||||
|
if [ ! -f "$dynamic_theme_dir/colors.toml" ]; then
|
||||||
|
echo "No dynamic theme yet. Pick a wallpaper first: blob-bg-menu" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BLOB_THEME_SKIP_BACKGROUND=1 exec blob-theme-set blob-dynamic
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=List the user-installed themes that came from a git clone
|
||||||
|
# blob:examples=blob theme extras
|
||||||
|
|
||||||
|
# Exits nonzero when there are none, so a caller can ask whether any exist
|
||||||
|
# without reading the list. A symlinked theme is someone's working copy and a
|
||||||
|
# `.git` file is a worktree pointing elsewhere; neither is ours to pull.
|
||||||
|
status=1
|
||||||
|
|
||||||
|
for theme in ~/.config/blob/themes/*; do
|
||||||
|
[[ ! -L $theme && -d $theme/.git ]] || continue
|
||||||
|
echo "$theme"
|
||||||
|
status=0
|
||||||
|
done
|
||||||
|
|
||||||
|
exit $status
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Apply current Blob theme colors to running Foot terminals
|
||||||
|
# blob:hidden=true
|
||||||
|
|
||||||
|
colors_toml=$HOME/.local/state/blob/current/theme/colors.toml
|
||||||
|
|
||||||
|
if [[ ! -f $colors_toml ]] || ! pgrep -x foot >/dev/null; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
foot_osc=$(blob-theme-osc "$colors_toml")
|
||||||
|
[[ -n $foot_osc ]] || exit 0
|
||||||
|
|
||||||
|
for foot_pid in $(pgrep -x foot); do
|
||||||
|
for child_pid in $(pgrep -P "$foot_pid"); do
|
||||||
|
tty=$(readlink "/proc/$child_pid/fd/1" 2>/dev/null)
|
||||||
|
[[ $tty == /dev/pts/* ]] && printf '%b' "$foot_osc" >"$tty"
|
||||||
|
done
|
||||||
|
done
|
||||||
Executable
+151
@@ -0,0 +1,151 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Generate a theme's colors.toml from its alacritty.toml palette
|
||||||
|
# blob:args=<theme-dir>
|
||||||
|
# blob:hidden=true
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
THEME_SOURCE="${1:-}"
|
||||||
|
COLORS_OUTPUT="$THEME_SOURCE/colors.toml"
|
||||||
|
ALACRITTY_FILE="$THEME_SOURCE/alacritty.toml"
|
||||||
|
|
||||||
|
if [[ -z $THEME_SOURCE ]]; then
|
||||||
|
echo "Usage: blob-theme-import <theme-dir>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Skip if colors.toml already exists in source theme
|
||||||
|
if [[ -f $COLORS_OUTPUT ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Skip if no alacritty.toml to extract from
|
||||||
|
if [[ ! -f $ALACRITTY_FILE ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse the alacritty TOML in a single awk pass, emitting one
|
||||||
|
# "<dotted-path>\t#<hex>" pair per color-valued key. Dotted keys are
|
||||||
|
# normalized (`normal.black = ...` under [colors] becomes
|
||||||
|
# colors.normal.black, same as `black = ...` under [colors.normal]),
|
||||||
|
# with the section form taking precedence and the first valid
|
||||||
|
# occurrence of a key winning.
|
||||||
|
declare -A COLORS
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r key value; do
|
||||||
|
COLORS[$key]=$value
|
||||||
|
done < <(awk '
|
||||||
|
/^[ \t]*\[/ {
|
||||||
|
line = $0
|
||||||
|
sub(/^[ \t]+/, "", line)
|
||||||
|
if (line ~ /^\[[^]]*\][ \t]*$/) {
|
||||||
|
section = substr(line, 2, index(line, "]") - 2)
|
||||||
|
} else {
|
||||||
|
section = ""
|
||||||
|
}
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
section != "" {
|
||||||
|
split_pos = index($0, "=")
|
||||||
|
if (split_pos == 0) {
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
key = substr($0, 1, split_pos - 1)
|
||||||
|
gsub(/^[ \t]+|[ \t]+$/, "", key)
|
||||||
|
if (key == "" || key ~ /^#/) {
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
value = substr($0, split_pos + 1)
|
||||||
|
gsub(/^[ \t]+|[ \t]+$/, "", value)
|
||||||
|
|
||||||
|
# Accept a lone hex color, optionally 0x- or #-prefixed and quoted,
|
||||||
|
# ignoring any trailing comment. The color always yields the first
|
||||||
|
# run of six hex digits.
|
||||||
|
if (value !~ /^["'\''](0[xX]|#)?[0-9a-fA-F]{6}(["'\'']([ \t]*#.*)?)?$/ &&
|
||||||
|
value !~ /^(0[xX])?[0-9a-fA-F]{6}([ \t]*#.*)?$/) {
|
||||||
|
next
|
||||||
|
}
|
||||||
|
match(value, /[0-9a-fA-F]{6}/)
|
||||||
|
hex = tolower(substr(value, RSTART, RLENGTH))
|
||||||
|
|
||||||
|
path = section "." key
|
||||||
|
if (section == "colors" && index(key, ".") > 0) {
|
||||||
|
if (!(path in dotted)) dotted[path] = hex
|
||||||
|
} else {
|
||||||
|
if (!(path in direct)) direct[path] = hex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
END {
|
||||||
|
for (path in direct) print path "\t#" direct[path]
|
||||||
|
for (path in dotted) if (!(path in direct)) print path "\t#" dotted[path]
|
||||||
|
}
|
||||||
|
' "$ALACRITTY_FILE")
|
||||||
|
|
||||||
|
names=(black red green yellow blue magenta cyan white)
|
||||||
|
|
||||||
|
# Extract normal colors (color0-7)
|
||||||
|
for i in {0..7}; do
|
||||||
|
name="${names[$i]}"
|
||||||
|
printf -v "color$i" "%s" "${COLORS[colors.normal.$name]}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Validate we have all normal colors (required)
|
||||||
|
for c in color0 color1 color2 color3 color4 color5 color6 color7; do
|
||||||
|
if [[ -z ${!c} ]]; then
|
||||||
|
echo "Warning: Cannot extract all normal colors from $ALACRITTY_FILE, skipping generation" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Extract bright colors (color8-15), falling back to normal
|
||||||
|
for i in {0..7}; do
|
||||||
|
normal="color$i"
|
||||||
|
name="${names[$i]}"
|
||||||
|
val="${COLORS[colors.bright.$name]}"
|
||||||
|
printf -v "color$((i + 8))" "%s" "${val:-${!normal}}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Extract primary and selection colors
|
||||||
|
background="${COLORS[colors.primary.background]}"
|
||||||
|
foreground="${COLORS[colors.primary.foreground]}"
|
||||||
|
selection_background="${COLORS[colors.selection.background]}"
|
||||||
|
|
||||||
|
# Apply defaults
|
||||||
|
background=${background:-$color0}
|
||||||
|
foreground=${foreground:-$color7}
|
||||||
|
color0=$background
|
||||||
|
color7=$foreground
|
||||||
|
selection_background=${selection_background:-$foreground}
|
||||||
|
accent=$color4
|
||||||
|
|
||||||
|
mkdir -p "$THEME_SOURCE"
|
||||||
|
|
||||||
|
cat > "$COLORS_OUTPUT" <<EOF
|
||||||
|
accent = "$accent"
|
||||||
|
selection = "$selection_background"
|
||||||
|
|
||||||
|
background = "$background"
|
||||||
|
foreground = "$foreground"
|
||||||
|
|
||||||
|
color0 = "$color0"
|
||||||
|
color1 = "$color1"
|
||||||
|
color2 = "$color2"
|
||||||
|
color3 = "$color3"
|
||||||
|
color4 = "$color4"
|
||||||
|
color5 = "$color5"
|
||||||
|
color6 = "$color6"
|
||||||
|
color7 = "$color7"
|
||||||
|
color8 = "$color8"
|
||||||
|
color9 = "$color9"
|
||||||
|
color10 = "$color10"
|
||||||
|
color11 = "$color11"
|
||||||
|
color12 = "$color12"
|
||||||
|
color13 = "$color13"
|
||||||
|
color14 = "$color14"
|
||||||
|
color15 = "$color15"
|
||||||
|
EOF
|
||||||
Executable
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Install a theme from a git repository
|
||||||
|
# blob:args=[git-repo-url]
|
||||||
|
# blob:examples=blob theme install https://github.com/example/blob-example-theme.git
|
||||||
|
# blob:examples=blob theme install git@github.com:example/blob-example-theme.git
|
||||||
|
|
||||||
|
if [[ -z $1 ]]; then
|
||||||
|
echo -e "\e[32mSee https://omarchy.org/themes/\n\e[0m"
|
||||||
|
REPO_URL=$(gum input --placeholder="Git repo URL (https or git@host:org/repo.git)" --header="")
|
||||||
|
else
|
||||||
|
REPO_URL="$1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z $REPO_URL ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Refuse a URL that names a git option or a transport helper before cloning. The
|
||||||
|
# check is shared with blob-plugin-add and explains itself; a missing checker
|
||||||
|
# leaves this non-zero, which refuses the URL rather than cloning it.
|
||||||
|
blob-git-url-check "$REPO_URL" || exit 1
|
||||||
|
|
||||||
|
THEMES_DIR="$HOME/.config/blob/themes"
|
||||||
|
|
||||||
|
# Strip user@host: prefix from scp-style SSH URLs so basename sees just the path.
|
||||||
|
# git reads a URL as scp-style when a colon appears before any slash, so the path
|
||||||
|
# after it need not hold one: `git@host:blob-blue-theme.git` is a repo in that
|
||||||
|
# user's home, and leaving its prefix on names the theme after the whole URL.
|
||||||
|
REPO_PATH="$REPO_URL"
|
||||||
|
[[ $REPO_PATH != *"://"* && $REPO_PATH == *:* && ${REPO_PATH%%:*} != */* ]] && REPO_PATH="${REPO_PATH#*:}"
|
||||||
|
THEME_NAME=$(basename -- "$REPO_PATH" .git | sed -E 's/^blob-//; s/-theme$//' | tr '[:upper:]' '[:lower:]')
|
||||||
|
THEME_PATH="$THEMES_DIR/$THEME_NAME"
|
||||||
|
|
||||||
|
# The name comes from the URL, is joined into a path that is about to be
|
||||||
|
# removed, and then names a directory the rest of Blob passes around by
|
||||||
|
# name: Style > Unlock builds a command line out of the one the picker
|
||||||
|
# returned. So it is held to the characters a theme name needs rather than
|
||||||
|
# screened for the harm of the day -- a repo called `..` would take
|
||||||
|
# ~/.config/blob with it, and one called `a';'id` would carry its own
|
||||||
|
# command into that picker. The leading character is kept out of `.` and `-`,
|
||||||
|
# which also covers `host:-s/foo.git` leaving basename with `.git`.
|
||||||
|
# A bracket range follows the locale's collation, not ASCII: `[a-z]` takes in
|
||||||
|
# `é` under en_US.UTF-8. Pin the locale so the set is the one written here.
|
||||||
|
if ! (LC_ALL=C; [[ $THEME_NAME =~ ^[a-z0-9_][a-z0-9._+-]*$ ]]); then
|
||||||
|
echo "Error: '$REPO_URL' does not give a usable theme name."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove existing theme if present
|
||||||
|
if [[ -d $THEME_PATH ]]; then
|
||||||
|
rm -rf "$THEME_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clone the repo directly to ~/.config/blob/themes
|
||||||
|
if ! git clone -- "$REPO_URL" "$THEME_PATH"; then
|
||||||
|
echo "Error: Failed to clone theme repo."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Apply the new theme with blob-theme-set, which stages only the files an
|
||||||
|
# extra theme is allowed to contribute and names anything it dropped.
|
||||||
|
blob-theme-set "$THEME_NAME"
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=List available themes
|
||||||
|
# blob:examples=blob theme list | blob theme set "Tokyo Night"
|
||||||
|
|
||||||
|
{
|
||||||
|
find ~/.config/blob/themes/ -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -printf '%f\n'
|
||||||
|
find "$BLOB_PATH/themes/" -mindepth 1 -maxdepth 1 -type d -printf '%f\n'
|
||||||
|
} | sort -u | sed -E 's/(^|-)([a-z])/\1\u\2/g; s/-/ /g'
|
||||||
Executable
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Pick a color theme from the local and bundled sets
|
||||||
|
# blob:args=[--mode|--print]
|
||||||
|
|
||||||
|
user_themes_dir="$HOME/.config/blob/themes"
|
||||||
|
current_dir="$HOME/.local/state/blob/current"
|
||||||
|
|
||||||
|
prettify() {
|
||||||
|
echo "$1" | sed -E 's/(^|-)([a-z])/\1\u\2/g; s/-/ /g'
|
||||||
|
}
|
||||||
|
|
||||||
|
current_mode() {
|
||||||
|
local name
|
||||||
|
name=$(cat "$current_dir/theme.name" 2>/dev/null)
|
||||||
|
if [ "$name" = "blob-dynamic" ]; then
|
||||||
|
echo "dynamic"
|
||||||
|
else
|
||||||
|
echo "static"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
list_theme_rows() {
|
||||||
|
printf 'Dynamic (from wallpaper)\tblob-dynamic\n'
|
||||||
|
|
||||||
|
{
|
||||||
|
find "$user_themes_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null
|
||||||
|
find "$BLOB_PATH/themes" -mindepth 1 -maxdepth 1 -type d 2>/dev/null
|
||||||
|
} | while read -r theme_dir; do
|
||||||
|
[ -f "$theme_dir/colors.toml" ] || continue
|
||||||
|
slug=$(basename "$theme_dir")
|
||||||
|
[ "$slug" = "blob-dynamic" ] && continue
|
||||||
|
printf '%s\t%s\n' "$(prettify "$slug")" "$slug"
|
||||||
|
done | sort -u -t$'\t' -k2,2
|
||||||
|
}
|
||||||
|
|
||||||
|
print_current_colors() {
|
||||||
|
local colors_file="$current_dir/theme/colors.toml"
|
||||||
|
if [ ! -f "$colors_file" ]; then
|
||||||
|
echo "No active theme colors.toml found at $colors_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
awk '1' "$colors_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
--mode)
|
||||||
|
current_mode
|
||||||
|
;;
|
||||||
|
--print)
|
||||||
|
print_current_colors
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
selection=$(list_theme_rows | blob-menu-select "Select Theme" -- --width 800) || exit 0
|
||||||
|
slug=$(printf '%s' "$selection" | cut -f2)
|
||||||
|
[ -n "$slug" ] || exit 0
|
||||||
|
if [ "$slug" = "blob-dynamic" ]; then
|
||||||
|
exec blob-theme-dynamic
|
||||||
|
fi
|
||||||
|
awww kill --all >/dev/null 2>&1
|
||||||
|
exec blob-theme-set "$slug"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Print OSC sequences for an Blob color theme
|
||||||
|
# blob:hidden=true
|
||||||
|
|
||||||
|
theme="${1:-$HOME/.local/state/blob/current/theme/colors.toml}"
|
||||||
|
|
||||||
|
[[ -f $theme ]] || exit 0
|
||||||
|
|
||||||
|
declare -A COLORS
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r key value; do
|
||||||
|
COLORS[$key]="$value"
|
||||||
|
done < <(blob-theme-color --file "$theme" --all)
|
||||||
|
|
||||||
|
emit_osc() {
|
||||||
|
local code="$1"
|
||||||
|
local key="$2"
|
||||||
|
|
||||||
|
[[ -n ${COLORS[$key]:-} ]] || return 0
|
||||||
|
printf '\033]%s;%s\007' "$code" "${COLORS[$key]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
emit_osc 10 foreground
|
||||||
|
emit_osc 11 background
|
||||||
|
emit_osc 12 cursor
|
||||||
|
emit_osc 17 selection_background
|
||||||
|
emit_osc 19 selection_foreground
|
||||||
|
|
||||||
|
for i in {0..15}; do
|
||||||
|
[[ -n ${COLORS[color$i]:-} ]] || continue
|
||||||
|
printf '\033]4;%d;%s\007' "$i" "${COLORS[color$i]}"
|
||||||
|
done
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Refresh the current theme from its templates.
|
||||||
|
|
||||||
|
THEME_NAME_PATH="$HOME/.local/state/blob/current/theme.name"
|
||||||
|
|
||||||
|
if [[ -f $THEME_NAME_PATH ]]; then
|
||||||
|
BLOB_THEME_SKIP_BACKGROUND=1 blob-theme-set "$(cat $THEME_NAME_PATH)"
|
||||||
|
fi
|
||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Remove a user-installed theme
|
||||||
|
# blob:args=[theme-name]
|
||||||
|
# blob:examples=blob theme remove "Tokyo Night"
|
||||||
|
|
||||||
|
if [[ -z $1 ]]; then
|
||||||
|
mapfile -t extra_themes < <(find ~/.config/blob/themes -mindepth 1 -maxdepth 1 -type d ! -xtype l -printf '%f\n')
|
||||||
|
|
||||||
|
if (( ${#extra_themes[@]} > 0 )); then
|
||||||
|
mapfile -t extra_themes < <(printf '%s\n' "${extra_themes[@]}" | sort)
|
||||||
|
THEME_NAME=$(blob-menu-select "Remove extra theme" "${extra_themes[@]}" -- --width 520 --maxheight 520)
|
||||||
|
else
|
||||||
|
echo "No extra themes installed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
THEME_NAME="$1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
THEMES_DIR="$HOME/.config/blob/themes"
|
||||||
|
THEME_PATH="$THEMES_DIR/$THEME_NAME"
|
||||||
|
|
||||||
|
# Ensure a theme was set, and that the name cannot climb out of THEMES_DIR
|
||||||
|
# on its way into the rm below.
|
||||||
|
if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if theme exists before attempting removal
|
||||||
|
if [[ ! -d $THEME_PATH ]]; then
|
||||||
|
echo "Error: Theme '$THEME_NAME' not found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Now remove the theme directory for THEME_NAME
|
||||||
|
rm -rf "$THEME_PATH"
|
||||||
|
blob-notify-send "Theme removed" "$THEME_NAME" || true
|
||||||
|
echo "Removed $THEME_NAME"
|
||||||
Executable
+337
@@ -0,0 +1,337 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Apply an Blob theme
|
||||||
|
# blob:args=<theme-name>
|
||||||
|
# blob:examples=blob theme list | blob theme set "Tokyo Night"
|
||||||
|
|
||||||
|
if [[ -z $1 ]]; then
|
||||||
|
echo "Usage: blob-theme-set <theme-name>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT_THEME_PATH="$HOME/.local/state/blob/current/theme"
|
||||||
|
NEXT_THEME_PATH="$HOME/.local/state/blob/current/next-theme"
|
||||||
|
CURRENT_BACKGROUND_LINK="$HOME/.local/state/blob/current/background"
|
||||||
|
BACKGROUND_TRANSITION_CACHE="$HOME/.cache/blob/background-transitions"
|
||||||
|
THEME_SET_LOCK="${XDG_RUNTIME_DIR:-/tmp}/blob-theme-set.lock"
|
||||||
|
USER_THEMES_PATH="$HOME/.config/blob/themes"
|
||||||
|
BLOB_THEMES_PATH="$BLOB_PATH/themes"
|
||||||
|
|
||||||
|
# What a theme installed from a git repo may not ship, because these run code.
|
||||||
|
# Hyprland requires a theme's hyprland.lua at login and Neovim loads its
|
||||||
|
# neovim.lua at startup, so no .lua from such a theme is staged at all. Each
|
||||||
|
# terminal config names the program the terminal launches. vscode.json is denied
|
||||||
|
# even though nothing here reads it, because a theme that ships one is naming a
|
||||||
|
# VS Code extension, which is arbitrary JavaScript. Everything else a theme
|
||||||
|
# ships is colour and is kept.
|
||||||
|
#
|
||||||
|
# Adding a template for another terminal, or for another editor that loads Lua,
|
||||||
|
# means adding it here.
|
||||||
|
INSTALLED_THEME_DENIED=(alacritty.toml foot.ini ghostty.conf kitty.conf vscode.json)
|
||||||
|
IGNORED_THEME_FILES=()
|
||||||
|
|
||||||
|
run_parallel() {
|
||||||
|
local pid
|
||||||
|
local pids=()
|
||||||
|
|
||||||
|
for command in "$@"; do
|
||||||
|
bash -lc "$command" &
|
||||||
|
pids+=("$!")
|
||||||
|
done
|
||||||
|
|
||||||
|
for pid in "${pids[@]}"; do
|
||||||
|
wait "$pid"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
shell_ipc() {
|
||||||
|
timeout 2 blob-shell "$@" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot_background_path() {
|
||||||
|
local background="$1"
|
||||||
|
local name="$2"
|
||||||
|
local snapshot extension
|
||||||
|
|
||||||
|
[[ -f $background ]] || return
|
||||||
|
|
||||||
|
mkdir -p "$BACKGROUND_TRANSITION_CACHE"
|
||||||
|
extension=${background##*.}
|
||||||
|
snapshot="$BACKGROUND_TRANSITION_CACHE/$name-$$.$extension"
|
||||||
|
ln "$background" "$snapshot" 2>/dev/null || cp "$background" "$snapshot"
|
||||||
|
echo "$snapshot"
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot_current_background() {
|
||||||
|
local current_background
|
||||||
|
|
||||||
|
current_background=$(readlink -f "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true)
|
||||||
|
snapshot_background_path "$current_background" "previous"
|
||||||
|
}
|
||||||
|
|
||||||
|
choose_theme_background() {
|
||||||
|
local backgrounds=()
|
||||||
|
local current_background index next_index i
|
||||||
|
|
||||||
|
CHOSEN_THEME_BACKGROUND=""
|
||||||
|
mapfile -d '' -t backgrounds < <(
|
||||||
|
find -L "$HOME/.config/blob/backgrounds/$THEME_NAME/" "$CURRENT_THEME_PATH/backgrounds/" -maxdepth 1 -type f \
|
||||||
|
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \
|
||||||
|
-print0 2>/dev/null | sort -z
|
||||||
|
)
|
||||||
|
|
||||||
|
(( ${#backgrounds[@]} > 0 )) || return 1
|
||||||
|
|
||||||
|
current_background=$(readlink "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true)
|
||||||
|
index=-1
|
||||||
|
for i in "${!backgrounds[@]}"; do
|
||||||
|
if [[ ${backgrounds[$i]} == $current_background ]]; then
|
||||||
|
index=$i
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if (( index == -1 )); then
|
||||||
|
CHOSEN_THEME_BACKGROUND="${backgrounds[0]}"
|
||||||
|
else
|
||||||
|
next_index=$(((index + 1) % ${#backgrounds[@]}))
|
||||||
|
CHOSEN_THEME_BACKGROUND="${backgrounds[$next_index]}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
set_theme_background_link() {
|
||||||
|
choose_theme_background || return 1
|
||||||
|
ln -nsf "$CHOSEN_THEME_BACKGROUND" "$CURRENT_BACKGROUND_LINK"
|
||||||
|
}
|
||||||
|
|
||||||
|
set_theme_background() {
|
||||||
|
local new_background new_background_snapshot
|
||||||
|
|
||||||
|
if ! choose_theme_background; then
|
||||||
|
blob-notify-send "No background was found for theme" -t 2000
|
||||||
|
shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
new_background="$CHOSEN_THEME_BACKGROUND"
|
||||||
|
new_background_snapshot=$(snapshot_background_path "$new_background" "next")
|
||||||
|
|
||||||
|
if [[ -f $OLD_BACKGROUND_SNAPSHOT && -f $new_background_snapshot ]]; then
|
||||||
|
shell_ipc background themeTransition "$OLD_BACKGROUND_SNAPSHOT" "$new_background_snapshot" "$new_background" "$colors_payload" "$shell_payload" || \
|
||||||
|
shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true
|
||||||
|
(sleep 3; rm -f "$OLD_BACKGROUND_SNAPSHOT" "$new_background_snapshot") &
|
||||||
|
elif [[ -f $new_background_snapshot ]]; then
|
||||||
|
shell_ipc background themeTransition "" "$new_background_snapshot" "$new_background" "$colors_payload" "$shell_payload" || \
|
||||||
|
shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true
|
||||||
|
(sleep 3; rm -f "$new_background_snapshot") &
|
||||||
|
else
|
||||||
|
shell_ipc background themeTransition "$OLD_BACKGROUND_SNAPSHOT" "$new_background" "$new_background" "$colors_payload" "$shell_payload" || \
|
||||||
|
shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true
|
||||||
|
if [[ -f $OLD_BACKGROUND_SNAPSHOT ]]; then
|
||||||
|
(sleep 3; rm -f "$OLD_BACKGROUND_SNAPSHOT") &
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
ln -nsf "$new_background" "$CURRENT_BACKGROUND_LINK"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_denied_installed_file() {
|
||||||
|
local name="$1"
|
||||||
|
local denied
|
||||||
|
|
||||||
|
[[ $name == *.lua ]] && return 0
|
||||||
|
|
||||||
|
for denied in "${INSTALLED_THEME_DENIED[@]}"; do
|
||||||
|
[[ $name == "$denied" ]] && return 0
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Themes older than colors.toml still get their palette, but their
|
||||||
|
# alacritty.toml never reaches the staged theme: an Alacritty config names the
|
||||||
|
# program the terminal launches.
|
||||||
|
stage_installed_colors_from_alacritty() {
|
||||||
|
local source="$1"
|
||||||
|
local scratch
|
||||||
|
|
||||||
|
if [[ -f $NEXT_THEME_PATH/colors.toml ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f $source/alacritty.toml || -L $source/alacritty.toml ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
scratch=$(mktemp -d)
|
||||||
|
cp "$source/alacritty.toml" "$scratch/alacritty.toml"
|
||||||
|
blob-theme-import "$scratch"
|
||||||
|
|
||||||
|
if [[ -f $scratch/colors.toml ]]; then
|
||||||
|
cp "$scratch/colors.toml" "$NEXT_THEME_PATH/colors.toml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$scratch"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copies a directory without ever following a symlink: in an installed theme one
|
||||||
|
# points wherever the theme author chose, which is how an unlock.png becomes a
|
||||||
|
# copy of any file the session can read.
|
||||||
|
stage_installed_dir() {
|
||||||
|
local source="$1"
|
||||||
|
local dest="$2"
|
||||||
|
local entry name
|
||||||
|
|
||||||
|
mkdir -p "$dest"
|
||||||
|
|
||||||
|
for entry in "$source"/*; do
|
||||||
|
[[ -e $entry && ! -L $entry ]] || continue
|
||||||
|
name=${entry##*/}
|
||||||
|
|
||||||
|
if [[ -d $entry ]]; then
|
||||||
|
stage_installed_dir "$entry" "$dest/$name"
|
||||||
|
else
|
||||||
|
cp "$entry" "$dest/$name"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# `blob theme install` clones into ~/.config/blob/themes, so a .git
|
||||||
|
# directory there means the contents came from a stranger and are held to the
|
||||||
|
# list above. A directory the user wrote themselves, and a symlink to their own
|
||||||
|
# working copy, are theirs to fill however they like -- the same distinction
|
||||||
|
# blob-theme-extras draws when it decides which themes it may pull.
|
||||||
|
theme_came_from_a_repo() {
|
||||||
|
local source="$1"
|
||||||
|
|
||||||
|
[[ ! -L $source && -d $source/.git ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
stage_installed_theme() {
|
||||||
|
local source="$1"
|
||||||
|
local entry name
|
||||||
|
|
||||||
|
[[ -d $source ]] || return 0
|
||||||
|
|
||||||
|
for entry in "$source"/*; do
|
||||||
|
[[ -e $entry ]] || continue
|
||||||
|
name=${entry##*/}
|
||||||
|
|
||||||
|
if [[ -L $entry ]] || is_denied_installed_file "$name"; then
|
||||||
|
case "${name,,}" in
|
||||||
|
readme* | license* | changelog* | *.md | *.txt) ;;
|
||||||
|
*) IGNORED_THEME_FILES+=("$name") ;;
|
||||||
|
esac
|
||||||
|
elif [[ -d $entry ]]; then
|
||||||
|
stage_installed_dir "$entry" "$NEXT_THEME_PATH/$name"
|
||||||
|
else
|
||||||
|
cp "$entry" "$NEXT_THEME_PATH/$name"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
stage_installed_colors_from_alacritty "$source"
|
||||||
|
}
|
||||||
|
|
||||||
|
report_ignored_theme_files() {
|
||||||
|
(( ${#IGNORED_THEME_FILES[@]} > 0 )) || return 0
|
||||||
|
|
||||||
|
echo "Ignored in $USER_THEMES_PATH/$THEME_NAME: ${IGNORED_THEME_FILES[*]}" >&2
|
||||||
|
echo "A theme installed from a git repo cannot supply Lua, a terminal config, or vscode.json." >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
THEME_NAME=$(echo "$1" | sed -E 's/<[^>]+>//g' | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
|
||||||
|
THEME_HEADLESS=0
|
||||||
|
if [[ ${BLOB_THEME_HEADLESS:-} == "1" || ${BLOB_THEME_OFFLINE:-} == "1" ]]; then
|
||||||
|
THEME_HEADLESS=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then
|
||||||
|
echo "Invalid theme name: $1"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d $BLOB_THEMES_PATH/$THEME_NAME ]] && [[ ! -d $USER_THEMES_PATH/$THEME_NAME ]]; then
|
||||||
|
echo "Theme '$THEME_NAME' does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Serialize theme changes. Theme switching rebuilds a shared next-theme staging
|
||||||
|
# directory and updates current theme/background symlinks; concurrent calls can
|
||||||
|
# otherwise race and make one selection appear to be ignored.
|
||||||
|
exec 9>"$THEME_SET_LOCK"
|
||||||
|
flock 9
|
||||||
|
|
||||||
|
# Setup clean next theme directory (for atomic theme config swapping)
|
||||||
|
rm -rf "$NEXT_THEME_PATH"
|
||||||
|
mkdir -p "$NEXT_THEME_PATH"
|
||||||
|
|
||||||
|
# Copy official theme first, then overlay the user's theme on top
|
||||||
|
cp -r "$BLOB_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null
|
||||||
|
|
||||||
|
if theme_came_from_a_repo "$USER_THEMES_PATH/$THEME_NAME"; then
|
||||||
|
stage_installed_theme "$USER_THEMES_PATH/$THEME_NAME"
|
||||||
|
report_ignored_theme_files
|
||||||
|
else
|
||||||
|
cp -r "$USER_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate colors.toml from alacritty.toml if theme is missing colors.toml
|
||||||
|
if [[ ! -f $NEXT_THEME_PATH/colors.toml && -f $NEXT_THEME_PATH/alacritty.toml ]]; then
|
||||||
|
blob-theme-import "$NEXT_THEME_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate dynamic configs
|
||||||
|
blob-theme-templates
|
||||||
|
|
||||||
|
OLD_BACKGROUND_SNAPSHOT=""
|
||||||
|
if [[ $THEME_HEADLESS != "1" && $BLOB_THEME_SKIP_BACKGROUND != "1" ]]; then
|
||||||
|
OLD_BACKGROUND_SNAPSHOT=$(snapshot_current_background)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Swap next theme in as current
|
||||||
|
rm -rf "$CURRENT_THEME_PATH"
|
||||||
|
mv "$NEXT_THEME_PATH" "$CURRENT_THEME_PATH"
|
||||||
|
|
||||||
|
# Store theme name for reference
|
||||||
|
echo "$THEME_NAME" >"$HOME/.local/state/blob/current/theme.name"
|
||||||
|
|
||||||
|
# Make the running shell pick up the new palette immediately while the rest of
|
||||||
|
# the theme hooks run.
|
||||||
|
colors_payload=$([[ -f $CURRENT_THEME_PATH/colors.toml ]] && base64 -w 0 "$CURRENT_THEME_PATH/colors.toml")
|
||||||
|
shell_payload=$([[ -f $CURRENT_THEME_PATH/shell.toml ]] && base64 -w 0 "$CURRENT_THEME_PATH/shell.toml")
|
||||||
|
if [[ $THEME_HEADLESS == "1" ]]; then
|
||||||
|
# No shell/session bus exists during ISO chroot finalization, but the first
|
||||||
|
# real login still needs a current background symlink for blob-shell to
|
||||||
|
# render.
|
||||||
|
[[ $BLOB_THEME_SKIP_BACKGROUND == "1" ]] || set_theme_background_link || true
|
||||||
|
elif [[ $BLOB_THEME_SKIP_BACKGROUND == "1" ]]; then
|
||||||
|
shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true
|
||||||
|
else
|
||||||
|
set_theme_background
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The shared staging/current symlinks are updated and the shell has accepted
|
||||||
|
# the transition. Let another theme selection queue only behind that critical
|
||||||
|
# section, not behind slower app-retint hooks and selector cache warmups.
|
||||||
|
flock -u 9
|
||||||
|
|
||||||
|
post_theme_commands=(
|
||||||
|
blob-restart-terminal
|
||||||
|
blob-hypr-restart
|
||||||
|
blob-restart-btop
|
||||||
|
blob-theme-foot
|
||||||
|
blob-theme-browser
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ $THEME_HEADLESS != "1" ]]; then
|
||||||
|
run_parallel "${post_theme_commands[@]}"
|
||||||
|
|
||||||
|
# Call hook on theme set
|
||||||
|
blob-hook theme-set "$THEME_NAME" >/dev/null
|
||||||
|
|
||||||
|
# Warm selector caches after the theme is applied. The shell hot-reloads theme
|
||||||
|
# colors/backgrounds, so keep the running instance alive and preload the picker
|
||||||
|
# rows/selection to avoid first-open carousel settling after a theme change.
|
||||||
|
blob-theme-switcher --preload >/dev/null 2>&1
|
||||||
|
blob-bg-cache >/dev/null 2>&1 &
|
||||||
|
fi
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Fetch a shared theme by link or id and apply it as blob-dynamic
|
||||||
|
# blob:args=<share-link-or-id>
|
||||||
|
# blob:examples=blob theme share https://wall-styles.vercel.app/?id=aB3xQ
|
||||||
|
|
||||||
|
dynamic_theme_dir="$HOME/.config/blob/themes/blob-dynamic"
|
||||||
|
base_url="${BLOB_THEME_URL:-https://wall-styles.vercel.app}"
|
||||||
|
|
||||||
|
input="$1"
|
||||||
|
|
||||||
|
if [ -z "$input" ]; then
|
||||||
|
echo "Usage: blob-theme-share <share-link-or-id>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $input == http*://* ]]; then
|
||||||
|
host=$(echo "$input" | sed -E 's#^(https?://[^/]+).*#\1#')
|
||||||
|
share_id=$(echo "$input" | grep -oE 'id=[A-Za-z0-9]+' | head -1 | cut -d= -f2)
|
||||||
|
if [ -z "$share_id" ]; then
|
||||||
|
echo "Error: no share id found in '$input'." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
api="$host/api/theme?id=$share_id&format=toml"
|
||||||
|
else
|
||||||
|
api="$base_url/api/theme?id=$input&format=toml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$dynamic_theme_dir/backgrounds"
|
||||||
|
|
||||||
|
echo "Fetching theme..."
|
||||||
|
downloaded=$(mktemp)
|
||||||
|
if ! curl -fsSL "$api" -o "$downloaded"; then
|
||||||
|
echo "Error: could not fetch the theme. The link may be expired (themes last one hour)." >&2
|
||||||
|
rm -f "$downloaded"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q '^background = ' "$downloaded"; then
|
||||||
|
echo "Error: the response did not look like a valid colors.toml." >&2
|
||||||
|
rm -f "$downloaded"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mv "$downloaded" "$dynamic_theme_dir/colors.toml"
|
||||||
|
|
||||||
|
cat >"$dynamic_theme_dir/neovim.lua" <<'NEOVIM'
|
||||||
|
return {
|
||||||
|
{
|
||||||
|
"LazyVim/LazyVim",
|
||||||
|
opts = {
|
||||||
|
colorscheme = "tokyonight",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
NEOVIM
|
||||||
|
|
||||||
|
blob-theme-set blob-dynamic
|
||||||
|
|
||||||
|
echo "Applied shared theme to blob-dynamic."
|
||||||
Executable
+125
@@ -0,0 +1,125 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Open the Blob theme switcher
|
||||||
|
|
||||||
|
preload=false
|
||||||
|
|
||||||
|
if [[ $1 == "--preload" ]]; then
|
||||||
|
preload=true
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
|
||||||
|
USER_THEMES_PATH="$HOME/.config/blob/themes"
|
||||||
|
BLOB_THEMES_PATH="$BLOB_PATH/themes"
|
||||||
|
CACHE_PATH="${XDG_CACHE_HOME:-$HOME/.cache}/blob/theme-selector"
|
||||||
|
preview_dir="$CACHE_PATH/previews"
|
||||||
|
signature_file="$CACHE_PATH/signature"
|
||||||
|
fast_signature_file="$CACHE_PATH/fast-signature"
|
||||||
|
|
||||||
|
mkdir -p "$preview_dir"
|
||||||
|
|
||||||
|
find_preview() {
|
||||||
|
local theme_path="$1"
|
||||||
|
local preview preview_name
|
||||||
|
|
||||||
|
for preview_name in preview.png preview.jpg preview.jpeg preview.webp preview.gif preview.bmp; do
|
||||||
|
preview=$(find -L "$theme_path" -maxdepth 1 -type f -iname "$preview_name" -print -quit 2>/dev/null)
|
||||||
|
|
||||||
|
if [[ -n $preview ]]; then
|
||||||
|
printf '%s\n' "$preview"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -d $theme_path/backgrounds ]]; then
|
||||||
|
find -L "$theme_path/backgrounds" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) -print 2>/dev/null | sort | head -n 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
add_theme_preview() {
|
||||||
|
local theme_name="$1"
|
||||||
|
local preview="$2"
|
||||||
|
local extension="${preview##*.}"
|
||||||
|
extension="${extension,,}"
|
||||||
|
|
||||||
|
[[ -n $preview ]] || return
|
||||||
|
[[ -e $preview_dir/$theme_name.$extension ]] && return
|
||||||
|
|
||||||
|
ln -s "$preview" "$preview_dir/$theme_name.$extension"
|
||||||
|
}
|
||||||
|
|
||||||
|
fast_signature="v1"$'\n'
|
||||||
|
for theme_dir in "$USER_THEMES_PATH" "$BLOB_THEMES_PATH"; do
|
||||||
|
if [[ -d $theme_dir ]]; then
|
||||||
|
fast_signature+="$theme_dir:$(stat -Lc '%Y' "$theme_dir")"$'\n'
|
||||||
|
|
||||||
|
while IFS= read -r -d '' theme_path; do
|
||||||
|
fast_signature+="$theme_path:$(stat -Lc '%Y' "$theme_path")"$'\n'
|
||||||
|
done < <(find -L "$theme_dir" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -print0 2>/dev/null | sort -z)
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ! -f $fast_signature_file ]] || ! cmp -s "$fast_signature_file" <(printf '%s' "$fast_signature"); then
|
||||||
|
theme_signature=""
|
||||||
|
for theme_dir in "$USER_THEMES_PATH" "$BLOB_THEMES_PATH"; do
|
||||||
|
if [[ -d $theme_dir ]]; then
|
||||||
|
theme_signature+="$theme_dir:$(stat -Lc '%Y' "$theme_dir")"$'\n'
|
||||||
|
|
||||||
|
while IFS= read -r -d '' theme_path; do
|
||||||
|
preview=$(find_preview "$theme_path")
|
||||||
|
theme_signature+="$theme_path:$(stat -Lc '%Y' "$theme_path")"$'\n'
|
||||||
|
|
||||||
|
if [[ -n $preview ]]; then
|
||||||
|
theme_signature+="$preview:$(stat -Lc '%s:%Y' "$preview")"$'\n'
|
||||||
|
fi
|
||||||
|
done < <(find -L "$theme_dir" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -print0 2>/dev/null | sort -z)
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f $fast_signature_file ]] || ! cmp -s "$fast_signature_file" <(printf '%s' "$fast_signature"); then
|
||||||
|
rm -rf "$preview_dir"
|
||||||
|
mkdir -p "$preview_dir"
|
||||||
|
|
||||||
|
while IFS= read -r theme_path; do
|
||||||
|
theme_name=${theme_path##*/}
|
||||||
|
preview=$(find_preview "$theme_path")
|
||||||
|
|
||||||
|
if [[ -z $preview ]]; then
|
||||||
|
preview=$(find_preview "$BLOB_THEMES_PATH/$theme_name")
|
||||||
|
fi
|
||||||
|
|
||||||
|
add_theme_preview "$theme_name" "$preview"
|
||||||
|
done < <(find -L "$USER_THEMES_PATH" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -print 2>/dev/null | sort)
|
||||||
|
|
||||||
|
while IFS= read -r theme_path; do
|
||||||
|
theme_name=${theme_path##*/}
|
||||||
|
preview=$(find_preview "$theme_path")
|
||||||
|
add_theme_preview "$theme_name" "$preview"
|
||||||
|
done < <(find -L "$BLOB_THEMES_PATH" -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null | sort)
|
||||||
|
|
||||||
|
printf '%s' "$theme_signature" >"$signature_file"
|
||||||
|
printf '%s' "$fast_signature" >"$fast_signature_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
current_theme=$(cat "$HOME/.local/state/blob/current/theme.name" 2>/dev/null)
|
||||||
|
selected_preview=""
|
||||||
|
for extension in png jpg jpeg webp gif bmp; do
|
||||||
|
if [[ -e $preview_dir/$current_theme.$extension ]]; then
|
||||||
|
selected_preview="$preview_dir/$current_theme.$extension"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
menu_args=(
|
||||||
|
--print-name
|
||||||
|
--show-labels
|
||||||
|
--filterable
|
||||||
|
--lazy-thumbnails
|
||||||
|
--selected "$selected_preview"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ $preload == true ]]; then
|
||||||
|
menu_args+=(--preload)
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec blob-menu-images "${menu_args[@]}" "$preview_dir"
|
||||||
Executable
+404
@@ -0,0 +1,404 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Generate themed config files from Blob templates
|
||||||
|
# blob:hidden=true
|
||||||
|
|
||||||
|
TEMPLATES_DIR="$BLOB_PATH/default/themed"
|
||||||
|
USER_TEMPLATES_DIR="$HOME/.config/blob/themed"
|
||||||
|
NEXT_THEME_DIR="$HOME/.local/state/blob/current/next-theme"
|
||||||
|
COLORS_FILE="$NEXT_THEME_DIR/colors.toml"
|
||||||
|
|
||||||
|
declare -A THEME_COLORS
|
||||||
|
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
trim() {
|
||||||
|
local value="$1"
|
||||||
|
|
||||||
|
value="${value#"${value%%[![:space:]]*}"}"
|
||||||
|
value="${value%"${value##*[![:space:]]}"}"
|
||||||
|
printf "%s" "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_theme_ref() {
|
||||||
|
local ref="$1"
|
||||||
|
local fallback="${2:-}"
|
||||||
|
|
||||||
|
if [[ -n ${THEME_COLORS[$ref]+_} ]]; then
|
||||||
|
printf "%s" "${THEME_COLORS[$ref]}"
|
||||||
|
elif [[ -n $fallback && -n ${THEME_COLORS[$fallback]+_} ]]; then
|
||||||
|
printf "%s" "${THEME_COLORS[$fallback]}"
|
||||||
|
elif [[ -n $fallback ]]; then
|
||||||
|
printf "%s" "$fallback"
|
||||||
|
else
|
||||||
|
printf "%s" "$ref"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_gradient_color() {
|
||||||
|
local color
|
||||||
|
|
||||||
|
color=$(trim "$1")
|
||||||
|
if [[ -n ${THEME_COLORS[$color]+_} ]]; then
|
||||||
|
color="${THEME_COLORS[$color]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "%s" "$color"
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_gradient() {
|
||||||
|
local spec="$1"
|
||||||
|
local part color
|
||||||
|
local -a parts
|
||||||
|
|
||||||
|
GRADIENT_COLORS=()
|
||||||
|
GRADIENT_ANGLE=""
|
||||||
|
|
||||||
|
read -ra parts <<<"$spec"
|
||||||
|
for part in "${parts[@]}"; do
|
||||||
|
[[ -n $part ]] || continue
|
||||||
|
|
||||||
|
if [[ $part =~ ^-?[0-9]+([.][0-9]+)?deg$ ]]; then
|
||||||
|
GRADIENT_ANGLE="${part%deg}"
|
||||||
|
else
|
||||||
|
color=$(resolve_gradient_color "$part")
|
||||||
|
GRADIENT_COLORS+=("$color")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
color_to_shell_hex() {
|
||||||
|
local color r g b
|
||||||
|
|
||||||
|
color=$(resolve_gradient_color "$1")
|
||||||
|
|
||||||
|
if [[ $color =~ ^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$ ]]; then
|
||||||
|
printf "#%s" "${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]}"
|
||||||
|
elif [[ $color =~ ^[Rr][Gg][Bb][Aa]?\(([0-9]+),([0-9]+),([0-9]+)(,[0-9.]+)?\)$ ]]; then
|
||||||
|
r=${BASH_REMATCH[1]}
|
||||||
|
g=${BASH_REMATCH[2]}
|
||||||
|
b=${BASH_REMATCH[3]}
|
||||||
|
(( r > 255 )) && r=255
|
||||||
|
(( g > 255 )) && g=255
|
||||||
|
(( b > 255 )) && b=255
|
||||||
|
printf "#%02x%02x%02x" "$r" "$g" "$b"
|
||||||
|
elif [[ $color =~ ^0x[0-9A-Fa-f]{8}$ ]]; then
|
||||||
|
printf "#%s" "${color:4:6}"
|
||||||
|
else
|
||||||
|
printf "%s" "$color"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
hypr_gradient_value() {
|
||||||
|
local spec color index
|
||||||
|
|
||||||
|
spec=$(resolve_theme_ref "$1" "${2:-}")
|
||||||
|
parse_gradient "$spec"
|
||||||
|
|
||||||
|
if (( ${#GRADIENT_COLORS[@]} == 0 )); then
|
||||||
|
printf '"%s"' "$spec"
|
||||||
|
elif (( ${#GRADIENT_COLORS[@]} == 1 )); then
|
||||||
|
printf '"%s"' "${GRADIENT_COLORS[0]}"
|
||||||
|
else
|
||||||
|
printf '{ colors = {'
|
||||||
|
for index in "${!GRADIENT_COLORS[@]}"; do
|
||||||
|
(( index > 0 )) && printf ','
|
||||||
|
printf ' "%s"' "${GRADIENT_COLORS[$index]}"
|
||||||
|
done
|
||||||
|
printf ' }'
|
||||||
|
[[ -n $GRADIENT_ANGLE ]] && printf ', angle = %s' "$GRADIENT_ANGLE"
|
||||||
|
printf ' }'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
gradient_start_value() {
|
||||||
|
local spec
|
||||||
|
|
||||||
|
spec=$(resolve_theme_ref "$1" "${2:-}")
|
||||||
|
parse_gradient "$spec"
|
||||||
|
|
||||||
|
if (( ${#GRADIENT_COLORS[@]} == 0 )); then
|
||||||
|
color_to_shell_hex "$spec"
|
||||||
|
else
|
||||||
|
color_to_shell_hex "${GRADIENT_COLORS[0]}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
shell_gradient_value() {
|
||||||
|
local spec index
|
||||||
|
|
||||||
|
spec=$(resolve_theme_ref "$1" "${2:-}")
|
||||||
|
parse_gradient "$spec"
|
||||||
|
|
||||||
|
if (( ${#GRADIENT_COLORS[@]} == 0 )); then
|
||||||
|
printf "%s" "$spec"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
for index in "${!GRADIENT_COLORS[@]}"; do
|
||||||
|
(( index > 0 )) && printf " "
|
||||||
|
printf "%s" "${GRADIENT_COLORS[$index]}"
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -n $GRADIENT_ANGLE ]] && printf " %sdeg" "$GRADIENT_ANGLE"
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
if [[ $value =~ ^#[0-9A-Fa-f]{6}$ ]]; then
|
||||||
|
rgb=$(hex_to_rgb "$value")
|
||||||
|
printf 's|{{ %s_rgb }}|%s|g\n' "$key" "$rgb" >>"$sed_script"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
add_mix_value() {
|
||||||
|
local token="$1"
|
||||||
|
local content fn start_key end_key amount start end value
|
||||||
|
|
||||||
|
content="${token#\{\{}"
|
||||||
|
content="${content%\}\}}"
|
||||||
|
read -r fn start_key end_key amount <<<"$content"
|
||||||
|
|
||||||
|
start="${THEME_COLORS[$start_key]:-}"
|
||||||
|
end="${THEME_COLORS[$end_key]:-}"
|
||||||
|
|
||||||
|
[[ $start =~ ^#[0-9A-Fa-f]{6}$ && $end =~ ^#[0-9A-Fa-f]{6}$ ]] || 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
|
||||||
|
}
|
||||||
|
|
||||||
|
add_gradient_function_value() {
|
||||||
|
local token="$1"
|
||||||
|
local content fn key fallback value
|
||||||
|
|
||||||
|
content="${token#\{\{}"
|
||||||
|
content="${content%\}\}}"
|
||||||
|
read -r fn key fallback <<<"$content"
|
||||||
|
|
||||||
|
case "$fn" in
|
||||||
|
hypr_gradient)
|
||||||
|
value=$(hypr_gradient_value "$key" "$fallback")
|
||||||
|
;;
|
||||||
|
gradient_start)
|
||||||
|
value=$(gradient_start_value "$key" "$fallback")
|
||||||
|
;;
|
||||||
|
shell_gradient)
|
||||||
|
value=$(shell_gradient_value "$key" "$fallback")
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
printf 's|%s|%s|g\n' "$token" "$value" >>"$sed_script"
|
||||||
|
}
|
||||||
|
|
||||||
|
add_gradient_function_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_gradient_function_value "$token"
|
||||||
|
done < <(grep -hEo '\{\{[[:space:]]*(hypr_gradient|gradient_start|shell_gradient)[[:space:]]+[^}]+[[:space:]]*\}\}' "$tpl" 2>/dev/null || true)
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
strip_shell_section_header() {
|
||||||
|
local section="$1"
|
||||||
|
local file="$2"
|
||||||
|
|
||||||
|
awk -v section="$section" '
|
||||||
|
BEGIN { skipping_header = 0 }
|
||||||
|
/^[[:space:]]*\[[^]]+\][[:space:]]*($|#)/ {
|
||||||
|
if ($0 ~ "^[[:space:]]*\\[" section "\\][[:space:]]*($|#)") {
|
||||||
|
skipping_header = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
' "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_shell_section_override() {
|
||||||
|
local override="$1"
|
||||||
|
local section tmp body
|
||||||
|
|
||||||
|
[[ -f $NEXT_THEME_DIR/shell.toml ]] || return
|
||||||
|
[[ -f $override ]] || return
|
||||||
|
|
||||||
|
section=$(basename "$override")
|
||||||
|
section="${section#shell.}"
|
||||||
|
section="${section%.toml}"
|
||||||
|
[[ $section =~ ^[A-Za-z0-9_-]+$ ]] || return
|
||||||
|
|
||||||
|
tmp=$(mktemp)
|
||||||
|
body=$(mktemp)
|
||||||
|
|
||||||
|
strip_shell_section_header "$section" "$override" >"$body"
|
||||||
|
|
||||||
|
awk -v section="$section" -v body="$body" '
|
||||||
|
function emit_override() {
|
||||||
|
if (emitted) return
|
||||||
|
print "[" section "]"
|
||||||
|
while ((getline line < body) > 0) print line
|
||||||
|
close(body)
|
||||||
|
emitted = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/^[[:space:]]*\[[^]]+\][[:space:]]*($|#)/ {
|
||||||
|
if (in_section) {
|
||||||
|
emit_override()
|
||||||
|
in_section = 0
|
||||||
|
}
|
||||||
|
if ($0 ~ "^[[:space:]]*\\[" section "\\][[:space:]]*($|#)") {
|
||||||
|
in_section = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
!in_section { print }
|
||||||
|
|
||||||
|
END {
|
||||||
|
if (in_section || !emitted) {
|
||||||
|
if (NR > 0) print ""
|
||||||
|
emit_override()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$NEXT_THEME_DIR/shell.toml" >"$tmp"
|
||||||
|
|
||||||
|
mv "$tmp" "$NEXT_THEME_DIR/shell.toml"
|
||||||
|
rm "$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_shell_section_overrides() {
|
||||||
|
local override
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
for override in "$NEXT_THEME_DIR"/shell.*.toml; do
|
||||||
|
[[ $(basename "$override") != "shell.toml" ]] || continue
|
||||||
|
apply_shell_section_override "$override"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only generate dynamic templates for themes with a colors.toml definition
|
||||||
|
if [[ -f $COLORS_FILE ]]; then
|
||||||
|
sed_script=$(mktemp)
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
template_files=("$USER_TEMPLATES_DIR"/*.tpl "$TEMPLATES_DIR"/*.tpl)
|
||||||
|
|
||||||
|
# Parsing and alias/fallback resolution (legacy colorN names, derived
|
||||||
|
# shades, mode/theme_type) is shared with every other colors.toml consumer.
|
||||||
|
while IFS=$'\t' read -r key value; do
|
||||||
|
THEME_COLORS[$key]="$value"
|
||||||
|
done < <(blob-theme-color --file "$COLORS_FILE" --all)
|
||||||
|
|
||||||
|
for key in "${!THEME_COLORS[@]}"; do
|
||||||
|
add_template_value "$key" "${THEME_COLORS[$key]}"
|
||||||
|
done
|
||||||
|
|
||||||
|
add_mix_values
|
||||||
|
add_gradient_function_values
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
fi
|
||||||
|
|
||||||
|
apply_shell_section_overrides
|
||||||
Executable
+98
@@ -0,0 +1,98 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Create a desktop launcher for a terminal UI app
|
||||||
|
# blob:args=[name command window-style icon-url-or-name]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
ICON_DIR="$HOME/.local/share/icons/hicolor/256x256/apps"
|
||||||
|
|
||||||
|
safe_icon_name() {
|
||||||
|
printf '%s\n' "$1" \
|
||||||
|
| tr '[:upper:]' '[:lower:]' \
|
||||||
|
| sed 's/[^[:alnum:]]\+/-/g; s/^-//; s/-$//'
|
||||||
|
}
|
||||||
|
|
||||||
|
icon_name_from_ref() {
|
||||||
|
local ref="$1"
|
||||||
|
local name
|
||||||
|
name=$(basename "$ref")
|
||||||
|
|
||||||
|
if [[ $name == *.* ]]; then
|
||||||
|
safe_icon_name "${name%.*}"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_user_icon() {
|
||||||
|
local source="$1"
|
||||||
|
local name="$2"
|
||||||
|
local ext="${source##*.}"
|
||||||
|
|
||||||
|
[[ $ext == "$source" ]] && ext="png"
|
||||||
|
mkdir -p "$ICON_DIR"
|
||||||
|
cp "$source" "$ICON_DIR/$name.$ext"
|
||||||
|
gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true
|
||||||
|
printf '%s\n' "$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (( $# != 4 )); then
|
||||||
|
echo -e "\e[32mLet's create a TUI shortcut you can start with the app launcher.\n\e[0m"
|
||||||
|
APP_NAME=$(gum input --prompt "Name> " --placeholder "My TUI")
|
||||||
|
APP_EXEC=$(gum input --prompt "Launch Command> " --placeholder "lazydocker or bash -c 'dust; read -n 1 -s'")
|
||||||
|
WINDOW_STYLE=$(gum choose --header "Window style" float tile)
|
||||||
|
ICON_REF=$(gum input --prompt "Icon URL/name> " --placeholder "See https://dashboardicons.com or enter an installed icon name")
|
||||||
|
else
|
||||||
|
APP_NAME="$1"
|
||||||
|
APP_EXEC="$2"
|
||||||
|
WINDOW_STYLE="$3"
|
||||||
|
ICON_REF="$4"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z $APP_NAME || -z $APP_EXEC || -z $ICON_REF ]]; then
|
||||||
|
echo "You must set app name, app command, and icon URL/name!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DESKTOP_FILE="$HOME/.local/share/applications/$APP_NAME.desktop"
|
||||||
|
mkdir -p "$(dirname "$DESKTOP_FILE")"
|
||||||
|
|
||||||
|
if [[ $ICON_REF =~ ^https?:// ]]; then
|
||||||
|
ICON_VALUE=$(safe_icon_name "$APP_NAME")
|
||||||
|
mkdir -p "$ICON_DIR"
|
||||||
|
if ! curl -sL -o "$ICON_DIR/$ICON_VALUE.png" "$ICON_REF"; then
|
||||||
|
echo "Error: Failed to download icon."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true
|
||||||
|
elif [[ -f $ICON_REF ]]; then
|
||||||
|
ICON_VALUE=$(install_user_icon "$ICON_REF" "$(safe_icon_name "$APP_NAME")")
|
||||||
|
else
|
||||||
|
# Bundled Blob icons are package-owned under /usr/share/icons/hicolor.
|
||||||
|
ICON_VALUE=$(icon_name_from_ref "$ICON_REF")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $WINDOW_STYLE == "float" ]]; then
|
||||||
|
APP_CLASS="TUI.float"
|
||||||
|
else
|
||||||
|
APP_CLASS="TUI.tile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >"$DESKTOP_FILE" <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Version=1.0
|
||||||
|
Name=$APP_NAME
|
||||||
|
Comment=$APP_NAME
|
||||||
|
Exec=xdg-terminal-exec --app-id=$APP_CLASS -e $APP_EXEC
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Icon=$ICON_VALUE
|
||||||
|
StartupNotify=true
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x "$DESKTOP_FILE"
|
||||||
|
|
||||||
|
if (( $# != 4 )); then
|
||||||
|
echo -e "You can now find $APP_NAME using the app launcher (SUPER + SPACE)\n"
|
||||||
|
fi
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Remove a terminal UI desktop launcher
|
||||||
|
# blob:args=[name]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
ICON_DIR="$HOME/.local/share/icons/hicolor/256x256/apps"
|
||||||
|
OLD_ICON_DIR="$HOME/.local/share/applications/icons"
|
||||||
|
DESKTOP_DIR="$HOME/.local/share/applications/"
|
||||||
|
|
||||||
|
if (( $# == 0 )); then
|
||||||
|
# Find all TUIs
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
if grep -qE '^Exec=.*(\$TERMINAL|xdg-terminal-exec).*-e' "$file"; then
|
||||||
|
TUIS+=("$(basename "${file%.desktop}")")
|
||||||
|
fi
|
||||||
|
done < <(find "$DESKTOP_DIR" -name '*.desktop' -print0)
|
||||||
|
|
||||||
|
if ((${#TUIS[@]})); then
|
||||||
|
mapfile -t SORTED_TUIS < <(printf '%s\n' "${TUIS[@]}" | sort)
|
||||||
|
APP_NAME=$(blob-menu-select "Select TUI to remove" "${SORTED_TUIS[@]}" -- --width 520 --maxheight 520)
|
||||||
|
else
|
||||||
|
echo "No TUIs to remove."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
APP_NAME="$*"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z $APP_NAME ]]; then
|
||||||
|
echo "You must select a TUI to remove."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
icon_name=$(printf '%s\n' "$APP_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^[:alnum:]]\+/-/g; s/^-//; s/-$//')
|
||||||
|
rm -f "$DESKTOP_DIR/$APP_NAME.desktop"
|
||||||
|
rm -f "$ICON_DIR/$icon_name.png" "$ICON_DIR/$APP_NAME.png" "$OLD_ICON_DIR/$APP_NAME.png"
|
||||||
|
if [[ ${BLOB_REMOVE_NOTIFY:-true} != "false" ]]; then
|
||||||
|
blob-notify-send -g "TUI removed" "$APP_NAME"
|
||||||
|
fi
|
||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Remove all TUIs installed via blob-tui-install.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_DIR="${1:-$HOME/.local/share/applications}"
|
||||||
|
ICON_DIR="$HOME/.local/share/icons/hicolor/256x256/apps"
|
||||||
|
OLD_ICON_DIR="$HOME/.local/share/applications/icons"
|
||||||
|
|
||||||
|
echo "Scanning for TUIs in $APP_DIR..."
|
||||||
|
|
||||||
|
tui_desktop_files=()
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
if grep -q "Exec=xdg-terminal-exec --app-id=TUI\." "$file" 2>/dev/null; then
|
||||||
|
tui_desktop_files+=("$file")
|
||||||
|
fi
|
||||||
|
done < <(find "$APP_DIR" -maxdepth 1 -name "*.desktop" -print0 2>/dev/null)
|
||||||
|
|
||||||
|
if (( ${#tui_desktop_files[@]} == 0 )); then
|
||||||
|
echo "No TUIs found."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for file in "${tui_desktop_files[@]}"; do
|
||||||
|
app_name=$(basename "$file" .desktop)
|
||||||
|
icon_name=$(printf '%s\n' "$app_name" | tr '[:upper:]' '[:lower:]' | sed 's/[^[:alnum:]]\+/-/g; s/^-//; s/-$//')
|
||||||
|
echo "Removing TUI: $app_name"
|
||||||
|
rm -f "$file"
|
||||||
|
rm -f "$ICON_DIR/$icon_name.png" "$ICON_DIR/$app_name.png" "$OLD_ICON_DIR/$app_name.png"
|
||||||
|
done
|
||||||
|
|
||||||
|
if blob-cmd-present update-desktop-database; then
|
||||||
|
update-desktop-database "$APP_DIR" &>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "TUIs removed successfully."
|
||||||
Executable
+146
@@ -0,0 +1,146 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Set a wallpaper from ~/wallpapers and recolor the desktop from it
|
||||||
|
# blob:args=[--menu|<file>]
|
||||||
|
|
||||||
|
WALLPAPER_DIR="$HOME/wallpapers"
|
||||||
|
THEME_DIR="$HOME/.config/blob/themes/blob-dynamic"
|
||||||
|
CURRENT_DIR="$HOME/.local/state/blob/current"
|
||||||
|
|
||||||
|
# Create the directory if it doesn't exist
|
||||||
|
mkdir -p "$WALLPAPER_DIR"
|
||||||
|
mkdir -p "$THEME_DIR/backgrounds"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "=== $(date '+%F %T') invoked with: $* ==="
|
||||||
|
echo "WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
|
||||||
|
echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
|
||||||
|
echo "DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS"
|
||||||
|
echo "PATH=$PATH"
|
||||||
|
} >> "$HOME/.cache/blob-wallpaper.log"
|
||||||
|
|
||||||
|
# Rows for blob-menu-select, as "<label><TAB><path>". The menu returns the
|
||||||
|
# label and the subtext, so the full path comes back as a stable key.
|
||||||
|
list_wallpaper_rows() {
|
||||||
|
find "$WALLPAPER_DIR" -maxdepth 1 -type f \
|
||||||
|
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \
|
||||||
|
2>/dev/null | sort | while read -r background; do
|
||||||
|
name=$(basename "$background")
|
||||||
|
label=$(echo "${name%.*}" | sed -E 's/^[0-9]+//; s/^[-_]//; s/[-_]/ /g; s/(^| )([a-z])/\1\u\2/g')
|
||||||
|
printf '%s\t%s\n' "$label" "$background"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -z "$1" ] || [ "$1" = "--menu" ]; then
|
||||||
|
selection=$(list_wallpaper_rows | blob-menu-select "Select Wallpaper" -- --width 800) || exit 0
|
||||||
|
IMAGE_PATH=$(printf '%s' "$selection" | cut -f2)
|
||||||
|
if [ -z "$IMAGE_PATH" ] || [ ! -f "$IMAGE_PATH" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Check if the argument is a file in the wallpapers directory
|
||||||
|
if [ -f "$WALLPAPER_DIR/$1" ]; then
|
||||||
|
IMAGE_PATH=$(realpath "$WALLPAPER_DIR/$1")
|
||||||
|
# Check if the argument is an absolute or relative path
|
||||||
|
elif [ -f "$1" ]; then
|
||||||
|
IMAGE_PATH=$(realpath "$1")
|
||||||
|
else
|
||||||
|
echo "Error: File '$1' does not exist in $WALLPAPER_DIR or as a valid path."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Extracting colors using Pywal..."
|
||||||
|
wal -i "$IMAGE_PATH" -n -q 2> >(grep -v "deprecated in IMv7" >&2)
|
||||||
|
|
||||||
|
# Enhance colors if the palette is too monotone
|
||||||
|
blob-theme-contrast "$HOME/.cache/wal/colors"
|
||||||
|
|
||||||
|
# Clear old backgrounds and copy the new one into the dynamic theme
|
||||||
|
rm -f "$THEME_DIR/backgrounds/"*
|
||||||
|
cp "$IMAGE_PATH" "$THEME_DIR/backgrounds/"
|
||||||
|
|
||||||
|
# Parse pywal colors and write to colors.toml in the dynamic theme
|
||||||
|
cat <<EOF > "$THEME_DIR/colors.toml"
|
||||||
|
accent = "$(sed -n '2p' ~/.cache/wal/colors)"
|
||||||
|
cursor = "$(sed -n '8p' ~/.cache/wal/colors)"
|
||||||
|
foreground = "$(sed -n '8p' ~/.cache/wal/colors)"
|
||||||
|
background = "$(sed -n '1p' ~/.cache/wal/colors)"
|
||||||
|
selection_foreground = "$(sed -n '1p' ~/.cache/wal/colors)"
|
||||||
|
selection_background = "$(sed -n '2p' ~/.cache/wal/colors)"
|
||||||
|
|
||||||
|
color0 = "$(sed -n '1p' ~/.cache/wal/colors)"
|
||||||
|
color1 = "$(sed -n '2p' ~/.cache/wal/colors)"
|
||||||
|
color2 = "$(sed -n '3p' ~/.cache/wal/colors)"
|
||||||
|
color3 = "$(sed -n '4p' ~/.cache/wal/colors)"
|
||||||
|
color4 = "$(sed -n '5p' ~/.cache/wal/colors)"
|
||||||
|
color5 = "$(sed -n '6p' ~/.cache/wal/colors)"
|
||||||
|
color6 = "$(sed -n '7p' ~/.cache/wal/colors)"
|
||||||
|
color7 = "$(sed -n '8p' ~/.cache/wal/colors)"
|
||||||
|
color8 = "$(sed -n '9p' ~/.cache/wal/colors)"
|
||||||
|
color9 = "$(sed -n '10p' ~/.cache/wal/colors)"
|
||||||
|
color10 = "$(sed -n '11p' ~/.cache/wal/colors)"
|
||||||
|
color11 = "$(sed -n '12p' ~/.cache/wal/colors)"
|
||||||
|
color12 = "$(sed -n '13p' ~/.cache/wal/colors)"
|
||||||
|
color13 = "$(sed -n '14p' ~/.cache/wal/colors)"
|
||||||
|
color14 = "$(sed -n '15p' ~/.cache/wal/colors)"
|
||||||
|
color15 = "$(sed -n '16p' ~/.cache/wal/colors)"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Write neovim.lua to satisfy LazyVim's theme symlink requirement
|
||||||
|
cat <<EOF > "$THEME_DIR/neovim.lua"
|
||||||
|
return {
|
||||||
|
{
|
||||||
|
"LazyVim/LazyVim",
|
||||||
|
opts = {
|
||||||
|
colorscheme = "tokyonight",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Only switch the active color theme to blob-dynamic if you're already
|
||||||
|
# in dynamic mode. The palette above and the background below are kept
|
||||||
|
# up to date regardless, so `blob_theme --dynamic` always reflects the
|
||||||
|
# latest wallpaper - but picking a wallpaper while on a static theme
|
||||||
|
# should change the wallpaper, not silently pull you out of it.
|
||||||
|
#
|
||||||
|
# When it does apply, skip blob-theme-set's own background step: it
|
||||||
|
# picks a background out of the theme folder and runs its own transition,
|
||||||
|
# which would race with the background handling below.
|
||||||
|
CURRENT_THEME_NAME=$(cat "$CURRENT_DIR/theme.name" 2>/dev/null)
|
||||||
|
if [ "$CURRENT_THEME_NAME" = "blob-dynamic" ]; then
|
||||||
|
BLOB_THEME_SKIP_BACKGROUND=1 blob-theme-set "blob-dynamic"
|
||||||
|
fi
|
||||||
|
|
||||||
|
NEW_BACKGROUND="$THEME_DIR/backgrounds/$(basename "$IMAGE_PATH")"
|
||||||
|
|
||||||
|
# Stop a gif daemon left over from a previous wallpaper. `awww kill` shuts the
|
||||||
|
# daemon down via its own IPC so it cleans up its socket, unlike a raw `pkill`.
|
||||||
|
awww kill --all >/dev/null 2>&1
|
||||||
|
pkill -x awww-daemon 2>/dev/null
|
||||||
|
|
||||||
|
# blob-bg-set updates ~/.local/state/blob/current/background and
|
||||||
|
# tells the running Blob shell to repaint, which is what draws the desktop
|
||||||
|
# background in Blob 4 (swaybg is no longer involved).
|
||||||
|
blob-bg-set "$NEW_BACKGROUND"
|
||||||
|
|
||||||
|
if [[ "${IMAGE_PATH,,}" == *.gif ]]; then
|
||||||
|
# The shell renders a still frame from the symlink above; awww layers the
|
||||||
|
# animation on top of it.
|
||||||
|
echo "GIF detected, using awww for animation..."
|
||||||
|
setsid uwsm-app -- awww-daemon >>"$HOME/.cache/awww-daemon.log" 2>&1 &
|
||||||
|
|
||||||
|
# Poll until the daemon's IPC socket is ready instead of guessing a fixed sleep
|
||||||
|
IMG_ERROR=""
|
||||||
|
for _ in {1..15}; do
|
||||||
|
IMG_ERROR=$(awww img "$NEW_BACKGROUND" 2>&1) && break
|
||||||
|
sleep 0.3
|
||||||
|
done
|
||||||
|
if [[ -n "$IMG_ERROR" ]]; then
|
||||||
|
echo "awww failed to set the wallpaper: $IMG_ERROR"
|
||||||
|
echo "See $HOME/.cache/awww-daemon.log for daemon output."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Wallpaper and dynamic theme applied successfully: $IMAGE_PATH"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
@@@@@@@@@@@@
|
||||||
|
@@@@ @@@@
|
||||||
|
@@@ @@@
|
||||||
|
@@@ @@@
|
||||||
|
@@@ @@
|
||||||
|
@@ @@ @@ @@
|
||||||
|
@ @@ @@ @@ @@ @@
|
||||||
|
@ @@ ##### @ @ @ @@
|
||||||
|
@ @ ##### @ @ ##### @ @@
|
||||||
|
@@ @ ### @ @ ###### @ @@
|
||||||
|
@ @ @ @ ######@@ @@
|
||||||
|
@@ @@@ @@@ @@ @@@ @
|
||||||
|
@@ @@
|
||||||
|
@@ @@
|
||||||
|
@@ @
|
||||||
|
@ @@
|
||||||
|
@@ @@
|
||||||
|
@@ @@
|
||||||
|
@ @@
|
||||||
|
@@ @@
|
||||||
|
@@ @@
|
||||||
|
@@@ @@
|
||||||
|
@@@ @@@
|
||||||
|
@@@ @@@
|
||||||
|
@@@@ @@@@
|
||||||
|
@@@@@@@@@@@@
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="520.000000pt" height="511.000000pt" viewBox="0 0 520.000000 511.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
<g transform="translate(0.000000,511.000000) scale(0.050000,-0.050000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M4790 10147 c-3157 -250 -5335 -3216 -4578 -6237 784 -3132 4321
|
||||||
|
-4735 7258 -3290 2905 1429 3734 5102 1723 7628 -1028 1292 -2741 2030 -4403
|
||||||
|
1899z m1090 -337 c3940 -604 5501 -5305 2677 -8061 -2334 -2278 -6268 -1614
|
||||||
|
-7707 1301 -1482 3002 510 6440 3940 6798 173 18 888 -7 1090 -38z"/>
|
||||||
|
<path d="M2810 8049 c-22 -5 -89 -19 -150 -31 -998 -194 -1377 -1466 -652
|
||||||
|
-2188 1078 -1073 2875 139 2209 1490 -238 483 -926 840 -1407 729z m550 -269
|
||||||
|
c782 -263 1032 -1216 470 -1788 -633 -642 -1757 -350 -1959 510 -195 826 658
|
||||||
|
1558 1489 1278z"/>
|
||||||
|
<path d="M2626 7544 c-486 -174 -571 -762 -147 -1018 572 -345 1209 298 812
|
||||||
|
819 -142 185 -447 277 -665 199z"/>
|
||||||
|
<path d="M6930 8029 c-1037 -228 -1420 -1465 -682 -2199 855 -850 2351 -270
|
||||||
|
2351 910 0 832 -830 1473 -1669 1289z m689 -256 c777 -270 1012 -1233 437
|
||||||
|
-1796 -638 -624 -1745 -325 -1945 525 -197 836 666 1564 1508 1271z"/>
|
||||||
|
<path d="M7295 7073 c-373 -105 -571 -506 -415 -839 286 -608 1249 -418 1249
|
||||||
|
246 0 411 -420 710 -834 593z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
▄████████ ▄█ ▄████████ ▀█████████▄ ▄█ ▄██████▄ ▀█████████▄
|
||||||
|
███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███
|
||||||
|
███ █▀ ███▌ ███ ███ ███ ███ ███ ███ ███ ███ ███
|
||||||
|
███ ███▌ ▄███▄▄▄▄██▀ ▄███▄▄▄██▀ ███ ███ ███ ▄███▄▄▄██▀
|
||||||
|
▀███████████ ███▌ ▀▀███▀▀▀▀▀ ▀▀███▀▀▀██▄ ███ ███ ███ ▀▀███▀▀▀██▄
|
||||||
|
███ ███ ▀███████████ ███ ██▄ ███ ███ ███ ███ ██▄
|
||||||
|
▄█ ███ ███ ███ ███ ███ ███ ███▌ ▄ ███ ███ ███ ███
|
||||||
|
▄████████▀ █▀ ███ ███ ▄█████████▀ █████▄▄██ ▀██████▀ ▄█████████▀
|
||||||
|
███ ███ ▀
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
_blob_completions() {
|
||||||
|
local current="${COMP_WORDS[COMP_CWORD]}"
|
||||||
|
local typed="${COMP_WORDS[*]:1:COMP_CWORD-1}"
|
||||||
|
local prefix="blob"
|
||||||
|
[[ -n $typed ]] && prefix="blob-${typed// /-}"
|
||||||
|
|
||||||
|
local candidates=()
|
||||||
|
local name suffix
|
||||||
|
while IFS= read -r name; do
|
||||||
|
suffix="${name#$prefix-}"
|
||||||
|
[[ $suffix == "$name" ]] && continue
|
||||||
|
candidates+=("${suffix%%-*}")
|
||||||
|
done < <(compgen -c -- "$prefix-" | sort -u)
|
||||||
|
|
||||||
|
if (( COMP_CWORD == 1 )); then
|
||||||
|
candidates+=(help)
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMPREPLY=($(compgen -W "$(printf '%s\n' "${candidates[@]}" | sort -u)" -- "$current"))
|
||||||
|
}
|
||||||
|
|
||||||
|
complete -F _blob_completions blob
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"idle": {
|
||||||
|
"screensaver": 150,
|
||||||
|
"lock": 300
|
||||||
|
},
|
||||||
|
"bar": {
|
||||||
|
"position": "top",
|
||||||
|
"transparent": false,
|
||||||
|
"centerAnchor": "blob.clock",
|
||||||
|
"layout": {
|
||||||
|
"left": [
|
||||||
|
{ "id": "blob.menu" },
|
||||||
|
{ "id": "blob.workspaces" }
|
||||||
|
],
|
||||||
|
"center": [
|
||||||
|
{ "id": "blob.indicators" },
|
||||||
|
{
|
||||||
|
"id": "blob.clock",
|
||||||
|
"format": "dddd HH:mm",
|
||||||
|
"formatAlt": "d MMMM 'W'ww yyyy",
|
||||||
|
"verticalFormat": "HH\n—\nmm"
|
||||||
|
},
|
||||||
|
{ "id": "blob.keyboard-layout" },
|
||||||
|
{ "id": "blob.weather" },
|
||||||
|
{ "id": "blob.system-update" }
|
||||||
|
],
|
||||||
|
"right": [
|
||||||
|
{ "id": "blob.tray" },
|
||||||
|
{ "id": "blob.bluetooth" },
|
||||||
|
{ "id": "blob.network" },
|
||||||
|
{ "id": "blob.audio" },
|
||||||
|
{ "id": "blob.monitor" },
|
||||||
|
{ "id": "blob.power" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# vim:ft=kitty
|
||||||
|
|
||||||
|
## name: Flats
|
||||||
|
## upstream: themes/flats/colors.toml
|
||||||
|
## blurb: Dark theme with a soft blue accent. Hand-translated from the
|
||||||
|
## Omarchy colors.toml, since macOS has no omarchy-theme-set.
|
||||||
|
|
||||||
|
|
||||||
|
# The basic colors
|
||||||
|
foreground #cbd2d9
|
||||||
|
background #0f1012
|
||||||
|
selection_foreground #0f1012
|
||||||
|
selection_background #7babe3
|
||||||
|
|
||||||
|
# Cursor colors
|
||||||
|
cursor #cbd2d9
|
||||||
|
cursor_text_color #0f1012
|
||||||
|
|
||||||
|
# URL underline color when hovering with mouse
|
||||||
|
url_color #7babe3
|
||||||
|
|
||||||
|
# Kitty window border colors
|
||||||
|
active_border_color #7babe3
|
||||||
|
inactive_border_color #949494
|
||||||
|
bell_border_color #d58638
|
||||||
|
|
||||||
|
# OS Window titlebar colors
|
||||||
|
wayland_titlebar_color system
|
||||||
|
macos_titlebar_color system
|
||||||
|
|
||||||
|
# Tab bar colors
|
||||||
|
active_tab_foreground #0f1012
|
||||||
|
active_tab_background #7babe3
|
||||||
|
inactive_tab_foreground #cbd2d9
|
||||||
|
inactive_tab_background #0f1012
|
||||||
|
tab_bar_background #000000
|
||||||
|
|
||||||
|
# Colors for marks (marked text in the terminal)
|
||||||
|
mark1_foreground #0f1012
|
||||||
|
mark1_background #7babe3
|
||||||
|
mark2_foreground #0f1012
|
||||||
|
mark2_background #38d586
|
||||||
|
mark3_foreground #0f1012
|
||||||
|
mark3_background #d58638
|
||||||
|
|
||||||
|
# The 16 terminal colors
|
||||||
|
#
|
||||||
|
# Note: flats maps the ANSI slots unconventionally — color4 (blue) holds a
|
||||||
|
# magenta and color6 (cyan) holds the blue, among others. Kept faithful to
|
||||||
|
# colors.toml so both machines render identically; the comments below name
|
||||||
|
# the ANSI slot, not the resulting hue.
|
||||||
|
|
||||||
|
# black
|
||||||
|
color0 #000000
|
||||||
|
color8 #949494
|
||||||
|
|
||||||
|
# red
|
||||||
|
color1 #d53838
|
||||||
|
color9 #d53838
|
||||||
|
|
||||||
|
# green
|
||||||
|
color2 #38d4d5
|
||||||
|
color10 #38d4d5
|
||||||
|
|
||||||
|
# yellow
|
||||||
|
color3 #d58638
|
||||||
|
color11 #d58638
|
||||||
|
|
||||||
|
# blue
|
||||||
|
color4 #d53886
|
||||||
|
color12 #d53886
|
||||||
|
|
||||||
|
# magenta
|
||||||
|
color5 #38d586
|
||||||
|
color13 #38d586
|
||||||
|
|
||||||
|
# cyan
|
||||||
|
color6 #3886d5
|
||||||
|
color14 #3886d5
|
||||||
|
|
||||||
|
# white
|
||||||
|
color7 #d4d4d4
|
||||||
|
color15 #d4d4d4
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# vim:ft=kitty
|
||||||
|
#
|
||||||
|
# kitty config for macOS. The Arch side of these dotfiles gets its terminal
|
||||||
|
# colors from omarchy-theme-set rewriting the active theme; there is no
|
||||||
|
# omarchy here, so current-theme.conf is checked in alongside this file and
|
||||||
|
# generated by hand from themes/<name>/colors.toml.
|
||||||
|
|
||||||
|
include current-theme.conf
|
||||||
|
|
||||||
|
|
||||||
|
# Fonts
|
||||||
|
font_family JetBrainsMono Nerd Font Regular
|
||||||
|
bold_font JetBrainsMono Nerd Font Bold
|
||||||
|
italic_font JetBrainsMono Nerd Font Italic
|
||||||
|
bold_italic_font JetBrainsMono Nerd Font Bold Italic
|
||||||
|
font_size 14.0
|
||||||
|
adjust_line_height 115%
|
||||||
|
disable_ligatures cursor
|
||||||
|
|
||||||
|
# Use CoreText's glyph rendering rather than kitty's own blending.
|
||||||
|
text_composition_strategy platform
|
||||||
|
|
||||||
|
|
||||||
|
# Window
|
||||||
|
window_padding_width 18
|
||||||
|
hide_window_decorations no
|
||||||
|
background_opacity 1.0
|
||||||
|
background_blur 0
|
||||||
|
confirm_os_window_close 0
|
||||||
|
remember_window_size yes
|
||||||
|
initial_window_width 120c
|
||||||
|
initial_window_height 34c
|
||||||
|
inactive_text_alpha 0.85
|
||||||
|
|
||||||
|
|
||||||
|
# Tabs
|
||||||
|
tab_bar_edge top
|
||||||
|
tab_bar_style powerline
|
||||||
|
tab_powerline_style slanted
|
||||||
|
tab_bar_min_tabs 2
|
||||||
|
tab_title_template "{index} {title}"
|
||||||
|
|
||||||
|
|
||||||
|
# Cursor
|
||||||
|
cursor_shape beam
|
||||||
|
cursor_beam_thickness 1.6
|
||||||
|
cursor_blink_interval 0
|
||||||
|
cursor_trail 0
|
||||||
|
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
sync_to_monitor yes
|
||||||
|
repaint_delay 8
|
||||||
|
input_delay 2
|
||||||
|
wheel_scroll_multiplier 3.0
|
||||||
|
|
||||||
|
|
||||||
|
# Bell
|
||||||
|
enable_audio_bell no
|
||||||
|
visual_bell_duration 0.1
|
||||||
|
|
||||||
|
|
||||||
|
# Scrollback
|
||||||
|
scrollback_lines 20000
|
||||||
|
|
||||||
|
|
||||||
|
# macOS specific
|
||||||
|
macos_option_as_alt yes
|
||||||
|
macos_quit_when_last_window_closed yes
|
||||||
|
macos_show_window_title_in all
|
||||||
|
macos_traditional_fullscreen no
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Menu tree
|
||||||
|
|
||||||
|
`menu/menu.jsonc` is the single source for both menus: the apps menu on
|
||||||
|
`Super + Space` and the root menu on `Super + Alt + Space`. There is no default
|
||||||
|
tree merged underneath it and no user extension file layered on top, so a row
|
||||||
|
sits where the file puts it. That is why the `before:` hint machinery the old
|
||||||
|
menu fork carried is gone.
|
||||||
|
|
||||||
|
## Trimmed from upstream
|
||||||
|
|
||||||
|
Upstream ships 338 rows. This tree has 178. What came out:
|
||||||
|
|
||||||
|
| Section | Dropped | Why |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `install.*` | 84 of 90 | the software catalogue: browsers, gaming, dev languages, AI tools, services. Kept: Package, AUR, TUI, and the Style subtree (theme, background, font) |
|
||||||
|
| `remove.*` | 56 of 59 | same catalogue in reverse. Kept: Package, TUI, Theme |
|
||||||
|
| `setup.default.agent.*` | 14 | AI agent defaults |
|
||||||
|
| `update.channel.*` | 5 | release channels for a package that will not exist |
|
||||||
|
| `learn.*` | 4 | Omarchy's manual and Discord, plus tmux and herdr keybindings |
|
||||||
|
| `trigger.transcode` | 1 | transcode dropped |
|
||||||
|
| `style.unlock` | 1 | plymouth theme switcher; `blob-boot-splash` covers splash images |
|
||||||
|
| `update.config.tmux` | 1 | tmux dropped |
|
||||||
|
| `setup.reset` | 1 | factory reset restores Omarchy defaults, which is wrong here |
|
||||||
|
|
||||||
|
Docs rows now open in the browser (`blob-launch-browser`) rather than as web apps,
|
||||||
|
which also keeps them working whichever way the web app question lands.
|
||||||
|
|
||||||
|
## Commands the menu still needs
|
||||||
|
|
||||||
|
The tree references 95 commands. 72 are not in `bin/` yet - this is the
|
||||||
|
Phase 6 vendor list, grouped by area:
|
||||||
|
|
||||||
|
- **audio**: `blob-audio-restart`
|
||||||
|
- **bar**: `blob-bar`
|
||||||
|
- **bluetooth**: `blob-bluetooth-restart`
|
||||||
|
- **branding**: `blob-branding-about`, `blob-branding-screensaver`
|
||||||
|
- **capture**: `blob-capture-qr`, `blob-capture-record`, `blob-capture-record-webcam`, `blob-capture-screenshot`, `blob-capture-text`
|
||||||
|
- **drive**: `blob-drive-password`
|
||||||
|
- **hibernation**: `blob-hibernation-available`
|
||||||
|
- **hw**: `blob-hw-dell-xps-haptic-touchpad`, `blob-hw-fingerprint`, `blob-hw-hybrid-gpu`, `blob-hw-laptop`, `blob-hw-touchpad`, `blob-hw-touchscreen`, `blob-hw-webcam`
|
||||||
|
- **hypr**: `blob-hypr-window-gaps-toggle`, `blob-hypr-window-single-square-aspect-toggle`, `blob-hypr-workspace-layout-toggle`
|
||||||
|
- **install**: `blob-install-font`
|
||||||
|
- **launch**: `blob-launch-about`, `blob-launch-config-editor`, `blob-launch-screensaver`
|
||||||
|
- **menu**: `blob-menu-emoji`, `blob-menu-keybindings`, `blob-menu-plugin`, `blob-menu-share`, `blob-menu-timezone`
|
||||||
|
- **network**: `blob-network-dns`, `blob-network-restart`, `blob-network-status`
|
||||||
|
- **pkg**: `blob-pkg-aur-install`, `blob-pkg-install`, `blob-pkg-remove`
|
||||||
|
- **plugin**: `blob-plugin-add`
|
||||||
|
- **refresh**: `blob-refresh-hyprland`, `blob-refresh-hyprsunset`, `blob-refresh-plymouth`, `blob-refresh-shell`
|
||||||
|
- **reminder**: `blob-reminder`
|
||||||
|
- **restart**: `blob-restart-hyprsunset`, `blob-restart-trackpad`, `blob-restart-xcompose`
|
||||||
|
- **setup**: `blob-setup-direct-boot`, `blob-setup-security-fido2`, `blob-setup-security-fingerprint`, `blob-setup-security-sshd`, `blob-setup-security-sudoless-docker`
|
||||||
|
- **sudo**: `blob-sudo-docker`, `blob-sudo-passwordless`
|
||||||
|
- **system**: `blob-system-lock`, `blob-system-logout`, `blob-system-reboot`, `blob-system-shutdown`
|
||||||
|
- **theme**: `blob-theme-update`
|
||||||
|
- **toggle**: `blob-toggle-bar`, `blob-toggle-crash-capture`, `blob-toggle-enabled`, `blob-toggle-glass`, `blob-toggle-hybrid-gpu`, `blob-toggle-idle`, `blob-toggle-nightlight`, `blob-toggle-screensaver`, `blob-toggle-silencing`, `blob-toggle-touchpad`, `blob-toggle-touchscreen`
|
||||||
|
- **update**: `blob-update`, `blob-update-firmware`, `blob-update-time`
|
||||||
|
|
||||||
|
Every one of these is reachable from a menu row, so a missing command shows up
|
||||||
|
as a row that does nothing rather than a broken menu.
|
||||||
|
|
||||||
|
## Reversed from the plan
|
||||||
|
|
||||||
|
`02-keep-drop.md` put `omarchy-setup-*` in the drop list as one-time setup to run
|
||||||
|
by hand. The menu disagrees: Setup > Security has rows for fingerprint, fido2,
|
||||||
|
sshd and sudoless docker, and Setup > Direct Boot has one too. Those scripts are
|
||||||
|
small, self-contained and genuinely useful, so they are vendored and the rows
|
||||||
|
stay.
|
||||||
|
|
||||||
|
`blob-capture-record-webcam` also stays a separate command rather than folding
|
||||||
|
into `blob-capture-record --webcam` as planned, because the menu row calls it
|
||||||
|
directly and upstream ships it as its own script.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Blob icon font
|
||||||
|
|
||||||
|
The private-use glyphs in `blob.ttf` are:
|
||||||
|
|
||||||
|
- `U+E900` — Blob
|
||||||
|
- `U+E901` — Pi, from <https://pi.dev/logo-auto.svg>
|
||||||
|
- `U+E902` — OpenCode, from <https://opencode.ai/favicon-96x96-v3.png>
|
||||||
|
- `U+E903` — omp, from <https://omp.sh/favicon.svg>
|
||||||
|
- `U+E904` — Grok, from <https://grok.com/images/favicon.svg>
|
||||||
|
- `U+E905` — Codex, from the OpenAI mark on <https://developers.openai.com/codex/>
|
||||||
|
- `U+E906` — LM Studio, from <https://simpleicons.org/icons/lmstudio.svg>
|
||||||
|
- `U+E907` — Ollama, from <https://simpleicons.org/icons/ollama.svg>
|
||||||
|
- `U+E908` — T3 Code, traced from the app icon in <https://aur.archlinux.org/cgit/aur.git/plain/t3code-icon.png?h=t3code-bin>, since upstream publishes no monochrome SVG
|
||||||
|
|
||||||
|
- `U+E90A` — Hermes, Font Awesome's staff-snake (CC BY 4.0) from <https://fontawesome.com/icons/staff-snake>, the mark Hermes serves as its favicon: their app icon is a portrait that reads as a smudge at menu size
|
||||||
|
- `U+E90B` — Perplexity, from <https://simpleicons.org/icons/perplexity.svg>
|
||||||
|
- `U+E90C` — OpenClaw, traced from the lobster mascot the openclaw package ships as `dist/control-ui/favicon.svg`, since upstream publishes no monochrome SVG
|
||||||
|
- `U+E90D` — Cursor, from <https://simpleicons.org/icons/cursor.svg>
|
||||||
|
|
||||||
|
The agent marks are monochrome so the menu can render them using the active
|
||||||
|
theme's foreground and selection colors.
|
||||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
-- Extra autostart processes.
|
||||||
|
o.launch_on_start("blob-toggle-tablet autostart")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Personal keybinding overrides on top of the defaults.
|
||||||
|
-- See current bindings and descriptions: blob menu keybindings --print
|
||||||
|
|
||||||
|
o.bind("SUPER + ALT + W", "Wallpaper picker", "blob-wallpaper-set --menu")
|
||||||
|
|
||||||
|
-- Apps on SUPER + SPACE, root menu on ALT, swapping the shipped defaults back.
|
||||||
|
hl.unbind("SUPER + SPACE")
|
||||||
|
hl.unbind("SUPER + ALT + SPACE")
|
||||||
|
o.bind("SUPER + SPACE", "Apps menu", "blob-menu toggle apps")
|
||||||
|
o.bind("SUPER + ALT + SPACE", "Blob menu", "blob-menu toggle")
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- App-specific tweaks.
|
||||||
|
local paths = require("default.hypr.paths")
|
||||||
|
local require_all = require("default.hypr.require_all")
|
||||||
|
|
||||||
|
require_all.files(paths.blob_path .. "/default/hypr/apps", "default.hypr.apps")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
o.window("^(1[p|P]assword)$", { no_screen_share = true, tag = "+floating-window" })
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Battle.net launches under Proton; all its windows share class steam_app_battlenet.
|
||||||
|
|
||||||
|
-- The actual launcher: float-centered.
|
||||||
|
o.window({ class = "^steam_app_battlenet$", title = "^Battle\\.net$" }, {
|
||||||
|
float = true,
|
||||||
|
center = true,
|
||||||
|
size = { 1280, 800 },
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Installer: drop decorations and backdrop blur/shadow so the Blizzard chrome
|
||||||
|
-- isn't framed by the WM.
|
||||||
|
o.window({ class = "^steam_app_battlenet$", title = "^Battle\\.net Setup$" }, {
|
||||||
|
decorate = false,
|
||||||
|
no_blur = true,
|
||||||
|
no_shadow = true,
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
o.window("^(Bitwarden)$", { no_screen_share = true, tag = "+floating-window" })
|
||||||
|
|
||||||
|
o.window("chrome-nngceckbapebfimnlniiiahkandclblb-Default", {
|
||||||
|
no_screen_share = true,
|
||||||
|
tag = "+floating-window",
|
||||||
|
})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Browser tags and styling.
|
||||||
|
o.window("((google-)?[cC]hrom(e|ium)|[bB]rave-browser|[mM]icrosoft-edge|Vivaldi-stable|helium)", { tag = "+chromium-based-browser" })
|
||||||
|
o.window("([fF]irefox|zen|librewolf)", { tag = "+firefox-based-browser" })
|
||||||
|
o.window({ tag = "chromium-based-browser" }, { tag = "-default-opacity", tile = true, opacity = "1.0 0.985" })
|
||||||
|
o.window({ tag = "firefox-based-browser" }, { tag = "-default-opacity", opacity = "1.0 0.985" })
|
||||||
|
|
||||||
|
-- Video apps: remove chromium browser tag so they don't get opacity applied.
|
||||||
|
o.window("(^.+-youtube\\.com__.*$|^.+-app\\.zoom\\.us__wc_home.*$)", { tag = "-chromium-based-browser" })
|
||||||
|
o.window("(^.+-youtube\\.com__.*$|^.+-app\\.zoom\\.us__wc_home.*$)", { tag = "-default-opacity" })
|
||||||
|
|
||||||
|
-- Hide screen sharing notification windows.
|
||||||
|
o.window({ title = ".*is sharing.*" }, { workspace = "special silent" })
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- DaVinci Resolve window focus handling. Kept fully opaque: the default
|
||||||
|
-- translucency distorts colour-critical grading work.
|
||||||
|
o.window(".*[Rr]esolve.*", {
|
||||||
|
float = true,
|
||||||
|
stay_focused = true,
|
||||||
|
tag = "-default-opacity",
|
||||||
|
opacity = "1 1",
|
||||||
|
})
|
||||||
|
|
||||||
|
o.window({ class = ".*[Rr]esolve.*", title = "^DaVinci Resolve( Studio)? - .+$" }, { fullscreen = true })
|
||||||
|
o.window({ class = ".*[Rr]esolve.*", title = "^(DaVinci Resolve( Studio)? - .+|Project Manager)$" }, { stay_focused = false })
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user