# # filter-select # # using filter-select, you can incrementaly filter candidate # and select one with ^N/^P keys. # # press enter for filter-select to update $reply and return 0, # press meta (alt) + enter to update $reply but return 1, # and press ^C or ^G not to update $reply and return 1. # # you can use ^@ to mark items. marked items are stored in $reply_marked. # # you can customize keybinds using bindkey command. # first, you call:: # # autoload -U filter-select; filter-select -i # # to initialize `filterselect` keymap and then do like:: # # bindkey -M filterselect '^E' accept-search # # # usage: # filter-select [-t title] [-A assoc-array-name] # [-d array-of-description] [-D assoc-array-of-descrption] # [-s initial-filter-contents] # [-n] [-r] [-m] [-e exit-zle-widget-name]... [--] [arg]... # filter-select -i # # -t title # title string displayed top of selection. # # -A assoc-array-name # name of associative array that contains candidates. # this option is designed to speed up history selection. # # -d array-of-description # name of array that contains each candidate's descriptions. # it is used to display and filter candidates. # # if not specified, copied from candidates. # # -s initial-filter-contents # initial contents of the filter buffer that users type into # # -n # assign a number to the description when -d is not specified # # -D assoc-array-of-descrption # same as ``-d`` but associative array. # # -r # reverse order. # # -m # enable mark feature # # -e exit-zle-widget-name # if keys bound to `exit-zle-widget-name` is pressed, # filter-select exits and set it's name to $reply[1]. # # args # selection candidates. # # -i # only initialize `filterselect` keymaps. # # # default key binds in filterselect: # enter: accept-line (update $reply and return) # meta + enter: accept-search (update $reply but return 1) # ^G: send-break (return 0) # ^H, backspace: backward-delete-char # ^F, right key: forward-char # ^B, left key: backward-char # ^A: beginning-of-line # ^E: end-of-line # ^W: backward-kill-word # ^K: kill-line # ^U: kill-whole-line # ^N, down key: down-line-or-history (select next item) # ^P, up key: up-line-or-history (select previous item) # ^V, page up key: forward-word (page down) # ^[V, page down key: backward-word (page up) # ^[<, home key: beginning-of-history (select first item) # ^[>, end key: end-of-history (select last item) # # available zstyles: # ':filter-select:highlight' selected # ':filter-select:highlight' matched # ':filter-select:highlight' title # ':filter-select:highlight' error # ':filter-select' max-lines # ':filter-select' rotate-list # ':filter-select' case-insensitive # ':filter-select' extended-search # # example: # zstyle ':filter-select:highlight' matched fg=yellow,standout # zstyle ':filter-select' max-lines 10 # use 10 lines for filter-select # zstyle ':filter-select' max-lines -10 # use $LINES - 10 for filter-select # zstyle ':filter-select' rotate-list yes # enable rotation for filter-select # zstyle ':filter-select' case-insensitive yes # enable case-insensitive search # zstyle ':filter-select' extended-search yes # enable extended search regardless of the case-insensitive style # # extended-search: # If this style set to be true value, the searching bahavior will be # extended as follows: # # ^ Match the beginning of the line if the word begins with ^ # $ Match the end of the line if the word ends with $ # ! Match anything except the word following it if the word begins with ! # so-called smartcase searching # # If you want to search these metacharacters, please doubly escape them. typeset -ga reply_marked function filter-select() { emulate -L zsh setopt local_options extended_glob # save ZLE related variables local orig_lbuffer="${LBUFFER}" local orig_rbuffer="${RBUFFER}" local orig_predisplay="${PREDISPLAY}" local orig_postdisplay="${POSTDISPLAY}" local -a orig_region_highlight words orig_region_highlight=("${region_highlight[@]}") local key cand lines selected cand_disp buffer_pre_zle last_buffer initbuffer='' local opt pattern msg unused title='' exit_pattern nl=$'\n' local selected_index mark_idx_disp hi start end spec local desc desc_num desc_disp bounds local -a displays matched_desc_keys match mbegin mend outs exit_wigdets local -a init_region_highlight marked_lines local -A candidates descriptions matched_descs integer i bottom_lines cursor_line=1 display_head_line=1 cand_num disp_num ii num_desc integer offset display_bottom_line selected_num rev=0 ret=0 enum=0 integer mark_idx markable=0 is_marked local hi_selected hi_matched hi_marked hi_title hi_error zstyle -s ':filter-select:highlight' selected hi_selected || hi_selected='standout' zstyle -s ':filter-select:highlight' matched hi_matched || hi_matched='fg=magenta,underline' zstyle -s ':filter-select:highlight' marked hi_marked || hi_marked='fg=blue,standout' zstyle -s ':filter-select:highlight' title hi_title || hi_title='bold' zstyle -s ':filter-select:highlight' error hi_error || hi_error='fg=white,bg=red' integer max_lines zstyle -s ':filter-select' max-lines max_lines || max_lines=0 local rotate_list zstyle -b ':filter-select' rotate-list rotate_list _filter-select-init-keybind candidates=() descriptions=() exit_wigdets=(accept-line accept-search send-break) while getopts 't:A:d:D:nrme:s:i' opt; do case "${opt}" in t) title="${OPTARG}" ;; A) # copy input assc array candidates=("${(@kvP)${OPTARG}}") ;; d) # copy input array integer i=0 for desc in "${(@P)${OPTARG}}"; do (( i++ )) descriptions+=( $i "${desc}" ) done ;; D) # copy input assc array descriptions=("${(@kvP)${OPTARG}}") ;; n) enum=1 ;; r) # reverse ordering rev=1 ;; m) # can use set-mark-command markable=1 ;; e) exit_wigdets+="${OPTARG}" ;; s) initbuffer="${OPTARG}" ;; i) # do nothing. only keybinds are initialized return esac done if (( OPTIND > 1 )); then shift $(( OPTIND - 1 )) fi integer i=0 for cand in "$@"; do (( i++ )) candidates+=( $i "${cand}" ) done if (( ${#descriptions} == 0 )); then # copy candidates descriptions=("${(@kv)candidates}") # add number if (( enum )); then num_desc="${#descriptions}" for i in {1.."$num_desc"}; do if (( rev )); then ii="$(($num_desc-$i+1))" else ii="$i" fi descriptions[$i]="${(r.5.)ii} ${descriptions[$i]}" done fi fi desc_num="${#descriptions}" matched_desc_keys=("${(onk@)descriptions}") if (( rev )); then matched_desc_keys=("${(Oa@)matched_desc_keys}") fi key='' bounds='' # clear edit buffer BUFFER="$initbuffer" # display original edit buffer's contants as PREDISPLAY PREDISPLAY="${orig_predisplay}${orig_lbuffer}${orig_rbuffer}${orig_postdisplay}${nl}" # re-calculate region_highlight init_region_highlight=() for hi in "${(@)orig_region_highlight}"; do if [[ "${hi}" == P* ]]; then init_region_highlight+="${hi}" else print -r -- "${hi}" | read start end spec init_region_highlight+="P$(( start + ${#orig_predisplay} )) $(( end + ${#orig_predisplay} )) $spec" fi done # prompt for filter-select PREDISPLAY+="filter: " # clear strings displayed below the command line zle -Rc _filter-select-reset exit_pattern="(${(j:|:)exit_wigdets})" while [[ "${bounds}" != ${~exit_pattern} ]]; do case "${bounds}" in set-mark-command) if (( markable )); then # check if ${selected_index} is already in the marked_lines if (( ${marked_lines[(ie)${selected_index}]} <= ${#marked_lines} )); then # remove selected_index marked_lines=("${(@)marked_lines:#${selected_index}}") else marked_lines+="${selected_index}" fi fi ;; *down-line-or-history) (( cursor_line++ )) ;; *up-line-or-history) (( cursor_line-- )) ;; *forward-word) (( cursor_line += bottom_lines )) ;; *backward-word) (( cursor_line -= bottom_lines )) ;; beginning-of-history) (( cursor_line = 1 )) (( display_head_line = 1 )) ;; end-of-history) (( cursor_line = desc_num )) ;; self-insert|undefined-key) LBUFFER="${LBUFFER}${key}" _filter-select-reset ;; '') # empty, initial state ;; *) buffer_pre_zle="${BUFFER}" zle "${bounds}" if [[ "${BUFFER}" != "${buffer_pre_zle}" ]]; then _filter-select-reset fi esac if (( cursor_line < 1 )); then (( display_head_line -= 1 - cursor_line )) if (( display_head_line < 1 )); then (( display_head_line = 1 )) fi if [[ $rotate_list == "yes" ]] && (( selected_num <= 1 )); then (( cursor_line = bottom_lines )) (( display_head_line = desc_num - bottom_lines + 1 )) else (( cursor_line = 1 )) fi elif (( bottom_lines == 0 )); then (( display_head_line = 1 )) (( cursor_line = 1 )) elif (( cursor_line > bottom_lines )); then (( display_head_line += cursor_line - bottom_lines )) if (( display_head_line > desc_num - bottom_lines + 1 )); then (( display_head_line = desc_num - bottom_lines + 1 )) fi if [[ $rotate_list == "yes" ]] && (( selected_num >= desc_num )); then (( cursor_line = 1 )) (( display_head_line = 1 )) else (( cursor_line = bottom_lines )) fi fi if (( ! PENDING )); then region_highlight=("${(@)init_region_highlight}") displays=() offset="${#BUFFER}" if [[ -n "${title}" ]]; then offset+=$(( 1 + ${#title} )) fi selected="" selected_num=0 if [[ "${BUFFER}" != "${last_buffer}" ]]; then if [[ -n "${BUFFER}" ]]; then if _filter-select-buffer-words words; then matched_descs=("${(kv@)descriptions}") for pattern in $words; do matched_descs=("${(kv@)matched_descs[(R)*${pattern}*]}") done matched_desc_keys=("${(onk@)matched_descs}") else matched_desc_keys=("${(onk@)descriptions}") fi else matched_desc_keys=("${(onk@)descriptions}") fi if (( rev )); then matched_desc_keys=("${(Oa@)matched_desc_keys}") fi last_buffer="${BUFFER}" fi # nums pattern matched desc_num="${#matched_desc_keys}" # nums displayed disp_num=0 _filter-select-update-bottom-lines display_bottom_line=$(( display_head_line + bottom_lines)) if (( desc_num )); then for i in "${(@)matched_desc_keys[${display_head_line},$(( display_bottom_line - 1 ))]}"; do (( disp_num++ )) desc="${descriptions[$i]}" desc_disp="${desc}" if zstyle -T ':filter-select' escape-descriptions ; then # escape \r\n\t\ desc_disp="${desc_disp//\\/\\\\}" desc_disp="${desc_disp//$'\n'/\\n}" desc_disp="${desc_disp//$'\r'/\\r}" desc_disp="${desc_disp//$'\t'/\\t}" fi mark_idx="${marked_lines[(ie)${i}]}" (( is_marked = mark_idx <= ${#marked_lines} )) if (( is_marked )); then mark_idx_disp=" (${mark_idx})" else mark_idx_disp="" fi if (( ${(m)#desc_disp} + ${#mark_idx_disp} > COLUMNS - 1 )); then # strip long line desc_disp="${(mr:$(( COLUMNS - ${#mark_idx_disp} - 6 )):::::)desc_disp} ...${mark_idx_disp}" else desc_disp="${desc_disp}${mark_idx_disp}" fi displays+="${desc_disp}" if [[ -n "${BUFFER}" ]]; then # highlight matched words for pattern in \ "(${(j.|.)${(@M)words:#*'(#e)'}})" \ "(${(j.|.)${(@)words:#(\~*|*'(#e)')}})" ; do if [[ "$pattern" != '()' ]]; then region_highlight+=( "${(f)${(S)desc_disp//*(#b)${~pattern}/$(( offset + mbegin[1] )) $(( offset + mend[1] + 1 )) ${hi_matched}${nl}}%$nl*}" ) fi done fi if (( is_marked )); then region_highlight+="${offset} $(( offset + ${#desc_disp} - ${#mark_idx_disp} + 1 )) ${hi_marked}" fi if (( disp_num == cursor_line )); then region_highlight+="${offset} $(( offset + ${#desc_disp} + 1 )) ${hi_selected}" selected="${candidates[$i]}" (( selected_num = display_head_line + disp_num - 1 )) selected_index="${i}" fi (( offset += ${#desc_disp} + 1 )) # +1 -> \n done fi POSTDISPLAY=$'\n' if [[ -n "${title}" ]]; then POSTDISPLAY+="${title}"$'\n' region_highlight+="${#BUFFER} $(( ${#BUFFER} + ${#title} + 1 )) ${hi_title}" fi if (( ${#displays} == 0 )); then if (( ${#candidates} == 0 )); then msg='no candidate' else msg='pattern not found' fi POSTDISPLAY+="${msg}" region_highlight+="${offset} $(( offset + ${#msg} + 1 )) ${hi_error}" fi POSTDISPLAY+="${(F)displays}"$'\n'"[${selected_num}/${desc_num}]" zle -R fi _filter-select-read-keys if [[ $? != 0 ]]; then # maybe ^C key='' bounds='' break else key="${reply}" # TODO: key sequence outs=("${(z)$( bindkey -M filterselect -- "${key}" )}") # XXX: will $outs contains more than two values? bounds="${outs[2]}" fi done if [[ -z "${key}" && -z "${bounds}" ]]; then # ^C reply=() reply_marked=() ret=1 elif [[ "${bounds}" == send-break ]]; then # ^G reply=() reply_marked=() ret=1 elif (( ${#displays} == 0 )); then # no candidate matches pattern (no candidate selected) reply=() reply_marked=() ret=1 else reply=("${bounds}" "${selected}") reply_marked=() if (( ${#marked_lines} > 0 )); then for i in "${(@)marked_lines}"; do reply_marked+="${candidates[${i}]}" done fi ret=0 fi LBUFFER="${orig_lbuffer}" RBUFFER="${orig_rbuffer}" PREDISPLAY="${orig_predisplay}" POSTDISPLAY="${orig_postdisplay}" region_highlight=("${orig_region_highlight[@]}") zle -Rc zle reset-prompt return $ret } function _filter-select-update-lines() { # XXX: this function override ${lines} # that define as local in filter-select # also use ${title} local _tmp_postdisplay="${POSTDISPLAY}" # to re-calculate ${BUFFERLINES} if [[ -n "${title}" ]]; then POSTDISPLAY="${title}"$'\n' else POSTDISPLAY="" fi zle -R # lines that can be used to display candidates # -1 for current/total number display area (( lines = LINES - BUFFERLINES - 1 )) POSTDISPLAY="${_tmp_postdisplay}" zle -R } function _filter-select-update-bottom-lines() { # cursor が移動できる一番下の行 # ${max_lines} か ${lines} か ${desc_num} の小さい方を使う if (( max_lines > 0 && max_lines < lines )); then (( bottom_lines = max_lines )) elif (( max_lines < 0 )); then (( bottom_lines = lines + max_lines )) else (( bottom_lines = lines )) fi if (( desc_num < bottom_lines )); then (( bottom_lines = desc_num )) fi if (( bottom_lines < 1 )); then (( bottom_lines = 1 )) fi } function _filter-select-reset() { display_head_line=1 cursor_line=1 _filter-select-update-lines _filter-select-update-bottom-lines } function _filter-select-buffer-words() { local place="$1" local -a a local MATCH MBEGIN MEND # split into words using shell's command line parsing, # unquote the words, remove duplicated, # escape "(", ")", "[", "]" and "#" to avoid crash # also escape "|" and "~" a=("${(@)${(@Qu)${(z)BUFFER}}//(#m)[()[\]#\|~]/\\${MATCH}}") if ! zstyle -t ':filter-select' extended-search ; then if zstyle -t ':filter-select' case-insensitive; then : ${(A)a::=(#i)${^a}} fi else # remove single "\\", "!", # "^" like the history-incremental-pattern-searches', # and "!^". : ${(A)a::=${a:#([\\!^]|'!^')}} # escape "^" other than the beginning's # unescape "\\^" one level : ${(A)a::=${a//(#m)('^'~(#s)'^')/\\${MATCH}}} : ${(A)a::=${a//(#m)'\\^'/${MATCH#\\}}} # "!aoe" -> "~*aoe", # ("a!oe" should be held on, the beginning's "!" only be considered) # unescape "\\!" one level : ${(A)a::=${a/(#m)(#s)\!?##/\~\*${MATCH#\!}}} : ${(A)a::=${a//(#m)'\!'/${MATCH#\\}}} # XXX: not '\\!' though... # "^abc" -> "(#s)abc", # ("a^bc" should be held on, the beginning's "^" only be considered) : ${(A)a::=${a/(#m)(#s)\^?##/(#s)${MATCH#\^}}} # "xyz$" -> "xyz(#e)", # ("x$yz" shoud be held on, the ending's "$" only be considered) # unescape "\\$" one level : ${(A)a::=${a/(#m)*[^\\]\$(#e)/${MATCH%\$}(#e)}} : ${(A)a::=${a//(#m)'\$'/${MATCH#\\}}} # XXX: not '\\$' though... # smartcase searching ("(#i)(#I)Search" searches case sensitively) : ${(A)a::=${a/(#m)*[[:upper:]##]*/(#I)${MATCH}}} : ${(A)a::=(#i)${^a}} # make "~" to be at the beginning #: ${(A)a::=${a/#(#b)('(#i)'('(#I)')#)'~'/\~${match[1]}}} : ${(A)a::=${a/#'(#i)(#I)~'/\~(#i)(#I)}} : ${(A)a::=${a/#'(#i)~'/\~(#i)}} # fixup the '!^'; "~(#i)*\^" -> "~(#i)(#s)" # (for example, "!^aoe" -> "~(#i)*\^aoe" -> "~(#i)(#s)aoe") #: ${(A)a::=${a/#(#b)'~'('(#i)'('(#I)')#)'*\^'/\~${match[1]}(#s)}} : ${(A)a::=${a/#'~(#i)(#I)*\^'/'~(#i)(#I)(#s)'}} : ${(A)a::=${a/#'~(#i)*\^'/'~(#i)(#s)'}} fi : ${(PA)place::=$a} (( ${#a} > 1 )) || (( ${#a} == 1 )) && [[ -n "$a" ]] } function _filter-select-init-keybind() { integer fd ret # be quiet and check filterselect keybind defined exec {fd}>&2 2>/dev/null bindkey -l filterselect > /dev/null ret=$? exec 2>&${fd} {fd}>&- if (( ret != 0 )); then bindkey -N filterselect bindkey -M filterselect '^J' accept-line bindkey -M filterselect '^M' accept-line bindkey -M filterselect '\e^J' accept-search bindkey -M filterselect '\e^M' accept-search bindkey -M filterselect '\e^G' send-break bindkey -M filterselect '^G' send-break bindkey -M filterselect '^@' set-mark-command bindkey -M filterselect '^H' backward-delete-char bindkey -M filterselect '^?' backward-delete-char bindkey -M filterselect '^F' forward-char bindkey -M filterselect '\e[C' forward-char bindkey -M filterselect '\eOC' forward-char bindkey -M filterselect '^B' backward-char bindkey -M filterselect '\e[D' backward-char bindkey -M filterselect '\eOD' backward-char bindkey -M filterselect '^A' beginning-of-line bindkey -M filterselect '^E' end-of-line bindkey -M filterselect '^W' backward-kill-word bindkey -M filterselect '^K' kill-line bindkey -M filterselect '^U' kill-whole-line # move cursor down/up bindkey -M filterselect '^N' down-line-or-history bindkey -M filterselect '\e[B' down-line-or-history bindkey -M filterselect '\eOB' down-line-or-history bindkey -M filterselect '^P' up-line-or-history bindkey -M filterselect '\e[A' up-line-or-history bindkey -M filterselect '\eOA' up-line-or-history # page down/up bindkey -M filterselect '^V' forward-word bindkey -M filterselect '\e[6~' forward-word bindkey -M filterselect '\eV' backward-word bindkey -M filterselect '\ev' backward-word bindkey -M filterselect '\e[5~' backward-word # home/end bindkey -M filterselect '\e<' beginning-of-history bindkey -M filterselect '\e[1~' beginning-of-history bindkey -M filterselect '\e>' end-of-history bindkey -M filterselect '\e[4~' end-of-history fi } function _filter-select-read-keys() { local key key2 key3 nkey integer ret read -k key ret=$? reply="${key}" if [[ '#key' -eq '#\\e' ]]; then # M-... read -t $(( KEYTIMEOUT / 1000 )) -k key2 if [[ "${key2}" == 'O' ]]; then # ^[O (SS3) affects next character only. # Example: cursor keys on some terminals. read -k key3 ret=$? reply="${key}${key2}${key3}" else if [[ "${key2}" == '[' ]]; then # ^[[ (CSI) starts a sequence of [0-9;?] terminated by [@-~]. # Examples: Home, End, PgUp, PgDn ... reply="${key}${key2}" while true; do read -k nkey reply+=$nkey ret=$? (( $ret == 0 )) && [[ "${nkey}" =~ '^[0-9;?]$' ]] || break done else reply="${key}${key2}" fi fi else reply="${key}" fi return $ret } filter-select "$@"