From 712d34edddad341ba9664f989a18133b1b85f072 Mon Sep 17 00:00:00 2001 From: tavo-wasd Date: Sun, 18 Feb 2024 11:40:03 -0600 Subject: [PATCH] imgviewers --- scripts/o | 1 + scripts/vv | 2439 ++++++++++++++++++++++++++++++++++++++++++++++++++ shell/envvar | 6 +- 3 files changed, 2443 insertions(+), 3 deletions(-) create mode 100755 scripts/vv diff --git a/scripts/o b/scripts/o index d4c0974..b67a77c 100755 --- a/scripts/o +++ b/scripts/o @@ -16,6 +16,7 @@ case $file in *.jpg) "$IMAGE" "$file" ;; *.gif) "$IMAGE" "$file" ;; *.svg) "$IMAGE" "$file" ;; + *.webp) "$IMAGE" "$file" ;; # Videos *.webm) "$VIDEO" "$file" ;; *.mp4) "$VIDEO" "$file" ;; diff --git a/scripts/vv b/scripts/vv new file mode 100755 index 0000000..ba915d0 --- /dev/null +++ b/scripts/vv @@ -0,0 +1,2439 @@ +#!/bin/bash + +# vv: VT View + +# Version 1.9.3 +# B9 May 2023 + +# Use sixel graphics to show images inside a terminal (e.g., xterm). +# Copyright (c) 2019..2023 hackerb9, under the terms of GNU GPL 3+. + +# Some nice features: +# * Can display any image type. (Via ImageMagick). +# * Detects images by magic, not by extension. +# * Shows everything in the current working directory by default. +# * Loads directories recursively (default: ask; -r: always; -R: never). +# * Scales down large images to fit within terminal. +# * Defaults to fast viewing [-F] to quickly preview and delete (d) images, +# or use [-f] to default to full-size (slower, high quality) view. +# * Hit (f) to view full-size (slower, high quality) for current image only, +# or use (F) to toggle between fast and full-size viewing mode. +# * Preview size can be increased (+), decreased (-), set (=), or reset (0). +# * Supports "fit width" (w) mode; portrait pictures scroll vertically. +# * Can view at "100% zoom" (1). Cuts large picture into pieces, if necessary. +# * Has "best fit" (B), autorotate to use as much of the screen as possible. +# * Deleted (d) images are moved to Trashcan (Freedesktop standard). +# * Can undo (u) last trashed file. +# * Reverse image search using w3m (R). (Currently scrapes yandex). +# * Slideshow mode (S). +# * Renaming a file (r) uses readline for easier editing. +# * Move (m) and save-a-copy (s) remember previously used directory. +# * Edit embedded comments (C). Works with JPEG, PNG, TIFF, GIF, and more. +# * Embedded comments can have multiple lines (use ^V^J for a new line). +# * Sets xterm to use more color registers for higher quality pictures. +# * Should work on true DEC hardware (e.g., VT340). +# * Resizes current image when terminal resizes. (SIGWINCH). +# * Pixel height of preview is a multiple of the current text font height, +# so previews should be the right size no matter your screen resolution. +# * Pixel width of preview can use the full width of the terminal. +# * View videos (v) such as animated GIFs. (Requires 'sixvid' and ffmpeg). +# * Icons and small images can be zoomed in to screen size. (z to toggle). +# * Breadth first search. Images in current dir are shown before subdirs. + +# Required Dependencies: +# * A sixel capable terminal, such as 'xterm -ti vt340'. +# * ImageMagick + +# Recommended but not required: +# * ncurses-bin (for tput) +# * file (determine filetype by magic numbers rather than extension) +# * mediainfo (quickly count number of frames in a video) +# * GNU coreutils (for realpath, for deleting images to trashcan) +# * curl, w3m and w3m-img for text based reverse image search +# * Bash version >=5 (read bug workaround) +# * sixvid & ffmpeg: VT viewer so viewing an mp4 will play an animation +# * poppler-utils (for pdfinfo to count pages in PDF files) + +# Note: This is designed mainly for quickly previewing, deleting, +# and renaming images over an ssh connection. For that reason, +# fast, small, low-quality images are emphasized and deletion +# is a single keystroke. (Note, undeletion is also a single key). +# +# Design: +# +# This should feel like a command-line program that just happens +# to have graphics. In particular, it should *NOT* take over the +# whole screen. +# +# Current work is appended at the bottom. Old images should +# scroll off the top. +# +# Keep hands on home row. Allow harder to reach keys -- like Esc +# and arrows -- only as aliases for easier to hit keys. +# +# Common functions are mapped to a single key, without shift. +# The most frequently used functions are on the home row. +# Less common functions may include shift. +# +# As much as possible, reduce repetitive work. (Temporal locality.) +# +# Go as fast as possible, trading off quality if necessary. +# +# Should work even over a laggy ssh connection with a vintage VT340. +# +# Unix keys ^C, ^Z, ^W, ^L act reasonably and do not mess up the terminal. +# +# The user isn't a dummy, don't hide info merely to seem simple. +# +# Reduce cognitive load: +# +# * User shouldn't have to memorize which boolean flags are set. +# Show status every time an image is displayed. +# +# * Help should not be overwhelming. Give just the most salient +# bits, with ways the user can ask for more help. +# +# * "Don't make me read." As much as possible, make the state +# predictable so an expert could type keys without seeing the +# prompts and know exactly how vv will react. (Note: This is +# the opposite of current touch screen interfaces). +# + +# +# For more info or to report a bug, please see +# https://github.com/hackerb9/vv + +# Defaults you can change. +declare -g timeout=0.25 # Max secs to wait for control sequence response. +declare -gi ssdelay=5 # Num secs to linger during slideshow. +declare -g viewmode="fast" # Show "fast" or "full" images. +declare -gi previewlines=8 # Height of fast preview image (in text lines). +declare -g background="gray20" # Default for bg (alpha) if autodetection fails +declare -g foreground="gray80" # Default for fg (text) if autodetection fails +declare -g rflag="ask" # Empty string means do not recurse dirs. +declare -g sortorder="-v" # `ls` flag to sort files in "natural" order. +#declare -g sortorder="-tru" # `ls` flag to sort files. Least recent viewed. +#declare -g sortorder="-t" # `ls` flag to sort files. Most recent changed. +declare -g traversal="breadth" # dir recursion: breadth/depth first or neither. +declare -g shownotices="y" # Show optional messages.("Install X to get Y"). +#declare -g shownotices="" # Empty string to never mention such frippery. + +# Defaults for image processing flags. Note that opposite of "y" is "", not "n". +# To disable, set to empty string. To enable, set to any string. +declare -g dither="y" # Improve image quality by dithering if viewmode==full. +declare -g aalias="y" # Smooths jaggies when zooming in on small images. +declare -g enhance="" # Lightens dark images with gamma +1.5. Set by (e) +declare -g zoomin="y" # Magnify small images to screen size (slower). + +# Defaults if terminal autodetection fails. +declare -gi numcolors=16 # Num colors the terminal can show. +declare -gi defaultwidth=800 defaultheight=480 # Default sixel screen size. + +# Global variables +declare -gi LINES COLUMNS # Screen width and height in rows, cols of text. +declare -gi width height # Screen width and height in pixels. +declare -gi fontheight # Height of terminal's font in pixels. +declare -gi previewsize=0 # Height of preview in pixels. (C.f., previewlines, above) +declare -gi forcepsize=0 # Size of preview, if given on commandline. +declare -g tmpdir # Directory for storing temporary files. +declare -gA termkey # Array to hold terminfo key input sequences. +declare -g currentview=$viewmode # Most recently used viewmode. +declare -gi FrameCount # Number of image frames in file. >1 for videos. +declare -gi ImageWidth # Image width in pixels. +declare -gi ImageHeight # Image height in pixels. + +# Debugging stuff +declare -g DEBUG=${DEBUG} # Set to anything to enable debugging +declare -g TIME= # Set to anything to benchmark sixel creation + + +debug() { + if [[ "${DEBUG:-}" ]]; then + echo "$@" >&2 + fi +} + +cleanup() { + # If the user hits ^C, we don't want them stuck in SIXEL mode + echo -n $'\e\\' # Escape sequence to stop SIXEL + stty -F/dev/tty echo # Reset terminal to show input characters. + stty -F/dev/tty icanon # Allow backspace to work again. + stty -F/dev/tty sane # For bash 4.4's SIGWINCH-in-read bug + echo -en "\r"; tput el # Clear line for prompt. + tput rmkx # Disable terminal application key sequences + tput cnorm # Show the cursor. + [[ -d "$tmpdir" ]] && rm -r "$tmpdir" # Erase temporary files. + exit 0 +} +trap cleanup SIGINT SIGHUP SIGABRT EXIT + + +usage() { + cat <&2 + echo "$(basename $0): FATAL ERROR: $@" >&2 +} + +notice() { + # Display a "notice" — a minor message, such as a missing + # optional program that would improve functionality, but + # isn't strictly required. + if [[ "$shownotices" ]]; then + echo >&2 + echo "$(basename $0): notice: $@" >&2 + fi +} + + +prerequisites() { + # Check that all necessary programs are installed. + local -i returncode=0 + + # Critical dependencies which cause fatal errors + if ! command -v convert >/dev/null; then + error "Please install ImageMagick for convert and identify" + returncode=1 + fi + + # Non-fatal errors that simply degrade performance or capability + if ! command -v tput >/dev/null; then + notice "Please install ncurses-bin for tput and infocmp" + # 'tput el' is used to clear the line. + # 'infocmp' is used to interpret the arrow key sequences. + alias tput=true; alias infocmp=true + fi + if ! command -v file >/dev/null; then + notice "Please install 'file' for autodetecting images by magic" + alias file=fakefile + fi + if ! command -v mediainfo >/dev/null; then + # Note cannot use 'alias' here because it is run in a subshell. + mediainfo() { fakemediainfo "$@" ; } + fi + if ! command -v realpath >/dev/null; then + notice "Please install GNU coreutils for realpath" + # Note cannot use 'alias' here because it is run in a subshell. + realpath() { fakerealpath "$@" ; } + fi + if ! command -v w3m >/dev/null || command -v w3m-img >/dev/null; then + function w3m { + notice "Please install w3m and w3m-img for reverse image search" + } + alias w3m-img=w3m + fi + if ! command -v curl >/dev/null; then + notice "Please install curl for reverse image search" + fi + if ! command -v numfmt >/dev/null; then + numfmt() { tr '\t' ' '; } # Only needed for nicely grouping thousands + fi + if ! command -v fmt >/dev/null; then + fmt() { cat ; } # Only needed for wrapping long comments + fi + if ! command -v pdfinfo >/dev/null; then + pdfinfo() { + notice "Page count is slow. Please install pdfinfo (poppler-utils)." + echo "Pages: $(identify "$1" | grep -c PDF)" + } + fi + if [[ $BASH_VERSINFO -lt 5 ]]; then + notice "Kludging read for Bash4" + BASH4BUG="-e" + else + BASH4BUG="" + fi + + if ! tmpdir=$(mktemp -d --tmpdir "$(basename $0).$$.XXXXXX"); then + echo "Error: Could not create temporary directory! Disk full?" >&2 + returncode=1 + fi + + if [[ returncode -eq 0 ]]; then + return 0 + else + # Die on fatal errors + exit $returncode + fi + +} + +autodetect() { + # Various terminal initialization and configuration routines. + + # Before querying the terminal, wait for it to be ready. + waitforterminal + + # Don't show escape sequences the terminal doesn't understand. + stty -echo # Hush-a Mandara Ni Pari + + # Don't consume backspace key before 'read'. + stty -icanon + + # Hide the cursor so it doesn't dash around when we show the prompt. + tput civis + + # Make sure the terminal supports sixel graphics. + hassixelordie + + # Terminal color palette ("registers") autodetection. + read numcolors < <(getnumcolors $numcolors) + + # BUG: ImageMagick 6.9 can't send more than 256 colors anyhow. + [[ numcolors -le 256 ]] || numcolors=256 + + # Don't dither if we have enough colors. (Dither adds +1s to processing). + # BUG: Unfortunately, we have to always dither since max colors is 256. + [[ numcolors -le 256 ]] && dither="yup" || dither="" + + # Detect terminal background color (for alpha channel). + background=$(getbg) + + # Autodetect the sixel screen size . + windowchange + + # Make terminfo keys valid so we can catch arrow keys. + inittermkey + + # Configure readline for input with 'read -e' + configreadline +} + +getbg() { + # Send the xterm escape sequence to get the background color of + # the terminal. Terminal responds: $'\e]11;rgb:ffff/0000/ffff\e\\'. + # Prints results as hexadecimal (16-bit per channel) in the form + # #RRRRGGGGBBBB (useful for ImageMagick). + local p=11 # 11 is bg, 10 is fg in escape sequence + + # wait for terminal to be ready (up to 5 seconds) + waitforterminal 5 + + # Check for reverse video + IFS=";?$" read -a REPLY -s -t $timeout -d "y" -p $'\e[?5$p' + if [[ $? -le 128 && ( ${REPLY[2]} == 1 || ${REPLY[2]} == 3 ) ]]; then + # Reversed colors! + p=10 + fi + + # Query the terminal background (or foreground) color. + IFS=";:/" read -a REPLY -r -s -t $timeout -d "\\" -p $'\e]'$p$';?\e\\' + if [[ $? -le 128 && ${REPLY[1]:-} =~ ^rgb ]]; then + echo '#'${REPLY[2]}${REPLY[3]}${REPLY[4]%%$'\e'*} + else + echo $background + fi +} + +getfg() { + # Send the xterm escape sequence to get the fg color of the terminal. + local p=10 # 11 is bg, 10 is fg in escape sequence + + # wait for terminal to be ready (up to 5 seconds) + waitforterminal 5 + + # Check for reverse video + IFS=";?$" read -a REPLY -s -t $timeout -d "y" -p $'\e[?5$p' + if [[ ${REPLY[2]} == 1 || ${REPLY[2]} == 3 ]]; then + # Reversed colors! + p=11 + fi + + # Query the terminal background (or foreground) color. + IFS=";:/" read -a REPLY -r -s -t $timeout -d "\\" -p $'\e]'$p$';?\e\\' + if [[ ${REPLY[1]} =~ ^rgb ]]; then + echo '#'${REPLY[2]}${REPLY[3]}${REPLY[4]%%$'\e'*} + else + echo $background + fi +} + +windowchange() { + # SIGWINCH handler: Called when terminal window is resized. + # Send control sequence to query the sixel graphics geometry to + # find out how large of a sixel image can be shown. + # Sets global variables LINES, COLUMNS, width, height, and fontheight. + local IFS=""; unset IFS # In case IFS was set before SIGWINCH + read width height < <(getwindowsize) + + # Calculate height of terminal font in pixels + # Note: this could be wrong because xterm returns max == 1000x1000. + # TODO: maybe use echo $'\e[16t' to get width, height of font. + LINES=$(tput lines); COLUMNS=$(tput cols) + [[ $LINES -gt 0 ]] || LINES=24 # Default 24 rows of text. + fontheight=height/LINES + [[ fontheight -gt 0 ]] || fontheight=20 # Default 20px high font. + debug "font height is $fontheight" + + # Trim returned height so we have room for two lines of text + height=$(( fontheight * (LINES-2) )) + debug "usable screen height is $height" + + if [[ $forcepsize == 0 ]]; then + # Preview size height changes based on font size. + if [[ $previewsize -ne $((fontheight*previewlines)) ]]; then + [[ $previewsize ]] && SHOULDREDRAW=yup + previewsize=fontheight*previewlines + debug "preview height = fontheight × preview lines = $fontheight × $previewlines = $previewsize" + fi + else + # Preview size forced by command line. + previewsize=$forcepsize + fi + debug "preview size is ${width}x${previewsize} pixels" + + if [[ "$viewmode" == full || "$currentview" == full ]]; then + SHOULDREDRAW=yup # Redraw the current image + fi +} +trap windowchange SIGWINCH + + +hassixelordie() { + # Returns true if terminal is Sixel capable. + # Otherwise, prints an error message and dies. + local hassixel="" code IFS=";?c" + + # IS TERMINAL SIXEL CAPABLE? # Send Device Attributes + if read -a REPLY -s -t 1 -d "c" -p $'\e[c' >&2; then + for code in "${REPLY[@]}"; do + if [[ $code == "4" ]]; then + hassixel=yup + break + fi + done + fi + + # YAFT is vt102 compatible, cannot respond to vt220 escape sequence. + if [[ "$TERM" == yaft* ]]; then hassixel=yeah; fi + + if [[ -z "$hassixel" ]]; then + cat <<-EOF >&2 + Error: Your terminal does not report having sixel graphics support. + + Please use a sixel capable terminal, such as xterm -ti vt340, or + ask your terminal manufacturer to add sixel support. + + You may test your terminal by viewing a single image, like so: + + convert foo.jpg -geometry 800x480 sixel:- + + If your terminal actually does support sixel, please file a bug + report at http://github.com/hackerb9/vv/issues + EOF + read -s -t 1 -d "c" -p $'\e[c' >&2 + if [[ "$REPLY" ]]; then + echo + cat -v <<< "(Please mention device attribute codes: ${REPLY}c)" + fi + + exit 1 + fi +} + + +getnumcolors() { + # Detects number of colors the terminal can show and attempts to + # increase the number if it's too low. + # Argument $1: default number of colors if detection fails. + # Output: Prints how many color registers the terminal has. + local -i n=0 + local IFS=";" # Split reads on semicolons + + if read -a REPLY -s -t ${timeout} -d "S" -p $'\e[?1;1;0S' >&2; then + [[ ${REPLY[1]} == "0" ]] && n=${REPLY[2]} + fi + # YAFT is vt102 compatible, cannot respond to vt220 escape sequence. + if [[ "$TERM" == yaft* ]]; then n=256; fi + + # Increase colors, if needed + if [[ $n -lt 256 ]]; then + # Attempt to set the number of colors to 256. + # This will work for xterm, but fail on a real vt340. + if read -a REPLY -s -t ${timeout} -d "S" -p $'\e[?1;3;256S' >&2; then + [[ ${REPLY[1]} == "0" ]] && n=${REPLY[2]} + fi + fi + + # Return the results (or the default if querying didn't work) + [[ $n -gt 0 ]] || n=${1:-16} + echo $n + return +} + + +getwindowsize() { + # Send control sequences to find how large of a sixel image can be shown. + # Uses globals $defaultwidth, $defaultheight if terminal does not respond. + # Outputs geometry as width then height. (echo $w $h) + + waitforterminal # Wait for terminal to be ready before querying. + # Necessary for Xterm-344 due to slow redraw bug. + + local -i w=0 h=0 # for reading width and height integers + local IFS=";" # temporarily split on semicolons + local REPLY=(0 0 0 0) # array of results from terminal + + # Send control sequence to query the sixel graphics geometry. + if read -a REPLY -s -t ${timeout} -d "S" -p $'\e[?2;1;0S' >&2; then + if [[ ${#REPLY[@]} -ge 4 && ${REPLY[2]} -gt 0 && ${REPLY[3]} -gt 0 ]]; then + w=${REPLY[2]} + h=${REPLY[3]} + else + # Nope. Fall back to dtterm WindowOps to approximate sixel geometry. + waitforterminal + if read -a REPLY -s -t ${timeout} -d "t" -p $'\e[14t' >&2; then + if [[ $? == 0 && ${#REPLY[@]} -ge 3 && ${REPLY[2]} -gt 0 ]]; then + w=${REPLY[2]} + h=${REPLY[1]} + fi + fi + fi + fi + + # Discard responses slow to arrive responses from the terminal. + flushstdin + + # Return the results (or the default if querying didn't work) + [[ w -gt 0 ]] || w=${defaultwidth} + [[ h -gt 0 ]] || h=${defaultheight} + echo $w $h + debug "window size is $w x $h" + return +} + +waitforterminal() { + # Send an escape sequence and wait for a response from the terminal. + # This routine will let us know when an image transfer has finished + # and it's okay to send escape sequences that request results. + # WARNING. This *should* work with any terminal, but if it fails + # it'll end up waiting for approximately forever (i.e., 60 seconds). + flushstdin + read -s -t ${1:-60} -d "c" -p $'\e[c' + flushstdin +} + +inittermkey() { + # Build termkey array so we can recognize when the user hits keys + # that send multiple characters. E.g., \eOB maps to "Down Arrow". + local a x k + + # To be valid, we must enable terminal application mode key sequences. + if tput smkx; then + for k in $(infocmp -L1 | egrep 'key_|cursor_'); do + a=""; x="" + # Example "k: key_down=\EOB," + a=${k#*key_} + a=${a#*cursor_} + a=${a%=*} # a: down + x=${k#*=} + x=${x%,*} # x: \EOB + #debug termkey["$x"]="$a" + termkey["$x"]="$a" + done + fi +} + +configreadline() { + # Configure input from the user so that 'read -e' (readline) works. + # Note, bind will complain that "line editing is not enabled", but + # we toss that spurious warning in /dev/null. + + # Editing happens on only one line of text, scrolling horizontally. + bind "set horizontal-scroll-mode on" 2>/dev/null + + # XXX If I knew how to disable readline adding a space after + # filename completion I would do so here. (See saverenamemove()). +} + +parseescape() { + # Parse terminal escape sequence for F-keys, arrows. + # Read all the characters following an escape key has been hit + # and then look it up in the termkey array. + while read -t 0; do + read -n 1 -s temp + REPLY+="$temp" + done + esc=$'\E' + REPLY=${REPLY/$esc/\\E} # Replace escape with backslash E + REPLY=${termkey["$REPLY"]:-"$REPLY"} +} + +flushstdin() { + # flush stdin in case a key was hit twice by accident + local REPLY + while read -s -n1 -t .001; do :; done +} + +e() { + # Clear current line and print text on it without a linefeed + echo -en "\r" + tput el + echo -en "$(tildify "$*")" # Not using $@ so I can pass args to echo. +} + +E() { + # Clear current line and print text on it with a linefeed + e "$@" + echo +} + +tildify() { + # Any arguments that look like $HOME/ get shortened to ~/ + local arg + for arg; do + arg=${arg/#$HOME\//\~/} # At beginning of line. + arg=${arg// $HOME\// \~/} # After a space. + arg=${arg//\n$HOME\//\n\~/} # After a newline escape. + arg=${arg//\t$HOME\//\t\~/} # After a tab escape. + arg=${arg// $HOME\// \~/} # After a tab. + echo -n "$arg" + done + echo + } + +s() { + # Plural(s) + [[ ${1%.*} -ne 1 ]] && echo -n "s" +} + + +declare -g lastuseddir="" # Static variable for default save/move dir. +saverenamemove() { + # Interactively save/rename/move image file $2 to a new name or dir. + + # "save" and "move" default to prompting for a directory name, + # presuming the filename will be the same. + + # "rename" defaults to prompting for a filename, presuming the + # directory will be the same. + + # Move and rename both use 'mv', so are functionally equivalent. + # Move prompts with a directory, rename suggests a filename. + # They both set outer scope "$f", which may be a bad idea. + # (This allows user to rename again if need be.) + # [TODO: This will get cleared up once we have a proper array of files.] + + local mode="$1" + local file="$2" + + if [[ "$mode" == "rename" ]]; then + local path=$(realpath --relative-base="$PWD" "$file") + else + local path=$(realpath "$file") + fi + local dir=$(dirname "$path") + local ext=${f##*.}; [[ "$f" != "$ext" ]] || ext="" + local base=$(basename "$file" ".$ext") + local IFS=$'\n' # Tildify shouldn't split on spaaces + tput el + stty echo # Temporarily show characters the user types. + tput cnorm # Temporarily show the cursor. + if [[ "$mode" == "rename" ]]; then + read -d $'\r' -p "Rename to: " -e -i $(tildify "$path") newname + elif [[ "$mode" == "save" ]]; then + read -d $'\r' -p "Save a copy of $base in: " -e \ + -i $(tildify "${lastuseddir:-$dir}/") newname + else + read -d $'\r' -p "Move to: " -e -i "$(tildify "${lastuseddir:-$dir}/")" newname + fi + tput civis # Hide cursor. + stty -echo # Hide input keys. + + newname=${newname/#~\//$HOME/} # untildify (for realpath.) + + # Remove trailing space from tab completion. + if [[ "${newname}" =~ (.*[^ ])([ ]+)$ ]]; then + newname="${BASH_REMATCH[1]}" + fi + + if [[ $mode == "save" || $mode == "move" ]]; then + [[ "$newname" == /* ]] || newname="$dir/$newname" # Make path absolute. + fi + [[ -d "$newname" ]] && newname+="/." # Use existing directory. + [[ "$newname" != */ ]] || newname+="." # Never end with a "/" (for dirname.) + mkdir -p $(dirname "$newname") + newname=$(realpath "$newname" 2>/dev/null) # Canonicalize + + if [[ "$newname" && "$newname" != "$dir" && ! "$newname" -ef "$path" ]] + then + if [[ -d "$newname" ]]; then + # New directory specified, so keep same filename + newname="$newname/$base.$ext" + fi + + local newext="${newname##*.}" + if [[ "$newext" =~ jpe?g && "$ext" =~ jpe?g ]]; then + # Special case: Allow renaming .jpg to .jpeg and vice-versa. + ext="$newext" + fi + + if [[ "$newname" != *$ext ]]; then + # User specified filename without the extension. + newname="$newname.$ext" + fi + + local overwrite="" + if [[ -e "$newname" ]]; then + # Hmm... a file of that name already exists. + if cmp -s "$path" "$newname"; then + E "Notice: image already saved in $newname." + return 1 + else + E "Warning: A different image already exists with that filename." + sidebyside "$path" "$newname" + overwrite="(overwrite!) " + fi + fi + + # Extra warning to double check dangerous operations. + if [[ "$overwrite" ]]; then + # Okay to save/rename/move (possibly overwriting)? + e "$mode $file to $overwrite$newname (y/N)? " + + read -s -n1 + case "$REPLY" in + y|Y) # Yes, just continue. + ;; + n|N|*) + E "$mode cancelled." + return 1 + ;; + esac + fi + + if [[ $mode == "save" ]]; then + if cp -p "$path" "$newname"; then + E "Saved a copy of $file to $newname" + else + return 1 + fi + elif [[ $mode == "rename" || $mode == "move" ]]; then + if mv "$file" "$newname"; then + E "Filename is now $newname"; + f="$newname" # Write to outerscope variable "$f". (!!!) + else + return 1 + fi + fi + else + E "$mode cancelled." + return + fi + + if [[ $mode == "save" || $mode == "move" ]]; then + # Remember the directory for easy saving next time. + lastuseddir=$(dirname "$newname") + fi + + return +} + + + +sidebyside() { + # Show two images side by side for quick comparison. + local -i half=width/2 + montage -background $background -fill $foreground \ + -auto-orient \ + -geometry ">${half}x>$((height-fontheight))" \ + -label "$1" "$1" -label "$2" "$2" sixel:- + viewmode=ss + SHOULDREDRAW=yup +} + +editproperty() { + # Given a property name and a filename, + # show the property and let the user edit it. + # Example property names: "comment", "caption", "title", "description" + local propname="$1" + local file="$2" + local frame0="file://$file[0]" # Without file://, IM barfs on colons. + local property=$(identify -format "%[$propname]" "$frame0" 2>/dev/null) + local prompt="New $propname: " + + if [[ $(echo "$property" | wc -l) -gt 1 ]]; then + prompt="" + echo "WARNING: Existing $propname has multiple lines." + echo " Use ^V^J to add new lines." + fi + + stty echo # Temporarily show characters the user types. + tput cnorm # Temporarily show the cursor. + IFS= read -d $'\r' -e -i "$property" -p "$prompt" newproperty + tput civis # Hide cursor. + stty -echo # Hide input keys. + + if [[ "$newproperty" == "$property" || -z "$newproperty" ]]; then + E "$propname editing cancelled." + return + fi + + e "Writing comment into $file..." + local t=$(mktemp "/tmp/$(basename $0).XXXXXX") || return 1 + cp "$file" "$t" && convert "$t" -set "$propname" "$newproperty" "$file" + rm "$t" +} + +showproperty() { + # Given a property name (e.g., "comment") and a filename, + # Show the property. Return failure if the property doesn't exist. + local propname="$1" + local file="$2" + local frame0="file://$file[0]" + local property=$(identify -format "%[$propname]" "$frame0" 2>/dev/null) && + echo "$propname: $property" +} + +reverseimagesearch() { + # Given a filename of an image, do a web search for it to see if + # it has been seen anywhere else. + + # Tip to future self: to screen scrape XHR / JSON, use Firefox's + # Network inspector while uploading image to search engine. Right + # click on the POST or GET and go to "COPY cURL". Then, from the + # command line, prune that command down to the bare minimum. + # Use json_pp to pretty print JSON data. + + yandeximagesearch "$1" +} + +yandeximagesearch() { + # Reverse image search using yandex and w3m + # Yandex is not the best but has a simple interface we can access + # without javascript. (Unlike Tineye, Bing, and Google.) + local file="$1" + local output resultPage deleteme="" # Local vars + local scale=">1000x>1000" # Image size to scale to before searching. + tput cnorm # Temporarily show cursor for w3m + + if [[ $FrameCount -gt 1 ]]; then + # It's a video, so search only for first frame. + echo "searching only first frame of video" >&2 + file="$tmpdir/frame0.jpg" + convert "file://$1[0]" "$file" + deleteme="$file" + fi + + url="https://yandex.com/images/search?rpt=imageview" + url+="&format=json" + url+='&request=%7B%22blocks%22%3A%5B%7B%22block%22%3A%22cbir-controller__upload%3Aajax%22%7D%5D%7D' + cookie="yp=999999999999.sp.aflt%3A999999999999.szm.1; my=YywBAAA=" + + # Upload the image and save the output to search through. + # Note: Curl's "@filename" splits filenames with commas & semicolons. + output=$(convert -sample "$scale" "$file" jpeg:- | + curl --max-time 30 --cookie "$cookie" \ + --form upfile='@-;filename=foo.jpg' $url) + if [[ $? -gt 0 ]]; then return; fi + + # First, check for Content-Based Image Recognition. + # Look for "params":{"url":"cbir_id=4725%2FkrTprMr_05694&rpt=imageview" + resultPage=$(echo "$output" | grep -o '"cbir_id=[^"]*"' ) + if [[ "$resultPage" ]]; then + resultPage=${resultPage#\"}; resultPage=${resultPage%\"} + resultPage="/images/search?$resultPage" + fi + + if [[ -z "$resultPage" ]]; then + echo "Couldn't find CBIR result from Yandex, trying img_url." >&2 + # Look for, eg: "nextPageUrl":"/images/search?text=&img_url=https%3A//www.fotopolis.pl/i/images/4/2/3/d2FjPTIzNDB4MQ%3D%3D_src_179423-1555946732rlzp7d96a9729_2048px22_1100mv.jpg&rpt=imagedups" + resultPage=$( + echo "$output" | + sed -rn ' + s/&/\&/g; + s/.*img_url=(http[^"&;]*).*/\1/p; + ') + if [[ "$resultPage" ]]; then + echo "Oh good, img_url worked." >&2 + resultPage="/images/search?url=$resultPage&rpt=imageview" + fi + fi + + + if [[ "$resultPage" ]]; then + echo "Found $(unuriescape "$resultPage")" >&2 + w3m https://yandex.com$resultPage + else + echo "Error parsing results from Yandex." >&2 + fi + + # Old way. Maybe delete this. + ## Use cookies to get page, then show in w3m + # curl --cookie "$cookie" --form upfile=@"$file" $url | tee debug | + # sed 's###' | + # w3m -T text/html + + if [[ "$deleteme" ]]; then + rm -f "$deleteme" + fi + + tput civis # Hide cursor again +} + +numframes() { + # Given a filename, print the number of images in the file. + # (For most images, this will be 1.) + local file="$1" + + # ImageMagick can count frames in a gif or apng, but doesn't do well when + # faced with a video. Rely on mediainfo/ffprobe instead. + case $(mimetype "$file") in + video/*) # («Radio killed?») + # MediaInfo is faster than convert, but doesn't handle GIF, APNG. + mediainfo --Inform='Video;%FrameCount%' "$file" + ;; + *|image/*) # An image file. (default) + shopt -s nocasematch + if [[ $file == *gif || $file == *png ]]; then + # Note, it is much faster to pipe to 'wc' than to use + # `convert "$file[-1]" -format "%[scene]" info:-` + identify "$file" | wc -l + else + echo 1 + fi + shopt -u nocasematch + ;; + esac +} + +fakemediainfo() { + notice "Please install 'mediainfo' for counting video frames" + + # If mediainfo isn't installed, fake it using ffprobe. + # This is slow and could take a quarter of a second to run. + # (Mediainfo takes only a twentieth of a second.) + while [[ $1 == -* ]]; do shift; done + + # Although the ffprobe documentation claims to have a -count_frames option, + # it doesn't actually work. We must resort to an ugly kludge: + # print everything and count the word "video". + ffprobe -show_packets "$1" 2>/dev/null | grep -c video +} + +fakerealpath() { + if [[ "$1" == -* ]]; then + notice "Please install GNU coreutils for realpath" + while [[ "$1" == -* ]]; do shift; done + fi + [[ "$1" && "$1" != /* ]] && echo -n "$PWD/" + echo "$1" +} + + +isimage() { + # Returns true (0) if $1 is an image or video. + + # We use "file" first to check for magic cookies identifying images to + # prune out formats that ImageMagick's "identify" would churn needlessly + # turning into an image (ASCII text, word processing docs). + local file="$1" + local type=$(mimetype "$file") + + if [[ $type == image/svg* ]]; then + # SVG files are special: ImageMagick might call inkscape which is slow. +# if identify "msvg:$file[0]" >/dev/null 2>&1; then +# return 0 +# else + return 1 +# fi + elif [[ "$file" == *sixel ]]; then + # The magic database doesn't know sixel files yet, so go by fileext. + return 0 + elif [[ $type == image/* ]]; then + # Return true for an image. + if identify "file://$file[0]" >/dev/null 2>&1 \ + || identify "$file" >/dev/null 2>&1; then + return 0 + fi + elif [[ $type == application/pdf || $type == application/postscript ]]; then + # We could pretend PDFs are images by returning 0 here. + # But, they ought to be treated like directories which the + # user is asked if they wish to open. + return 1 + elif [[ $type == application/dicom ]]; then + # ImageMagick can view DICOM (Medical Imaging Data) + return 0 + elif [[ $type == video/* ]]; then + # Return true for videos. + if mediainfo "$file" >/dev/null 2>&1; then + return 0 + fi + fi + + # Not viewable, return false. + return 1 +} + +trim() { + # Chop leading and trailing spaces from named variables. + local IFS; unset IFS + for var; do + read -rd '' $var <<<"${!var}" + done +} + +imageinfo() { + # Show name of image plus width, height, etc. + # This is called every time a new image is shown. + # Sets global variables FrameCount, ImageWidth, ImageHeight. + # If file is invalid, returns 1 (FALSE in Bourne shell). + local file="$1" + local type=$(mimetype "$file") + local key value + FrameCount=0 + + # Kludge since magic database doesn't recognize sixel file format yet. + if [[ "$file" =~ \.six(el)?$ ]]; then + type=image/x-sixel + fi + + # Based on the filetype, detect parameters and print info. + case $type in + image/*|application/dicom) + # NB: Running sed repeatedly is faster than "identify -format". + local id=$(identify "$file[0]" 2>/dev/null) + # sample.miff MIFF 100x100 3072x2304+1486+1102 8-bit Palette sRGB 33c 33507B 0.000u 0:00.000 + local wxh=$(sed -rn 's/.* ([0-9]+x[0-9]+) .*/\1/p' <<<$id) + local bitdepth=$(sed -rn 's/.* ([0-9]+-bit) .*/\1/p' <<<$id) + local colorspace=$(sed -rn 's/.*-bit ([^0-9]+) .*/\1/p' <<<$id) + local colors=$(sed -rn 's/.* ([0-9]+c) .*/\1/p' <<<$id) + ImageWidth=${wxh%x*}; ImageHeight=${wxh#*x} + echo -n " $wxh $bitdepth $colorspace $colors" + FrameCount=$(numframes "$file") + [[ $FrameCount -gt 1 ]] && echo -n " $FrameCount frames" + ;; + # NOTE: I used sed because identify -format can be very slow. + # For example, %w is fine, but %[width] takes 30x longer. + # Also slow: %z, %[bit-depth], %r, %[colorspace]. + + application/pdf) + declare -A info + while IFS=: read key val + do + trim key val + info["$key"]="$val" + done < <(pdfinfo "$file") + local p=${info["Pages"]:-1} + local s=${info["Page size"]:-} + local t=${info["Title"]:-} + echo -n " $p page$(s $p)${s:+, }$s${t:+, }$t" + FrameCount=1 # Fake a FrameCount, else image will not be shown. + ImageWidth=0; ImageHeight=0; # Fake width and height + ;; + + application/postscript) + FrameCount=1 # Fake a FrameCount, else image will not be shown. + ImageWidth=0; ImageHeight=0; # Fake width and height + ;; + + video/*) + local -A a=(["Width"]= ["Height"]= ["Frame rate"]= ["Duration"]=) + local IFS=$'\n' + for line in $(mediainfo "$file"); do + key=${line%:*} + value=${line#*:} + trim key + trim value + a[$key]="$value" + done + + # Cleanup "1 366 pixels" -> "1366". + a["Width"]="${a["Width"]% pixels}" + a["Height"]="${a["Height"]% pixels}" + a["Width"]="${a["Width"]// }" # Mediainfo puts spaces in numbers! + a["Height"]="${a["Height"]// }" + + # Set global variables. + FrameCount=$(mediainfo --Inform='Video;%FrameCount%' "$file") + ImageWidth=${a["Width"]} + ImageHeight=${a["Height"]} + + # Okay, finally show the video info. + echo -n ${ImageWidth}x${ImageHeight} pixels, " " + echo -n ${FrameCount} frame$(s $FrameCount), " @" + echo -n ${a["Frame rate"]% FPS*} "fps, " ${a["Duration"]}. + ;; + + application/zip|application/rar|application/tar|application/7z) + case "$file" in + *.cb?|*.CB?) + echo -e " - ComicBook archives not supported (yet).\n" + return 1 + ;; + *) + echo -e " - Skipping filetype '$type'.\n" + return 1 + ;; + esac + ;; + *) + echo -e " - Skipping filetype '$type'.\n" + return 1 + ;; + esac + + if [[ $FrameCount -eq 0 ]]; then + e "No images in $file\r" + return 1 + else + echo # New line for sixel image to start at. + return 0 + fi +} + +mimetype() { + # Given an image filename, report its MIME type. (e.g., "image/jpeg"). + local type=$(file --mime-type --brief --dereference -- "$file") + + # Rarely, some file formats will not have a good MIME type. + if [[ $type == application/octet-stream ]]; then + case $file in + *miff|*MIFF) type="image/miff" ;; # ImageMagick. + *rgb|*RGB) type="image/sgi" ;; # SGI RLE color. + *bw|*BW) type="image/sgi" ;; # SGI RLE bw. + esac + fi + echo "$type" +} + +id() { + # Identify an image tersely, but more verbosely than imageinfo. + # Relies on imageinfo already having been called to set FrameCount. + local f="$1" + local size=$(du -h "$f") + size=${size%%$'\t'*} + local rp=$(realpath "$f") + if [[ $rp =~ ^$HOME ]]; then + rp=${rp/$HOME/\~} # Replace /home/username with ~ + fi + echo -n "$rp: " + local format="%wx%h %m, ${size}B" +# format+=", %[colors] colors" # Calculated (too slow?) + format+="\n" + if [[ "$f" != *svg ]]; then + identify -format "$format" "file://$f[0]" + else + identify -format "$format" "msvg:$f[0]" + fi + local comment=$(identify -format "%[comment]" "file://$f[0]" 2>/dev/null) + if [[ "$comment" ]]; then + echo "Comment: $comment" | fmt -w "${COLUMNS:-75}" -s + fi +} + +fakefile() { + # A fake version of file that doesn't use magic cookies or look + # inside the file at all. Instead, it just considers the filename + # and returns "image/emagi" if the filename has a well-known + # extension (e.g., "bar.jpg"). [Note: "Emagi" are the magicians + # of the matrix and the makers of magic cookies.] + while [[ $1 == -* ]]; do shift; done + + ext=${1##*.} + shopt -s nocasematch + case "$ext" in + six|sixel|miff|mvg|\ + jpg|jpeg|jp2|jpx|\ + png|apng|gif|tif|tiff|\ + webp|\ + svg|eps|ps|pdf|psd|\ + heif|jxr|jpeg2000|avif|\ + raw|dng|\ + pnm|ppm|pbm|xpm|xbm|\ + pcx|sgi|yuv|dcx|dpx|avs|palm|pcd|pcds|fits|cin|\ + targa|tga|\ + macpaint|mac|pntg|pict|pic|\ + amigapaint|iff|ilm|ilbm|lbm|\ + bmp|ico|icon) + echo "image/$ext" + ;; + mp4|webm|mkv|avi|\ + ogm|ogv|vp8|\ + mov|m4v|mp2|\ + wmv|asf|flv|3gp) + echo "video/$ext" + ;; + *) + echo "who/knows" + ;; + esac + shopt -u nocasematch +} + +showimage() { + # Shows the file named in $1 using sixel. + + # Global variable $viewmode is either "full", which enlarges it to + # the full size of the terminal window, or it is "fast", which + # scales the image down, removes colors, and takes other shortcuts + # for the fastest preview. + + # Note that "full size" is not exactly the same as "full screen". + # In a terminal emulator, the sixel graphics are just a window. + # However, if the terminal emulator is then maximized (F11 key, often), + # vv will automatically rescale the image to use the entire space, + # minus two lines for the title and prompt. + + [[ "$1" ]] || echo "Error, showimage() called without filename">&2 + + # Show filename and other info + e "$f\t" + imageinfo "$f" || return 1 # Return error if 0 frames in file. + + if [[ "$f" =~ \.six(el)?$ ]]; then + echo "Warning: ImageMagick may show blackness when scaling sixel images." >&2 + fi + + # dorotate gets set if viewmode is best fit and rotating the image + # would allow us to see more of it on the screen. + local dorotate="" + + # Maybe use ">$width" in geometry to disable zooming on tiny images + local gt="" + if [[ "$zoomin" ]]; then gt=""; else gt=">"; fi + + # Centering an image can slow down convert by <200ms. XXX Worth it? + local centering="-gravity center -extent ${width}x${height}" + + # crop gets set in "fit width" mode, below. + local crop="" + + # -flatten is needed for transparent PNGs, + # but would interfere with fitwidth's -crop option. + local flatten="-flatten" + + # geometry for ImageMagick's -geometry flag. + local geometry="$gt${width}x$gt${height}" + + local extract="-extract >${width}x>${height}" # Speed up large images + + # Configure options based on viewmode + case $viewmode in + fast) + geometry="$gt${width}x${previewsize}" + ;; + full) + ;; + bestfit) + dorotate=$(mayberotate "$1") + if [[ "$dorotate" ]]; then + extract="-extract >${height}x>${width}" + fi + ;; + width) + # fit width fits image width to screen, but not height + geometry="$gt${width}x" + # Round crop down to the height of a line of text to avoid gaps + crop="-crop x$(( (height/fontheight) * fontheight ))" + # Disable centering's -extent + centering="" + # Disable flattening multiple layers + flatten="" + # Don't shrink to screen height, as we'll be showing multiple crops. + extract=${extract%x*} + ;; + *) + error "Unknown viewmode '$viewmode'" + exit 1 + esac + + currentview=$viewmode # Remember last used viewmode. + + # Save the sixel output so we can clear "Processing..." message. + local output="" + + # OSC 8 URI linking so user can click on image to open it + local osc8uri="file://`hostname --fqdn``realpath "$1"`" + debug "OSC 8 URI link goes to $osc8uri" + + # All set let's show it. + case $viewmode in + full|width|bestfit) # Large, high quality and slow. (about 1 second) + e "preparing $viewmode" + # If antialiasing is off, use -sample to resize the image. + if [[ $aalias ]]; then resize=geometry; else resize=sample; fi + + echo -n "${dorotate:+, rotated}" + echo -n "${enhance:+, enhanced}" + echo -n "${dither:+, dithered}" + echo -n "${aalias:+, antialiased}" + echo -en " view...\r" + output=$( ${DEBUG:+debugdo} ${TIME} \ + convert -background $background -auto-orient ${dorotate} \ + $extract \ + -$resize "$geometry" \ + $crop \ + ${enhance:+-channel rgb -auto-level -gamma 1.5} \ + +dither ${dither:+-dither floyd-steinberg} \ + $centering \ + -colors $numcolors \ + "file://$1[0]" \ + $flatten \ + sixel:-) + # Note: -crop is used by fitwidth and interferes with -flatten. + ;; + *|fast) # Fast and cruddy + echo -en "preparing fastest preview\r" # About 0.15 seconds + if [[ $aalias ]]; then resize=geometry; else resize=sample; fi + # Note that -sample is faster for large images, but super cruddy. + # 3,000,000 pixels empirically found as where time benefit began. + [[ ImageWidth*ImageHeight -lt 3000000 ]] || resize=sample + + output=$( + echo -en "\e]8;;${osc8uri}\e\\" # Begin OSC 8 URI linking + ${DEBUG:+debugdo} ${TIME} \ + convert "file://$1[0]" \ + -auto-orient \ + -$resize "$geometry" \ + +repage -background $background \ + -flatten +dither -colors $numcolors \ + sixel:- + echo -en "\e]8;;\e\\" # End OSC 8 URI linking + ) + # Note: -flatten and -background is needed for transparency. + # Note: +repage is needed for images with virtual canvases. + # Note: +dither (disable dither) saves ~200ms sixel creation time. + # Note: -colors 256 saves another ~200ms sixel creation time. + ;; + esac + tput el + if [[ "$output" ]]; then + echo -n "$output" + SHOULDREDRAW="" # Reset redraw flag until SIGWINCH trips it + else + # Error. ImageMagick must have failed as $output is empty. + return 1 + fi + return +} + +mayberotate() { + # Checks global variables $ImageWidth and $ImageHeight + # against the screen size in $width x $height. + # Prints "-rotate +90" if the image should be rotated to best fit + # in the current screen. Prints nothing otherwise. + if [[ $ImageHeight == $ImageWidth || $height == $width ]]; then + return + fi + if [[ -z "$zoomin" \ + && $ImageHeight -le $height \ + && $ImageWidth -lt $width ]]; then + # Small enough to fit in screen and not enlarging. + return + fi + + + local iorient=$((ImageHeight > ImageWidth)) + local sorient=$((height > width)) + if [[ $iorient != $sorient ]]; then + echo "-rotate +90" + fi +} + + +showprompt() { + if [[ $COLUMNS -lt 80 ]]; then + e "vv: " # Terse prompt + else + # Show long prompt after image. + echo -en "\r" + echo -n "Press space to keep, 'd' to delete, " + if [[ FrameCount -gt 1 ]]; then + echo -n "'v' for video, " + elif [[ $currentview != full ]]; then + echo -n "'f' for full, " + fi + echo -n "'h' for help, 'q' to quit." + tput el + fi +} + + + +declare -ig recursionlevel=0 # How many times doit() has called itself. +declare -g abortrecursion="" # Used to pop back up to the top level dir. +declare -g poprecursion="" # Used by checkrflag to pop up one level. +declare -g slideshow="" # Gets set to "yes" when running slideshow. +doit () { + # For every file, show it and ask what is to be done. + # This is the main loop of the program. + # With "-r", doit() calls itself recursively for subdirectories. + for f; do + if [[ "$abortrecursion" ]]; then + if [[ $recursionlevel -gt 1 ]]; then + recursionlevel=recursionlevel-1 + return + else + abortrecursion="" + fi + fi + + # Found a directory, so maybe recurse + if [[ -d "$f" && ! -L "$f" ]]; then + # Allow Enter key to switch recursion flag to 'ask'. + if [[ $rflag ]] && read -t 0; then + rflag="ask" + fi + + f=$(echo "$f" | tr -s /) # Squeeze repeated /// to single /. + f=${f%/} # No / at end. + + # Recurse if directory was specified on command line or rflag≅yes. + if [[ $recursionlevel -eq 0 ]] || checkrflag "$f/"; then + e "$f/" + recursionlevel=recursionlevel+1 + mapfile -t < <(sortrecur "$f"/*) + doit "${MAPFILE[@]}" + continue + else + # Special case for checkrflag: user hit 'x' to exit recursion. + if [[ "$poprecursion" ]]; then + poprecursion="" + recursionlevel=recursionlevel-1 + return + fi + fi + fi + [[ -f "$f" ]] || continue; + + # Skip non-image files discovered through recursion. + # (Always attempts to show files specified on command line). + if [[ recursionlevel -gt 0 ]] && ! isimage "$f"; then + # The current file isn't an image. + throb + continue + fi + + # Show the image using sixels. + showimage "$f" || continue # Can return error if 0 frames in file. + + # Discard any keystrokes hit before read loop started. + flushstdin + + # Read a key loop. + while true; do + + if [[ $SHOULDREDRAW ]]; then + # Sigwinch interrupt handler resized screen + SHOULDREDRAW="" + echo + viewmode=$currentview showimage "$f" + continue 1 + fi + + showprompt # Show "Press space to keep..." + + # Use read timeout to check several times a second for window resize. + REPLY= + while [[ -z $REPLY && -z $SHOULDREDRAW ]]; do + read $BASH4BUG -s -n1 -t 0.25 < /dev/tty + local e=$? + if [[ $e -gt 0 ]]; then + # Read command returned an error (usually timeout). + if [[ "$slideshow" ]]; then + if doslideshow "$f"; then + continue 3 # User hit q to quit vv + else + continue 2 # User hit ESC to stop slideshow + fi + elif [[ $e -gt 128 ]]; then + # normal read timeout (just loop to allow for SIGWINCH) + continue 1 # Read a key loop. + else + echo "Error in read: $e. This shouldn't happen." >&2 + continue 1 + fi + fi + done + + if [[ $SHOULDREDRAW ]]; then + # Sigwinch interrupt handler resized screen + SHOULDREDRAW="" + echo + viewmode=$currentview showimage "$f" + continue 1 + fi + + # if [[ "$slideshow" ]]; then + # if doslideshow "$f"; then + # continue 2 + # fi + # fi + + # If we get here, then 'read' successfully got a key. + echo -en "\r" + tput el + + # Parse terminal escape sequence for F-keys, arrows. + if [[ "$REPLY" == $'\e' ]]; then parseescape; fi + + flushstdin # Remove extraneous keys. (E.g., SPC held down). + + case $REPLY in + " ") continue 2 # Space: Skip to next image. + ;; + d|t) mygio trash "$f" && echo "trashed $f" + sleep 0.25; flushstdin # Slow down deletes + continue 2 + ;; + u) f=$(untrash) && echo "untrashed '$(tildify $f)'" && showimage "$f" + ;; + T) # Undocumented tool to show Trashcan in GUI browser. + mygio open trash:// + ;; + # $'\cD') # Immediate, unrevocable delete. Too dangerous? + # rm "$f" && echo "deleted $f" + # sleep 0.25; flushstdin # Slow down deletes + # continue 2 + # ;; + + f) E "Fitting to full size once ('F' for always)" + viewmode=full showimage "$f" + ;; + F) # Toggle full/fast view mode. + [[ $viewmode != full ]] && viewmode=full || viewmode=fast + E "Switching default viewmode to $viewmode." + if [[ $currentview != $viewmode ]]; then + showimage "$f" + fi + ;; + w) E "Fitting to screen width once ('W' for always)" + viewmode=width showimage "$f" + ;; + W) # Toggle fitwidth/fast view mode. + [[ $viewmode != width ]] && viewmode=width || viewmode=fast + E "Switching default viewmode to $viewmode." + if [[ $currentview != $viewmode ]]; then + showimage "$f" + fi + ;; + v) if [[ $FrameCount -le 1 ]]; then + # Just a single image, so temporarily use full mode. + viewmode=full showimage "$f" + else + # Allow lowercase 'v' for files we detect as videos. + e "Playing video\r" + sixvid "$f" + flushstdin + fi + ;; + V) e "Playing video\r" + sixvid "$f" + flushstdin + ;; + + M) e "Playing video\r" + # Undocumented for now. Should this use xdg-open? + mpv -loop "$f" || animate "$f" + flushstdin + ;; + b|$'\c?'|$'\cH') E "Back not implemented yet" + # b reserved for "back" once we implement it. + ;; + + c) identify -format \ + "Comment: %[comment]\n" "file://$f[0]" 2>/dev/null \ + | fmt -w "${COLUMNS:-75}" -s + + ;; + C) editproperty comment "$f" + ;; + i) id "$f" # Quick info + ;; + I) # Verbose info. + convert "file://$f[0]" \ + -print "%[option:*]%[artifact:*]%[*]" \ + null:- | grep -v '^filename=' + echo $(realpath "$f") + local size=$(du -b "$f" | numfmt --grouping) + size=${size%% *} + echo "$size bytes" + identify -format '%[colors] unique colors\n' "$f" + ;; + r) # rename "$f" + saverenamemove rename "$f" + ;; + m) # rename "$f" # XXX do document. + saverenamemove move "$f" + ;; + s) #saveacopy "$f" + saverenamemove save "$f" + ;; + S|f5) # Allow F5 for folks used to other viewers. + if [[ "$slideshow" ]]; then + E "Slide show stopped" + slideshow="" + viewmode=${oldvmss:-fast} + else + E "Slide show mode. Hit 'p' to pause, 'Escape' to stop." + slideshow="yup" + oldvmss=$viewmode + viewmode="full" + fi + showimage "$f" + ;; + A) # Toggle antialiasing + if [[ "$aalias" ]]; then + E "Turning antialiasing off (show pixels when zooming in on small images)" + aalias="" + else + E "Turning antialiasing on (smooth small images when blown up)" + aalias=yup + fi + viewmode=$currentview showimage "$f" + ;; + B) # Toggle autorotate image to fit screen (portrait/landscape). + [[ $viewmode != bestfit ]] && viewmode=bestfit || viewmode=fast + E "Switching default viewmode to $viewmode." + if [[ $currentview != $viewmode ]]; then + showimage "$f" + fi + ;; + $'\cb') # Ctrl-B: Rotate this one image to fit the screen. + viewmode=bestfit showimage "$f" + ;; + D) # Toggle dither + if [[ "$dither" ]]; then + E "Turning dither off (load faster by not dithering)" + dither="" + else + E "Turning dither on (visually smooth fullscreen images)" + dither=yup + fi + if [[ $currentview != "fast" ]]; then + viewmode=$currentview showimage "$f" + fi + ;; + E) # Toggle enhance view + if [[ "$enhance" ]]; then + E "Turning enhanced view off" + enhance="" + else + E "Turning enhanced view on" + enhance=yup + fi + # XXX should enhance apply to fast view? + if [[ $currentview != "fast" ]]; then + viewmode=$currentview showimage "$f" + fi + ;; + e) e "temporarily showing enhanced view\r" + enhance=yup viewmode=full showimage "$f" + ;; + =) # Set preview size to specific number + if [[ $currentview == "fast" ]]; then + stty echo # Temporarily show characters the user types. + tput cnorm # Temporarily show the cursor. + echo -en "\r"; tput el # Clear line for prompt. + IFS= read -e -r -p "Height of preview in lines [$previewlines] ? " + tput civis + stty -echo + + if [[ "$REPLY" -le 0 || "$REPLY" -eq $previewlines ]]; then + E "Preview lines unchanged" + else + [[ "$REPLY" -lt $LINES ]] || REPLY=$((LINES-2)) # Max is #rows - 2 + previewlines=$REPLY + E "Preview lines changed to $previewlines" + windowchange + fi + fi + ;; + +) # Zoom in preview + if [[ $currentview == "fast" ]]; then + previewlines=previewlines+1 + [[ previewlines -le $((LINES-2)) ]] || previewlines=$((LINES-2)) + windowchange + e "Preview lines changed to $previewlines" + fi + ;; + -) # Zoom out preview + if [[ $currentview == "fast" ]]; then + previewlines=previewlines-1 + [[ previewlines -ge 1 ]] || previewlines=1 + windowchange + e "Preview lines changed to $previewlines" + fi + ;; + 0) # Reset previewlines + previewlines=8 + windowchange + e "Preview lines changed to $previewlines" + ;; + 1) # Show 100% dot-for-dot view + dotfordot "$f" + ;; + Z|z) # Magnify small images up to size of screen + togglezoomin "$f" + ;; + l|$'\cL') # l or ^L to redraw screen. + clear # Note: ^L doesn't work with bash4. + showimage "$f" + ;; + "") # ENTER key: show imageinfo and preview again. + # (XXX: Maybe not a good use of such a convenient key?) + viewmode=fast showimage "$f" + ;; + x) if [[ $recursionlevel -gt 0 ]]; then + E "Exiting directory $(dirname "$f")" + recursionlevel=recursionlevel-1 + return + else + continue 2 # Allow x to also skip to next image + fi + ;; + X) if [[ $recursionlevel -gt 0 ]]; then + E "Exiting to top level directory" + abortrecursion=yes + recursionlevel=recursionlevel-1 + return + fi + ;; + R) reverseimagesearch "$f" + showimage "$f" + ;; + y) yandeximagesearch "$f" + showimage "$f" + ;; + h|'?') + E "$(basename $0): Rapidly view and delete image files" + showcommonkeys + ;; + H) + E "$(basename $0): Advanced options" + showadvancedkeys + ;; + q|Q|'\E') exit + ;; + ,) enabledebugging + ;; + *) + echo "Unknown key: $REPLY" |cat -v >&2 + ;; + esac; + done + done + + recursionlevel=recursionlevel-1 + return +} + +showcommonkeys() { + cat << EOF +MOST USEFUL KEYS: + + SPACE: skip to next image. + f: view just the current image as full-size, higher quality. + F: toggle between always showing full-size images and fast previews. + v: Plays video using sixel (no sound). + +/-/0/=: increase/decrease/reset/set fast preview size. + d: trash image (also try 'gio list trash:' and 'gio trash --empty') + u: undo previous trash (can be used multiple times) + r: rename image file. + m: move image file to another directory. + s: save a copy of the image to another directory or filename. + q: quit. + H: SHOW ADVANCED KEYS. + +EOF +} +showadvancedkeys() { + cat </dev/null) + debug "$traversal" + + + case $traversal in + bfs|[Bb]readth*|[Bb]reath*) + # Breadth First Search: show images before opening subdirs. + sortdirslast "${MAPFILE[@]}" + ;; + dfs|[Dd]epth*|[Dd]eath*) + # "Death First Search" -- Prof. Vazirani. + echo "XXX Depth First Search: does anyone actually want this?" >&2 + sortdirsfirst "${MAPFILE[@]}" + ;; + neither|*) + # Dirs are not special and are recursed into whenever seen. + showargs "${MAPFILE[@]}" + ;; + esac +} + +sortdirslast() { + # Input: a list of file and directory names. + # Output: list with files before directories. + # Breadth First Search makes sense when entering a directory, we + # usually want to see every image in it first before recursing + # into subdirectories. + local -a directories= + local name + local IFS=$'\n' # Don't split on spaces in filenames. + for name; do + if [[ -d "$name" ]]; then + directories+=($name) + else + echo "$name" + fi + done + for name in "${directories[@]}"; do + echo "$name" + done +} + +sortdirsfirst() { + # Input: a list of file and directory names. + # Output: list with directories before files. + # Depth First Search makes no sense. Just implementing it for completeness. + local -a files= + local name + local IFS=$'\n' # Don't split on spaces in filenames. + for name; do + if [[ -d "$name" ]]; then + echo "$name" + else + files+=($name) + fi + done + for name in "${files[@]}"; do + echo "$name" + done +} + +showargs() { + # Input: a list of file and directory names. + # Output: same list, with newlines between each item + local name + local IFS=$'\n' # Don't split on spaces in filenames. + for name; do + echo "$name" + done +} + + +togglezoomin() { + # Toggle zooming in on small images and update the view if necessary. + # Uses global variable "currentview". + # Sets global variable "zoomin". + local f="$1" + if [[ -z "$zoomin" ]]; then + echo "Zooming in on small images enabled." + zoomin=yup + else + echo "Zooming in on small images disabled" + zoomin="" + fi + viewmode=$currentview showimage "$f" +} + +doslideshow() { + # Wait for the user to hit a key or timeout after $ssdelay seconds. + # Returns false only if user wants to stop the slideshow. + + local file="$1" + e "Slideshow: p to pause, Esc to stop, Q to quit." + + while true; do + if read -s -n1 -t $ssdelay; then + tput el + if [[ "$REPLY" == $'\e' ]]; then parseescape; fi + case $REPLY in + " ") #Space for next image + break; + ;; + S|f5|'\E') # quit slideshow, but not whole program. + slideshow="" + viewmode=${oldvmss:-fast} + return 1 + ;; + q|Q) exit # Quit everything! + ;; + p|P|*) # pause + e "Slideshow paused. Hit any key to continue or Esc to stop, Q to quit.\r" + read -s -n1 + tput el + if [[ "$REPLY" == $'\e' ]]; then parseescape; fi + case $REPLY in + " ") break ;; + S|f5|'\E') # Stop slideshow. + slideshow="" + viewmode=${oldvmss:-fast} + return 1 + ;; + q|Q) exit # Quit program completely. + ;; + esac + e "Slideshow: Press p to pause, Esc to stop, Q to quit." + ;; + esac + else + # Normal screensaver delay timeout; show the next image. + return 0 + fi + done +} + +enabledebugging() { + # Debugging gobbledygook + DEBUG=yup + TIME=time + set -o functrace + trap '{ for ((i=${#FUNCNAME[@]}-1; i>=0; i--)); do echo -en "${BASH_LINENO[i]}: ${FUNCNAME[i]} " >&2 ; [[ $i != 0 ]] && echo -n " --> " ; done; echo ; } >&2' RETURN +} + +debugdo() { + # Both show the command and execute it + echo >&2 + echo >&2 + echo "$@" >&2 + echo >&2 + "$@" +} + +# ---------------------------------------------------------------------- +# START OF TRASH SCRIPTS TO REPLACE 'gio trash' command. +mygio() { + # If the libglib2.0-bin's gio command isn't installed, we fake it. + + if command -v gioXXXXX >/dev/null; then + # gio exists, so use it. + # Ugh. Buggy when a filename has a colon in it. Prepending "file:" doesn't fix it. + gio "$@" + else + + # gio from libglib2.0-bin is missing, so use my shell script version. + case "$1" in + trash) + shift + trash "$@" + ;; + open) + shift + xdg-open "$@" + ;; + *) echo "Unsupported mygio command '$1'" >&2 + ;; + esac + fi +} + +trash() { + # trash: Move files to ~/.local/share/Trash/files/. + # Well-nigh-compliant with FreeDesktop Trash Specification. + # https://specifications.freedesktop.org/trash-spec/trashspec-latest.html + # + # Handles same filename deletions, race conditions, URI-style escaping. + # Bugs: Does not handle $topdir for foreign mounts (e.g., removable drives). + + local trashdir=${XDG_DATA_HOME:-$HOME/.local/share}/Trash + if ! mkdir -p $trashdir/files || ! mkdir -p $trashdir/info; then + echo "Error: trash() could not create $trashdir/{files,info}.">&2 + echo "Not deleting $@." >&2 + return 1 + fi + + for file; do + local path=$(uriescape "$(realpath "$file")") + local newname=$(realpath "$file" | tr / \!) + + # Allow same filename to be deleted repeatedly without loss. + # Creates trashinfo file first (atomically, to avoid race collisions). + if ! atomictouch "$trashdir/info/$newname.trashinfo"; then + local -i i=1 + while ! atomictouch "$trashdir/info/$newname.$i.trashinfo"; do + i=i+1 + done + newname=$newname.$i + fi + + # Write metadata to .trashinfo file + cat > "$trashdir/info/$newname.trashinfo" << EOF +[Trash Info] +Path=$path +DeletionDate=$(date +Y-%m-%dT%H:%M:%S) +EOF + mv "$file" "$trashdir/files/$newname" + done +} + +atomictouch() { + # If the file named by $1 does not exist, this creates it atomically. + # Otherwise, it return an error. + # This is atomic because Bash uses O_EXCL for noclobber. + ( set -C; >"$1"; ) 2>&- +} + +uriescape() { + # FROM RFC 2396, Uniform Resource Identifiers + # urireserved=";/?:@&=+$," + # alnum="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + # mark="\-\_\.\!\~\*\'\(\)" + # unreserved="$alnum$mark" + # okaychars="$unreserved$urireserved" + # excluded='] <>#%"{}|\^[`' # also exclude control (00-1F and 7F hex). + local -i i + for (( i=0; i<${#1}; i++ )); do + local c=${1:i:1} + case $c in + a|b|c|d|e|f|g|h|i|j|k|l|m|n|o|p|q|r|s|t|u|v|w|x|y|z|A|B|C|D|E|F|G|H|I|J|K|L|M|N|O|P|Q|R|S|T|U|V|W|X|Y|Z|0|1|2|3|4|5|6|7|8|9|\;|\/|\?|\:|\@|\&|\=|\+|\$|\,|\-|\_|\.|\!|\~|\*|\'|\(|\)) + echo -n "$c" + ;; + *) echo -n "$c" | hexdump -ve '/1 ":%02X"' | tr : % + ;; + esac + done + echo +} + +unuriescape() { + # FROM RFC 2396, Uniform Resource Identifiers + # urireserved=";/?:@&=+$," + # alnum="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + # mark="\-\_\.\!\~\*\'\(\)" + # unreserved="$alnum$mark" + # okaychars="$unreserved$urireserved" + # excluded='] <>#%"{}|\^[`' # also exclude control (00-1F and 7F hex). + local -i i + for (( i=0; i<${#1}; i++ )); do + local c=${1:i:1} + case $c in + %) # Next two letters must be hex digits. + local -i j=i+1; local -i k=i+2 + local h="${1:j:1}${1:k:1}" + i=i+2 + c=$(echo -en '\x'"$h") + ;; + esac + echo -n "$c" + done + echo +} + +untrash() { + # untrash: Take the most recent file out of ~/.local/share/Trash/files. + # + # Puts file back where it came from, unless it would clobber another file. + # If a file of that name already exists, rename existing as .1, .2, ... + # + # If directory it was in is gone, then recreates the directory. + # + # Outputs the filename the of the untrashed file. + # Returns error if trash is empty. + + # Find last created infofile that points at an existing file + local trashdir=${XDG_DATA_HOME:-$HOME/.local/share}/Trash + local infofile=/dev/null.d/nonexistant + while true; do + local infofile=$(ls -tr "$trashdir/info/"* 2>/dev/null | tail -1) + if [[ ! "$infofile" ]]; then + E "Trashcan is empty." >&2 + return 1 + fi + + # Sanity check + [[ -e "$infofile" ]] || return 1 # No info, nothing to untrash. + + local trashname="${infofile%.trashinfo}" + trashname="${trashname#$trashdir/info/}" + trashname="$trashdir/files/$trashname" + + if [[ -e "$trashname" ]]; then + break + else + rm -f "$infofile" # Delete an infofile that points at nothing. + fi + done + + local Path=$(awk -e '/^Path=/ {print substr($0,6); exit;}' "$infofile") + Path=$(unuriescape "$Path") + + # Does directory even existing for us to write to? + if ! mkdir -p $(dirname "$Path") || ! [[ -w $(dirname "$Path") ]]; then + echo "Warning: Cannot write to '$(dirname "$Path")'" >&2 + echo -n "Warning: Restoring to " >&2 + if [[ -w "$PWD" ]]; then + tildify "$PWD" >&2 + Path="$PWD/$(basename "$Path")" + else + tildify "$HOME" >&2 + Path="$HOME/$(basename "$Path")" + fi + fi + + # Finally, we can restore the file. + if mv --backup=numbered "$trashname" "$Path" && rm "$infofile"; then + echo "$Path" # Return filename untrashed + return 0 + else + return 1 + fi +} + + +# END OF TRASH SCRIPTS +#---------------------------------------------------------------------- + + + +### MAIN ### +set -o nounset # Just for laughs, let's die on typos. ;-) + +# Check prerequisites +prerequisites + +# Determine window size, set up terminal +autodetect + +# Parse command line args +TEMP=$(getopt -o 'r::R,fF,S::,B,n:,dD,aA,g:,p:P:,?' \ + --long 'recursive::,full-size,fast-view' \ + --long 'slide-show::,slideshow::,best-fit' \ + --long 'num-colors:,dither,no-dither' \ + --long 'antialias,no-antialias' \ + --long 'geometry:,preview-size:,preview-lines:' \ + --long 'help' \ + -n 'vv' -- "$@") + +if [ $? -ne 0 ]; then + echo >&2 + usage >&2 + exit 1 +fi + +# Use getopt's results from TEMP +eval set -- "$TEMP" +unset TEMP + +while true; do + case "$1" in + -r|--recursi*) + case "$2" in + '') # No optional argument, so do nothing special. + rflag=yup + ;; + *) # Optional argument: yes, no, ask. + rflag=$2 + ;; + esac + case "$rflag" in + ask) echo "Recursion will ask before entering each directory." + ;; + ""|no|off|false|False|F|0|nil|disable*) + rflag="" + echo "Recursion disabled." + ;; + *) + echo "Recursion enabled." | cat -v + ;; + esac + shift 2 + continue + ;; + -R) + rflag="" + echo "Recursion disabled." + shift + continue + ;; + -f|--full*) + echo 'Full screen mode (slower, better quality)' + viewmode="full" + shift + continue + ;; + -F|--fast*) + echo 'Fast view mode (small, quick, low quality)' + viewmode="fast" + shift + continue + ;; + -B|--best-fit) + echo "Best-fit (full screen, auto-rotate to maximize screen usage)" + viewmode="bestfit" + shift + continue + ;; + -S|--slide-show|--slideshow) + slideshow=yup + oldvmss=fast + viewmode=full + case "$2" in + '') # No optional argument, so do nothing special. + true + ;; + *) # Optional argument sets time delay. + ssdelay=$2 + ;; + esac + echo "Slideshow mode ($ssdelay second delay)" + shift 2 + continue + ;; + -n|--num-colors) + numcolors=$2 + echo "Number of colors: $numcolors. (Not used in Fast view)." + if [[ numcolors -eq 0 ]]; then usage; exit 1; fi + shift 2 + continue + ;; + -d|--dither) + dither=yup + echo "Dithering on. (Smoother gradients in Full screen mode)." + shift + continue + ;; + -D|--no-dither) + dither="" + echo "Dithering off. (Faster rendering in Full screen mode)." + shift + continue + ;; + -a|--antialias) + aalias=yup + echo "Antialias on. (Smooth jaggies when scaling image)." + shift + continue + ;; + -A|--no-antialias) + aalias="" + echo "Antialiasing off. (Show pixel art as sharp)." + shift + continue + ;; + -g|--geometry) + width=${2%x*} + height=${2#*x} + echo "Terminal gemetry set to ${width}x${height} px." + if [[ width -eq 0 || height -eq 0 ]]; then usage; exit 1; fi + shift 2 + continue + ;; + -P|--preview-size) + forcepsize=${2#*x} # Ignore width if given geometry (e.g., 800x256) + echo "Fast-view's preview size set to ${width}x${forcepsize}." + if [[ $forcepsize -eq 0 ]]; then usage; exit 1; fi + previewsize=$forcepsize + shift 2 + continue + ;; + -p|--preview-lines) + previewlines=$2 + if [[ $previewlines -eq 0 ]]; then usage; exit 1; fi + previewsize=fontheight*previewlines + echo "Fast-view's preview size set to ${width}x${previewsize}." + shift 2 + continue + ;; + '-?'|--help) + usage + exit 0 + ;; + --) + shift + break + ;; + *) + echo 'Internal error!' >&2 + exit 1 + ;; + esac +done + + +if [[ $# -gt 0 ]]; then + # Show all files/dirs mentioned on command line. + recursionlevel=0 # Allow one level of recursion for any + doit "$@" # directories named on command line. +else + # No arguments, so show everything in current working directory. + recursionlevel=1 # Do not complain about non-image files. + mapfile -t < <(sortrecur *) # Sort files for recursion. + doit "${MAPFILE[@]}" # Start recursing. +fi + +# Don't quit until images have finished transferring. +waitforterminal diff --git a/shell/envvar b/shell/envvar index 0c26fc8..99953bc 100644 --- a/shell/envvar +++ b/shell/envvar @@ -24,11 +24,11 @@ export \ export \ OPENER="xdg-open" \ READER="zathura" \ - BROWSER="brave" \ - TERMINAL="st" \ + BROWSER="firefox" \ + TERMINAL="foot" \ EDITOR="nvim" \ VISUAL="nvim" \ - IMAGE="sxiv" \ + IMAGE="swayimg" \ VIDEO="mpv" \ # Theming