#!/usr/bin/env bash
###############################################################################
# dclip — Clipboard History Manager via dmenu
#
# Maintainer: Kris Yotam <krisyotam@pm.me>
# License: MIT
# Created: 2026-02-15
# Based on: BreadOnPenguins/scripts (img-text-clipboard-history)
#
# Refactored improvements:
# - Text clips stored IN files (not as filenames)
# - dmenu interface instead of fzf/st
# - Proper multiline text handling
# - Configurable max history size
# - Image previews via notify-send
# - Deduplication of text clips
#
# Usage:
# dclip # Browse and re-copy from history
# dclip add # Capture current selection to history
# dclip copy-last # Re-copy most recent clip
# dclip out <cmd> # Pipe command output to clipboard + history
# dclip link <url> # Download image URL to history
# dclip clear # Clear all history
# dclip wipe <n> # Delete entry by number
#
# Bind suggestions (dwm/sxhkd):
# Super+v → dclip (browse history)
# Super+Shift+v → dclip add (capture selection)
###############################################################################
HIST_DIR="${HOME}/.cache/dclip"
TEXT_DIR="${HIST_DIR}/text"
IMG_DIR="${HIST_DIR}/img"
INDEX_FILE="${HIST_DIR}/index"
MAX_HISTORY=200
mkdir -p "$TEXT_DIR" "$IMG_DIR"
touch "$INDEX_FILE"
###############################################################################
# helpers
###############################################################################
timestamp() {
date '+%s'
}
human_date() {
date -d "@$1" '+%b %d %H:%M' 2>/dev/null || date '+%b %d %H:%M'
}
# Truncate text for dmenu display (single line, max 80 chars)
truncate_text() {
echo "$1" | tr '\n' ' ' | cut -c1-80
}
# Prune history to MAX_HISTORY entries
prune() {
local count
count=$(wc -l < "$INDEX_FILE")
if [ "$count" -gt "$MAX_HISTORY" ]; then
local excess=$((count - MAX_HISTORY))
# Get entries to remove (oldest first)
head -n "$excess" "$INDEX_FILE" | while IFS='|' read -r ts type path; do
[ -f "$path" ] && rm -f "$path"
done
# Keep only recent entries
tail -n "$MAX_HISTORY" "$INDEX_FILE" > "${INDEX_FILE}.tmp"
mv "${INDEX_FILE}.tmp" "$INDEX_FILE"
fi
}
###############################################################################
# add — capture clipboard/selection to history
###############################################################################
add() {
# Check for image first
local targets
targets=$(xclip -selection clipboard -o -t TARGETS 2>/dev/null)
if echo "$targets" | grep -q 'image/png'; then
local fname
fname="$(timestamp).png"
xclip -selection clipboard -t image/png -o > "${IMG_DIR}/${fname}"
echo "$(timestamp)|img|${IMG_DIR}/${fname}" >> "$INDEX_FILE"
notify-send -i "${IMG_DIR}/${fname}" " Clipped image"
else
# Copy primary selection to clipboard, then save
local text
text=$(xclip -o -selection primary 2>/dev/null | xclip -i -f -selection clipboard 2>/dev/null)
if [ -z "$text" ]; then
text=$(xclip -o -selection clipboard 2>/dev/null)
fi
[ -z "$text" ] && return
# Deduplicate: check if this exact text already exists
local hash
hash=$(echo "$text" | md5sum | cut -d' ' -f1)
local existing
existing="${TEXT_DIR}/${hash}.txt"
if [ -f "$existing" ]; then
# Move to top of index (remove old entry, add new)
grep -v "|${existing}$" "$INDEX_FILE" > "${INDEX_FILE}.tmp"
mv "${INDEX_FILE}.tmp" "$INDEX_FILE"
fi
printf '%s' "$text" > "$existing"
echo "$(timestamp)|txt|${existing}" >> "$INDEX_FILE"
notify-send " Clipped" "$(truncate_text "$text")"
fi
prune
}
###############################################################################
# out — pipe command output to clipboard + history
###############################################################################
out() {
shift # remove 'out' arg
local text
text=$("$@" 2>&1)
printf '%s' "$text" | xclip -i -selection clipboard
local hash
hash=$(echo "$text" | md5sum | cut -d' ' -f1)
printf '%s' "$text" > "${TEXT_DIR}/${hash}.txt"
echo "$(timestamp)|txt|${TEXT_DIR}/${hash}.txt" >> "$INDEX_FILE"
notify-send " Output clipped" "$(truncate_text "$text")"
prune
}
###############################################################################
# link — download image URL to history
###############################################################################
link() {
local url="$2"
[ -z "$url" ] && { notify-send "dclip" "No URL provided"; return 1; }
local fname
fname="$(timestamp).png"
if wget -q "$url" -O "${IMG_DIR}/${fname}"; then
echo "$(timestamp)|img|${IMG_DIR}/${fname}" >> "$INDEX_FILE"
notify-send -i "${IMG_DIR}/${fname}" " Image saved"
else
rm -f "${IMG_DIR}/${fname}"
notify-send "dclip" "Download failed"
fi
}
###############################################################################
# sel — browse history via dmenu and re-copy
###############################################################################
sel() {
[ ! -s "$INDEX_FILE" ] && { notify-send "dclip" "History empty"; exit 0; }
# Build dmenu list (newest first)
local menu=""
local -a entries=()
local i=0
while IFS='|' read -r ts type path; do
[ -f "$path" ] || continue
local display
if [ "$type" = "img" ]; then
local fname
fname=$(basename "$path")
display=" ${fname}"
else
local content
content=$(cat "$path" 2>/dev/null)
display=" $(truncate_text "$content")"
fi
entries+=("$i|$type|$path")
menu+="${display}"$'\n'
i=$((i + 1))
done < <(tac "$INDEX_FILE")
[ -z "$menu" ] && { notify-send "dclip" "History empty"; exit 0; }
# Show dmenu
local choice
choice=$(printf '%s' "$menu" | sed '/^$/d' | dmenu -i -l 20 -p " Clipboard")
[ -z "$choice" ] && exit 0
# Find the matching entry by line number
local line_num
line_num=$(printf '%s' "$menu" | sed '/^$/d' | grep -nF "$choice" | head -1 | cut -d: -f1)
[ -z "$line_num" ] && exit 0
local idx=$((line_num - 1))
local entry="${entries[$idx]}"
local type path
IFS='|' read -r _ type path <<< "$entry"
# Re-copy to clipboard
if [ "$type" = "img" ]; then
xclip -i -selection clipboard -t image/png "$path"
notify-send -i "$path" " Re-clipped image"
else
xclip -i -selection clipboard < "$path"
local content
content=$(cat "$path")
notify-send " Re-clipped" "$(truncate_text "$content")"
fi
}
###############################################################################
# clear / wipe
###############################################################################
do_clear() {
local confirm
confirm=$(printf '%s\n' "Yes, clear all" "No, cancel" | dmenu -i -p " Clear history?")
if [[ "$confirm" == "Yes"* ]]; then
rm -rf "${TEXT_DIR:?}"/* "${IMG_DIR:?}"/*
: > "$INDEX_FILE"
notify-send "dclip" "History cleared"
fi
}
copy_last() {
[ ! -s "$INDEX_FILE" ] && { notify-send "dclip" "History empty"; exit 0; }
local last
last=$(tail -1 "$INDEX_FILE")
IFS='|' read -r ts type path <<< "$last"
if [ "$type" = "img" ]; then
xclip -i -selection clipboard -t image/png "$path"
notify-send -i "$path" " Last clip (image)"
else
xclip -i -selection clipboard < "$path"
notify-send " Last clip" "$(truncate_text "$(cat "$path")")"
fi
}
###############################################################################
# main
###############################################################################
case "${1:-sel}" in
add) add ;;
out) out "$@" ;;
link) link "$@" ;;
sel|"") sel ;;
copy-last) copy_last ;;
clear) do_clear ;;
*)
notify-send "dclip" "Usage: dclip [add|sel|out|link|clear|copy-last]"
;;
esac