#!/usr/bin/env bash ############################################################################### # dclip — Clipboard History Manager via dmenu # # Maintainer: Kris Yotam # 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 # Pipe command output to clipboard + history # dclip link # Download image URL to history # dclip clear # Clear all history # dclip wipe # 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