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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -36,7 +36,7 @@ binds {
Alt+Space { spawn-sh "pkill -x rofi || rofi -show drun"; }
// Actions
Mod+V { spawn "shorinclip-wrap"; }
Mod+V { spawn-sh "fzfclip-wrap"; }
Mod+Period { spawn-sh "pkill -x rofi || rofi-emoji"; }
Ctrl+Alt+Delete { spawn-sh "pkill -x wlogout || wlogout -p layer-shell"; }
Print { spawn "niri" "msg" "action" "screenshot-screen"; }
@@ -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