#!/bin/bash

# $Id: slargs,v 2.54 2022/11/20 16:12:31 bscott Exp bscott $

# NAME
#
# slargs - Interactively select from arguments, then run them as a command
#
# SYNOPSIS
#
# slargs [switches] <args>
#
# DESCRIPTION
#
# slargs presents arguments given to it, in a full-screen menu (drawn by the
# dialog(1) utility).  Each argument can then be (de)selected interactively.
# Press ENTER/OK to accept the menu selections.  All selected items are
# assembled as a command line and run.  This allows ad-hoc selection of files
# or other arguments for arbitrary commands.
#
# The first selected item becomes the command to run.  You can provide
# multiple candidate commands to slargs and select just one; the first
# selected item becomes the command regardless.  If you press ESC/CANCEL, or
# select no items at all, no command is run.
#
# The first non-switch argument is assumed to be a command and will be
# pre-selected.  Any following switches are assumed to be for the command and
# will be pre-selected.  For certain well-known commands that specify a
# destination as their last argument -- namely cp(1) and mv(1) -- the last
# argument will be pre-selected.  These behaviors can be adjusted by options
# to slargs (see below).  Pre-selected items can still be reviewed and
# de-selected in the menu.
#
# OPTIONS
#
# Any switches to slargs must come before any other arguments to slargs
# (including the command slargs will run).  Once a non-switch argument is
# encountered, slargs assumes all following arguments are for the command to
# run.
#
# -v  Show the command before running it
# -n  Do not actually run the command, just show it (implies -v)
# -a  Pre-select all items
# -A  Do not pre-select any items (same as -L -c -s)
# -l  Pre-select the last item
# -L  Do not pre-select the last item, even if it looks like we should
# -C  Do not pre-select the first item (the assumed command)
# -S  Do not pre-select arguments that look like switches
# --  Stop looking for switches to slargs; treat as run command/argument
#
# EXAMPLES
#
# Pick a file from the current directory to view.
#
#	slargs less *
#
# Pick files in the current directory, and copy them somewhere else.
# The last argument (the destination) is automatically pre-selected.
#
# 	slargs cp * /somewhere/else/
#
# Pick backup files in the current directory, and remove them.
# The last argument is *not* automatically pre-selected.
#
# 	slargs rm *~
#
# Pick a command from the current directory to run.
#
# 	slargs -C *
#
# Pick your favorite Beatle.
#
#	slargs echo John Paul George Ringo
#
# MISCELLANEOUS
#
# Written by Benjamin Scott.  http://www.dragonhawk.org/
#
# Inspired by the SELECT command from 4DOS.  http://www.jpsoft.com/
#
# This is free and unencumbered software released into the public domain.
# You can do whatever you want with it.  There is no warranty.
# See the "Unlicense" at https://unlicense.org/ for details.


######################################################################
# constants

progname=slargs
exit_error=1
exit_nosel=1

# These are commands which expect a destination as the last argument.
# See also the -l and -L switches.
#
# This is a bit of a kludge, since it introduces inconsistent behavior.
# But it's the right thing for the common case, and slargs is for humans.
#
# Associative array, with key existence significant, the value ignored.
declare -A wants_dest=( [cp]=1 [mv]=1 )

######################################################################
# functions

function die () {
	echo "$progname: $*" >/dev/stderr
	exit $exit_error
	}

function decho () {
	[ -z "$DEBUG" ] && return
	echo "$progname: DEBUG: $*" >/dev/stderr
	}

######################################################################
# main program

# we use this to capture the results of dialog(1), later on
shopt -s lastpipe || die "failed to set shell option: lastpipe"

# arguments to dialog(1) are built up in $args[]
declare -a args
unset args

