Refactor clipboard management and graphics query scripts

This commit is contained in:
2026-02-07 16:18:18 +01:00
parent 0863da0f47
commit 2a5b2fd9c2
12 changed files with 472 additions and 11 deletions
@@ -13,8 +13,6 @@
set -euo pipefail
trap 'echo $LINENO: $BASH_COMMAND' ERR
# Lock
exec {LOCK_FD}>/tmp/"$(basename "$0")".lock
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env bash
# shellcheck disable=SC2016
# Description:
# View and manage clipboard history using fzf, with support for
# image previews in compatible terminals.
# Requirements:
# - cliphist
# - wl-clipboard
# - fzf
# - sixel-query & kgp-query from this repository
# - chafa (for image previews)
# - ffmpegthumbnailer (optional, for video thumbnails)
# Credits:
# - Original idea and some code adapted from https://github.com/SHORiN-KiWATA/shorinclip
# LICENSE:
# # MIT License
# #
# # Copyright (c) 2026 shorinkiwata
# #
# # Permission is hereby granted, free of charge, to any person obtaining a copy
# # of this software and associated documentation files (the "Software"), to deal
# # in the Software without restriction, including without limitation the rights
# # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# # copies of the Software, and to permit persons to whom the Software is
# # furnished to do so, subject to the following conditions:
# #
# # The above copyright notice and this permission notice shall be included in all
# # copies or substantial portions of the Software.
# #
# # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# # SOFTWARE.
set -euo pipefail
CACHE_DIR=$(mktemp -d)
export CACHE_DIR
# trap rm later
export C_TERTIARY='\x1b[1;35m'
export C_PRIMARY='\x1b[1;34m'
export C_CYAN='\x1b[1;36m'
export C_RESET='\x1b[0m'
# Check for terminal graphics support and set environment variables accordingly
_check_kitty_icat() {
# workaround for WezTerm
if [ -n "${WEZTERM_EXECUTABLE:-}" ]; then
return 1
fi
kgp-query
}
_check_sixel() {
# workaround for Zellij
if [ -n "${ZELLIJ_SESSION_NAME:-}" ]; then
return 1
# same for tmux, unless otherwise configured
elif [ -n "${TMUX:-}" ]; then
return 1
fi
sixel-query
}
_check_iterm2() {
iterm2-query
}
ENABLE_ICAT=0
ENABLE_SIXEL=0
ENABLE_ITERM2=0
# Priority: KGP > sixel > iterm2
if _check_kitty_icat; then
export ENABLE_ICAT=1
elif _check_sixel; then
export ENABLE_SIXEL=1
elif _check_iterm2; then
export ENABLE_ITERM2=1
fi
export ENABLE_ICAT
export ENABLE_SIXEL
export ENABLE_ITERM2
# Preview functions
_preview_image() {
local file="$1"
if [ "$ENABLE_ICAT" -eq 1 ]; then
printf "\x1b_Ga=d\x1b\\"
chafa -f kitty --animate=off --size="${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}" "$file"
elif [ "$ENABLE_SIXEL" -eq 1 ]; then
chafa -f sixels --animate=off --size="${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}" "$file"
elif [ "$ENABLE_ITERM2" -eq 1 ]; then
chafa -f iterm2 --animate=off --size="${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}" "$file"
else
chafa -f symbols --animate=off --size="${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}" "$file"
fi
}
export -f _preview_image
_preview_text() {
local content="$1"
if [ "$ENABLE_ICAT" -eq 1 ]; then
printf "\x1b_Ga=d\x1b\\"
fi
echo "$content" | head -n 100
}
export -f _preview_text
_preview_file() {
path="$1"
path_mime=$(file -b --mime-type "$path")
if [[ $path_mime =~ image ]]; then
_preview_image "$path"
elif [[ "$path_mime" =~ video ]]; then
video_hash=$(echo "$path" | md5sum | cut -d" " -f1)
thumb_file="$CACHE_DIR/$video_hash.png"
if [ ! -f "$thumb_file" ]; then
if command -v ffmpegthumbnailer &>/dev/null;then
ffmpegthumbnailer -i "$path" -o "$thumb_file" -s 480 -t 0 >/dev/null 2>&1
else
_preview_text "ffmpegthumbnailer not installed, cannot generate thumbnail for video."
fi
fi
if [ -s "$thumb_file" ]; then
_preview_image "$thumb_file"
else
_preview_text "Video: $path (No thumbnail)"
fi
else
_preview_text "$path"
fi
}
export -f _preview_file
preview() {
entry="$1"
content=$(echo "$entry" | cut -f2-)
mimeType=$(echo "$entry" | cliphist decode | file -b --mime-type -)
ext=$(echo "$mimeType" | awk -F"/" "{print \$2}")
if [[ $mimeType =~ image ]]; then
img_hash=$(echo "$entry" | cliphist decode | md5sum | cut -d" " -f1)
cache_file="$CACHE_DIR/$img_hash.$ext"
[ -f "$cache_file" ] || echo "$entry" | cliphist decode > "$cache_file"
_preview_image "$cache_file"
elif [ "$mimeType" = "text/html" ] && echo "$content" | grep -q QQ; then
qq_img_file=$(echo "$entry" | cliphist decode | grep -oP "^<img src=\"file://\K[^\"]+")
#qq_ext="${qq_img_file##*.}"
#qq_img_cache_file=$CACHE_DIR/$id.$qq_ext
#cp $qq_img_file $qq_img_cache_file
if [ -f "$qq_img_file" ]; then
_preview_image "$qq_img_file"
else
_preview_text "$qq_img_file does not exist."
fi
elif path=$(echo "$entry" | cliphist decode) && [[ "$path" == /* ]]; then
if [ -e "$path" ]; then
_preview_file "$path"
else
_preview_text "$path does not exist."
fi
elif decoded=$(echo "$entry" | cliphist decode) && [[ "$decoded" == file://* ]]; then
raw_path="${decoded#file://}"
raw_path=$(echo "$raw_path" | python -c "import sys, urllib.parse; print(urllib.parse.unquote(sys.stdin.read().strip()))")
if [ -e "$raw_path" ]; then
_preview_file "$raw_path"
else
_preview_text "$raw_path does not exist."
fi
else
if [ "$ENABLE_ICAT" = 1 ]; then
printf "\x1b_Ga=d\x1b\\"
fi
_preview_text "$(echo "$entry" | cliphist decode)"
fi
}
export -f preview
# Optimize entry formatting
format_clip_list() {
sed -E \
-e "s/(\t).*\.(mp4|mkv|webm|avi|mov|flv|wmv)$/\1${C_TERTIARY}[VIDEO]File.\2${C_RESET}/" \
-e "s/(\t)file:\/\/.*\.(mp4|mkv|webm|avi|mov|flv|wmv)$/\1${C_TERTIARY}[VIDEO]Url.\2${C_RESET}/" \
-e "s/(\t).*src=\"file:\/\/.*[qQ][qQ].*/\1${C_PRIMARY}[IMG_HTML]QQ${C_RESET}/" \
-e "s/(\t)file:\/\/.*xwechat.*temp.*/\1${C_PRIMARY}[IMG]WeChat${C_RESET}/" \
-e "s/(\t)file:\/\/.*\.gif$/\1${C_PRIMARY}[IMG]Url.gif${C_RESET}/" \
-e "s/(\t)file:\/\/.*\.(png|jpg|jpeg|webp|bmp)$/\1${C_TERTIARY}[IMG]Url.\2${C_RESET}/" \
-e "s/(\t)file:\/\/.*/\1${C_CYAN}[URL]File${C_RESET}/" \
-e "s/(\t)\/.*\.gif$/\1${C_PRIMARY}[IMG]Path.gif${C_RESET}/" \
-e "s/(\t)\/.*\.(png|jpg|jpeg|webp|bmp)$/\1${C_TERTIARY}[IMG]Path.\2${C_RESET}/" \
-e "s/\[\[ binary data .* (png|jpg|jpeg|gif|webp) .*\]\]/${C_TERTIARY}[IMG]Bin.\1${C_RESET}/" \
-e "s/\[\[ binary data .* \]\]/${C_CYAN}[BINARY]${C_RESET}/"
}
export -f format_clip_list
add_num() {
awk -F '\t' '{printf "%s\t\x1b[90m%-2d \x1b[0m%s\n", $1, NR, $2}'
}
export -f add_num
# Action when confirmed
copy_selection() {
local input="$1"
local decoded
decoded=$(echo "$input" | cliphist decode)
local mime
mime=$(echo "$decoded" | file -b --mime-type -)
url_encode() {
python -c "import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))" "$1"
}
# Image
if [[ "$mime" =~ image ]]; then
echo "$decoded" | wl-copy
# HTML with QQ image
elif [ "$mime" = "text/html" ]; then
local qq_src
qq_src=$(echo "$decoded" | grep -oP "^<img src=\"file://\K[^\"]+")
if [ -f "$qq_src" ]; then
local encoded_path
encoded_path=$(url_encode "$qq_src")
echo "file://$encoded_path" | wl-copy --type text/uri-list
else
echo "$decoded" | wl-copy
fi
# URL starting with file://
elif [[ "$decoded" == file://* ]]; then
echo "$decoded" | wl-copy --type text/uri-list
# file path
elif [[ "$decoded" == /* ]] && [ -e "$decoded" ]; then
local encoded_path
encoded_path=$(url_encode "$decoded")
echo "file://$encoded_path" | wl-copy --type text/uri-list
# Other data, just copy
else
echo "$decoded" | wl-copy
fi
}
export -f copy_selection
# Reload mechanism
FZF_PORT=$(shuf -i 10000-60000 -n 1)
RELOAD_CMD="cliphist list | format_clip_list | add_num"
wl-paste --watch bash -c "curl -s -X POST -d 'reload($RELOAD_CMD)' http://localhost:$FZF_PORT" >/dev/null 2>&1 &
WATCH_PID=$!
trap 'rm -rf "$CACHE_DIR"; kill $WATCH_PID 2>/dev/null' EXIT
# Ensure terminal is large enough
wait_timeout=50
while [[ $(tput cols) -lt 35 || $(tput lines) -lt 25 ]]; do
printf "\rWaiting for terminal size at least 35x25... %d" "$wait_timeout"
sleep 1
((wait_timeout--))
[ "$wait_timeout" -eq 0 ] && exit 1
done
cliphist list | format_clip_list | add_num | fzf \
--ansi \
--listen "$FZF_PORT" \
--bind "ctrl-r:reload($RELOAD_CMD)" \
--bind "ctrl-x:execute-silent(bash -c 'cliphist delete <<< \"\$1\"' -- {})+reload($RELOAD_CMD)" \
--prompt="󰅍 > " \
--header='CTRL-X: Delete | CTRL-R: Reload | ENTER: Paste' \
--color='header:italic:yellow,prompt:blue,pointer:blue' \
--info=hidden \
--no-sort \
--layout=reverse \
--with-nth 2.. \
--delimiter '\t' \
--preview-window=down:60%,wrap \
--preview "preview {}" \
--bind "enter:execute-silent(bash -c 'copy_selection \"\$1\"' -- {})+accept"
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Description:
# Wrapper for fzfclip to ensure only one instance is
# running and to launch it in ghostty.
set -euo pipefail
exec {LOCK_FD}>/tmp/"$(basename "$0")".lock
flock -n "$LOCK_FD" || {
echo "Another instance is running. Exiting." >&2
exit 1
}
ghostty -e fzfclip "$@"
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Description:
# Query terminal capabilities for graphics support, including:
# - Kitty Graphics Protocol (KGP)
# - iTerm2 inline images
# - Sixel graphics
# This script will print three lines of output:
# SUPPORT_KGP=1 or 0
# SUPPORT_ITERM2=1 or 0
# SUPPORT_SIXEL=1 or 0
#
# Usage:
# Do NOT source this script directly, as it will modify the terminal state
# and print its result directly to stdout.
# Instead, use command substitution to capture the output, for example:
# eval "$(graphics-query)"
# or parse the output manually.
#
# See also:
# - kgp-query: specifically checks for Kitty Graphics Protocol support
# - iterm2-query: specifically checks for iTerm2 inline image support
# - sixel-query: specifically checks for Sixel graphics support
set -euo pipefail
# Ensure in a interactive terminal
[ ! -t 0 ] && exit 1
# Construct query
KGP_QUERY_ID=$RANDOM
KGP_QUERY_CODE=$(printf "\033_Gi=%d,s=1,v=1,a=q,t=d,f=24;AAAA\033\\" "$KGP_QUERY_ID")
ITERM2_QUERY_CODE=$(printf "\033]1337;ReportCellSize\a")
KGP_EXPECTED_RESPONSE=$(printf "\033_Gi=%d;OK\033\\" "$KGP_QUERY_ID")
ITERM2_EXPECTED_RESPONSE=$(printf "\033]1337;") # followed by "ReportCellSize=...", but only the prefix is enough
FENCE_CODE=$(printf "\033[c")
# Set terminal to raw mode with timeout
stty_orig=$(stty -g)
trap 'stty "$stty_orig"' EXIT
stty -echo -icanon min 1 time 0
printf "%s%s%s" "$ITERM2_QUERY_CODE" "$KGP_QUERY_CODE" "$FENCE_CODE" > /dev/tty
response=""
support_kgp=0
support_iterm2=0
while true; do
IFS= read -r -N 1 -t 0.3 char || {
[ -z "$char" ] && break
}
response+="$char"
if [[ "$response" == *"$KGP_EXPECTED_RESPONSE"* ]]; then
support_kgp=1
fi
if [[ "$response" == *"$ITERM2_EXPECTED_RESPONSE"* ]]; then
support_iterm2=1
fi
if [[ "$response" == *$'\033['*'c' ]]; then
break
fi
if [ ${#response} -gt 1024 ]; then
break
fi
done
support_sixel=0
if [[ "$response" =~ $'\x1b'\[\?([0-9;]*)c ]]; then
params="${BASH_REMATCH[1]}"
IFS=';' read -ra codes <<< "$params"
for code in "${codes[@]}"; do
if [[ "$code" == "4" ]]; then
support_sixel=1
break
fi
done
fi
if [ "$support_kgp" -eq 1 ]; then
echo "SUPPORT_KGP=1"
else
echo "SUPPORT_KGP=0"
fi
if [ "$support_iterm2" -eq 1 ]; then
echo "SUPPORT_ITERM2=1"
else
echo "SUPPORT_ITERM2=0"
fi
if [ "$support_sixel" -eq 1 ]; then
echo "SUPPORT_SIXEL=1"
else
echo "SUPPORT_SIXEL=0"
fi
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
# Ensure in a interactive terminal
[ ! -t 0 ] && exit 1
# Construct query
QUERY_CODE=$(printf "\033]1337;ReportCellSize\a")
EXPECTED_RESPONSE=$(printf "\033]1337;") # followed by "ReportCellSize=...", but only the prefix is enough
# Construct fence code that most terminals will respond to
# to ensure that at least something will be sent back and
# to detect the end of the response
FENCE_CODE=$(printf "\033[c")
# Set terminal to raw mode
stty_orig=$(stty -g)
trap 'stty "$stty_orig"' EXIT
stty -echo -icanon min 1 time 0
printf "%s%s" "$QUERY_CODE" "$FENCE_CODE" > /dev/tty
response=""
ret=1
while true; do
IFS= read -r -N 1 -t 0.3 char || {
[ -z "$char" ] && break
}
response+="$char"
if [[ "$response" == *"$EXPECTED_RESPONSE"* ]]; then
ret=0
# Keep reading until response is complete to
# avoid leaving unread data in the terminal
fi
if [[ "$response" == *$'\033['*'c' ]]; then
break
fi
if [ ${#response} -gt 1024 ]; then
break
fi
done
exit $ret
+1 -1
View File
@@ -16,7 +16,7 @@ EXPECTED_RESPONSE=$(printf "\033_Gi=%d;OK\033\\" "$QUERY_ID")
# to detect the end of the response
FENCE_CODE=$(printf "\033[c")
# Set terminal to raw mode with timeout as 0.5s
# Set terminal to raw mode with timeout
stty_orig=$(stty -g)
trap 'stty "$stty_orig"' EXIT
stty -echo -icanon min 1 time 0
@@ -1,3 +0,0 @@
#!/bin/sh
pkill -f "ghostty -e shorinclip" || ghostty -e shorinclip