update
This commit is contained in:
Executable
+453
@@ -0,0 +1,453 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Description:
|
||||
# View and manage clipboard history using fzf, with support for
|
||||
# image preview in compatible terminals.
|
||||
# Requirements:
|
||||
# - fzf
|
||||
# - cliphist
|
||||
# - wl-clipboard (including wl-copy and wl-paste)
|
||||
# - python3 with urllib (for URL quoting/unquoting)
|
||||
# - chafa (optional, for image preview)
|
||||
# - 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
|
||||
|
||||
SHELL=$(command -v bash)
|
||||
export SHELL
|
||||
|
||||
_cleanup() {
|
||||
if [ -n "${CACHE_DIR:-}" ] && [ -d "$CACHE_DIR" ]; then
|
||||
rm -rf "$CACHE_DIR"
|
||||
fi
|
||||
if [ -n "${WATCH_PID:-}" ]; then
|
||||
kill "$WATCH_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [ -n "${FZF_SOCKET:-}" ] && [ -S "$FZF_SOCKET" ]; then
|
||||
rm -f "$FZF_SOCKET"
|
||||
fi
|
||||
}
|
||||
trap _cleanup EXIT
|
||||
|
||||
_check_dependencies() {
|
||||
local missing=()
|
||||
for cmd in fzf cliphist wl-copy wl-paste python3; do
|
||||
if ! type "$cmd" &>/dev/null; then
|
||||
missing+=("$cmd")
|
||||
fi
|
||||
done
|
||||
if [ ${#missing[@]} -ne 0 ]; then
|
||||
echo "Error: Missing dependencies: ${missing[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for cmd in chafa ffmpegthumbnailer; do
|
||||
if ! type "$cmd" &>/dev/null; then
|
||||
echo "Warning: Optional dependency '$cmd' not found. Some features may be unavailable." >&2
|
||||
fi
|
||||
done
|
||||
}
|
||||
_check_dependencies
|
||||
|
||||
CACHE_DIR=$(mktemp -d)
|
||||
export CACHE_DIR
|
||||
|
||||
export C_TERTIARY='\x1b[1;35m'
|
||||
export C_PRIMARY='\x1b[1;34m'
|
||||
export C_CYAN='\x1b[1;36m'
|
||||
export C_RESET='\x1b[0m'
|
||||
|
||||
export C_PATTERN='\x1b\[[0-9];?([0-9]+)?m'
|
||||
|
||||
# Check for terminal graphics support and set environment variables accordingly
|
||||
|
||||
_graphics_query() {
|
||||
# Port of [graphics-query](https://github.com/Uyanide/dotfiles/blob/main/config/scripts/.local/scripts/graphics-query)
|
||||
|
||||
# Ensure in a interactive terminal
|
||||
[ ! -t 0 ] && return
|
||||
|
||||
(
|
||||
# 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
|
||||
|
||||
support_kgp=0
|
||||
support_iterm2=0
|
||||
support_sixel=0
|
||||
|
||||
response=""
|
||||
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
|
||||
|
||||
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 "kitty"
|
||||
fi
|
||||
|
||||
if [ "$support_iterm2" -eq 1 ]; then
|
||||
echo "iterm"
|
||||
fi
|
||||
|
||||
if [ "$support_sixel" -eq 1 ]; then
|
||||
echo "sixels"
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
SUPPORT_ICAT=0
|
||||
SUPPORT_SIXEL=0
|
||||
SUPPORT_ITERM2=0
|
||||
|
||||
ENABLE_ICAT=0
|
||||
ENABLE_SIXEL=0
|
||||
ENABLE_ITERM2=0
|
||||
|
||||
_check_graphics_support() {
|
||||
local result
|
||||
result=$(_graphics_query)
|
||||
if [[ "$result" == *"kitty"* ]]; then
|
||||
SUPPORT_ICAT=1
|
||||
fi
|
||||
if [[ "$result" == *"sixels"* ]]; then
|
||||
SUPPORT_SIXEL=1
|
||||
fi
|
||||
if [[ "$result" == *"iterm"* ]]; then
|
||||
SUPPORT_ITERM2=1
|
||||
fi
|
||||
}
|
||||
|
||||
_check_kitty_icat() {
|
||||
# # workaround for WezTerm
|
||||
if [ -n "${WEZTERM_EXECUTABLE:-}" ]; then
|
||||
return 1
|
||||
fi
|
||||
[[ "$SUPPORT_ICAT" -eq 1 ]]
|
||||
}
|
||||
|
||||
_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
|
||||
[[ "$SUPPORT_SIXEL" -eq 1 ]]
|
||||
}
|
||||
|
||||
_check_iterm2() {
|
||||
[[ "$SUPPORT_ITERM2" -eq 1 ]]
|
||||
}
|
||||
|
||||
# Priority: KGP > sixel > iterm2
|
||||
_check_graphics_support
|
||||
if _check_kitty_icat; then
|
||||
ENABLE_ICAT=1
|
||||
elif _check_sixel; then
|
||||
ENABLE_SIXEL=1
|
||||
elif _check_iterm2; then
|
||||
ENABLE_ITERM2=1
|
||||
fi
|
||||
|
||||
export ENABLE_ICAT
|
||||
export ENABLE_SIXEL
|
||||
export ENABLE_ITERM2
|
||||
|
||||
# URL handling (for file:// URLs)
|
||||
|
||||
url_unquote() {
|
||||
if type python3 &>/dev/null; then
|
||||
python3 -c "import sys, urllib.parse; print(urllib.parse.unquote(sys.stdin.read().strip()), end='')"
|
||||
else
|
||||
cat
|
||||
fi
|
||||
}
|
||||
|
||||
url_quote() {
|
||||
if type python3 &>/dev/null; then
|
||||
python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip()), end='')"
|
||||
else
|
||||
cat
|
||||
fi
|
||||
}
|
||||
export -f url_unquote
|
||||
export -f url_quote
|
||||
|
||||
# Preview functions
|
||||
|
||||
_clear_preview() {
|
||||
# chafa --clear will simply send '\x1b[H\x1b[2J' which may not work
|
||||
# for images displayed with KGP, so we send the specific clear sequence
|
||||
# manually in this case.
|
||||
if [ "$ENABLE_ICAT" -eq 1 ]; then
|
||||
printf "\x1b_Ga=d\x1b\\"
|
||||
fi
|
||||
}
|
||||
export -f _clear_preview
|
||||
|
||||
_preview_image() {
|
||||
local file="$1"
|
||||
if ! type chafa >/dev/null 2>&1; then
|
||||
text="Preview not available (chafa not found)."$'\n'
|
||||
text+="Image: $file"
|
||||
_preview_text "$text"
|
||||
return
|
||||
fi
|
||||
# Though chafa is able to detect which output format to use based on the terminal capabilities,
|
||||
# it is yet not always reliable, so we force it based on our checks.
|
||||
if [ "$ENABLE_ICAT" -eq 1 ]; then
|
||||
chafa -f kitty --size="${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}" "$file"
|
||||
elif [ "$ENABLE_SIXEL" -eq 1 ]; then
|
||||
chafa -f sixels --size="${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}" "$file"
|
||||
elif [ "$ENABLE_ITERM2" -eq 1 ]; then
|
||||
chafa -f iterm2 -size="${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}" "$file"
|
||||
else
|
||||
chafa -f symbols --size="${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}" "$file"
|
||||
fi
|
||||
}
|
||||
export -f _preview_image
|
||||
|
||||
_preview_text() {
|
||||
local content="$1"
|
||||
printf "%s" "$content" | head -n 100
|
||||
}
|
||||
export -f _preview_text
|
||||
|
||||
_preview_video() {
|
||||
local video_hash thumb_file path
|
||||
path="$1"
|
||||
video_hash=$(echo -n "$path" | md5sum | cut -d" " -f1)
|
||||
thumb_file="$CACHE_DIR/$video_hash.png"
|
||||
if [ ! -f "$thumb_file" ]; then
|
||||
if type ffmpegthumbnailer &>/dev/null; then
|
||||
ffmpegthumbnailer -i "$path" -o "$thumb_file" -s 480 -t 0 >/dev/null 2>&1
|
||||
else
|
||||
_preview_text "Thumbnail not available (ffmpegthumbnailer not found)."$'\n'
|
||||
fi
|
||||
fi
|
||||
if [ -s "$thumb_file" ]; then
|
||||
_preview_image "$thumb_file"
|
||||
else
|
||||
_preview_text "Video: $path"
|
||||
fi
|
||||
}
|
||||
export -f _preview_video
|
||||
|
||||
# Kinda buggy right now
|
||||
# _preview_audio() {
|
||||
# local path="$1"
|
||||
# if type mpv &>/dev/null; then
|
||||
# echo "Playing audio: $path" >&2
|
||||
# exec mpv --no-video --keep-open=no --loop-file=no --loop-playlist=no "$path" &>/dev/null
|
||||
# else
|
||||
# _preview_text "Audio: $path"
|
||||
# fi
|
||||
# }
|
||||
# export -f _preview_audio
|
||||
|
||||
_preview_file() {
|
||||
path="$1"
|
||||
path_mime=$(file -b --mime-type "$path")
|
||||
if [[ $path_mime =~ image ]]; then
|
||||
_preview_image "$path"
|
||||
elif [[ "$path_mime" =~ video ]]; then
|
||||
_preview_video "$path"
|
||||
# elif [[ "$path_mime" =~ audio ]]; then
|
||||
# _preview_audio "$path"
|
||||
else
|
||||
_preview_text "$path"
|
||||
fi
|
||||
}
|
||||
export -f _preview_file
|
||||
|
||||
preview() {
|
||||
_clear_preview
|
||||
|
||||
entry="$1"
|
||||
|
||||
mimeType=$(echo -n "$entry" | cliphist decode | file -b --mime-type -)
|
||||
ext=$(echo -n "$mimeType" | awk -F"/" "{print \$2}")
|
||||
|
||||
if [[ $mimeType =~ image ]]; then
|
||||
img_hash=$(echo -n "$entry" | cliphist decode | md5sum | cut -d" " -f1)
|
||||
cache_file="$CACHE_DIR/$img_hash.$ext"
|
||||
[ -f "$cache_file" ] || echo -n "$entry" | cliphist decode >"$cache_file"
|
||||
_preview_image "$cache_file"
|
||||
|
||||
elif path=$(echo -n "$entry" | cliphist decode) && [[ "$path" == /* ]]; then
|
||||
if [ -e "$path" ]; then
|
||||
_preview_file "$path"
|
||||
else
|
||||
_preview_text "$path does not exist."
|
||||
fi
|
||||
|
||||
elif decoded=$(echo -n "$entry" | cliphist decode) && [[ "$decoded" == file://* ]]; then
|
||||
paths=()
|
||||
for path in $decoded; do
|
||||
raw_path="${path#file://}"
|
||||
raw_path=$(echo -n "$raw_path" | url_unquote)
|
||||
paths+=("$raw_path")
|
||||
done
|
||||
|
||||
if [ "${#paths[@]}" -eq 1 ] && [ -e "${paths[0]}" ]; then
|
||||
_preview_file "${paths[0]}"
|
||||
else
|
||||
text="Multiple files:"$'\n'
|
||||
for p in "${paths[@]}"; do
|
||||
text+="$p"$'\n'
|
||||
done
|
||||
_preview_text "$text"
|
||||
fi
|
||||
|
||||
else
|
||||
if [ "$ENABLE_ICAT" = 1 ]; then
|
||||
printf "\x1b_Ga=d\x1b\\"
|
||||
fi
|
||||
_preview_text "$(echo -n "$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}[URL]Video.\2${C_RESET}/" \
|
||||
-e "s/(\t)file:\/\/.*\.gif$/\1${C_PRIMARY}[URL]Image.gif${C_RESET}/" \
|
||||
-e "s/(\t)file:\/\/.*\.(png|jpg|jpeg|webp|bmp)$/\1${C_TERTIARY}[URL]Image.\2${C_RESET}/" \
|
||||
-e "s/(\t)file:\/\/.*/\1${C_CYAN}[URL]File${C_RESET}/" \
|
||||
-e "s/(\t)\/.*\.gif$/\1${C_PRIMARY}[PATH]Image.gif${C_RESET}/" \
|
||||
-e "s/(\t)\/.*\.(png|jpg|jpeg|webp|bmp)$/\1${C_TERTIARY}[PATH]Image.\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 content
|
||||
content=$(echo -n "$input" | awk "{print \$3}")
|
||||
|
||||
if [[ "$content" == "[URL]"* ]]; then
|
||||
echo -n "$input" | cliphist decode | wl-copy --type text/uri-list
|
||||
|
||||
else
|
||||
echo -n "$input" | cliphist decode | wl-copy
|
||||
fi
|
||||
|
||||
echo -n "$input" | cliphist delete || true
|
||||
}
|
||||
export -f copy_selection
|
||||
|
||||
# Reload mechanism
|
||||
|
||||
RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp}"
|
||||
FZF_SOCKET=$(mktemp -u "$RUNTIME_DIR/fzfclip.XXXXXX.sock")
|
||||
RELOAD_CMD="cliphist list | format_clip_list | add_num"
|
||||
wl-paste --watch bash -c "curl -s --unix-socket '$FZF_SOCKET' -X POST -d 'reload($RELOAD_CMD)' http://localhost" &>/dev/null &
|
||||
WATCH_PID=$!
|
||||
|
||||
# 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
|
||||
|
||||
$RELOAD_CMD | fzf \
|
||||
--ansi \
|
||||
--listen "$FZF_SOCKET" \
|
||||
--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" \
|
||||
>/dev/null || [ $? -eq 141 ]
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
lockfile=/tmp/fzfclip.lock
|
||||
exec {LOCK_FD}>"$lockfile"
|
||||
flock -n "$LOCK_FD" || {
|
||||
echo "Another instance is running." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
fzfclip "$@" {LOCK_FD}>&-
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Description:
|
||||
# ~~Quick~~ snippet for cliphist + rofi + wl-copy
|
||||
#
|
||||
# Credit: https://github.com/sentriz/cliphist/blob/master/contrib/cliphist-rofi-img
|
||||
|
||||
tmp_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
mkdir -p "$tmp_dir"
|
||||
|
||||
read -r -d '' prog <<EOF
|
||||
/^[0-9]+\s<meta http-equiv=/ { next }
|
||||
match(\$0, /^([0-9]+)\s(\[\[\s)?binary.*(jpg|jpeg|png|bmp)/, grp) {
|
||||
system("echo " grp[1] "\\\\\t | cliphist decode >$tmp_dir/"grp[1]"."grp[3])
|
||||
print \$0"\0icon\x1f$tmp_dir/"grp[1]"."grp[3]
|
||||
next
|
||||
}
|
||||
1
|
||||
EOF
|
||||
|
||||
# Pipeline logic:
|
||||
# 1. cliphist list: gives "ID <tab> Content"
|
||||
# 2. gawk: adds icon paths
|
||||
# 3. rofi -dmenu: shows list, hides column 1 (ID), returns "ID <tab> Content" on select
|
||||
# 4. cliphist decode: reads ID from line, gets original content
|
||||
|
||||
result=$(cliphist list | gawk "$prog" | rofi -dmenu -display-columns 2 -config "$HOME/.config/rofi/dmenu.rasi" -show-icons -p "Clipboard")
|
||||
|
||||
if [[ -n "$result" ]]; then
|
||||
echo "$result" | cliphist decode | wl-copy
|
||||
fi
|
||||
Executable
+1890
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user