e0967a8388
Updated set_config to dynamically adjust leading whitespace of trailing comments when toggling between true/false (or any values of different lengths). This ensures that inline comments in config.toml remain vertically aligned after programmatic updates.
853 lines
28 KiB
Bash
Executable File
853 lines
28 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# color-tool — pick, convert, and format colors
|
|
# Supports: hex → RGB/RGBA, KDE Plasma color picker, clipboard copy, JSON output, color names
|
|
#
|
|
# Copyright (C) 2026 Rootiest.
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
set -euo pipefail
|
|
|
|
# ── Configuration ─────────────────────────────────────────────────────────────
|
|
|
|
# Resolve the real directory of this script so we can find bundled helpers
|
|
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
|
CONFIG_FILE="$HOME/.config/color-tool/config.toml"
|
|
REPO_URL="https://git.rootiest.dev/rootiest/color-tool"
|
|
|
|
# ── Initial Defaults ──────────────────────────────────────────────────────────
|
|
|
|
# Hardcoded base defaults
|
|
json_mode=0
|
|
alpha_mode=0
|
|
name_mode=0
|
|
copy_mode=0
|
|
desktop_mode=0
|
|
notify_mode=0
|
|
swatch_mode=0
|
|
config_pick=0
|
|
output_formats="hex"
|
|
|
|
# Desktop-specific defaults (initially same as base, can be overridden by config)
|
|
desktop_json=0
|
|
desktop_alpha=0
|
|
desktop_name=0
|
|
desktop_notify=1
|
|
desktop_copy=1
|
|
desktop_output="hex"
|
|
desktop_swatch=1
|
|
|
|
# Track which settings were explicitly set via CLI flags to ensure they override config
|
|
cli_json=""
|
|
cli_alpha=""
|
|
cli_name=""
|
|
cli_copy=""
|
|
cli_notify=""
|
|
cli_swatch=""
|
|
cli_pick=""
|
|
cli_output=""
|
|
|
|
# ── Config Loader ─────────────────────────────────────────────────────────────
|
|
|
|
get_config() {
|
|
if [[ ! -f "$CONFIG_FILE" ]]; then
|
|
echo "Error: Config file not found at $CONFIG_FILE" >&2
|
|
exit 1
|
|
fi
|
|
|
|
local first_section=1
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
local stripped="${line%%#*}"
|
|
stripped="${stripped%"${stripped##*[![:space:]]}"}"
|
|
if [[ -z "$stripped" ]]; then
|
|
continue
|
|
fi
|
|
|
|
if [[ "$stripped" =~ ^\[([A-Za-z_-]+)\]$ ]]; then
|
|
if [[ $first_section -eq 0 ]]; then
|
|
echo ""
|
|
fi
|
|
first_section=0
|
|
echo "$stripped"
|
|
elif [[ "$stripped" =~ ^[[:space:]]*([A-Za-z_]+)[[:space:]]*=[[:space:]]*(.*)$ ]]; then
|
|
local key="${BASH_REMATCH[1]}"
|
|
local val="${BASH_REMATCH[2]}"
|
|
printf "%-6s = %s\n" "$key" "$val"
|
|
fi
|
|
done < "$CONFIG_FILE"
|
|
}
|
|
|
|
set_config() {
|
|
local target_section="defaults"
|
|
if [[ $# -gt 0 ]]; then
|
|
if [[ "$1" == "default" || "$1" == "defaults" ]]; then
|
|
target_section="defaults"
|
|
shift
|
|
elif [[ "$1" == "desktop" ]]; then
|
|
target_section="desktop"
|
|
shift
|
|
fi
|
|
fi
|
|
|
|
local new_output=""
|
|
local new_json=""
|
|
local new_swatch=""
|
|
local new_name=""
|
|
local new_copy=""
|
|
local new_pick=""
|
|
local new_notify=""
|
|
local new_alpha=""
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--output=*) new_output="${1#*=}" ;;
|
|
output=*) new_output="${1#*=}" ;;
|
|
--json) new_json="true" ;;
|
|
--no-json) new_json="false" ;;
|
|
--alpha) new_alpha="true" ;;
|
|
--no-alpha) new_alpha="false" ;;
|
|
--name) new_name="true" ;;
|
|
--no-name) new_name="false" ;;
|
|
--swatch) new_swatch="true" ;;
|
|
--no-swatch) new_swatch="false" ;;
|
|
--copy) new_copy="true" ;;
|
|
--no-copy) new_copy="false" ;;
|
|
--notify) new_notify="true" ;;
|
|
--no-notify) new_notify="false" ;;
|
|
--pick) new_pick="true" ;;
|
|
--no-pick) new_pick="false" ;;
|
|
-*)
|
|
echo "Error: Unknown option for --set-config: $1" >&2
|
|
exit 1
|
|
;;
|
|
*)
|
|
echo "Error: Unknown argument for --set-config: $1" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
if [[ -n "$new_output" ]]; then
|
|
new_output="${new_output#\"}"
|
|
new_output="${new_output%\"}"
|
|
fi
|
|
|
|
if [[ ! -f "$CONFIG_FILE" ]]; then
|
|
echo "Error: Config file not found at $CONFIG_FILE. Run --install first." >&2
|
|
exit 1
|
|
fi
|
|
|
|
local temp_file
|
|
temp_file="$(mktemp)"
|
|
local current_section=""
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
local original_line="$line"
|
|
local parsed_line="${line%%#*}"
|
|
|
|
if [[ "$parsed_line" =~ ^\[([A-Za-z_-]+)\]$ ]]; then
|
|
current_section="${BASH_REMATCH[1]}"
|
|
echo "$original_line" >> "$temp_file"
|
|
continue
|
|
fi
|
|
|
|
if [[ "$current_section" == "$target_section" && "$parsed_line" =~ ^[[:space:]]*([A-Za-z_]+)[[:space:]]*= ]]; then
|
|
local key="${BASH_REMATCH[1]}"
|
|
local new_val=""
|
|
case "$key" in
|
|
output)
|
|
if [[ -n "$new_output" ]]; then new_val="\"$new_output\""; fi
|
|
;;
|
|
json)
|
|
if [[ -n "$new_json" ]]; then new_val="$new_json"; fi
|
|
;;
|
|
alpha)
|
|
if [[ -n "$new_alpha" ]]; then new_val="$new_alpha"; fi
|
|
;;
|
|
swatch)
|
|
if [[ -n "$new_swatch" ]]; then new_val="$new_swatch"; fi
|
|
;;
|
|
name)
|
|
if [[ -n "$new_name" ]]; then new_val="$new_name"; fi
|
|
;;
|
|
copy)
|
|
if [[ -n "$new_copy" ]]; then new_val="$new_copy"; fi
|
|
;;
|
|
pick)
|
|
if [[ -n "$new_pick" ]]; then new_val="$new_pick"; fi
|
|
;;
|
|
notify)
|
|
if [[ -n "$new_notify" ]]; then new_val="$new_notify"; fi
|
|
;;
|
|
esac
|
|
|
|
if [[ -n "$new_val" ]]; then
|
|
if [[ "$original_line" =~ ^([[:space:]]*[A-Za-z_]+[[:space:]]*=[[:space:]]*)([^[:space:]#]+)(.*)$ ]]; then
|
|
local prefix="${BASH_REMATCH[1]}"
|
|
local old_val="${BASH_REMATCH[2]}"
|
|
local suffix="${BASH_REMATCH[3]}"
|
|
|
|
# Adjust whitespace to keep comments aligned if the value length changed
|
|
if [[ "$key" != "output" ]]; then
|
|
local old_len=${#old_val}
|
|
local new_len=${#new_val}
|
|
local diff=$((old_len - new_len))
|
|
|
|
if [[ $diff -gt 0 ]]; then
|
|
# new value is shorter, add spaces to suffix
|
|
local spaces=""
|
|
for ((i=0; i<diff; i++)); do spaces+=" "; done
|
|
suffix="$spaces$suffix"
|
|
elif [[ $diff -lt 0 ]]; then
|
|
# new value is longer, remove spaces from suffix
|
|
local remove=$((-diff))
|
|
for ((i=0; i<remove; i++)); do
|
|
if [[ "$suffix" == " "* ]]; then
|
|
suffix="${suffix# }"
|
|
fi
|
|
done
|
|
fi
|
|
fi
|
|
|
|
echo "$prefix$new_val$suffix" >> "$temp_file"
|
|
continue
|
|
elif [[ "$original_line" =~ ^([[:space:]]*[A-Za-z_]+[[:space:]]*=[[:space:]]*)(.*)$ ]]; then
|
|
echo "${BASH_REMATCH[1]}$new_val" >> "$temp_file"
|
|
continue
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
echo "$original_line" >> "$temp_file"
|
|
done < "$CONFIG_FILE"
|
|
|
|
mv "$temp_file" "$CONFIG_FILE"
|
|
}
|
|
|
|
# Parse ~/.config/color-tool/config.toml and apply [defaults] and [desktop] values.
|
|
load_config() {
|
|
[[ ! -f "$CONFIG_FILE" ]] && return 0
|
|
|
|
local section="" key val
|
|
while IFS= read -r line; do
|
|
line="${line%%#*}" # strip inline comments
|
|
if [[ "$line" =~ ^[[:space:]]*$ ]]; then continue; fi
|
|
|
|
# Section header
|
|
if [[ "$line" =~ ^\[([A-Za-z_-]+)\]$ ]]; then
|
|
section="${BASH_REMATCH[1]}"
|
|
continue
|
|
fi
|
|
|
|
# key = value
|
|
if [[ "$line" =~ ^[[:space:]]*([A-Za-z_]+)[[:space:]]*=[[:space:]]*([^[:space:]]+) ]]; then
|
|
key="${BASH_REMATCH[1]}"
|
|
val="${BASH_REMATCH[2],,}"
|
|
val="${val#\"}"
|
|
val="${val%\"}"
|
|
case "$section:$key" in
|
|
defaults:json) [[ "$val" == "true" ]] && json_mode=1 || json_mode=0 ;;
|
|
defaults:alpha) [[ "$val" == "true" ]] && alpha_mode=1 || alpha_mode=0 ;;
|
|
defaults:name) [[ "$val" == "true" ]] && name_mode=1 || name_mode=0 ;;
|
|
defaults:copy) [[ "$val" == "true" ]] && copy_mode=1 || copy_mode=0 ;;
|
|
defaults:pick) [[ "$val" == "true" ]] && config_pick=1 || config_pick=0 ;;
|
|
defaults:notify) [[ "$val" == "true" ]] && notify_mode=1 || notify_mode=0 ;;
|
|
defaults:swatch) [[ "$val" == "true" ]] && swatch_mode=1 || swatch_mode=0 ;;
|
|
defaults:output) output_formats="$val" ;;
|
|
desktop:json) [[ "$val" == "true" ]] && desktop_json=1 || desktop_json=0 ;;
|
|
desktop:alpha) [[ "$val" == "true" ]] && desktop_alpha=1 || desktop_alpha=0 ;;
|
|
desktop:name) [[ "$val" == "true" ]] && desktop_name=1 || desktop_name=0 ;;
|
|
desktop:notify) [[ "$val" == "true" ]] && desktop_notify=1 || desktop_notify=0 ;;
|
|
desktop:swatch) [[ "$val" == "true" ]] && desktop_swatch=1 || desktop_swatch=0 ;;
|
|
desktop:copy) [[ "$val" == "true" ]] && desktop_copy=1 || desktop_copy=0 ;;
|
|
desktop:output) desktop_output="$val" ;;
|
|
esac
|
|
fi
|
|
done <"$CONFIG_FILE"
|
|
}
|
|
|
|
write_default_config() {
|
|
mkdir -p "$(dirname "$CONFIG_FILE")"
|
|
cat >"$CONFIG_FILE" <<EOF
|
|
# color-tool configuration
|
|
# Source: $REPO_URL
|
|
|
|
[defaults]
|
|
# Set any option to true to enable it by default when using the terminal
|
|
output = "hex" # default output format(s): hex, rgb, hsl, rgba, hsla, hexa, all
|
|
json = false # output in JSON format
|
|
swatch = false # show color swatch in terminal
|
|
name = false # fetch color name from thecolorapi.com
|
|
copy = false # copy result to clipboard
|
|
pick = false # auto-launch color picker when invoked with no arguments
|
|
notify = false # show desktop notification
|
|
|
|
[desktop]
|
|
# Defaults for --desktop mode (launched from the app menu; copy is always enabled by default)
|
|
output = "hex" # format to copy
|
|
json = false # copy JSON format instead of plain text
|
|
name = false # fetch color name (requires network)
|
|
copy = true # copy result to clipboard
|
|
notify = true # show desktop notification with the copied value
|
|
swatch = true # show color swatch in notification
|
|
EOF
|
|
}
|
|
|
|
reset_config() {
|
|
write_default_config
|
|
echo "Configuration reset to defaults: $CONFIG_FILE"
|
|
}
|
|
|
|
# ── Help ──────────────────────────────────────────────────────────────────────
|
|
|
|
show_help() {
|
|
local bold='\033[1m'
|
|
local dim='\033[2m'
|
|
local reset='\033[0m'
|
|
local cyan='\033[36m'
|
|
local yellow='\033[33m'
|
|
local mag='\033[35m'
|
|
|
|
printf "\n"
|
|
# Title: "color" in a rainbow gradient, "-tool" in bold
|
|
printf " ${bold}\033[38;2;255;80;80mc\033[38;2;255;160;0mo\033[38;2;220;210;0ml\033[38;2;80;200;80mo\033[38;2;80;160;255mr${reset}${bold}-tool${reset}"
|
|
printf " ${dim}— pick, convert, and format colors${reset}\n\n"
|
|
|
|
printf " ${bold}${yellow}Usage${reset} ${cyan}${0##*/}${reset} ${dim}[OPTIONS]${reset} [COLOR ...]\n\n"
|
|
|
|
printf " ${bold}${yellow}Options${reset}\n"
|
|
printf " ${bold}${cyan}--[no-]pick${reset} Open the KDE Plasma color picker ${dim}(requires KDE + Wayland)${reset}\n"
|
|
printf " ${bold}${cyan}--output${reset} ${dim}FMT${reset} Format(s) to output: ${cyan}hex, rgb, hsl, rgba, hsla, hexa, all${reset} ${dim}(comma-separated)${reset}\n"
|
|
printf " ${bold}${cyan}--[no-]json${reset} Output as a JSON table of selected formats\n"
|
|
printf " ${bold}${cyan}--[no-]name${reset} Fetch nearest color name from thecolorapi.com ${dim}(requires curl, jq)${reset}\n"
|
|
printf " ${bold}${cyan}--[no-]swatch${reset} Include a color swatch in the terminal output and desktop notification\n"
|
|
printf " ${bold}${cyan}--[no-]copy${reset} Copy result to clipboard ${dim}(wl-clipboard preferred, xclip as fallback)${reset}\n"
|
|
printf " ${bold}${cyan}--[no-]notify${reset} Show desktop notification ${dim}(on by default in --desktop)${reset}\n"
|
|
printf " ${bold}${cyan}--desktop${reset} GUI mode: pick → copy → notify ${dim}(for app menu / .desktop launcher)${reset}\n"
|
|
printf " ${bold}${cyan}--get-config${reset} Print the current configuration\n"
|
|
printf " ${bold}${cyan}--set-config${reset} Update the configuration ${dim}(e.g. --set-config desktop --copy)${reset}\n"
|
|
printf " ${bold}${cyan}--reset-config${reset} Restore the configuration file to its default values\n"
|
|
printf " ${bold}${cyan}--install${reset} Install to ~/.local/share/color-tool/ and symlink into ~/.local/bin/\n"
|
|
printf " ${bold}${cyan}--help${reset}, ${bold}${cyan}-h${reset} Show this help message\n\n"
|
|
|
|
printf " ${bold}${yellow}Examples${reset}\n"
|
|
printf " ${cyan}${0##*/}${reset} ${mag}\"#534145\"${reset} --swatch\n"
|
|
printf " ${cyan}${0##*/}${reset} --pick --output rgb,hsl --json\n"
|
|
printf " ${cyan}${0##*/}${reset} \"rgb(255, 100, 0)\" --output hex,hsla\n\n"
|
|
|
|
printf " ${bold}${yellow}Config${reset} ${dim}~/.config/color-tool/config.toml${reset}\n"
|
|
printf " ${dim}[defaults]${reset} keys: ${cyan}output json swatch name copy pick notify${reset}\n"
|
|
printf " ${dim}[desktop]${reset} keys: ${cyan}output json name notify copy swatch${reset} ${dim}(pick always on)${reset}\n\n"
|
|
}
|
|
|
|
# ── Environment detection ─────────────────────────────────────────────────────
|
|
|
|
check_kde_wayland() {
|
|
if [[ -z "${WAYLAND_DISPLAY:-}" ]]; then
|
|
echo "Error: color picker requires a Wayland session (WAYLAND_DISPLAY is not set)" >&2
|
|
return 1
|
|
fi
|
|
local desktop="${XDG_CURRENT_DESKTOP:-}"
|
|
if [[ "$desktop" != *KDE* ]] && [[ -z "${KDE_FULL_SESSION:-}" ]]; then
|
|
echo "Error: color picker requires KDE Plasma (current desktop: ${desktop:-unknown})" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# ── Clipboard & Notifications ─────────────────────────────────────────────────
|
|
|
|
copy_to_clipboard() {
|
|
local text="$1"
|
|
if command -v wl-copy &>/dev/null; then
|
|
printf '%s' "$text" | wl-copy
|
|
elif command -v xclip &>/dev/null; then
|
|
printf '%s' "$text" | xclip -selection clipboard
|
|
else
|
|
echo "Warning: Missing clipboard utility. Please install wl-clipboard (preferred) or xclip." >&2
|
|
echo " Value: $text" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
notify_result() {
|
|
local value="$1"
|
|
local hex_color="${2:-}"
|
|
command -v notify-send &>/dev/null || return 0
|
|
|
|
local icon_name="color-picker"
|
|
if [[ $swatch_mode -eq 1 && -n "$hex_color" ]]; then
|
|
local swatch_path="/tmp/color_swatch_${hex_color//\#/}.png"
|
|
if command -v magick >/dev/null 2>&1; then
|
|
if magick -size 64x64 xc:"$hex_color" "$swatch_path" 2>/dev/null; then
|
|
icon_name="$swatch_path"
|
|
fi
|
|
elif command -v convert >/dev/null 2>&1; then
|
|
if convert -size 64x64 xc:"$hex_color" "$swatch_path" 2>/dev/null; then
|
|
icon_name="$swatch_path"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
notify-send -i "$icon_name" "color-tool" "$value" || true
|
|
}
|
|
|
|
notify_error() {
|
|
local value="$1"
|
|
command -v notify-send &>/dev/null || return 0
|
|
notify-send -u normal -i "dialog-warning" "color-tool" "$value" || true
|
|
}
|
|
|
|
# ── Color picker ──────────────────────────────────────────────────────────────
|
|
|
|
# Generate the internal Python helper for KDE Plasma color picking
|
|
generate_picker_script() {
|
|
local target="$1"
|
|
cat >"$target" <<'EOF'
|
|
#!/usr/bin/python3
|
|
#
|
|
# Original work Copyright (C) 2024 SASUPERNOVA
|
|
# Source: https://github.com/SASUPERNOVA/wl-colorpicker-plasma
|
|
#
|
|
# This program is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation; either version 2 of the License.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
|
|
from PyQt6.QtDBus import QDBusConnection, QDBusMessage, QDBusPendingReply, QDBusPendingCallWatcher
|
|
from PyQt6.QtCore import QCoreApplication
|
|
from PyQt6.QtGui import QColor
|
|
import sys
|
|
|
|
def colorPicked():
|
|
reply = QDBusPendingReply(call)
|
|
if reply.isError():
|
|
print(reply.error().message(), file=sys.stderr)
|
|
else:
|
|
print(QColor(reply.argumentAt(0)[0]).name())
|
|
|
|
app = QCoreApplication(sys.argv)
|
|
msg = QDBusMessage.createMethodCall("org.kde.KWin", "/ColorPicker", "org.kde.kwin.ColorPicker", "pick")
|
|
call = QDBusConnection.sessionBus().asyncCall(msg)
|
|
watcher = QDBusPendingCallWatcher(call, app)
|
|
watcher.finished.connect(colorPicked)
|
|
watcher.waitForFinished()
|
|
EOF
|
|
chmod +x "$target"
|
|
}
|
|
|
|
run_color_picker() {
|
|
check_kde_wayland || return 1
|
|
|
|
local picker_path="$SCRIPT_DIR/wl-colorpicker-plasma.py"
|
|
local cleanup=0
|
|
|
|
if [[ ! -f "$picker_path" ]]; then
|
|
picker_path=$(mktemp /tmp/wl-colorpicker-XXXXXX.py)
|
|
generate_picker_script "$picker_path"
|
|
cleanup=1
|
|
fi
|
|
|
|
local picked
|
|
picked=$(python3 "$picker_path")
|
|
local exit_code=$?
|
|
|
|
[[ $cleanup -eq 1 ]] && rm -f "$picker_path"
|
|
|
|
if [[ $exit_code -eq 0 ]]; then
|
|
echo "$picked"
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# ── Installation Helpers ──────────────────────────────────────────────────────
|
|
|
|
suggest_path_add() {
|
|
local bin_dir="$1" is_fish=0
|
|
[[ "${SHELL:-}" == */fish ]] && is_fish=1
|
|
if [[ $is_fish -eq 0 ]]; then
|
|
local parent
|
|
parent=$(ps -p "$PPID" -o comm= 2>/dev/null || true)
|
|
[[ "$parent" == *fish* ]] && is_fish=1
|
|
fi
|
|
if [[ $is_fish -eq 1 ]]; then
|
|
printf " Run: fish_add_path %s\n" "$bin_dir"
|
|
else
|
|
printf " For bash/zsh: export PATH=\"%s:\$PATH\"\n" "$bin_dir"
|
|
printf " For fish: fish_add_path %s\n" "$bin_dir"
|
|
fi
|
|
}
|
|
|
|
check_dependencies() {
|
|
local missing=()
|
|
|
|
if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then
|
|
missing+=("ImageMagick (magick or convert) — required for notification color swatches")
|
|
fi
|
|
|
|
if ! command -v wl-copy >/dev/null 2>&1 && ! command -v xclip >/dev/null 2>&1; then
|
|
missing+=("wl-clipboard (wl-copy) or xclip — required for clipboard copy support")
|
|
fi
|
|
|
|
if ! command -v python3 >/dev/null 2>&1; then
|
|
missing+=("python3 — required for color conversions and picker GUI")
|
|
elif ! python3 -c "import PyQt6.QtDBus" >/dev/null 2>&1; then
|
|
missing+=("python3-pyqt6 — required for the KDE color picker DBus interface")
|
|
fi
|
|
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
missing+=("jq — required for JSON formatting and color names")
|
|
fi
|
|
|
|
if ! command -v curl >/dev/null 2>&1; then
|
|
missing+=("curl — required for fetching color names from the API")
|
|
fi
|
|
|
|
if ! command -v notify-send >/dev/null 2>&1; then
|
|
missing+=("libnotify (notify-send) — required for desktop notifications")
|
|
fi
|
|
|
|
local env_ok=1
|
|
if [[ -z "${WAYLAND_DISPLAY:-}" ]]; then
|
|
env_ok=0
|
|
fi
|
|
local desktop="${XDG_CURRENT_DESKTOP:-}"
|
|
if [[ "$desktop" != *KDE* ]] && [[ -z "${KDE_FULL_SESSION:-}" ]]; then
|
|
env_ok=0
|
|
fi
|
|
|
|
if [[ $env_ok -eq 0 ]]; then
|
|
missing+=("KDE Plasma on Wayland — required for the screen color picker feature")
|
|
fi
|
|
|
|
if [[ ${#missing[@]} -gt 0 ]]; then
|
|
local yellow='\033[33m'
|
|
local bold='\033[1m'
|
|
local reset='\033[0m'
|
|
printf "\n ${bold}${yellow}Warnings — Missing optional dependencies:${reset}\n"
|
|
for item in "${missing[@]}"; do
|
|
printf " - %s\n" "$item"
|
|
done
|
|
printf " ${yellow}Functionality will be limited without these installed.${reset}\n"
|
|
fi
|
|
}
|
|
|
|
do_install() {
|
|
local data_dir="$HOME/.local/share/color-tool"
|
|
local bin_dir="$HOME/.local/bin"
|
|
local bin_link="$bin_dir/color-tool"
|
|
local src_script
|
|
src_script="$(readlink -f "$0")"
|
|
|
|
mkdir -p "$data_dir" "$bin_dir"
|
|
cp "$src_script" "$data_dir/color-tool"
|
|
chmod +x "$data_dir/color-tool"
|
|
generate_picker_script "$data_dir/wl-colorpicker-plasma.py"
|
|
ln -sf "$data_dir/color-tool" "$bin_link"
|
|
|
|
local config_dir="$HOME/.config/color-tool"
|
|
local config_file="$config_dir/config.toml"
|
|
if [[ ! -f "$config_file" ]]; then
|
|
write_default_config
|
|
printf " config %s (sample created)\n" "$config_file"
|
|
else
|
|
printf " config %s\n" "$config_file"
|
|
fi
|
|
|
|
local app_dir="$HOME/.local/share/applications"
|
|
local desktop_file="$app_dir/color-tool.desktop"
|
|
local bin_path="$HOME/.local/bin/color-tool"
|
|
mkdir -p "$app_dir"
|
|
cat >"$desktop_file" <<EOF
|
|
[Desktop Entry]
|
|
Version=1.1
|
|
Type=Application
|
|
Name=Color Tool
|
|
Comment=Pick a color and copy it to the clipboard
|
|
Exec=$bin_path --desktop
|
|
Icon=color-picker
|
|
Categories=Utility;Graphics;
|
|
Keywords=color;picker;hex;rgb;clipboard;
|
|
Terminal=false
|
|
NoDisplay=false
|
|
EOF
|
|
printf " app %s\n" "$desktop_file"
|
|
|
|
printf "Installed:\n"
|
|
printf " data %s/\n" "$data_dir"
|
|
printf " bin %s -> %s\n" "$bin_link" "$data_dir/color-tool"
|
|
|
|
if [[ ":$PATH:" != *":$bin_dir:"* ]]; then
|
|
printf "\nNote: %s is not in your PATH.\n" "$bin_dir"
|
|
suggest_path_add "$bin_dir"
|
|
fi
|
|
|
|
check_dependencies
|
|
}
|
|
|
|
# ── Color conversion ──────────────────────────────────────────────────────────
|
|
|
|
get_all_formats() {
|
|
local input="$1"
|
|
python3 -c "
|
|
import colorsys, json, sys, re
|
|
|
|
def parse_input(val):
|
|
val = val.strip().lower()
|
|
hex_match = re.match(r'^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$', val)
|
|
if hex_match:
|
|
h = hex_match.group(1)
|
|
if len(h) == 3: h = ''.join([c*2 for c in h])
|
|
if len(h) == 6: h += 'ff'
|
|
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), int(h[6:8], 16)
|
|
rgba_match = re.match(r'^rgba?\(?(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)?$', val)
|
|
if rgba_match:
|
|
r, g, b = map(int, rgba_match.groups()[:3])
|
|
r, g, b = max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b))
|
|
a = float(rgba_match.group(4)) if rgba_match.group(4) else 1.0
|
|
return r, g, b, int(max(0, min(1, a)) * 255)
|
|
hsla_match = re.match(r'^hsla?\(?(\d+),\s*(\d+)%?,\s*(\d+)%?(?:,\s*([\d.]+))?\)?$', val)
|
|
if hsla_match:
|
|
h, s, l = map(int, hsla_match.groups()[:3])
|
|
h, s, l = h % 360, max(0, min(100, s)), max(0, min(100, l))
|
|
a = float(hsla_match.group(4)) if hsla_match.group(4) else 1.0
|
|
r, g, b = colorsys.hls_to_rgb(h/360.0, l/100.0, s/100.0)
|
|
return int(r*255), int(g*255), int(b*255), int(max(0, min(1, a)) * 255)
|
|
return None
|
|
|
|
res = parse_input('''$input''')
|
|
if not res: sys.exit(1)
|
|
r, g, b, a = res
|
|
h, l, s = colorsys.rgb_to_hls(r/255.0, g/255.0, b/255.0)
|
|
formats = {
|
|
'hex': f'#{r:02x}{g:02x}{b:02x}',
|
|
'hexa': f'#{r:02x}{g:02x}{b:02x}{a:02x}',
|
|
'rgb': f'rgb({r}, {g}, {b})',
|
|
'rgba': f'rgba({r}, {g}, {b}, {round(a/255.0, 2)})',
|
|
'hsl': f'hsl({round(h*360)}, {round(s*100)}%, {round(l*100)}%)',
|
|
'hsla': f'hsla({round(h*360)}, {round(s*100)}%, {round(l*100)}%, {round(a/255.0, 2)})',
|
|
'_raw': {'r': r, 'g': g, 'b': b, 'a': a}
|
|
}
|
|
print(json.dumps(formats))
|
|
"
|
|
}
|
|
|
|
validate_output_formats() {
|
|
local formats="$1"
|
|
local valid_fmts=("hex" "hexa" "rgb" "rgba" "hsl" "hsla")
|
|
IFS=',' read -ra ADDR <<<"$formats"
|
|
for fmt in "${ADDR[@]}"; do
|
|
[[ "$fmt" == "all" ]] && continue
|
|
local is_valid=0
|
|
for v in "${valid_fmts[@]}"; do [[ "$fmt" == "$v" ]] && is_valid=1 && break; done
|
|
if [[ $is_valid -eq 0 ]]; then
|
|
echo "Error: Invalid output format: $fmt" >&2
|
|
echo "Valid formats: ${valid_fmts[*]}, all" >&2
|
|
return 1
|
|
fi
|
|
done
|
|
return 0
|
|
}
|
|
|
|
process_color() {
|
|
local input="$1"
|
|
local formats_json
|
|
formats_json=$(get_all_formats "$input") || {
|
|
echo "Error: Invalid color: $input" >&2
|
|
return 1
|
|
}
|
|
|
|
local hex_color
|
|
hex_color=$(echo "$formats_json" | jq -r '.hex // empty')
|
|
|
|
local name=""
|
|
if [[ $name_mode -eq 1 ]]; then
|
|
local hex_for_api="${hex_color//#/}"
|
|
name=$(curl -s "https://www.thecolorapi.com/id?hex=$hex_for_api" | jq -r '.name.value // empty')
|
|
fi
|
|
|
|
local selected_fmts=()
|
|
local valid_fmts=("hex" "hexa" "rgb" "rgba" "hsl" "hsla")
|
|
IFS=',' read -ra ADDR <<<"$output_formats"
|
|
for fmt in "${ADDR[@]}"; do
|
|
if [[ "$fmt" == "all" ]]; then
|
|
selected_fmts=("${valid_fmts[@]}")
|
|
break
|
|
else
|
|
selected_fmts+=("$fmt")
|
|
fi
|
|
done
|
|
|
|
local display_parts=()
|
|
local json_obj="{}"
|
|
for fmt in "${selected_fmts[@]}"; do
|
|
local val
|
|
val=$(echo "$formats_json" | jq -r ".$fmt // empty")
|
|
if [[ -n "$val" ]]; then
|
|
display_parts+=("$val")
|
|
json_obj=$(echo "$json_obj" | jq --arg k "$fmt" --arg v "$val" '. + {($k): $v}')
|
|
fi
|
|
done
|
|
|
|
[[ -n "$name" ]] && json_obj=$(echo "$json_obj" | jq --arg v "$name" '. + {"name": $v}')
|
|
|
|
local output_text
|
|
if [[ $json_mode -eq 1 ]]; then
|
|
output_text="$json_obj"
|
|
else
|
|
output_text=$(
|
|
IFS=' '
|
|
echo "${display_parts[*]}"
|
|
)
|
|
[[ -n "$name" ]] && output_text="$output_text ($name)"
|
|
fi
|
|
|
|
if [[ $desktop_mode -eq 0 ]]; then
|
|
if [[ $swatch_mode -eq 1 ]]; then
|
|
local r g b
|
|
r=$(echo "$formats_json" | jq -r '._raw.r')
|
|
g=$(echo "$formats_json" | jq -r '._raw.g')
|
|
b=$(echo "$formats_json" | jq -r '._raw.b')
|
|
if [[ $json_mode -eq 1 ]]; then
|
|
printf "\033[48;2;${r};${g};${b}m \033[0m\n"
|
|
else printf "\033[48;2;${r};${g};${b}m \033[0m "; fi
|
|
fi
|
|
echo -e "$output_text"
|
|
fi
|
|
|
|
local copy_failed=0
|
|
if [[ $copy_mode -eq 1 ]]; then
|
|
if ! copy_to_clipboard "$output_text"; then
|
|
copy_failed=1
|
|
fi
|
|
fi
|
|
|
|
if [[ $notify_mode -eq 1 ]]; then
|
|
if [[ $copy_failed -eq 1 ]]; then
|
|
notify_error "Missing clipboard utility. Please install wl-clipboard (preferred) or xclip.
|
|
|
|
Value: $output_text"
|
|
else
|
|
notify_result "$output_text" "$hex_color"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# ── Argument parsing ──────────────────────────────────────────────────────────
|
|
|
|
load_config
|
|
args=()
|
|
do_pick=0
|
|
|
|
# First pass: collect CLI overrides
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--help | -h)
|
|
show_help
|
|
exit 0
|
|
;;
|
|
--output)
|
|
cli_output="$2"
|
|
shift
|
|
;;
|
|
--json) cli_json=1 ;;
|
|
--no-json) cli_json=0 ;;
|
|
--alpha)
|
|
cli_alpha=1
|
|
cli_output="hexa"
|
|
;;
|
|
--no-alpha) cli_alpha=0 ;;
|
|
--name) cli_name=1 ;;
|
|
--no-name) cli_name=0 ;;
|
|
--swatch) cli_swatch=1 ;;
|
|
--no-swatch) cli_swatch=0 ;;
|
|
--copy) cli_copy=1 ;;
|
|
--no-copy) cli_copy=0 ;;
|
|
--notify) cli_notify=1 ;;
|
|
--no-notify) cli_notify=0 ;;
|
|
--pick) cli_pick=1 ;;
|
|
--no-pick) cli_pick=0 ;;
|
|
--desktop) desktop_mode=1 ;;
|
|
--install)
|
|
do_install
|
|
exit 0
|
|
;;
|
|
--get-config)
|
|
get_config
|
|
exit 0
|
|
;;
|
|
--set-config)
|
|
shift
|
|
set_config "$@"
|
|
exit 0
|
|
;;
|
|
--reset-config)
|
|
reset_config
|
|
exit 0
|
|
;;
|
|
-*)
|
|
echo "Unknown option: $1" >&2
|
|
exit 1
|
|
;;
|
|
*) args+=("$1") ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
if [[ $desktop_mode -eq 1 ]]; then
|
|
json_mode=${cli_json:-$desktop_json}
|
|
alpha_mode=${cli_alpha:-$desktop_alpha}
|
|
name_mode=${cli_name:-$desktop_name}
|
|
notify_mode=${cli_notify:-$desktop_notify}
|
|
output_formats=${cli_output:-$desktop_output}
|
|
copy_mode=${cli_copy:-$desktop_copy}
|
|
swatch_mode=${cli_swatch:-$desktop_swatch}
|
|
do_pick=${cli_pick:-1}
|
|
else
|
|
json_mode=${cli_json:-$json_mode}
|
|
alpha_mode=${cli_alpha:-$alpha_mode}
|
|
name_mode=${cli_name:-$name_mode}
|
|
notify_mode=${cli_notify:-$notify_mode}
|
|
output_formats=${cli_output:-$output_formats}
|
|
copy_mode=${cli_copy:-$copy_mode}
|
|
do_pick=${cli_pick:-0}
|
|
swatch_mode=${cli_swatch:-$swatch_mode}
|
|
fi
|
|
|
|
# Validation
|
|
validate_output_formats "$output_formats" || exit 1
|
|
|
|
# Stdin support
|
|
[[ ! -t 0 ]] && while read -r line; do [[ -n "$line" ]] && args+=("$line"); done
|
|
|
|
# Execution
|
|
if [[ $do_pick -eq 1 ]]; then
|
|
picked="$(run_color_picker)" || exit 1
|
|
[[ -n "$picked" ]] && args+=("$picked")
|
|
elif [[ ${#args[@]} -eq 0 ]]; then
|
|
if [[ $config_pick -eq 1 ]]; then
|
|
picked="$(run_color_picker)" || exit 1
|
|
[[ -n "$picked" ]] && args+=("$picked")
|
|
elif [[ -t 0 ]]; then
|
|
show_help
|
|
exit 0
|
|
fi
|
|
fi
|
|
|
|
for color in "${args[@]}"; do process_color "$color"; done
|