[[ $# -gt 0 ]] || die 'missing argument(s): item(s) to select from'

# ----------------------------------------------------------------------
# fixed arguments to dialog(1)

# use alternate terminal screen to preserve content of main screen
args+=( --keep-tite )

# Make the selection list act as a tab-able UI sub-widget.
# Big benefit here is, one can speed search by pressing first letter.
args+=( --visit-items )

# just use the file names themselves as the selector tags
args+=( --no-items )

# view-only (not clickable) scroll bar to provide clue as to list size
args+=( --scrollbar )

# single quote filenames to suppress shell metacharacters
#args+=( --single-quoted )

# output each selected item on its own line
args+=( --separate-output )

# we are building a checkbox multi-select menu, with this title
args+=( --checklist 'Select items(s)' )

# auto-size the height, width, and list
args+=( 0 0 0 )

# ----------------------------------------------------------------------
# process our arguments

# Go through arguments to this script, handing any of our switches, and
# turning the rest into choices for dialog(1) to present.

# are we looking for switches to this script (vs to the command to run)?
look_switch=1
# have we seen an (assumed) command to run yet?
unset seen_cmd
# pre-select all items?  off by default, on by -a
unset sel_all
# pre-select the last item?  off by default, on by -l or $auto_last command
unset sel_last
# pre-select the last item based on command name?  on by default, off by -L
auto_last=1
# pre-select the command/first arg?  on by default, off by -C
sel_first=1
# pre-select things that look like switches?  on by default, off by -S
auto_switch=1
# verbose -v and no-op -n
unset verbose noop

while [[ $# -gt 0 ]] ; do

	state=off

	if [[ -n $look_switch ]] && [[ "${1:0:1}" = "-" ]]; then
		# switch for this script, not the run command
		pos=1
		len=${#1}
		decho "pos=$pos len=$len"
		while [[ $pos -lt $len ]]; do
			switch="${1:$pos:1}"
			decho "pos=$pos switch=$switch"
			case "$switch" in
				a) sel_all=1		;;
				C) unset sel_first	;;
				l) sel_last=1		;;
				L) unset auto_last	;;
				S) unset auto_switch	;;
				v) verbose=1		;;
				n) noop=1		;;
				-) unset look_switch	;;
				A)
					unset sel_all
					unset sel_first
					unset sel_last
					unset auto_last
					unset auto_switch
					;;
				*) die "unrecognized switch: $switch"
			esac
			pos=$(( pos + 1 ))
		done
		# next arg - the switch stack does not become part of $args
		shift
		continue
	elif [[ -z $seen_cmd ]]; then
		# Not a switch, assume it is a/the command to run
		seen_cmd=1
		unset look_switch
		# default the command to pre-selected
		[[ -n $sel_first ]] || [[ -n $sel_all ]] && state=on
		# if this command $wants_dest, then set $sel_last
		if [[ -n $auto_last ]] && [[ -v wants_dest[$1] ]]; then
			sel_last=1
		fi
	elif [[ -n $sel_all ]]; then
		state=on
	elif [[ -n $auto_switch ]] && [[ "${1:0:1}" = "-" ]]; then
		# first char is dash, assume switch argument, default sel'ed
		state=on
	elif [[ $# = 1 ]] && [[ -n $sel_last ]]; then
		# pre-select last argument if $last is defined
		state=on
	fi
	
	# --checklist expects a series of (name,status) pairs as arguments
	args+=( "$1" "$state" )
	
	shift

done

decho sel_all=$sel_all
decho sel_first=$sel_first
decho sel_last=$sel_last
decho auto_last=$auto_last
decho auto_switch=$auto_switch
decho verbose=$verbose
decho noop=$noop
decho look_switch=$look_switch
decho seen_cmd=$seen_cmd

IFS='|' ; decho "dialog args: |${args[*]}|" ; IFS=' '

# ----------------------------------------------------------------------
# run the selection dialog

# dupe stdout (FD 1) to a new FD; the FD gets stored in $tout
# the shell's idea of stdout is left untouched (still FD 1)
exec {tout}>&1
decho "tout=$tout"

# Present the list of our arguments in a menu, using dialog(1).
# Use the resulting dialog(1) selection to replace $args[].
dialog "${args[@]}" 2>&1 1>/dev/fd/$tout | readarray -t args

# close the file descriptor we opened earlier
exec {tout}>&-

# In the above:
#
# 2>&1
#
#  Makes dialog's stderr a dupe of the stdout of the pipe capture.
#  The dialog(1) utility writes selected items to its stderr, so this
#  sends those selections into the pipe (and thus into readarray).
#
# 1>/dev/fd/$tout
#
#  Make dialog's stdout a dupe of the file descriptor we opened earlier.
#  The dialog(1) utility writes screen control and UI elements to its stdout.
#  The $tout file descriptor is a dupe of the shell's stdout, presumably
#  the terminal.  So the UI goes to the terminal, not the pipe.
#
# This depends on lastpipe, since readarray sets $args, and pipe elements
# otherwise run in a subshell, and subshells have their own environment, and
# we (the parent shell environment) would never see the new $args.

# ----------------------------------------------------------------------
# take action based on selection

# no selection, no action
# Either selecting nothing, or, CANCEL/ESC, will yield this.
if ! [[ ${#args[@]} -gt 0 ]]; then
	echo "$progname: No selection" >/dev/stderr
	exit $exit_nosel
fi

if [ -n "$verbose" ]; then
	# the bash echo builtin has no switches, so this is safe
	echo "${args[*]}" >/dev/stderr
fi

# Unless no-op is set...
if [ -z "$noop" ]; then
	# ... do whatever we were told to do.
	"${args[@]}"
fi

## END ###############################################################
