From 1448d631a7c72905f62dbb343a8f231a1c3cc52c Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sun, 8 Jan 2017 01:30:31 +0900 Subject: [PATCH] Add --height option --- .travis.yml | 2 +- CHANGELOG.md | 6 + README.md | 36 +- bin/fzf-tmux | 3 + man/man1/fzf.1 | 9 +- plugin/fzf.vim | 26 +- shell/completion.bash | 57 +-- shell/completion.zsh | 14 +- shell/key-bindings.bash | 33 +- shell/key-bindings.fish | 13 +- shell/key-bindings.zsh | 10 +- src/options.go | 18 + src/result.go | 2 +- src/result_test.go | 15 +- src/terminal.go | 225 +++++++----- src/tui/light.go | 764 +++++++++++++++++++++++++++++++++++++++ src/tui/ncurses.go | 248 ++++++------- src/tui/tcell.go | 275 +++++++------- src/tui/tui.go | 170 +++++++-- src/tui/tui_test.go | 14 - src/util/util.go | 16 + src/util/util_unix.go | 6 + src/util/util_windows.go | 6 + test/test_go.rb | 264 +++++++------- 24 files changed, 1624 insertions(+), 608 deletions(-) create mode 100644 src/tui/light.go delete mode 100644 src/tui/tui_test.go diff --git a/.travis.yml b/.travis.yml index a1a6497..3f2a67f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ language: ruby matrix: include: - env: TAGS= - rvm: 2.2.0 + rvm: 2.3.3 # - env: TAGS=tcell # rvm: 2.2.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index a37b74c..f78270c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +0.16.0 +------ +- Added `--height HEIGHT[%]` option +- Preview window will truncate long lines by default. Line wrap can be enabled + by `:wrap` flag in `--preview-window`. + 0.15.9 ------ - Fixed rendering glitches introduced in 0.15.8 diff --git a/README.md b/README.md index 6381279..550ea77 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,29 @@ vim $(fzf) - Mouse: scroll, click, double-click; shift-click and shift-scroll on multi-select mode +#### Layout + +fzf by default starts in fullscreen mode, but you can make it start below the +cursor with `--height` option. + +```sh +vim $(fzf --height 40%) +``` + +Also check out `--reverse` option if you prefer "top-down" layout instead of +the default "bottom-up" layout. + +```sh +vim $(fzf --height 40% --reverse) +``` + +You can add these options to `$FZF_DEFAULT_OPTS` so that they're applied by +default. + +```sh +export FZF_DEFAULT_OPTS='--height 40% --reverse' +``` + #### Search syntax Unless otherwise specified, fzf starts in "extended-search mode" where you can @@ -189,6 +212,13 @@ cat /usr/share/dict/words | fzf-tmux -l 20% --multi --reverse It will still work even when you're not on tmux, silently ignoring `-[udlr]` options, so you can invariably use `fzf-tmux` in your scripts. +Alternatively, you can use `--height HEIGHT[%]` option not to start fzf in +fullscreen mode. + +```sh +fzf --height 40% +``` + Key bindings for command line ----------------------------- @@ -206,9 +236,9 @@ fish. - Set `FZF_ALT_C_COMMAND` to override the default command - Set `FZF_ALT_C_OPTS` to pass additional options -If you're on a tmux session, fzf will start in a split pane. You may disable -this tmux integration by setting `FZF_TMUX` to 0, or change the height of the -pane with `FZF_TMUX_HEIGHT` (e.g. `20`, `50%`). +If you're on a tmux session, you can start fzf in a split pane by setting +`FZF_TMUX` to 1, and change the height of the pane with `FZF_TMUX_HEIGHT` +(e.g. `20`, `50%`). If you use vi mode on bash, you need to add `set -o vi` *before* `source ~/.fzf.bash` in your .bashrc, so that it correctly sets up key bindings for vi diff --git a/bin/fzf-tmux b/bin/fzf-tmux index dd335d2..f201199 100755 --- a/bin/fzf-tmux +++ b/bin/fzf-tmux @@ -114,6 +114,9 @@ if [[ -z "$TMUX" || "$opt" =~ ^-h && "$columns" -le 40 || ! "$opt" =~ ^-h && "$l exit $? fi +# --height option is not allowed +args+=("--no-height") + # Handle zoomed tmux pane by moving it to a temp window if tmux list-panes -F '#F' | grep -q Z; then zoomed=1 diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 4e19ce9..fac2aab 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -130,6 +130,10 @@ on the center of the screen. Label characters for \fBjump\fR and \fBjump-accept\fR .SS Layout .TP +.BI "--height=" "HEIGHT[%]" +Display fzf window below the cursor with the given height instead of using +fullscreen. +.TP .B "--reverse" Reverse orientation .TP @@ -248,10 +252,11 @@ e.g. \fBfzf --preview="head -$LINES {}"\fR Note that you can escape a placeholder pattern by prepending a backslash. .RE .TP -.BI "--preview-window=" "[POSITION][:SIZE[%]][:hidden]" +.BI "--preview-window=" "[POSITION][:SIZE[%]][:wrap][:hidden]" Determine the layout of the preview window. If the argument ends with \fB:hidden\fR, the preview window will be hidden by default until -\fBtoggle-preview\fR action is triggered. +\fBtoggle-preview\fR action is triggered. Long lines are truncated by default. +Line wrap can be enabled with \fB:wrap\fR flag. .RS .B POSITION: (default: right) diff --git a/plugin/fzf.vim b/plugin/fzf.vim index 06af648..c8c6c91 100644 --- a/plugin/fzf.vim +++ b/plugin/fzf.vim @@ -296,14 +296,24 @@ try else let prefix = '' endif - let tmux = (!has('nvim') || get(g:, 'fzf_prefer_tmux', 0)) && s:tmux_enabled() && s:splittable(dict) + + let use_height = has_key(dict, 'down') && + \ !(has('nvim') || has('win32') || has('win64') || s:present(dict, 'up', 'left', 'right')) + let tmux = !use_height && (!has('nvim') || get(g:, 'fzf_prefer_tmux', 0)) && s:tmux_enabled() && s:splittable(dict) + let term = has('nvim') && !tmux + if use_height + let optstr .= ' --height='.s:calc_size(&lines, dict.down, dict) + elseif term + let optstr .= ' --no-height' + endif let command = prefix.(tmux ? s:fzf_tmux(dict) : fzf_exec).' '.optstr.' > '.temps.result - if has('nvim') && !tmux + if term return s:execute_term(dict, command, temps) endif - let lines = tmux ? s:execute_tmux(dict, command, temps) : s:execute(dict, command, temps) + let lines = tmux ? s:execute_tmux(dict, command, temps) + \ : s:execute(dict, command, use_height, temps) call s:callback(dict, lines) return lines finally @@ -400,9 +410,9 @@ function! s:exit_handler(code, command, ...) return 1 endfunction -function! s:execute(dict, command, temps) abort +function! s:execute(dict, command, use_height, temps) abort call s:pushd(a:dict) - if has('unix') + if has('unix') && !a:use_height silent! !clear 2> /dev/null endif let escaped = escape(substitute(a:command, '\n', '\\n', 'g'), '%#') @@ -416,7 +426,11 @@ function! s:execute(dict, command, temps) abort else let command = escaped endif - execute 'silent !'.command + if a:use_height + call system(printf('tput cup %d > /dev/tty; tput cnorm > /dev/tty; %s 2> /dev/tty', &lines, command)) + else + execute 'silent !'.command + endif let exit_status = v:shell_error redraw! return s:exit_handler(exit_status, command) ? s:collect(a:temps) : [] diff --git a/shell/completion.bash b/shell/completion.bash index 392aee2..d6d7238 100644 --- a/shell/completion.bash +++ b/shell/completion.bash @@ -5,7 +5,7 @@ # / __/ / /_/ __/ # /_/ /___/_/-completion.bash # -# - $FZF_TMUX (default: 1) +# - $FZF_TMUX (default: 0) # - $FZF_TMUX_HEIGHT (default: '40%') # - $FZF_COMPLETION_TRIGGER (default: '**') # - $FZF_COMPLETION_OPTS (default: empty) @@ -30,6 +30,15 @@ fi ########################################################### +# To redraw line after fzf closes (printf '\e[5n') +bind '"\e[0n": redraw-current-line' + +__fzfcmd_complete() { + [ -n "$TMUX_PANE" ] && [ "${FZF_TMUX:-0}" != 0 ] && [ ${LINES:-40} -gt 15 ] && + echo "fzf-tmux -d${FZF_TMUX_HEIGHT:-40%}" || + echo "fzf --height ${FZF_TMUX_HEIGHT:-40%} --reverse" +} + _fzf_orig_completion_filter() { sed 's/^\(.*-F\) *\([^ ]*\).* \([^ ]*\)$/export _fzf_orig_completion_\3="\1 %s \3 #\2";/' | awk -F= '{gsub(/[^A-Za-z0-9_= ;]/, "_", $1); print $1"="$2}' @@ -43,35 +52,42 @@ _fzf_opts_completion() { opts=" -x --extended -e --exact + --algo -i +i -n --nth + --with-nth -d --delimiter +s --no-sort --tac --tiebreak - --bind -m --multi --no-mouse - --color - --black - --reverse + --bind + --cycle --no-hscroll + --jump-labels + --height + --reverse + --margin --inline-info --prompt + --header + --header-lines + --ansi + --tabstop + --color + --no-bold + --history + --history-size + --preview + --preview-window -q --query -1 --select-1 -0 --exit-0 -f --filter --print-query --expect - --toggle-sort - --sync - --cycle - --history - --history-size - --header - --header-lines - --margin" + --sync" case "${prev}" in --tiebreak) @@ -116,7 +132,7 @@ _fzf_handle_dynamic_completion() { __fzf_generic_path_completion() { local cur base dir leftover matches trigger cmd fzf - [ "${FZF_TMUX:-1}" != 0 ] && fzf="fzf-tmux -d ${FZF_TMUX_HEIGHT:-40%}" || fzf="fzf" + fzf="$(__fzfcmd_complete)" cmd="${COMP_WORDS[0]//[^A-Za-z0-9_=]/_}" COMPREPLY=() trigger=${FZF_COMPLETION_TRIGGER-'**'} @@ -132,7 +148,6 @@ __fzf_generic_path_completion() { leftover=${leftover/#\/} [ -z "$dir" ] && dir='.' [ "$dir" != "/" ] && dir="${dir/%\//}" - tput sc matches=$(eval "$1 $(printf %q "$dir")" | $fzf $FZF_COMPLETION_OPTS $2 -q "$leftover" | while read -r item; do printf "%q$3 " "$item" done) @@ -142,7 +157,7 @@ __fzf_generic_path_completion() { else COMPREPLY=( "$cur" ) fi - tput rc + printf '\e[5n' return 0 fi dir=$(dirname "$dir") @@ -160,7 +175,7 @@ _fzf_complete() { local cur selected trigger cmd fzf post post="$(caller 0 | awk '{print $2}')_post" type -t "$post" > /dev/null 2>&1 || post=cat - [ "${FZF_TMUX:-1}" != 0 ] && fzf="fzf-tmux -d ${FZF_TMUX_HEIGHT:-40%}" || fzf="fzf" + fzf="$(__fzfcmd_complete)" cmd="${COMP_WORDS[0]//[^A-Za-z0-9_=]/_}" trigger=${FZF_COMPLETION_TRIGGER-'**'} @@ -168,10 +183,9 @@ _fzf_complete() { if [[ "$cur" == *"$trigger" ]]; then cur=${cur:0:${#cur}-${#trigger}} - tput sc selected=$(cat | $fzf $FZF_COMPLETION_OPTS $1 -q "$cur" | $post | tr '\n' ' ') selected=${selected% } # Strip trailing space not to repeat "-o nospace" - tput rc + printf '\e[5n' if [ -n "$selected" ]; then COMPREPLY=("$selected") @@ -200,10 +214,9 @@ _fzf_complete_kill() { [ -n "${COMP_WORDS[COMP_CWORD]}" ] && return 1 local selected fzf - [ "${FZF_TMUX:-1}" != 0 ] && fzf="fzf-tmux -d ${FZF_TMUX_HEIGHT:-40%}" || fzf="fzf" - tput sc + fzf="$(__fzfcmd_complete)" selected=$(ps -ef | sed 1d | $fzf -m $FZF_COMPLETION_OPTS | awk '{print $2}' | tr '\n' ' ') - tput rc + printf '\e[5n' if [ -n "$selected" ]; then COMPREPLY=( "$selected" ) diff --git a/shell/completion.zsh b/shell/completion.zsh index d3faef8..fb2c16a 100644 --- a/shell/completion.zsh +++ b/shell/completion.zsh @@ -5,7 +5,7 @@ # / __/ / /_/ __/ # /_/ /___/_/-completion.zsh # -# - $FZF_TMUX (default: 1) +# - $FZF_TMUX (default: 0) # - $FZF_TMUX_HEIGHT (default: '40%') # - $FZF_COMPLETION_TRIGGER (default: '**') # - $FZF_COMPLETION_OPTS (default: empty) @@ -30,6 +30,12 @@ fi ########################################################### +__fzfcmd_complete() { + [ -n "$TMUX_PANE" ] && [ "${FZF_TMUX:-0}" != 0 ] && [ ${LINES:-40} -gt 15 ] && + echo "fzf-tmux -d${FZF_TMUX_HEIGHT:-40%}" || + echo "fzf --height ${FZF_TMUX_HEIGHT:-40%} --reverse" +} + __fzf_generic_path_completion() { local base lbuf compgen fzf_opts suffix tail fzf dir leftover matches # (Q) flag removes a quoting level: "foo\ bar" => "foo bar" @@ -39,7 +45,7 @@ __fzf_generic_path_completion() { fzf_opts=$4 suffix=$5 tail=$6 - [ ${FZF_TMUX:-1} -eq 1 ] && fzf="fzf-tmux -d ${FZF_TMUX_HEIGHT:-40%}" || fzf="fzf" + fzf="$(__fzfcmd_complete)" setopt localoptions nonomatch dir="$base" @@ -90,7 +96,7 @@ _fzf_complete() { post="${funcstack[2]}_post" type $post > /dev/null 2>&1 || post=cat - [ ${FZF_TMUX:-1} -eq 1 ] && fzf="fzf-tmux -d ${FZF_TMUX_HEIGHT:-40%}" || fzf="fzf" + fzf="$(__fzfcmd_complete)" _fzf_feed_fifo "$fifo" matches=$(cat "$fifo" | ${=fzf} ${=FZF_COMPLETION_OPTS} ${=fzf_opts} -q "${(Q)prefix}" | $post | tr '\n' ' ') @@ -157,7 +163,7 @@ fzf-completion() { tail=${LBUFFER:$(( ${#LBUFFER} - ${#trigger} ))} # Kill completion (do not require trigger sequence) if [ $cmd = kill -a ${LBUFFER[-1]} = ' ' ]; then - [ ${FZF_TMUX:-1} -eq 1 ] && fzf="fzf-tmux -d ${FZF_TMUX_HEIGHT:-40%}" || fzf="fzf" + fzf="$(__fzfcmd_complete)" matches=$(ps -ef | sed 1d | ${=fzf} ${=FZF_COMPLETION_OPTS} -m | awk '{print $2}' | tr '\n' ' ') if [ -n "$matches" ]; then LBUFFER="$LBUFFER$matches" diff --git a/shell/key-bindings.bash b/shell/key-bindings.bash index 33d8bc8..382302d 100644 --- a/shell/key-bindings.bash +++ b/shell/key-bindings.bash @@ -5,7 +5,7 @@ __fzf_select__() { -o -type f -print \ -o -type d -print \ -o -type l -print 2> /dev/null | cut -b3-"}" - eval "$cmd | fzf -m $FZF_CTRL_T_OPTS" | while read -r item; do + eval "$cmd | fzf --height ${FZF_TMUX_HEIGHT:-40%} --reverse -m $@ $FZF_CTRL_T_OPTS" | while read -r item; do printf '%q ' "$item" done echo @@ -13,8 +13,14 @@ __fzf_select__() { if [[ $- =~ i ]]; then +__fzf_use_tmux__() { + [ -n "$TMUX_PANE" ] && [ "${FZF_TMUX:-0}" != 0 ] && [ ${LINES:-40} -gt 15 ] +} + __fzfcmd() { - [ "${FZF_TMUX:-1}" != 0 ] && echo "fzf-tmux -d${FZF_TMUX_HEIGHT:-40%}" || echo "fzf" + __fzf_use_tmux__ && + echo "fzf-tmux -d${FZF_TMUX_HEIGHT:-40%}" || + echo "fzf --height ${FZF_TMUX_HEIGHT:-40%} --reverse" } __fzf_select_tmux__() { @@ -26,14 +32,14 @@ __fzf_select_tmux__() { height="-l $height" fi - tmux split-window $height "cd $(printf %q "$PWD"); FZF_DEFAULT_OPTS=$(printf %q "$FZF_DEFAULT_OPTS") PATH=$(printf %q "$PATH") FZF_CTRL_T_COMMAND=$(printf %q "$FZF_CTRL_T_COMMAND") FZF_CTRL_T_OPTS=$(printf %q "$FZF_CTRL_T_OPTS") bash -c 'source \"${BASH_SOURCE[0]}\"; RESULT=\"\$(__fzf_select__)\"; tmux setb -b fzf \"\$RESULT\" \\; pasteb -b fzf -t $TMUX_PANE \\; deleteb -b fzf || tmux send-keys -t $TMUX_PANE \"\$RESULT\"'" + tmux split-window $height "cd $(printf %q "$PWD"); FZF_DEFAULT_OPTS=$(printf %q "$FZF_DEFAULT_OPTS") PATH=$(printf %q "$PATH") FZF_CTRL_T_COMMAND=$(printf %q "$FZF_CTRL_T_COMMAND") FZF_CTRL_T_OPTS=$(printf %q "$FZF_CTRL_T_OPTS") bash -c 'source \"${BASH_SOURCE[0]}\"; RESULT=\"\$(__fzf_select__ --no-height)\"; tmux setb -b fzf \"\$RESULT\" \\; pasteb -b fzf -t $TMUX_PANE \\; deleteb -b fzf || tmux send-keys -t $TMUX_PANE \"\$RESULT\"'" } fzf-file-widget() { if __fzf_use_tmux__; then __fzf_select_tmux__ else - local selected="$(__fzf_select__)" + local selected="$(__fzf_select__ --height ${FZF_TMUX_HEIGHT:-40%} --reverse)" READLINE_LINE="${READLINE_LINE:0:$READLINE_POINT}$selected${READLINE_LINE:$READLINE_POINT}" READLINE_POINT=$(( READLINE_POINT + ${#selected} )) fi @@ -51,7 +57,7 @@ __fzf_history__() ( shopt -u nocaseglob nocasematch line=$( HISTTIMEFORMAT= history | - eval "$(__fzfcmd) +s --tac +m -n2..,.. --tiebreak=index --toggle-sort=ctrl-r $FZF_CTRL_R_OPTS" | + eval "$(__fzfcmd) +s --tac --no-reverse +m -n2..,.. --tiebreak=index --toggle-sort=ctrl-r $FZF_CTRL_R_OPTS" | command grep '^ *[0-9]') && if [[ $- =~ H ]]; then sed 's/^ *\([0-9]*\)\** .*/!\1/' <<< "$line" @@ -60,22 +66,15 @@ __fzf_history__() ( fi ) -__fzf_use_tmux__() { - [ -n "$TMUX_PANE" ] && [ "${FZF_TMUX:-1}" != 0 ] && [ ${LINES:-40} -gt 15 ] -} - -[ $BASH_VERSINFO -gt 3 ] && __use_bind_x=1 || __use_bind_x=0 -__fzf_use_tmux__ && __use_tmux=1 || __use_tmux=0 - if [[ ! -o vi ]]; then # Required to refresh the prompt after fzf bind '"\er": redraw-current-line' bind '"\e^": history-expand-line' # CTRL-T - Paste the selected file path into the command line - if [ $__use_bind_x -eq 1 ]; then + if [ $BASH_VERSINFO -gt 3 ]; then bind -x '"\C-t": "fzf-file-widget"' - elif [ $__use_tmux -eq 1 ]; then + elif __fzf_use_tmux__; then bind '"\C-t": " \C-u \C-a\C-k`__fzf_select_tmux__`\e\C-e\C-y\C-a\C-d\C-y\ey\C-h"' else bind '"\C-t": " \C-u \C-a\C-k`__fzf_select__`\e\C-e\C-y\C-a\C-y\ey\C-h\C-e\er \C-h"' @@ -102,9 +101,9 @@ else # CTRL-T - Paste the selected file path into the command line # - FIXME: Selected items are attached to the end regardless of cursor position - if [ $__use_bind_x -eq 1 ]; then + if [ $BASH_VERSINFO -gt 3 ]; then bind -x '"\C-t": "fzf-file-widget"' - elif [ $__use_tmux -eq 1 ]; then + elif __fzf_use_tmux__; then bind '"\C-t": "\C-x\C-a$a \C-x\C-addi`__fzf_select_tmux__`\C-x\C-e\C-x\C-a0P$xa"' else bind '"\C-t": "\C-x\C-a$a \C-x\C-addi`__fzf_select__`\C-x\C-e\C-x\C-a0Px$a \C-x\C-r\C-x\C-axa "' @@ -120,6 +119,4 @@ else bind -m vi-command '"\ec": "ddi`__fzf_cd__`\C-x\C-e\C-x\C-r\C-m"' fi -unset -v __use_tmux __use_bind_x - fi diff --git a/shell/key-bindings.fish b/shell/key-bindings.fish index dd75fec..fc61844 100644 --- a/shell/key-bindings.fish +++ b/shell/key-bindings.fish @@ -39,7 +39,7 @@ function fzf_key_bindings end function fzf-history-widget -d "Show command history" - history | eval (__fzfcmd) +s +m --tiebreak=index $FZF_CTRL_R_OPTS -q '(commandline)' | read -l result + history | eval (__fzfcmd) +s +m --no-reverse --tiebreak=index $FZF_CTRL_R_OPTS -q '(commandline)' | read -l result and commandline -- $result commandline -f repaint end @@ -54,15 +54,12 @@ function fzf_key_bindings end function __fzfcmd - set -q FZF_TMUX; or set FZF_TMUX 1 + set -q FZF_TMUX; or set FZF_TMUX 0 + set -q FZF_TMUX_HEIGHT; or set FZF_TMUX_HEIGHT 40% if [ $FZF_TMUX -eq 1 ] - if set -q FZF_TMUX_HEIGHT - echo "fzf-tmux -d$FZF_TMUX_HEIGHT" - else - echo "fzf-tmux -d40%" - end + echo "fzf-tmux -d$FZF_TMUX_HEIGHT" else - echo "fzf" + echo "fzf --height $FZF_TMUX_HEIGHT --reverse" end end diff --git a/shell/key-bindings.zsh b/shell/key-bindings.zsh index fed0153..7e24d92 100644 --- a/shell/key-bindings.zsh +++ b/shell/key-bindings.zsh @@ -17,8 +17,14 @@ __fsel() { return $ret } +__fzf_use_tmux__() { + [ -n "$TMUX_PANE" ] && [ "${FZF_TMUX:-0}" != 0 ] && [ ${LINES:-40} -gt 15 ] +} + __fzfcmd() { - [ ${FZF_TMUX:-1} -eq 1 ] && echo "fzf-tmux -d${FZF_TMUX_HEIGHT:-40%}" || echo "fzf" + __fzf_use_tmux__ && + echo "fzf-tmux -d${FZF_TMUX_HEIGHT:-40%}" || + echo "fzf --height ${FZF_TMUX_HEIGHT:-40%} --reverse" } fzf-file-widget() { @@ -49,7 +55,7 @@ bindkey '\ec' fzf-cd-widget fzf-history-widget() { local selected num setopt localoptions noglobsubst pipefail 2> /dev/null - selected=( $(fc -l 1 | eval "$(__fzfcmd) +s --tac +m -n2..,.. --tiebreak=index --toggle-sort=ctrl-r $FZF_CTRL_R_OPTS -q ${(q)LBUFFER}") ) + selected=( $(fc -l 1 | eval "$(__fzfcmd) +s --tac --no-reverse +m -n2..,.. --tiebreak=index --toggle-sort=ctrl-r $FZF_CTRL_R_OPTS -q ${(q)LBUFFER}") ) local ret=$? if [ -n "$selected" ]; then num=$selected[1] diff --git a/src/options.go b/src/options.go index 6fd3f6c..0c6661f 100644 --- a/src/options.go +++ b/src/options.go @@ -10,6 +10,7 @@ import ( "github.com/junegunn/fzf/src/algo" "github.com/junegunn/fzf/src/tui" + "github.com/junegunn/fzf/src/util" "github.com/junegunn/go-shellwords" ) @@ -46,6 +47,8 @@ const usage = `usage: fzf [options] --jump-labels=CHARS Label characters for jump and jump-accept Layout + --height=HEIGHT[%] Display fzf window below the cursor with the given + height instead of using fullscreen --reverse Reverse orientation --margin=MARGIN Screen margin (TRBL / TB,RL / T,RL,B / T,R,B,L) --inline-info Display finder info inline with the query @@ -147,6 +150,7 @@ type Options struct { Theme *tui.ColorTheme Black bool Bold bool + Height sizeSpec Reverse bool Cycle bool Hscroll bool @@ -760,6 +764,14 @@ func parseSize(str string, maxPercent float64, label string) sizeSpec { return sizeSpec{val, percent} } +func parseHeight(str string) sizeSpec { + if util.IsWindows() { + errorExit("--height options is currently not supported on Windows") + } + size := parseSize(str, 100, "height") + return size +} + func parsePreviewWindow(opts *previewOpts, input string) { // Default opts.position = posRight @@ -1003,6 +1015,10 @@ func parseOptions(opts *Options, allArgs []string) { case "--preview-window": parsePreviewWindow(&opts.Preview, nextString(allArgs, &i, "preview window layout required: [up|down|left|right][:SIZE[%]][:wrap][:hidden]")) + case "--height": + opts.Height = parseHeight(nextString(allArgs, &i, "height required: [HEIGHT[%]]")) + case "--no-height": + opts.Height = sizeSpec{} case "--no-margin": opts.Margin = defaultMargin() case "--margin": @@ -1029,6 +1045,8 @@ func parseOptions(opts *Options, allArgs []string) { opts.WithNth = splitNth(value) } else if match, _ := optString(arg, "-s", "--sort="); match { opts.Sort = 1 // Don't care + } else if match, value := optString(arg, "--height="); match { + opts.Height = parseHeight(value) } else if match, value := optString(arg, "--toggle-sort="); match { parseToggleSort(opts.Keymap, value) } else if match, value := optString(arg, "--expect="); match { diff --git a/src/result.go b/src/result.go index e2d7c75..3d79176 100644 --- a/src/result.go +++ b/src/result.go @@ -166,7 +166,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, theme *tui.ColorTheme, } colors = append(colors, colorOffset{ offset: [2]int32{int32(start), int32(idx)}, - color: tui.PairFor(fg, bg), + color: tui.NewColorPair(fg, bg), attr: ansi.color.attr.Merge(attr)}) } } diff --git a/src/result_test.go b/src/result_test.go index 15b1bdb..0e91fc8 100644 --- a/src/result_test.go +++ b/src/result_test.go @@ -105,7 +105,8 @@ func TestColorOffset(t *testing.T) { ansiOffset{[2]int32{33, 40}, ansiState{4, 8, tui.Bold}}}}} // [{[0 5] 9 false} {[5 15] 99 false} {[15 20] 9 false} {[22 25] 10 true} {[25 35] 99 false} {[35 40] 11 true}] - colors := item.colorOffsets(offsets, tui.Dark256, 99, 0, true) + pair := tui.NewColorPair(99, 199) + colors := item.colorOffsets(offsets, tui.Dark256, pair, tui.AttrRegular, true) assert := func(idx int, b int32, e int32, c tui.ColorPair, bold bool) { var attr tui.Attr if bold { @@ -116,10 +117,10 @@ func TestColorOffset(t *testing.T) { t.Error(o) } } - assert(0, 0, 5, tui.ColUser, false) - assert(1, 5, 15, 99, false) - assert(2, 15, 20, tui.ColUser, false) - assert(3, 22, 25, tui.ColUser+1, true) - assert(4, 25, 35, 99, false) - assert(5, 35, 40, tui.ColUser+2, true) + assert(0, 0, 5, tui.NewColorPair(1, 5), false) + assert(1, 5, 15, pair, false) + assert(2, 15, 20, tui.NewColorPair(1, 5), false) + assert(3, 22, 25, tui.NewColorPair(2, 6), true) + assert(4, 25, 35, pair, false) + assert(5, 35, 40, tui.NewColorPair(4, 8), true) } diff --git a/src/terminal.go b/src/terminal.go index 5b482b0..8002923 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -15,8 +15,6 @@ import ( "github.com/junegunn/fzf/src/tui" "github.com/junegunn/fzf/src/util" - - "github.com/junegunn/go-runewidth" ) // import "github.com/pkg/profile" @@ -42,6 +40,14 @@ type previewer struct { enabled bool } +type itemLine struct { + current bool + label string + result Result +} + +var emptyLine = itemLine{} + // Terminal represents terminal input/output type Terminal struct { initDelay time.Duration @@ -69,11 +75,12 @@ type Terminal struct { header []string header0 []string ansi bool + tabstop int margin [4]sizeSpec strong tui.Attr - window *tui.Window - bwindow *tui.Window - pwindow *tui.Window + window tui.Window + bwindow tui.Window + pwindow tui.Window count int progress int reading bool @@ -89,10 +96,12 @@ type Terminal struct { eventBox *util.EventBox mutex sync.Mutex initFunc func() + prevLines []itemLine suppress bool startChan chan bool slab *util.Slab theme *tui.ColorTheme + tui tui.Renderer } type selectedItem struct { @@ -115,7 +124,6 @@ func (a byTimeOrder) Less(i, j int) bool { } var _spinner = []string{`-`, `\`, `|`, `/`, `-`, `\`, `|`, `/`} -var _runeWidths = make(map[rune]int) var _tabStop int const ( @@ -247,7 +255,6 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { } else { header = reverseStringArray(opts.Header) } - _tabStop = opts.Tabstop var delay time.Duration if opts.Tac { delay = initialDelayTac @@ -262,6 +269,24 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { if !opts.Bold { strongAttr = tui.AttrRegular } + var renderer tui.Renderer + if opts.Height.size > 0 { + maxHeightFunc := func(termHeight int) int { + var maxHeight int + if opts.Height.percent { + maxHeight = int(opts.Height.size * float64(termHeight) / 100.0) + } else { + maxHeight = util.Min(int(opts.Height.size), termHeight) + } + if opts.InlineInfo { + return util.Max(maxHeight, 3) + } + return util.Max(maxHeight, 4) + } + renderer = tui.NewLightRenderer(opts.Theme, opts.Black, opts.Mouse, opts.Tabstop, maxHeightFunc) + } else { + renderer = tui.NewFullscreenRenderer(opts.Theme, opts.Black, opts.Mouse) + } return &Terminal{ initDelay: delay, inlineInfo: opts.InlineInfo, @@ -290,6 +315,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { header: header, header0: header, ansi: opts.Ansi, + tabstop: opts.Tabstop, reading: true, jumping: jumpDisabled, jumpLabels: opts.JumpLabels, @@ -306,9 +332,8 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { slab: util.MakeSlab(slab16Size, slab32Size), theme: opts.Theme, startChan: make(chan bool, 1), - initFunc: func() { - tui.Init(opts.Theme, opts.Black, opts.Mouse) - }} + tui: renderer, + initFunc: func() { renderer.Init() }} } // Input returns current query string @@ -401,22 +426,10 @@ func (t *Terminal) sortSelected() []selectedItem { return sels } -func runeWidth(r rune, prefixWidth int) int { - if r == '\t' { - return _tabStop - prefixWidth%_tabStop - } else if w, found := _runeWidths[r]; found { - return w - } else { - w := runewidth.RuneWidth(r) - _runeWidths[r] = w - return w - } -} - -func displayWidth(runes []rune) int { +func (t *Terminal) displayWidth(runes []rune) int { l := 0 for _, r := range runes { - l += runeWidth(r, l) + l += util.RuneWidth(r, l, t.tabstop) } return l } @@ -437,9 +450,10 @@ func calculateSize(base int, size sizeSpec, margin int, minSize int) int { } func (t *Terminal) resizeWindows() { - screenWidth := tui.MaxX() - screenHeight := tui.MaxY() + screenWidth := t.tui.MaxX() + screenHeight := t.tui.MaxY() marginInt := [4]int{} + t.prevLines = make([]itemLine, screenHeight) for idx, sizeSpec := range t.margin { if sizeSpec.percent { var max float64 @@ -487,40 +501,40 @@ func (t *Terminal) resizeWindows() { height := screenHeight - marginInt[0] - marginInt[2] if t.isPreviewEnabled() { createPreviewWindow := func(y int, x int, w int, h int) { - t.bwindow = tui.NewWindow(y, x, w, h, true) + t.bwindow = t.tui.NewWindow(y, x, w, h, true) pwidth := w - 4 // ncurses auto-wraps the line when the cursor reaches the right-end of // the window. To prevent unintended line-wraps, we use the width one // column larger than the desired value. - if !t.preview.wrap && tui.DoesAutoWrap() { + if !t.preview.wrap && t.tui.DoesAutoWrap() { pwidth += 1 } - t.pwindow = tui.NewWindow(y+1, x+2, pwidth, h-2, false) + t.pwindow = t.tui.NewWindow(y+1, x+2, pwidth, h-2, false) } switch t.preview.position { case posUp: pheight := calculateSize(height, t.preview.size, minHeight, 3) - t.window = tui.NewWindow( + t.window = t.tui.NewWindow( marginInt[0]+pheight, marginInt[3], width, height-pheight, false) createPreviewWindow(marginInt[0], marginInt[3], width, pheight) case posDown: pheight := calculateSize(height, t.preview.size, minHeight, 3) - t.window = tui.NewWindow( + t.window = t.tui.NewWindow( marginInt[0], marginInt[3], width, height-pheight, false) createPreviewWindow(marginInt[0]+height-pheight, marginInt[3], width, pheight) case posLeft: pwidth := calculateSize(width, t.preview.size, minWidth, 5) - t.window = tui.NewWindow( + t.window = t.tui.NewWindow( marginInt[0], marginInt[3]+pwidth, width-pwidth, height, false) createPreviewWindow(marginInt[0], marginInt[3], pwidth, height) case posRight: pwidth := calculateSize(width, t.preview.size, minWidth, 5) - t.window = tui.NewWindow( + t.window = t.tui.NewWindow( marginInt[0], marginInt[3], width-pwidth, height, false) createPreviewWindow(marginInt[0], marginInt[3]+width-pwidth, pwidth, height) } } else { - t.window = tui.NewWindow( + t.window = t.tui.NewWindow( marginInt[0], marginInt[3], width, @@ -530,7 +544,7 @@ func (t *Terminal) resizeWindows() { func (t *Terminal) move(y int, x int, clear bool) { if !t.reverse { - y = t.window.Height - y - 1 + y = t.window.Height() - y - 1 } if clear { @@ -541,7 +555,7 @@ func (t *Terminal) move(y int, x int, clear bool) { } func (t *Terminal) placeCursor() { - t.move(0, displayWidth([]rune(t.prompt))+displayWidth(t.input[:t.cx]), false) + t.move(0, t.displayWidth([]rune(t.prompt))+t.displayWidth(t.input[:t.cx]), false) } func (t *Terminal) printPrompt() { @@ -552,7 +566,7 @@ func (t *Terminal) printPrompt() { func (t *Terminal) printInfo() { if t.inlineInfo { - t.move(0, displayWidth([]rune(t.prompt))+displayWidth(t.input)+1, true) + t.move(0, t.displayWidth([]rune(t.prompt))+t.displayWidth(t.input)+1, true) if t.reading { t.window.CPrint(tui.ColSpinner, t.strong, " < ") } else { @@ -589,7 +603,7 @@ func (t *Terminal) printHeader() { if len(t.header) == 0 { return } - max := t.window.Height + max := t.window.Height() var state *ansiState for idx, lineStr := range t.header { line := idx + 2 @@ -616,19 +630,25 @@ func (t *Terminal) printList() { maxy := t.maxItems() count := t.merger.Length() - t.offset - for i := 0; i < maxy; i++ { + for j := 0; j < maxy; j++ { + i := j + if !t.reverse { + i = maxy - 1 - j + } line := i + 2 + len(t.header) if t.inlineInfo { line-- } - t.move(line, 0, true) if i < count { - t.printItem(t.merger.Get(i+t.offset), i, i == t.cy-t.offset) + t.printItem(t.merger.Get(i+t.offset), line, i, i == t.cy-t.offset) + } else if t.prevLines[i] != emptyLine { + t.prevLines[i] = emptyLine + t.move(line, 0, true) } } } -func (t *Terminal) printItem(result *Result, i int, current bool) { +func (t *Terminal) printItem(result *Result, line int, i int, current bool) { item := result.item _, selected := t.selected[item.Index()] label := " " @@ -641,6 +661,15 @@ func (t *Terminal) printItem(result *Result, i int, current bool) { } else if current { label = ">" } + + // Avoid unnecessary redraw + newLine := itemLine{current, label, *result} + if t.prevLines[i] == newLine { + return + } + t.prevLines[i] = newLine + + t.move(line, 0, true) t.window.CPrint(tui.ColCursor, t.strong, label) if current { if selected { @@ -659,11 +688,11 @@ func (t *Terminal) printItem(result *Result, i int, current bool) { } } -func trimRight(runes []rune, width int) ([]rune, int) { +func (t *Terminal) trimRight(runes []rune, width int) ([]rune, int) { // We start from the beginning to handle tab characters l := 0 for idx, r := range runes { - l += runeWidth(r, l) + l += util.RuneWidth(r, l, t.tabstop) if l > width { return runes[:idx], len(runes) - idx } @@ -671,10 +700,10 @@ func trimRight(runes []rune, width int) ([]rune, int) { return runes, 0 } -func displayWidthWithLimit(runes []rune, prefixWidth int, limit int) int { +func (t *Terminal) displayWidthWithLimit(runes []rune, prefixWidth int, limit int) int { l := 0 for _, r := range runes { - l += runeWidth(r, l+prefixWidth) + l += util.RuneWidth(r, l+prefixWidth, t.tabstop) if l > limit { // Early exit return l @@ -683,27 +712,27 @@ func displayWidthWithLimit(runes []rune, prefixWidth int, limit int) int { return l } -func trimLeft(runes []rune, width int) ([]rune, int32) { +func (t *Terminal) trimLeft(runes []rune, width int) ([]rune, int32) { if len(runes) > maxDisplayWidthCalc && len(runes) > width { trimmed := len(runes) - width return runes[trimmed:], int32(trimmed) } - currentWidth := displayWidth(runes) + currentWidth := t.displayWidth(runes) var trimmed int32 for currentWidth > width && len(runes) > 0 { runes = runes[1:] trimmed++ - currentWidth = displayWidthWithLimit(runes, 2, width) + currentWidth = t.displayWidthWithLimit(runes, 2, width) } return runes, trimmed } -func overflow(runes []rune, max int) bool { +func (t *Terminal) overflow(runes []rune, max int) bool { l := 0 for _, r := range runes { - l += runeWidth(r, l) + l += util.RuneWidth(r, l, t.tabstop) if l > max { return true } @@ -737,22 +766,22 @@ func (t *Terminal) printHighlighted(result *Result, attr tui.Attr, col1 tui.Colo } offsets := result.colorOffsets(charOffsets, t.theme, col2, attr, current) - maxWidth := t.window.Width - 3 + maxWidth := t.window.Width() - 3 maxe = util.Constrain(maxe+util.Min(maxWidth/2-2, t.hscrollOff), 0, len(text)) - if overflow(text, maxWidth) { + if t.overflow(text, maxWidth) { if t.hscroll { // Stri.. - if !overflow(text[:maxe], maxWidth-2) { - text, _ = trimRight(text, maxWidth-2) + if !t.overflow(text[:maxe], maxWidth-2) { + text, _ = t.trimRight(text, maxWidth-2) text = append(text, []rune("..")...) } else { // Stri.. - if overflow(text[maxe:], 2) { + if t.overflow(text[maxe:], 2) { text = append(text[:maxe], []rune("..")...) } // ..ri.. var diff int32 - text, diff = trimLeft(text, maxWidth-2) + text, diff = t.trimLeft(text, maxWidth-2) // Transform offsets for idx, offset := range offsets { @@ -766,7 +795,7 @@ func (t *Terminal) printHighlighted(result *Result, attr tui.Attr, col1 tui.Colo text = append([]rune(".."), text...) } } else { - text, _ = trimRight(text, maxWidth-2) + text, _ = t.trimRight(text, maxWidth-2) text = append(text, []rune("..")...) for idx, offset := range offsets { @@ -784,11 +813,11 @@ func (t *Terminal) printHighlighted(result *Result, attr tui.Attr, col1 tui.Colo b := util.Constrain32(offset.offset[0], index, maxOffset) e := util.Constrain32(offset.offset[1], index, maxOffset) - substr, prefixWidth = processTabs(text[index:b], prefixWidth) + substr, prefixWidth = t.processTabs(text[index:b], prefixWidth) t.window.CPrint(col1, attr, substr) if b < e { - substr, prefixWidth = processTabs(text[b:e], prefixWidth) + substr, prefixWidth = t.processTabs(text[b:e], prefixWidth) t.window.CPrint(offset.color, offset.attr, substr) } @@ -798,7 +827,7 @@ func (t *Terminal) printHighlighted(result *Result, attr tui.Attr, col1 tui.Colo } } if index < maxOffset { - substr, _ = processTabs(text[index:], prefixWidth) + substr, _ = t.processTabs(text[index:], prefixWidth) t.window.CPrint(col1, attr, substr) } } @@ -835,38 +864,44 @@ func (t *Terminal) printPreview() { return true } } - if !t.preview.wrap { - lines := strings.Split(str, "\n") - for i, line := range lines { - limit := t.pwindow.Width - if tui.DoesAutoWrap() { - limit -= 1 - } - if i == 0 { - limit -= t.pwindow.X() - } - trimmed, _ := trimRight([]rune(line), limit) - lines[i], _ = processTabs(trimmed, 0) + lines := strings.Split(str, "\n") + for i, line := range lines { + limit := t.pwindow.Width() + if t.tui.DoesAutoWrap() { + limit -= 1 } + if i == 0 { + limit -= t.pwindow.X() + } + trimmed := []rune(line) + if !t.preview.wrap { + trimmed, _ = t.trimRight(trimmed, limit) + } + lines[i], _ = t.processTabs(trimmed, 0) str = strings.Join(lines, "\n") } if ansi != nil && ansi.colored() { - return t.pwindow.CFill(str, ansi.fg, ansi.bg, ansi.attr) + return t.pwindow.CFill(ansi.fg, ansi.bg, ansi.attr, str) } return t.pwindow.Fill(str) }) - if t.previewer.lines > t.pwindow.Height { + t.pwindow.FinishFill() + if t.previewer.lines > t.pwindow.Height() { offset := fmt.Sprintf("%d/%d", t.previewer.offset+1, t.previewer.lines) - t.pwindow.Move(0, t.pwindow.Width-len(offset)) + pos := t.pwindow.Width() - len(offset) + if t.tui.DoesAutoWrap() { + pos -= 1 + } + t.pwindow.Move(0, pos) t.pwindow.CPrint(tui.ColInfo, tui.Reverse, offset) } } -func processTabs(runes []rune, prefixWidth int) (string, int) { +func (t *Terminal) processTabs(runes []rune, prefixWidth int) (string, int) { var strbuf bytes.Buffer l := prefixWidth for _, r := range runes { - w := runeWidth(r, l) + w := util.RuneWidth(r, l, t.tabstop) l += w if r == '\t' { strbuf.WriteString(strings.Repeat(" ", w)) @@ -889,9 +924,9 @@ func (t *Terminal) printAll() { func (t *Terminal) refresh() { if !t.suppress { if t.isPreviewEnabled() { - tui.RefreshWindows([]*tui.Window{t.bwindow, t.pwindow, t.window}) + t.tui.RefreshWindows([]tui.Window{t.bwindow, t.pwindow, t.window}) } else { - tui.RefreshWindows([]*tui.Window{t.window}) + t.tui.RefreshWindows([]tui.Window{t.window}) } } } @@ -1013,9 +1048,9 @@ func (t *Terminal) executeCommand(template string, items []*Item) { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - tui.Pause() + t.tui.Pause() cmd.Run() - if tui.Resume() { + if t.tui.Resume() { t.printAll() } t.refresh() @@ -1162,11 +1197,11 @@ func (t *Terminal) Loop() { case reqRefresh: t.suppress = false case reqRedraw: - tui.Clear() - tui.Refresh() + t.tui.Clear() + t.tui.Refresh() t.printAll() case reqClose: - tui.Close() + t.tui.Close() if t.output() { exit(exitOk) } @@ -1179,11 +1214,11 @@ func (t *Terminal) Loop() { case reqPreviewRefresh: t.printPreview() case reqPrintQuery: - tui.Close() + t.tui.Close() t.printer(string(t.input)) exit(exitOk) case reqQuit: - tui.Close() + t.tui.Close() exit(exitInterrupt) } } @@ -1196,7 +1231,7 @@ func (t *Terminal) Loop() { looping := true for looping { - event := tui.GetChar() + event := t.tui.GetChar() t.mutex.Lock() previousInput := t.input @@ -1288,11 +1323,11 @@ func (t *Terminal) Loop() { } case actPreviewPageUp: if t.isPreviewEnabled() { - scrollPreview(-t.pwindow.Height) + scrollPreview(-t.pwindow.Height()) } case actPreviewPageDown: if t.isPreviewEnabled() { - scrollPreview(t.pwindow.Height) + scrollPreview(t.pwindow.Height()) } case actBeginningOfLine: t.cx = 0 @@ -1466,11 +1501,11 @@ func (t *Terminal) Loop() { scrollPreview(-me.S) } } else if t.window.Enclose(my, mx) { - mx -= t.window.Left - my -= t.window.Top - mx = util.Constrain(mx-displayWidth([]rune(t.prompt)), 0, len(t.input)) + mx -= t.window.Left() + my -= t.window.Top() + mx = util.Constrain(mx-t.displayWidth([]rune(t.prompt)), 0, len(t.input)) if !t.reverse { - my = t.window.Height - my - 1 + my = t.window.Height() - my - 1 } min := 2 + len(t.header) if t.inlineInfo { @@ -1582,7 +1617,7 @@ func (t *Terminal) vset(o int) bool { } func (t *Terminal) maxItems() int { - max := t.window.Height - 2 - len(t.header) + max := t.window.Height() - 2 - len(t.header) if t.inlineInfo { max++ } diff --git a/src/tui/light.go b/src/tui/light.go new file mode 100644 index 0000000..1273c8f --- /dev/null +++ b/src/tui/light.go @@ -0,0 +1,764 @@ +package tui + +import ( + "fmt" + "os" + "strconv" + "strings" + "syscall" + "time" + "unicode/utf8" + + "github.com/junegunn/fzf/src/util" +) + +const ( + defaultWidth = 80 + defaultHeight = 24 + + escPollInterval = 5 +) + +func openTtyIn() *os.File { + in, err := os.OpenFile("/dev/tty", syscall.O_RDONLY, 0) + if err != nil { + panic("Failed to open /dev/tty") + } + return in +} + +// FIXME: Need better handling of non-displayable characters +func (r *LightRenderer) stderr(str string) { + bytes := []byte(str) + runes := []rune{} + for len(bytes) > 0 { + r, sz := utf8.DecodeRune(bytes) + if r == utf8.RuneError || r != '\x1b' && r != '\n' && r < 32 { + runes = append(runes, '?') + } else { + runes = append(runes, r) + } + bytes = bytes[sz:] + } + r.queued += string(runes) +} + +func (r *LightRenderer) csi(code string) { + r.stderr("\x1b[" + code) +} + +func (r *LightRenderer) flush() { + if len(r.queued) > 0 { + fmt.Fprint(os.Stderr, r.queued) + r.queued = "" + } +} + +// Light renderer +type LightRenderer struct { + theme *ColorTheme + mouse bool + forceBlack bool + prevDownTime time.Time + clickY []int + ttyin *os.File + buffer []byte + ostty string + width int + height int + yoffset int + tabstop int + escDelay int + upOneLine bool + queued string + maxHeightFunc func(int) int +} + +type LightWindow struct { + renderer *LightRenderer + colored bool + border bool + top int + left int + width int + height int + posx int + posy int + tabstop int + bg Color +} + +func NewLightRenderer(theme *ColorTheme, forceBlack bool, mouse bool, tabstop int, maxHeightFunc func(int) int) Renderer { + r := LightRenderer{ + theme: theme, + forceBlack: forceBlack, + mouse: mouse, + ttyin: openTtyIn(), + yoffset: -1, + tabstop: tabstop, + upOneLine: false, + maxHeightFunc: maxHeightFunc} + return &r +} + +func (r *LightRenderer) defaultTheme() *ColorTheme { + colors, err := util.ExecCommand("tput colors").Output() + if err == nil && atoi(strings.TrimSpace(string(colors)), 16) > 16 { + return Dark256 + } + return Default16 +} + +func stty(cmd string) string { + out, err := util.ExecCommand("stty " + cmd + " < /dev/tty").Output() + if err != nil { + // Not sure how to handle this + panic("stty " + cmd + ": " + err.Error()) + } + return strings.TrimSpace(string(out)) +} + +func (r *LightRenderer) findOffset() (row int, col int) { + r.csi("6n") + r.flush() + bytes := r.getBytesInternal([]byte{}) + + // ^[[*;*R + if len(bytes) > 5 && bytes[0] == 27 && bytes[1] == 91 && bytes[len(bytes)-1] == 'R' { + nums := strings.Split(string(bytes[2:len(bytes)-1]), ";") + if len(nums) == 2 { + return atoi(nums[0], 0) - 1, atoi(nums[1], 0) - 1 + } + return -1, -1 + } + + // No idea + return -1, -1 +} + +func repeat(s string, times int) string { + if times > 0 { + return strings.Repeat(s, times) + } + return "" +} + +func atoi(s string, defaultValue int) int { + value, err := strconv.Atoi(s) + if err != nil { + return defaultValue + } + return value +} + +func (r *LightRenderer) Init() { + delay := 100 + delayEnv := os.Getenv("ESCDELAY") + if len(delayEnv) > 0 { + num, err := strconv.Atoi(delayEnv) + if err == nil && num >= 0 { + delay = num + } + } + r.escDelay = delay + + r.ostty = stty("-g") + stty("raw") + r.updateTerminalSize() + initTheme(r.theme, r.defaultTheme(), r.forceBlack) + + _, x := r.findOffset() + if x > 0 { + r.upOneLine = true + r.stderr("\n") + } + for i := 1; i < r.MaxY(); i++ { + r.stderr("\n") + r.csi("G") + } + + if r.mouse { + r.csi("?1000h") + } + r.csi(fmt.Sprintf("%dA", r.MaxY()-1)) + r.csi("G") + r.csi("s") + r.yoffset, _ = r.findOffset() +} + +func (r *LightRenderer) updateTerminalSize() { + sizes := strings.Split(stty("size"), " ") + if len(sizes) < 2 { + r.width = defaultWidth + r.height = r.maxHeightFunc(defaultHeight) + } else { + r.width = atoi(sizes[1], defaultWidth) + r.height = r.maxHeightFunc(atoi(sizes[0], defaultHeight)) + } +} + +func (r *LightRenderer) getch(nonblock bool) int { + b := make([]byte, 1) + util.SetNonblock(r.ttyin, nonblock) + _, err := r.ttyin.Read(b) + if err != nil { + return -1 + } + return int(b[0]) +} + +func (r *LightRenderer) getBytes() []byte { + return r.getBytesInternal(r.buffer) +} + +func (r *LightRenderer) getBytesInternal(buffer []byte) []byte { + c := r.getch(false) + + retries := 0 + if c == ESC { + retries = r.escDelay / escPollInterval + } + buffer = append(buffer, byte(c)) + + for { + c = r.getch(true) + if c == -1 { + if retries > 0 { + retries-- + time.Sleep(escPollInterval * time.Millisecond) + continue + } + break + } + retries = 0 + buffer = append(buffer, byte(c)) + } + + return buffer +} + +func (r *LightRenderer) GetChar() Event { + if len(r.buffer) == 0 { + r.buffer = r.getBytes() + } + if len(r.buffer) == 0 { + panic("Empty buffer") + } + + sz := 1 + defer func() { + r.buffer = r.buffer[sz:] + }() + + switch r.buffer[0] { + case CtrlC: + return Event{CtrlC, 0, nil} + case CtrlG: + return Event{CtrlG, 0, nil} + case CtrlQ: + return Event{CtrlQ, 0, nil} + case 127: + return Event{BSpace, 0, nil} + case ESC: + ev := r.escSequence(&sz) + // Second chance + if ev.Type == Invalid { + r.buffer = r.getBytes() + ev = r.escSequence(&sz) + } + return ev + } + + // CTRL-A ~ CTRL-Z + if r.buffer[0] <= CtrlZ { + return Event{int(r.buffer[0]), 0, nil} + } + char, rsz := utf8.DecodeRune(r.buffer) + if char == utf8.RuneError { + return Event{ESC, 0, nil} + } + sz = rsz + return Event{Rune, char, nil} +} + +func (r *LightRenderer) escSequence(sz *int) Event { + if len(r.buffer) < 2 { + return Event{ESC, 0, nil} + } + *sz = 2 + switch r.buffer[1] { + case 13: + return Event{AltEnter, 0, nil} + case 32: + return Event{AltSpace, 0, nil} + case 47: + return Event{AltSlash, 0, nil} + case 98: + return Event{AltB, 0, nil} + case 100: + return Event{AltD, 0, nil} + case 102: + return Event{AltF, 0, nil} + case 127: + return Event{AltBS, 0, nil} + case 91, 79: + if len(r.buffer) < 3 { + return Event{Invalid, 0, nil} + } + *sz = 3 + switch r.buffer[2] { + case 68: + return Event{Left, 0, nil} + case 67: + return Event{Right, 0, nil} + case 66: + return Event{Down, 0, nil} + case 65: + return Event{Up, 0, nil} + case 90: + return Event{BTab, 0, nil} + case 72: + return Event{Home, 0, nil} + case 70: + return Event{End, 0, nil} + case 77: + return r.mouseSequence(sz) + case 80: + return Event{F1, 0, nil} + case 81: + return Event{F2, 0, nil} + case 82: + return Event{F3, 0, nil} + case 83: + return Event{F4, 0, nil} + case 49, 50, 51, 52, 53, 54: + if len(r.buffer) < 4 { + return Event{Invalid, 0, nil} + } + *sz = 4 + switch r.buffer[2] { + case 50: + if len(r.buffer) == 5 && r.buffer[4] == 126 { + *sz = 5 + switch r.buffer[3] { + case 48: + return Event{F9, 0, nil} + case 49: + return Event{F10, 0, nil} + case 51: + return Event{F11, 0, nil} + case 52: + return Event{F12, 0, nil} + } + } + // Bracketed paste mode \e[200~ / \e[201 + if r.buffer[3] == 48 && (r.buffer[4] == 48 || r.buffer[4] == 49) && r.buffer[5] == 126 { + *sz = 6 + return Event{Invalid, 0, nil} + } + return Event{Invalid, 0, nil} // INS + case 51: + return Event{Del, 0, nil} + case 52: + return Event{End, 0, nil} + case 53: + return Event{PgUp, 0, nil} + case 54: + return Event{PgDn, 0, nil} + case 49: + switch r.buffer[3] { + case 126: + return Event{Home, 0, nil} + case 53, 55, 56, 57: + if len(r.buffer) == 5 && r.buffer[4] == 126 { + *sz = 5 + switch r.buffer[3] { + case 53: + return Event{F5, 0, nil} + case 55: + return Event{F6, 0, nil} + case 56: + return Event{F7, 0, nil} + case 57: + return Event{F8, 0, nil} + } + } + return Event{Invalid, 0, nil} + case 59: + if len(r.buffer) != 6 { + return Event{Invalid, 0, nil} + } + *sz = 6 + switch r.buffer[4] { + case 50: + switch r.buffer[5] { + case 68: + return Event{Home, 0, nil} + case 67: + return Event{End, 0, nil} + } + case 53: + switch r.buffer[5] { + case 68: + return Event{SLeft, 0, nil} + case 67: + return Event{SRight, 0, nil} + } + } // r.buffer[4] + } // r.buffer[3] + } // r.buffer[2] + } // r.buffer[2] + } // r.buffer[1] + if r.buffer[1] >= 'a' && r.buffer[1] <= 'z' { + return Event{AltA + int(r.buffer[1]) - 'a', 0, nil} + } + return Event{Invalid, 0, nil} +} + +func (r *LightRenderer) mouseSequence(sz *int) Event { + if len(r.buffer) < 6 || r.yoffset < 0 { + return Event{Invalid, 0, nil} + } + *sz = 6 + switch r.buffer[3] { + case 32, 36, 40, 48, // mouse-down / shift / cmd / ctrl + 35, 39, 43, 51: // mouse-up / shift / cmd / ctrl + mod := r.buffer[3] >= 36 + down := r.buffer[3]%2 == 0 + x := int(r.buffer[4] - 33) + y := int(r.buffer[5]-33) - r.yoffset + double := false + if down { + now := time.Now() + if now.Sub(r.prevDownTime) < doubleClickDuration { + r.clickY = append(r.clickY, y) + } else { + r.clickY = []int{y} + } + r.prevDownTime = now + } else { + if len(r.clickY) > 1 && r.clickY[0] == r.clickY[1] && + time.Now().Sub(r.prevDownTime) < doubleClickDuration { + double = true + } + } + + return Event{Mouse, 0, &MouseEvent{y, x, 0, down, double, mod}} + case 96, 100, 104, 112, // scroll-up / shift / cmd / ctrl + 97, 101, 105, 113: // scroll-down / shift / cmd / ctrl + mod := r.buffer[3] >= 100 + s := 1 - int(r.buffer[3]%2)*2 + x := int(r.buffer[4] - 33) + y := int(r.buffer[5]-33) - r.yoffset + return Event{Mouse, 0, &MouseEvent{y, x, s, false, false, mod}} + } + return Event{Invalid, 0, nil} +} + +func (r *LightRenderer) Pause() { + stty(fmt.Sprintf("%q", r.ostty)) + r.csi("?1049h") + r.flush() +} + +func (r *LightRenderer) Resume() bool { + stty("raw") + r.csi("?1049l") + r.flush() + // Should redraw + return true +} + +func (r *LightRenderer) Clear() { + r.csi("u") + r.csi("J") + r.flush() +} + +func (r *LightRenderer) RefreshWindows(windows []Window) { + r.flush() +} + +func (r *LightRenderer) Refresh() { + r.updateTerminalSize() +} + +func (r *LightRenderer) Close() { + r.csi("u") + r.csi("J") + if r.mouse { + r.csi("?1000l") + } + if r.upOneLine { + r.csi("A") + } + r.flush() + stty(fmt.Sprintf("%q", r.ostty)) +} + +func (r *LightRenderer) MaxX() int { + return r.width +} + +func (r *LightRenderer) MaxY() int { + return r.height +} + +func (r *LightRenderer) DoesAutoWrap() bool { + return true +} + +func (r *LightRenderer) NewWindow(top int, left int, width int, height int, border bool) Window { + w := &LightWindow{ + renderer: r, + colored: r.theme != nil, + border: border, + top: top, + left: left, + width: width, + height: height, + tabstop: r.tabstop, + bg: colDefault} + if r.theme != nil { + w.bg = r.theme.Bg + } + if w.border { + w.drawBorder() + } + return w +} + +func (w *LightWindow) drawBorder() { + w.Move(0, 0) + w.CPrint(ColBorder, AttrRegular, "┌"+repeat("─", w.width-2)+"┐") + for y := 1; y < w.height-1; y++ { + w.Move(y, 0) + w.CPrint(ColBorder, AttrRegular, "│") + w.cprint2(colDefault, w.bg, AttrRegular, repeat(" ", w.width-2)) + w.CPrint(ColBorder, AttrRegular, "│") + } + w.Move(w.height-1, 0) + w.CPrint(ColBorder, AttrRegular, "└"+repeat("─", w.width-2)+"┘") +} + +func (w *LightWindow) csi(code string) { + w.renderer.csi(code) +} + +func (w *LightWindow) stderr(str string) { + w.renderer.stderr(str) +} + +func (w *LightWindow) Top() int { + return w.top +} + +func (w *LightWindow) Left() int { + return w.left +} + +func (w *LightWindow) Width() int { + return w.width +} + +func (w *LightWindow) Height() int { + return w.height +} + +func (w *LightWindow) Refresh() { +} + +func (w *LightWindow) Close() { +} + +func (w *LightWindow) X() int { + return w.posx +} + +func (w *LightWindow) Enclose(y int, x int) bool { + return x >= w.left && x < (w.left+w.width) && + y >= w.top && y < (w.top+w.height) +} + +func (w *LightWindow) Move(y int, x int) { + w.posx = x + w.posy = y + + w.csi("u") + y += w.Top() + if y > 0 { + w.csi(fmt.Sprintf("%dB", y)) + } + x += w.Left() + if x > 0 { + w.csi(fmt.Sprintf("%dC", x)) + } +} + +func (w *LightWindow) MoveAndClear(y int, x int) { + w.Move(y, x) + // We should not delete preview window on the right + // csi("K") + w.Print(repeat(" ", w.width-x)) + w.Move(y, x) +} + +func attrCodes(attr Attr) []string { + codes := []string{} + if (attr & Bold) > 0 { + codes = append(codes, "1") + } + if (attr & Dim) > 0 { + codes = append(codes, "2") + } + if (attr & Italic) > 0 { + codes = append(codes, "3") + } + if (attr & Underline) > 0 { + codes = append(codes, "4") + } + if (attr & Blink) > 0 { + codes = append(codes, "5") + } + if (attr & Reverse) > 0 { + codes = append(codes, "7") + } + return codes +} + +func colorCodes(fg Color, bg Color) []string { + codes := []string{} + appendCode := func(c Color, offset int) { + if c == colDefault { + return + } + if c.is24() { + r := (c >> 16) & 0xff + g := (c >> 8) & 0xff + b := (c) & 0xff + codes = append(codes, fmt.Sprintf("%d;2;%d;%d;%d", 38+offset, r, g, b)) + } else if c >= colBlack && c <= colWhite { + codes = append(codes, fmt.Sprintf("%d", int(c)+30+offset)) + } else if c > colWhite && c < 16 { + codes = append(codes, fmt.Sprintf("%d", int(c)+90+offset-8)) + } else if c >= 16 && c < 256 { + codes = append(codes, fmt.Sprintf("%d;5;%d", 38+offset, c)) + } + } + appendCode(fg, 0) + appendCode(bg, 10) + return codes +} + +func (w *LightWindow) csiColor(fg Color, bg Color, attr Attr) bool { + codes := append(attrCodes(attr), colorCodes(fg, bg)...) + w.csi(";" + strings.Join(codes, ";") + "m") + return len(codes) > 0 +} + +func (w *LightWindow) Print(text string) { + w.cprint2(colDefault, w.bg, AttrRegular, text) +} + +func (w *LightWindow) CPrint(pair ColorPair, attr Attr, text string) { + if !w.colored { + w.csiColor(colDefault, colDefault, attrFor(pair, attr)) + } else { + w.csiColor(pair.Fg(), pair.Bg(), attr) + } + w.stderr(text) + w.csi("m") +} + +func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) { + if w.csiColor(fg, bg, attr) { + defer w.csi("m") + } + w.stderr(text) +} + +type wrappedLine struct { + text string + displayWidth int +} + +func wrapLine(input string, prefixLength int, max int, tabstop int) []wrappedLine { + lines := []wrappedLine{} + width := 0 + line := "" + for _, r := range input { + w := util.Max(util.RuneWidth(r, prefixLength+width, 8), 1) + width += w + str := string(r) + if r == '\t' { + str = repeat(" ", w) + } + if prefixLength+width <= max { + line += str + } else { + lines = append(lines, wrappedLine{string(line), width - w}) + line = str + prefixLength = 0 + width = util.RuneWidth(r, prefixLength, 8) + } + } + lines = append(lines, wrappedLine{string(line), width}) + return lines +} + +func (w *LightWindow) fill(str string, onMove func()) bool { + allLines := strings.Split(str, "\n") + for i, line := range allLines { + lines := wrapLine(line, w.posx, w.width, w.tabstop) + for j, wl := range lines { + w.stderr(wl.text) + w.posx += wl.displayWidth + if j < len(lines)-1 || i < len(allLines)-1 { + if w.posy+1 >= w.height { + return false + } + w.MoveAndClear(w.posy+1, 0) + onMove() + } + } + } + return true +} + +func (w *LightWindow) setBg() { + if w.bg != colDefault { + w.csiColor(colDefault, w.bg, AttrRegular) + } +} + +func (w *LightWindow) Fill(text string) bool { + w.MoveAndClear(w.posy, w.posx) + w.setBg() + return w.fill(text, w.setBg) +} + +func (w *LightWindow) CFill(fg Color, bg Color, attr Attr, text string) bool { + w.MoveAndClear(w.posy, w.posx) + if bg == colDefault { + bg = w.bg + } + if w.csiColor(fg, bg, attr) { + return w.fill(text, func() { w.csiColor(fg, bg, attr) }) + defer w.csi("m") + } + return w.fill(text, w.setBg) +} + +func (w *LightWindow) FinishFill() { + for y := w.posy + 1; y < w.height; y++ { + w.MoveAndClear(y, 0) + } +} + +func (w *LightWindow) Erase() { + if w.border { + w.drawBorder() + } + // We don't erase the window here to avoid flickering during scroll + w.Move(0, 0) +} diff --git a/src/tui/ncurses.go b/src/tui/ncurses.go index 7f515b2..b160692 100644 --- a/src/tui/ncurses.go +++ b/src/tui/ncurses.go @@ -33,9 +33,39 @@ import ( "unicode/utf8" ) -type ColorPair int16 type Attr C.uint -type WindowImpl C.WINDOW + +type CursesWindow struct { + impl *C.WINDOW + top int + left int + width int + height int +} + +func (w *CursesWindow) Top() int { + return w.top +} + +func (w *CursesWindow) Left() int { + return w.left +} + +func (w *CursesWindow) Width() int { + return w.width +} + +func (w *CursesWindow) Height() int { + return w.height +} + +func (w *CursesWindow) Refresh() { + C.wnoutrefresh(w.impl) +} + +func (w *CursesWindow) FinishFill() { + // NO-OP +} const ( Bold Attr = C.A_BOLD @@ -51,31 +81,14 @@ const ( AttrRegular Attr = 0 ) -// Pallete -const ( - ColDefault ColorPair = iota - ColNormal - ColPrompt - ColMatch - ColCurrent - ColCurrentMatch - ColSpinner - ColInfo - ColCursor - ColSelected - ColHeader - ColBorder - ColUser // Should be the last entry -) - var ( _screen *C.SCREEN - _colorMap map[int]ColorPair + _colorMap map[int]int16 _colorFn func(ColorPair, Attr) (C.short, C.int) ) func init() { - _colorMap = make(map[int]ColorPair) + _colorMap = make(map[int]int16) if strings.HasPrefix(C.GoString(C.curses_version()), "ncurses 5") { Italic = C.A_NORMAL } @@ -85,14 +98,14 @@ func (a Attr) Merge(b Attr) Attr { return a | b } -func DefaultTheme() *ColorTheme { +func (r *FullscreenRenderer) defaultTheme() *ColorTheme { if C.tigetnum(C.CString("colors")) >= 256 { return Dark256 } return Default16 } -func Init(theme *ColorTheme, black bool, mouse bool) { +func (r *FullscreenRenderer) Init() { C.setlocale(C.LC_ALL, C.CString("")) tty := C.c_tty() if tty == nil { @@ -105,7 +118,7 @@ func Init(theme *ColorTheme, black bool, mouse bool) { os.Exit(2) } C.set_term(_screen) - if mouse { + if r.mouse { C.mousemask(C.ALL_MOUSE_EVENTS, nil) C.mouseinterval(0) } @@ -124,14 +137,14 @@ func Init(theme *ColorTheme, black bool, mouse bool) { } C.set_escdelay(C.int(delay)) - _color = theme != nil - if _color { + if r.theme != nil { C.start_color() - InitTheme(theme, black) - initPairs(theme) - C.bkgd(C.chtype(C.COLOR_PAIR(C.int(ColNormal)))) + initTheme(r.theme, r.defaultTheme(), r.forceBlack) + initPairs(r.theme) + C.bkgd(C.chtype(C.COLOR_PAIR(C.int(ColNormal.index())))) _colorFn = attrColored } else { + initTheme(r.theme, nil, r.forceBlack) _colorFn = attrMono } @@ -145,39 +158,39 @@ func Init(theme *ColorTheme, black bool, mouse bool) { func initPairs(theme *ColorTheme) { C.assume_default_colors(C.int(theme.Fg), C.int(theme.Bg)) - initPair := func(group ColorPair, fg Color, bg Color) { - C.init_pair(C.short(group), C.short(fg), C.short(bg)) + for _, pair := range []ColorPair{ + ColNormal, + ColPrompt, + ColMatch, + ColCurrent, + ColCurrentMatch, + ColSpinner, + ColInfo, + ColCursor, + ColSelected, + ColHeader, + ColBorder} { + C.init_pair(C.short(pair.index()), C.short(pair.Fg()), C.short(pair.Bg())) } - initPair(ColNormal, theme.Fg, theme.Bg) - initPair(ColPrompt, theme.Prompt, theme.Bg) - initPair(ColMatch, theme.Match, theme.Bg) - initPair(ColCurrent, theme.Current, theme.DarkBg) - initPair(ColCurrentMatch, theme.CurrentMatch, theme.DarkBg) - initPair(ColSpinner, theme.Spinner, theme.Bg) - initPair(ColInfo, theme.Info, theme.Bg) - initPair(ColCursor, theme.Cursor, theme.DarkBg) - initPair(ColSelected, theme.Selected, theme.DarkBg) - initPair(ColHeader, theme.Header, theme.Bg) - initPair(ColBorder, theme.Border, theme.Bg) } -func Pause() { +func (r *FullscreenRenderer) Pause() { C.endwin() } -func Resume() bool { +func (r *FullscreenRenderer) Resume() bool { return false } -func Close() { +func (r *FullscreenRenderer) Close() { C.endwin() C.delscreen(_screen) } -func NewWindow(top int, left int, width int, height int, border bool) *Window { +func (r *FullscreenRenderer) NewWindow(top int, left int, width int, height int, border bool) Window { win := C.newwin(C.int(height), C.int(width), C.int(top), C.int(left)) - if _color { - C.wbkgd(win, C.chtype(C.COLOR_PAIR(C.int(ColNormal)))) + if r.theme != nil { + C.wbkgd(win, C.chtype(C.COLOR_PAIR(C.int(ColNormal.index())))) } if border { pair, attr := _colorFn(ColBorder, 0) @@ -188,66 +201,50 @@ func NewWindow(top int, left int, width int, height int, border bool) *Window { C.wcolor_set(win, 0, nil) } - return &Window{ - impl: (*WindowImpl)(win), - Top: top, - Left: left, - Width: width, - Height: height, + return &CursesWindow{ + impl: win, + top: top, + left: left, + width: width, + height: height, } } -func attrColored(pair ColorPair, a Attr) (C.short, C.int) { - return C.short(pair), C.int(a) +func attrColored(color ColorPair, a Attr) (C.short, C.int) { + return C.short(color.index()), C.int(a) } -func attrMono(pair ColorPair, a Attr) (C.short, C.int) { - var attr C.int - switch pair { - case ColCurrent: - attr = C.A_REVERSE - case ColMatch: - attr = C.A_UNDERLINE - case ColCurrentMatch: - attr = C.A_UNDERLINE | C.A_REVERSE - } - if C.int(a)&C.A_BOLD == C.A_BOLD { - attr = attr | C.A_BOLD - } - return 0, attr +func attrMono(color ColorPair, a Attr) (C.short, C.int) { + return 0, C.int(attrFor(color, a)) } -func MaxX() int { +func (r *FullscreenRenderer) MaxX() int { return int(C.COLS) } -func MaxY() int { +func (r *FullscreenRenderer) MaxY() int { return int(C.LINES) } -func (w *Window) win() *C.WINDOW { - return (*C.WINDOW)(w.impl) +func (w *CursesWindow) Close() { + C.delwin(w.impl) } -func (w *Window) Close() { - C.delwin(w.win()) +func (w *CursesWindow) Enclose(y int, x int) bool { + return bool(C.wenclose(w.impl, C.int(y), C.int(x))) } -func (w *Window) Enclose(y int, x int) bool { - return bool(C.wenclose(w.win(), C.int(y), C.int(x))) +func (w *CursesWindow) Move(y int, x int) { + C.wmove(w.impl, C.int(y), C.int(x)) } -func (w *Window) Move(y int, x int) { - C.wmove(w.win(), C.int(y), C.int(x)) -} - -func (w *Window) MoveAndClear(y int, x int) { +func (w *CursesWindow) MoveAndClear(y int, x int) { w.Move(y, x) - C.wclrtoeol(w.win()) + C.wclrtoeol(w.impl) } -func (w *Window) Print(text string) { - C.waddstr(w.win(), C.CString(strings.Map(func(r rune) rune { +func (w *CursesWindow) Print(text string) { + C.waddstr(w.impl, C.CString(strings.Map(func(r rune) rune { if r < 32 { return -1 } @@ -255,69 +252,74 @@ func (w *Window) Print(text string) { }, text))) } -func (w *Window) CPrint(pair ColorPair, attr Attr, text string) { - p, a := _colorFn(pair, attr) - C.wcolor_set(w.win(), p, nil) - C.wattron(w.win(), a) +func (w *CursesWindow) CPrint(color ColorPair, attr Attr, text string) { + p, a := _colorFn(color, attr) + C.wcolor_set(w.impl, p, nil) + C.wattron(w.impl, a) w.Print(text) - C.wattroff(w.win(), a) - C.wcolor_set(w.win(), 0, nil) + C.wattroff(w.impl, a) + C.wcolor_set(w.impl, 0, nil) } -func Clear() { +func (r *FullscreenRenderer) Clear() { C.clear() C.endwin() } -func Refresh() { +func (r *FullscreenRenderer) Refresh() { C.refresh() } -func (w *Window) Erase() { - C.werase(w.win()) +func (w *CursesWindow) Erase() { + C.werase(w.impl) } -func (w *Window) X() int { - return int(C.c_getcurx(w.win())) +func (w *CursesWindow) X() int { + return int(C.c_getcurx(w.impl)) } -func DoesAutoWrap() bool { +func (r *FullscreenRenderer) DoesAutoWrap() bool { return true } -func (w *Window) Fill(str string) bool { - return C.waddstr(w.win(), C.CString(str)) == C.OK +func (w *CursesWindow) Fill(str string) bool { + return C.waddstr(w.impl, C.CString(str)) == C.OK } -func (w *Window) CFill(str string, fg Color, bg Color, attr Attr) bool { - pair := PairFor(fg, bg) - C.wcolor_set(w.win(), C.short(pair), nil) - C.wattron(w.win(), C.int(attr)) +func (w *CursesWindow) CFill(fg Color, bg Color, attr Attr, str string) bool { + index := ColorPair{fg, bg, -1}.index() + C.wcolor_set(w.impl, C.short(index), nil) + C.wattron(w.impl, C.int(attr)) ret := w.Fill(str) - C.wattroff(w.win(), C.int(attr)) - C.wcolor_set(w.win(), 0, nil) + C.wattroff(w.impl, C.int(attr)) + C.wcolor_set(w.impl, 0, nil) return ret } -func RefreshWindows(windows []*Window) { +func (r *FullscreenRenderer) RefreshWindows(windows []Window) { for _, w := range windows { - C.wnoutrefresh(w.win()) + w.Refresh() } C.doupdate() } -func PairFor(fg Color, bg Color) ColorPair { - // ncurses does not support 24-bit colors - if fg.is24() || bg.is24() { - return ColDefault +func (p ColorPair) index() int16 { + if p.id >= 0 { + return p.id } - key := (int(fg) << 8) + int(bg) + + // ncurses does not support 24-bit colors + if p.is24() { + return ColDefault.index() + } + + key := p.key() if found, prs := _colorMap[key]; prs { return found } - id := ColorPair(len(_colorMap) + int(ColUser)) - C.init_pair(C.short(id), C.short(fg), C.short(bg)) + id := int16(len(_colorMap)) + ColUser.id + C.init_pair(C.short(id), C.short(p.Fg()), C.short(p.Bg())) _colorMap[key] = id return id } @@ -369,7 +371,7 @@ func escSequence() Event { return Event{Invalid, 0, nil} } -func GetChar() Event { +func (r *FullscreenRenderer) GetChar() Event { c := C.getch() switch c { case C.ERR: @@ -435,17 +437,17 @@ func GetChar() Event { /* Cannot use BUTTON1_DOUBLE_CLICKED due to mouseinterval(0) */ if (me.bstate & C.BUTTON1_PRESSED) > 0 { now := time.Now() - if now.Sub(_prevDownTime) < doubleClickDuration { - _clickY = append(_clickY, y) + if now.Sub(r.prevDownTime) < doubleClickDuration { + r.clickY = append(r.clickY, y) } else { - _clickY = []int{y} - _prevDownTime = now + r.clickY = []int{y} + r.prevDownTime = now } return Event{Mouse, 0, &MouseEvent{y, x, 0, true, false, mod}} } else if (me.bstate & C.BUTTON1_RELEASED) > 0 { double := false - if len(_clickY) > 1 && _clickY[0] == _clickY[1] && - time.Now().Sub(_prevDownTime) < doubleClickDuration { + if len(r.clickY) > 1 && r.clickY[0] == r.clickY[1] && + time.Now().Sub(r.prevDownTime) < doubleClickDuration { double = true } return Event{Mouse, 0, &MouseEvent{y, x, 0, false, double, mod}} diff --git a/src/tui/tcell.go b/src/tui/tcell.go index 1793836..460bfd5 100644 --- a/src/tui/tcell.go +++ b/src/tui/tcell.go @@ -18,30 +18,56 @@ import ( "github.com/junegunn/go-runewidth" ) -type ColorPair [2]Color - -func (p ColorPair) fg() Color { - return p[0] -} - -func (p ColorPair) bg() Color { - return p[1] -} - func (p ColorPair) style() tcell.Style { style := tcell.StyleDefault - return style.Foreground(tcell.Color(p.fg())).Background(tcell.Color(p.bg())) + return style.Foreground(tcell.Color(p.Fg())).Background(tcell.Color(p.Bg())) } type Attr tcell.Style -type WindowTcell struct { - LastX int - LastY int - MoveCursor bool - Border bool +type TcellWindow struct { + color bool + top int + left int + width int + height int + lastX int + lastY int + moveCursor bool + border bool +} + +func (w *TcellWindow) Top() int { + return w.top +} + +func (w *TcellWindow) Left() int { + return w.left +} + +func (w *TcellWindow) Width() int { + return w.width +} + +func (w *TcellWindow) Height() int { + return w.height +} + +func (w *TcellWindow) Refresh() { + if w.moveCursor { + _screen.ShowCursor(w.left+w.lastX, w.top+w.lastY) + w.moveCursor = false + } + w.lastX = 0 + w.lastY = 0 + if w.border { + w.drawBorder() + } +} + +func (w *TcellWindow) FinishFill() { + // NO-OP } -type WindowImpl WindowTcell const ( Bold Attr = Attr(tcell.AttrBold) @@ -56,33 +82,13 @@ const ( AttrRegular Attr = 0 ) -var ( - ColDefault = ColorPair{colDefault, colDefault} - ColNormal ColorPair - ColPrompt ColorPair - ColMatch ColorPair - ColCurrent ColorPair - ColCurrentMatch ColorPair - ColSpinner ColorPair - ColInfo ColorPair - ColCursor ColorPair - ColSelected ColorPair - ColHeader ColorPair - ColBorder ColorPair - ColUser ColorPair -) - -func DefaultTheme() *ColorTheme { +func (r *FullscreenRenderer) defaultTheme() *ColorTheme { if _screen.Colors() >= 256 { return Dark256 } return Default16 } -func PairFor(fg Color, bg Color) ColorPair { - return [2]Color{fg, bg} -} - var ( _colorToAttribute = []tcell.Color{ tcell.ColorBlack, @@ -112,10 +118,9 @@ func (a Attr) Merge(b Attr) Attr { var ( _screen tcell.Screen - _mouse bool ) -func initScreen() { +func (r *FullscreenRenderer) initScreen() { s, e := tcell.NewScreen() if e != nil { fmt.Fprintf(os.Stderr, "%v\n", e) @@ -125,7 +130,7 @@ func initScreen() { fmt.Fprintf(os.Stderr, "%v\n", e) os.Exit(2) } - if _mouse { + if r.mouse { s.EnableMouse() } else { s.DisableMouse() @@ -133,63 +138,41 @@ func initScreen() { _screen = s } -func Init(theme *ColorTheme, black bool, mouse bool) { +func (r *FullscreenRenderer) Init() { encoding.Register() - _mouse = mouse - initScreen() - - _color = theme != nil - if _color { - InitTheme(theme, black) - } else { - theme = DefaultTheme() - } - ColNormal = ColorPair{theme.Fg, theme.Bg} - ColPrompt = ColorPair{theme.Prompt, theme.Bg} - ColMatch = ColorPair{theme.Match, theme.Bg} - ColCurrent = ColorPair{theme.Current, theme.DarkBg} - ColCurrentMatch = ColorPair{theme.CurrentMatch, theme.DarkBg} - ColSpinner = ColorPair{theme.Spinner, theme.Bg} - ColInfo = ColorPair{theme.Info, theme.Bg} - ColCursor = ColorPair{theme.Cursor, theme.DarkBg} - ColSelected = ColorPair{theme.Selected, theme.DarkBg} - ColHeader = ColorPair{theme.Header, theme.Bg} - ColBorder = ColorPair{theme.Border, theme.Bg} + r.initScreen() + initTheme(r.theme, r.defaultTheme(), r.forceBlack) } -func MaxX() int { +func (r *FullscreenRenderer) MaxX() int { ncols, _ := _screen.Size() return int(ncols) } -func MaxY() int { +func (r *FullscreenRenderer) MaxY() int { _, nlines := _screen.Size() return int(nlines) } -func (w *Window) win() *WindowTcell { - return (*WindowTcell)(w.impl) +func (w *TcellWindow) X() int { + return w.lastX } -func (w *Window) X() int { - return w.impl.LastX -} - -func DoesAutoWrap() bool { +func (r *FullscreenRenderer) DoesAutoWrap() bool { return false } -func Clear() { +func (r *FullscreenRenderer) Clear() { _screen.Sync() _screen.Clear() } -func Refresh() { +func (r *FullscreenRenderer) Refresh() { // noop } -func GetChar() Event { +func (r *FullscreenRenderer) GetChar() Event { ev := _screen.PollEvent() switch ev := ev.(type) { case *tcell.EventResize: @@ -213,15 +196,15 @@ func GetChar() Event { double := false if down { now := time.Now() - if now.Sub(_prevDownTime) < doubleClickDuration { - _clickY = append(_clickY, x) + if now.Sub(r.prevDownTime) < doubleClickDuration { + r.clickY = append(r.clickY, x) } else { - _clickY = []int{x} - _prevDownTime = now + r.clickY = []int{x} + r.prevDownTime = now } } else { - if len(_clickY) > 1 && _clickY[0] == _clickY[1] && - time.Now().Sub(_prevDownTime) < doubleClickDuration { + if len(r.clickY) > 1 && r.clickY[0] == r.clickY[1] && + time.Now().Sub(r.prevDownTime) < doubleClickDuration { double = true } } @@ -368,49 +351,39 @@ func GetChar() Event { return Event{Invalid, 0, nil} } -func Pause() { +func (r *FullscreenRenderer) Pause() { _screen.Fini() } -func Resume() bool { - initScreen() +func (r *FullscreenRenderer) Resume() bool { + r.initScreen() return true } -func Close() { +func (r *FullscreenRenderer) Close() { _screen.Fini() } -func RefreshWindows(windows []*Window) { +func (r *FullscreenRenderer) RefreshWindows(windows []Window) { // TODO for _, w := range windows { - if w.win().MoveCursor { - _screen.ShowCursor(w.Left+w.win().LastX, w.Top+w.win().LastY) - w.win().MoveCursor = false - } - w.win().LastX = 0 - w.win().LastY = 0 - if w.win().Border { - w.DrawBorder() - } + w.Refresh() } _screen.Show() } -func NewWindow(top int, left int, width int, height int, border bool) *Window { +func (r *FullscreenRenderer) NewWindow(top int, left int, width int, height int, border bool) Window { // TODO - win := new(WindowTcell) - win.Border = border - return &Window{ - impl: (*WindowImpl)(win), - Top: top, - Left: left, - Width: width, - Height: height, - } + return &TcellWindow{ + color: r.theme != nil, + top: top, + left: left, + width: width, + height: height, + border: border} } -func (w *Window) Close() { +func (w *TcellWindow) Close() { // TODO } @@ -422,40 +395,40 @@ func fill(x, y, w, h int, r rune) { } } -func (w *Window) Erase() { +func (w *TcellWindow) Erase() { // TODO - fill(w.Left, w.Top, w.Width, w.Height, ' ') + fill(w.left, w.top, w.width, w.height, ' ') } -func (w *Window) Enclose(y int, x int) bool { - return x >= w.Left && x <= (w.Left+w.Width) && - y >= w.Top && y <= (w.Top+w.Height) +func (w *TcellWindow) Enclose(y int, x int) bool { + return x >= w.left && x < (w.left+w.width) && + y >= w.top && y < (w.top+w.height) } -func (w *Window) Move(y int, x int) { - w.win().LastX = x - w.win().LastY = y - w.win().MoveCursor = true +func (w *TcellWindow) Move(y int, x int) { + w.lastX = x + w.lastY = y + w.moveCursor = true } -func (w *Window) MoveAndClear(y int, x int) { +func (w *TcellWindow) MoveAndClear(y int, x int) { w.Move(y, x) - for i := w.win().LastX; i < w.Width; i++ { - _screen.SetContent(i+w.Left, w.win().LastY+w.Top, rune(' '), nil, ColDefault.style()) + for i := w.lastX; i < w.width; i++ { + _screen.SetContent(i+w.left, w.lastY+w.top, rune(' '), nil, ColDefault.style()) } - w.win().LastX = x + w.lastX = x } -func (w *Window) Print(text string) { - w.PrintString(text, ColDefault, 0) +func (w *TcellWindow) Print(text string) { + w.printString(text, ColDefault, 0) } -func (w *Window) PrintString(text string, pair ColorPair, a Attr) { +func (w *TcellWindow) printString(text string, pair ColorPair, a Attr) { t := text lx := 0 var style tcell.Style - if _color { + if w.color { style = pair.style(). Reverse(a&Attr(tcell.AttrReverse) != 0). Underline(a&Attr(tcell.AttrUnderline) != 0) @@ -481,7 +454,7 @@ func (w *Window) PrintString(text string, pair ColorPair, a Attr) { } if r == '\n' { - w.win().LastY++ + w.lastY++ lx = 0 } else { @@ -489,26 +462,26 @@ func (w *Window) PrintString(text string, pair ColorPair, a Attr) { continue } - var xPos = w.Left + w.win().LastX + lx - var yPos = w.Top + w.win().LastY - if xPos < (w.Left+w.Width) && yPos < (w.Top+w.Height) { + var xPos = w.left + w.lastX + lx + var yPos = w.top + w.lastY + if xPos < (w.left+w.width) && yPos < (w.top+w.height) { _screen.SetContent(xPos, yPos, r, nil, style) } lx += runewidth.RuneWidth(r) } } - w.win().LastX += lx + w.lastX += lx } -func (w *Window) CPrint(pair ColorPair, a Attr, text string) { - w.PrintString(text, pair, a) +func (w *TcellWindow) CPrint(pair ColorPair, attr Attr, text string) { + w.printString(text, pair, attr) } -func (w *Window) FillString(text string, pair ColorPair, a Attr) bool { +func (w *TcellWindow) fillString(text string, pair ColorPair, a Attr) bool { lx := 0 var style tcell.Style - if _color { + if w.color { style = pair.style() } else { style = ColDefault.style() @@ -522,22 +495,22 @@ func (w *Window) FillString(text string, pair ColorPair, a Attr) bool { for _, r := range text { if r == '\n' { - w.win().LastY++ - w.win().LastX = 0 + w.lastY++ + w.lastX = 0 lx = 0 } else { - var xPos = w.Left + w.win().LastX + lx + var xPos = w.left + w.lastX + lx // word wrap: - if xPos >= (w.Left + w.Width) { - w.win().LastY++ - w.win().LastX = 0 + if xPos >= (w.left + w.width) { + w.lastY++ + w.lastX = 0 lx = 0 - xPos = w.Left + xPos = w.left } - var yPos = w.Top + w.win().LastY + var yPos = w.top + w.lastY - if yPos >= (w.Top + w.Height) { + if yPos >= (w.top + w.height) { return false } @@ -545,27 +518,27 @@ func (w *Window) FillString(text string, pair ColorPair, a Attr) bool { lx += runewidth.RuneWidth(r) } } - w.win().LastX += lx + w.lastX += lx return true } -func (w *Window) Fill(str string) bool { - return w.FillString(str, ColDefault, 0) +func (w *TcellWindow) Fill(str string) bool { + return w.fillString(str, ColDefault, 0) } -func (w *Window) CFill(str string, fg Color, bg Color, a Attr) bool { - return w.FillString(str, ColorPair{fg, bg}, a) +func (w *TcellWindow) CFill(fg Color, bg Color, a Attr, str string) bool { + return w.fillString(str, ColorPair{fg, bg, -1}, a) } -func (w *Window) DrawBorder() { - left := w.Left - right := left + w.Width - top := w.Top - bot := top + w.Height +func (w *TcellWindow) drawBorder() { + left := w.left + right := left + w.width + top := w.top + bot := top + w.height var style tcell.Style - if _color { + if w.color { style = ColBorder.style() } else { style = ColDefault.style() diff --git a/src/tui/tui.go b/src/tui/tui.go index 125611c..859eed7 100644 --- a/src/tui/tui.go +++ b/src/tui/tui.go @@ -115,6 +115,32 @@ const ( colWhite ) +type ColorPair struct { + fg Color + bg Color + id int16 +} + +func NewColorPair(fg Color, bg Color) ColorPair { + return ColorPair{fg, bg, -1} +} + +func (p ColorPair) Fg() Color { + return p.fg +} + +func (p ColorPair) Bg() Color { + return p.bg +} + +func (p ColorPair) key() int { + return (int(p.Fg()) << 8) + int(p.Bg()) +} + +func (p ColorPair) is24() bool { + return p.Fg().is24() || p.Bg().is24() +} + type ColorTheme struct { Fg Color Bg Color @@ -146,23 +172,84 @@ type MouseEvent struct { Mod bool } -var ( - _color bool - _prevDownTime time.Time - _clickY []int - Default16 *ColorTheme - Dark256 *ColorTheme - Light256 *ColorTheme -) +type Renderer interface { + Init() + Pause() + Resume() bool + Clear() + RefreshWindows(windows []Window) + Refresh() + Close() -type Window struct { - impl *WindowImpl - Top int - Left int - Width int - Height int + GetChar() Event + + MaxX() int + MaxY() int + DoesAutoWrap() bool + + NewWindow(top int, left int, width int, height int, border bool) Window } +type Window interface { + Top() int + Left() int + Width() int + Height() int + + Refresh() + FinishFill() + Close() + + X() int + Enclose(y int, x int) bool + + Move(y int, x int) + MoveAndClear(y int, x int) + Print(text string) + CPrint(color ColorPair, attr Attr, text string) + Fill(text string) bool + CFill(fg Color, bg Color, attr Attr, text string) bool + Erase() +} + +type FullscreenRenderer struct { + theme *ColorTheme + mouse bool + forceBlack bool + prevDownTime time.Time + clickY []int +} + +func NewFullscreenRenderer(theme *ColorTheme, forceBlack bool, mouse bool) Renderer { + r := &FullscreenRenderer{ + theme: theme, + mouse: mouse, + forceBlack: forceBlack, + prevDownTime: time.Unix(0, 0), + clickY: []int{}} + return r +} + +var ( + Default16 *ColorTheme + Dark256 *ColorTheme + Light256 *ColorTheme + + ColDefault ColorPair + ColNormal ColorPair + ColPrompt ColorPair + ColMatch ColorPair + ColCurrent ColorPair + ColCurrentMatch ColorPair + ColSpinner ColorPair + ColInfo ColorPair + ColCursor ColorPair + ColSelected ColorPair + ColHeader ColorPair + ColBorder ColorPair + ColUser ColorPair +) + func EmptyTheme() *ColorTheme { return &ColorTheme{ Fg: colUndefined, @@ -181,8 +268,6 @@ func EmptyTheme() *ColorTheme { } func init() { - _prevDownTime = time.Unix(0, 0) - _clickY = []int{} Default16 = &ColorTheme{ Fg: colDefault, Bg: colDefault, @@ -227,14 +312,13 @@ func init() { Border: 145} } -func InitTheme(theme *ColorTheme, black bool) { - _color = theme != nil - if !_color { +func initTheme(theme *ColorTheme, baseTheme *ColorTheme, forceBlack bool) { + if theme == nil { + initPalette(theme) return } - baseTheme := DefaultTheme() - if black { + if forceBlack { theme.Bg = colBlack } @@ -257,4 +341,48 @@ func InitTheme(theme *ColorTheme, black bool) { theme.Selected = o(baseTheme.Selected, theme.Selected) theme.Header = o(baseTheme.Header, theme.Header) theme.Border = o(baseTheme.Border, theme.Border) + + initPalette(theme) +} + +func initPalette(theme *ColorTheme) { + ColDefault = ColorPair{colDefault, colDefault, 0} + if theme != nil { + ColNormal = ColorPair{theme.Fg, theme.Bg, 1} + ColPrompt = ColorPair{theme.Prompt, theme.Bg, 2} + ColMatch = ColorPair{theme.Match, theme.Bg, 3} + ColCurrent = ColorPair{theme.Current, theme.DarkBg, 4} + ColCurrentMatch = ColorPair{theme.CurrentMatch, theme.DarkBg, 5} + ColSpinner = ColorPair{theme.Spinner, theme.Bg, 6} + ColInfo = ColorPair{theme.Info, theme.Bg, 7} + ColCursor = ColorPair{theme.Cursor, theme.DarkBg, 8} + ColSelected = ColorPair{theme.Selected, theme.DarkBg, 9} + ColHeader = ColorPair{theme.Header, theme.Bg, 10} + ColBorder = ColorPair{theme.Border, theme.Bg, 11} + } else { + ColNormal = ColorPair{colDefault, colDefault, 1} + ColPrompt = ColorPair{colDefault, colDefault, 2} + ColMatch = ColorPair{colDefault, colDefault, 3} + ColCurrent = ColorPair{colDefault, colDefault, 4} + ColCurrentMatch = ColorPair{colDefault, colDefault, 5} + ColSpinner = ColorPair{colDefault, colDefault, 6} + ColInfo = ColorPair{colDefault, colDefault, 7} + ColCursor = ColorPair{colDefault, colDefault, 8} + ColSelected = ColorPair{colDefault, colDefault, 9} + ColHeader = ColorPair{colDefault, colDefault, 10} + ColBorder = ColorPair{colDefault, colDefault, 11} + } + ColUser = ColorPair{colDefault, colDefault, 12} +} + +func attrFor(color ColorPair, attr Attr) Attr { + switch color { + case ColCurrent: + return attr | Reverse + case ColMatch: + return attr | Underline + case ColCurrentMatch: + return attr | Underline | Reverse + } + return attr } diff --git a/src/tui/tui_test.go b/src/tui/tui_test.go deleted file mode 100644 index 4a2fee9..0000000 --- a/src/tui/tui_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package tui - -import ( - "testing" -) - -func TestPairFor(t *testing.T) { - if PairFor(30, 50) != PairFor(30, 50) { - t.Fail() - } - if PairFor(-1, 10) != PairFor(-1, 10) { - t.Fail() - } -} diff --git a/src/util/util.go b/src/util/util.go index 2a1607c..29e8017 100644 --- a/src/util/util.go +++ b/src/util/util.go @@ -6,8 +6,24 @@ import ( "time" "github.com/junegunn/go-isatty" + "github.com/junegunn/go-runewidth" ) +var _runeWidths = make(map[rune]int) + +// RuneWidth returns rune width +func RuneWidth(r rune, prefixWidth int, tabstop int) int { + if r == '\t' { + return tabstop - prefixWidth%tabstop + } else if w, found := _runeWidths[r]; found { + return w + } else { + w := runewidth.RuneWidth(r) + _runeWidths[r] = w + return w + } +} + // Max returns the largest integer func Max(first int, second int) int { if first >= second { diff --git a/src/util/util_unix.go b/src/util/util_unix.go index 29e0d30..bc1b7b5 100644 --- a/src/util/util_unix.go +++ b/src/util/util_unix.go @@ -5,6 +5,7 @@ package util import ( "os" "os/exec" + "syscall" ) // ExecCommand executes the given command with $SHELL @@ -20,3 +21,8 @@ func ExecCommand(command string) *exec.Cmd { func IsWindows() bool { return false } + +// SetNonBlock executes syscall.SetNonblock on file descriptor +func SetNonblock(file *os.File, nonblock bool) { + syscall.SetNonblock(int(file.Fd()), nonblock) +} diff --git a/src/util/util_windows.go b/src/util/util_windows.go index 3aa8660..9ba4f79 100644 --- a/src/util/util_windows.go +++ b/src/util/util_windows.go @@ -5,6 +5,7 @@ package util import ( "os" "os/exec" + "syscall" "github.com/junegunn/go-shellwords" ) @@ -26,3 +27,8 @@ func ExecCommand(command string) *exec.Cmd { func IsWindows() bool { return true } + +// SetNonBlock executes syscall.SetNonblock on file descriptor +func SetNonblock(file *os.File, nonblock bool) { + syscall.SetNonblock(syscall.Handle(file.Fd()), nonblock) +} diff --git a/test/test_go.rb b/test/test_go.rb index 4cd4a4f..55422aa 100644 --- a/test/test_go.rb +++ b/test/test_go.rb @@ -117,8 +117,28 @@ class Tmux wait do lines = capture(pane) class << lines + def counts + self.lazy + .map { |l| l.scan /^. ([0-9]+)\/([0-9]+)( \(([0-9]+)\))?/ } + .reject(&:empty?) + .first&.first&.map(&:to_i)&.values_at(0, 1, 3) || [0, 0, 0] + end + + def match_count + counts[0] + end + def item_count - self[-2] ? self[-2].strip.split('/').last.to_i : 0 + counts[1] + end + + def select_count + counts[2] + end + + def any_include? val + method = val.is_a?(Regexp) ? :match : :include? + self.select { |line| line.send method, val }.first end end yield lines @@ -163,6 +183,11 @@ class TestBase < Minitest::Test @temp_suffix].join '-' end + def writelines path, lines + File.unlink path while File.exists? path + File.open(path, 'w') { |f| f << lines.join($/) + $/ } + end + def readonce wait { File.exists?(tempname) } File.read(tempname) @@ -1190,12 +1215,6 @@ class TestGoFZF < TestBase tmux.send_keys '?' tmux.until { |lines| lines[-1] == '> 555' } end - -private - def writelines path, lines - File.unlink path while File.exists? path - File.open(path, 'w') { |f| f << lines.join($/) + $/ } - end end module TestShell @@ -1213,79 +1232,60 @@ module TestShell tmux.prepare end - def test_ctrl_t + def unset_var name + tmux.prepare + tmux.send_keys "unset #{name}", :Enter tmux.prepare - tmux.send_keys 'C-t', pane: 0 - lines = tmux.until(1) { |lines| lines.item_count > 1 } - expected = lines.values_at(-3, -4).map { |line| line[2..-1] }.join(' ') - tmux.send_keys :BTab, :BTab, pane: 1 - tmux.until(1) { |lines| lines[-2].include?('(2)') } - tmux.send_keys :Enter, pane: 1 - tmux.until(0) { |lines| lines[-1].include? expected } - tmux.send_keys 'C-c' - - # FZF_TMUX=0 - new_shell - tmux.send_keys 'C-t', pane: 0 - lines = tmux.until(0) { |lines| lines.item_count > 1 } - expected = lines.values_at(-3, -4).map { |line| line[2..-1] }.join(' ') - tmux.send_keys :BTab, :BTab, pane: 0 - tmux.until(0) { |lines| lines[-2].include?('(2)') } - tmux.send_keys :Enter, pane: 0 - tmux.until(0) { |lines| lines[-1].include? expected } - tmux.send_keys 'C-c', 'C-d' end - def test_ctrl_t_command + def test_ctrl_t set_var "FZF_CTRL_T_COMMAND", "seq 100" - tmux.send_keys 'C-t', pane: 0 - lines = tmux.until(1) { |lines| lines.item_count == 100 } - tmux.send_keys :BTab, :BTab, :BTab, pane: 1 - tmux.until(1) { |lines| lines[-2].include?('(3)') } - tmux.send_keys :Enter, pane: 1 - tmux.until(0) { |lines| lines[-1].include? '1 2 3' } + + tmux.prepare + tmux.send_keys 'C-t' + lines = tmux.until { |lines| lines.item_count == 100 } + tmux.send_keys :Tab, :Tab, :Tab + tmux.until { |lines| lines.any_include? ' (3)' } + tmux.send_keys :Enter + tmux.until { |lines| lines.any_include? '1 2 3' } + tmux.send_keys 'C-c' end def test_ctrl_t_unicode - FileUtils.mkdir_p '/tmp/fzf-test' - tmux.paste 'cd /tmp/fzf-test; echo -n test1 > "fzf-unicode 테스트1"; echo -n test2 > "fzf-unicode 테스트2"' + writelines tempname, ['fzf-unicode 테스트1', 'fzf-unicode 테스트2'] + set_var "FZF_CTRL_T_COMMAND", "cat #{tempname}" + tmux.prepare - tmux.send_keys 'cat ', 'C-t', pane: 0 - tmux.until(1) { |lines| lines.item_count >= 1 } - tmux.send_keys 'fzf-unicode', pane: 1 - redraw = ->() { tmux.send_keys 'C-l', pane: 1 } - tmux.until(1) { |lines| redraw.(); lines[-2] =~ %r(^ *2/) } + tmux.send_keys 'echo ', 'C-t' + tmux.until { |lines| lines.item_count == 2 } + tmux.send_keys 'fzf-unicode' + tmux.until { |lines| lines.match_count == 2 } - tmux.send_keys '1', pane: 1 - tmux.until(1) { |lines| redraw.(); lines[-2] =~ %r(^ *1/) } - tmux.send_keys :BTab, pane: 1 - tmux.until(1) { |lines| redraw.(); lines[-2].include? '(1)' } + tmux.send_keys '1' + tmux.until { |lines| lines.match_count == 1 } + tmux.send_keys :Tab + tmux.until { |lines| lines.select_count == 1 } - tmux.send_keys :BSpace, pane: 1 - tmux.until(1) { |lines| redraw.(); lines[-2] =~ %r(^ *2/) } + tmux.send_keys :BSpace + tmux.until { |lines| lines.match_count == 2 } - tmux.send_keys '2', pane: 1 - tmux.until(1) { |lines| redraw.(); lines[-2] =~ %r(^ *1/) } - tmux.send_keys :BTab, pane: 1 - tmux.until(1) { |lines| redraw.(); lines[-2].include? '(2)' } + tmux.send_keys '2' + tmux.until { |lines| lines.match_count == 1 } + tmux.send_keys :Tab + tmux.until { |lines| lines.select_count == 2 } - tmux.send_keys :Enter, pane: 1 - tmux.until do |lines| - tmux.send_keys 'C-l' - [-1, -2].map { |offset| lines[offset] }.any? do |line| - line.start_with?('cat') && line.include?('fzf-unicode') - end - end tmux.send_keys :Enter - tmux.until { |lines| lines[-1].include? 'test1test2' } + tmux.until { |lines| lines.any_include? /echo.*fzf-unicode.*1.*fzf-unicode.*2/ } + tmux.send_keys :Enter + tmux.until { |lines| lines.any_include? /^fzf-unicode.*1.*fzf-unicode.*2/ } end def test_alt_c tmux.prepare - tmux.send_keys :Escape, :c, pane: 0 - lines = tmux.until(1) { |lines| lines.item_count > 0 && lines[-3][2..-1] } - expected = lines[-3][2..-1] - tmux.send_keys :Enter, pane: 1 + tmux.send_keys :Escape, :c + lines = tmux.until { |lines| lines.item_count > 0 } + expected = lines.reverse.select { |l| l.start_with? '>' }.first[2..-1] + tmux.send_keys :Enter tmux.prepare tmux.send_keys :pwd, :Enter tmux.until { |lines| lines[-1].end_with?(expected) } @@ -1298,9 +1298,9 @@ module TestShell tmux.send_keys 'cd /', :Enter tmux.prepare - tmux.send_keys :Escape, :c, pane: 0 - lines = tmux.until(1) { |lines| lines.item_count == 1 } - tmux.send_keys :Enter, pane: 1 + tmux.send_keys :Escape, :c + lines = tmux.until { |lines| lines.item_count == 1 } + tmux.send_keys :Enter tmux.prepare tmux.send_keys :pwd, :Enter @@ -1314,11 +1314,11 @@ module TestShell tmux.send_keys 'echo 3d', :Enter; tmux.prepare tmux.send_keys 'echo 3rd', :Enter; tmux.prepare tmux.send_keys 'echo 4th', :Enter; tmux.prepare - tmux.send_keys 'C-r', pane: 0 - tmux.until(1) { |lines| lines.item_count > 0 } - tmux.send_keys '3d', pane: 1 - tmux.until(1) { |lines| lines[-3].end_with? 'echo 3rd' } # --no-sort - tmux.send_keys :Enter, pane: 1 + tmux.send_keys 'C-r' + tmux.until { |lines| lines.item_count > 0 } + tmux.send_keys '3d' + tmux.until { |lines| lines[-3].end_with? 'echo 3rd' } + tmux.send_keys :Enter tmux.until { |lines| lines[-1] == 'echo 3rd' } tmux.send_keys :Enter tmux.until { |lines| lines[-1] == '3rd' } @@ -1334,12 +1334,12 @@ module CompletionTest FileUtils.touch File.expand_path(f) end tmux.prepare - tmux.send_keys 'cat /tmp/fzf-test/10**', :Tab, pane: 0 - tmux.until(1) { |lines| lines.item_count > 0 } + tmux.send_keys 'cat /tmp/fzf-test/10**', :Tab + tmux.until { |lines| lines.item_count > 0 } tmux.send_keys ' !d' - tmux.until(1) { |lines| lines[-2].include?(' 2/') } - tmux.send_keys :BTab, :BTab - tmux.until(1) { |lines| lines[-2].include?('(2)') } + tmux.until { |lines| lines.match_count == 2 } + tmux.send_keys :Tab, :Tab + tmux.until { |lines| lines.select_count == 2 } tmux.send_keys :Enter tmux.until do |lines| tmux.send_keys 'C-L' @@ -1349,10 +1349,10 @@ module CompletionTest # ~USERNAME** tmux.send_keys 'C-u' - tmux.send_keys "cat ~#{ENV['USER']}**", :Tab, pane: 0 - tmux.until(1) { |lines| lines.item_count > 0 } - tmux.send_keys '.fzf-home' - tmux.until(1) { |lines| lines[-3].end_with? '.fzf-home' } + tmux.send_keys "cat ~#{ENV['USER']}**", :Tab + tmux.until { |lines| lines.item_count > 0 } + tmux.send_keys "'.fzf-home" + tmux.until { |lines| lines.select { |l| l.include? '.fzf-home' }.count > 1 } tmux.send_keys :Enter tmux.until do |lines| tmux.send_keys 'C-L' @@ -1361,8 +1361,8 @@ module CompletionTest # ~INVALID_USERNAME** tmux.send_keys 'C-u' - tmux.send_keys "cat ~such**", :Tab, pane: 0 - tmux.until(1) { |lines| lines[-3].end_with? 'no~such~user' } + tmux.send_keys "cat ~such**", :Tab + tmux.until { |lines| lines.any_include? 'no~such~user' } tmux.send_keys :Enter tmux.until do |lines| tmux.send_keys 'C-L' @@ -1371,9 +1371,11 @@ module CompletionTest # /tmp/fzf\ test** tmux.send_keys 'C-u' - tmux.send_keys 'cat /tmp/fzf\ test/**', :Tab, pane: 0 - tmux.until(1) { |lines| lines.item_count > 0 } - tmux.send_keys 'C-K', :Enter + tmux.send_keys 'cat /tmp/fzf\ test/**', :Tab + tmux.until { |lines| lines.item_count > 0 } + tmux.send_keys 'foobar$' + tmux.until { |lines| lines.match_count == 1 } + tmux.send_keys :Enter tmux.until do |lines| tmux.send_keys 'C-L' lines[-1].end_with?('/tmp/fzf\ test/foobar') @@ -1382,11 +1384,10 @@ module CompletionTest # Should include hidden files (1..100).each { |i| FileUtils.touch "/tmp/fzf-test/.hidden-#{i}" } tmux.send_keys 'C-u' - tmux.send_keys 'cat /tmp/fzf-test/hidden**', :Tab, pane: 0 - tmux.until(1) do |lines| + tmux.send_keys 'cat /tmp/fzf-test/hidden**', :Tab + tmux.until do |lines| tmux.send_keys 'C-L' - lines[-2].include?('100/') && - lines[-3].include?('/tmp/fzf-test/.hidden-') + lines.match_count == 100 && lines.any_include?('/tmp/fzf-test/.hidden-') end tmux.send_keys :Enter ensure @@ -1396,19 +1397,22 @@ module CompletionTest end def test_file_completion_root - tmux.send_keys 'ls /**', :Tab, pane: 0 - tmux.until(1) { |lines| lines.item_count > 0 } + tmux.send_keys 'ls /**', :Tab + tmux.until { |lines| lines.item_count > 0 } tmux.send_keys :Enter end def test_dir_completion - tmux.send_keys 'mkdir -p /tmp/fzf-test/d{1..100}; touch /tmp/fzf-test/d55/xxx', :Enter + (1..100).each do |idx| + FileUtils.mkdir_p "/tmp/fzf-test/d#{idx}" + end + FileUtils.touch '/tmp/fzf-test/d55/xxx' tmux.prepare - tmux.send_keys 'cd /tmp/fzf-test/**', :Tab, pane: 0 - tmux.until(1) { |lines| lines.item_count > 0 } - tmux.send_keys :BTab, :BTab # BTab does not work here + tmux.send_keys 'cd /tmp/fzf-test/**', :Tab + tmux.until { |lines| lines.item_count > 0 } + tmux.send_keys :Tab, :Tab # Tab does not work here tmux.send_keys 55 - tmux.until(1) { |lines| lines[-2].start_with? ' 1/' } + tmux.until { |lines| lines.match_count == 1 } tmux.send_keys :Enter tmux.until do |lines| tmux.send_keys 'C-L' @@ -1435,14 +1439,15 @@ module CompletionTest lines = tmux.until { |lines| lines[-1].start_with? '[1]' } pid = lines[-1].split.last tmux.prepare - tmux.send_keys 'kill ', :Tab, pane: 0 - tmux.until(1) { |lines| lines.item_count > 0 } + tmux.send_keys 'C-L' + tmux.send_keys 'kill ', :Tab + tmux.until { |lines| lines.item_count > 0 } tmux.send_keys 'sleep12345' - tmux.until(1) { |lines| lines[-3].include? 'sleep 12345' } + tmux.until { |lines| lines.any_include? 'sleep 12345' } tmux.send_keys :Enter tmux.until do |lines| tmux.send_keys 'C-L' - lines[-1] == "kill #{pid}" + lines[-1].include? "kill #{pid}" end ensure Process.kill 'KILL', pid.to_i rescue nil if pid @@ -1451,10 +1456,10 @@ module CompletionTest def test_custom_completion tmux.send_keys '_fzf_compgen_path() { echo "\$1"; seq 10; }', :Enter tmux.prepare - tmux.send_keys 'ls /tmp/**', :Tab, pane: 0 - tmux.until(1) { |lines| lines.item_count == 11 } - tmux.send_keys :BTab, :BTab, :BTab - tmux.until(1) { |lines| lines[-2].include? '(3)' } + tmux.send_keys 'ls /tmp/**', :Tab + tmux.until { |lines| lines.item_count == 11 } + tmux.send_keys :Tab, :Tab, :Tab + tmux.until { |lines| lines.select_count == 3 } tmux.send_keys :Enter tmux.until do |lines| tmux.send_keys 'C-L' @@ -1463,49 +1468,48 @@ module CompletionTest end def test_unset_completion - tmux.send_keys 'export FOO=BAR', :Enter + tmux.send_keys 'export FZFFOOBAR=BAZ', :Enter tmux.prepare # Using tmux - tmux.send_keys 'unset FOO**', :Tab, pane: 0 - tmux.until(1) { |lines| lines[-2].include? ' 1/' } + tmux.send_keys 'unset FZFFOO**', :Tab + tmux.until { |lines| lines.match_count == 1 } tmux.send_keys :Enter - tmux.until { |lines| lines[-1] == 'unset FOO' } + tmux.until { |lines| lines[-1].include? 'unset FZFFOOBAR' } tmux.send_keys 'C-c' - # FZF_TMUX=0 + # FZF_TMUX=1 new_shell - tmux.send_keys 'unset FOO**', :Tab - tmux.until { |lines| lines[-2].include? ' 1/' } + tmux.send_keys 'unset FZFFO**', :Tab, pane: 0 + tmux.until(1) { |lines| lines.match_count == 1 } tmux.send_keys :Enter - tmux.until { |lines| lines[-1] == 'unset FOO' } + tmux.until { |lines| lines[-1].include? 'unset FZFFOOBAR' } end def test_file_completion_unicode FileUtils.mkdir_p '/tmp/fzf-test' tmux.paste 'cd /tmp/fzf-test; echo -n test3 > "fzf-unicode 테스트1"; echo -n test4 > "fzf-unicode 테스트2"' tmux.prepare - tmux.send_keys 'cat fzf-unicode**', :Tab, pane: 0 - redraw = ->() { tmux.send_keys 'C-l', pane: 1 } - tmux.until(1) { |lines| redraw.(); lines[-2] =~ %r(^ *2/) } + tmux.send_keys 'cat fzf-unicode**', :Tab + tmux.until { |lines| lines.match_count == 2 } - tmux.send_keys '1', pane: 1 - tmux.until(1) { |lines| redraw.(); lines[-2] =~ %r(^ *1/) } - tmux.send_keys :BTab, pane: 1 - tmux.until(1) { |lines| redraw.(); lines[-2].include? '(1)' } + tmux.send_keys '1' + tmux.until { |lines| lines.match_count == 1 } + tmux.send_keys :Tab + tmux.until { |lines| lines.select_count == 1 } - tmux.send_keys :BSpace, pane: 1 - tmux.until(1) { |lines| redraw.(); lines[-2] =~ %r(^ *2/) } + tmux.send_keys :BSpace + tmux.until { |lines| lines.match_count == 2 } - tmux.send_keys '2', pane: 1 - tmux.until(1) { |lines| redraw.(); lines[-2] =~ %r(^ *1/) } - tmux.send_keys :BTab, pane: 1 - tmux.until(1) { |lines| redraw.(); lines[-2].include? '(2)' } + tmux.send_keys '2' + tmux.until { |lines| lines.select_count == 1 } + tmux.send_keys :Tab + tmux.until { |lines| lines.select_count == 2 } - tmux.send_keys :Enter, pane: 1 + tmux.send_keys :Enter tmux.until do |lines| tmux.send_keys 'C-l' - lines[-1].include?('cat') || lines[-2].include?('cat') + lines.any_include? 'cat' end tmux.send_keys :Enter tmux.until { |lines| lines[-1].include? 'test3test4' } @@ -1518,7 +1522,7 @@ class TestBash < TestBase def new_shell tmux.prepare - tmux.send_keys "FZF_TMUX=0 #{Shell.bash}", :Enter + tmux.send_keys "FZF_TMUX=1 #{Shell.bash}", :Enter tmux.prepare end @@ -1533,7 +1537,7 @@ class TestZsh < TestBase include CompletionTest def new_shell - tmux.send_keys "FZF_TMUX=0 #{Shell.zsh}", :Enter + tmux.send_keys "FZF_TMUX=1 #{Shell.zsh}", :Enter tmux.prepare end @@ -1547,7 +1551,7 @@ class TestFish < TestBase include TestShell def new_shell - tmux.send_keys 'env FZF_TMUX=0 fish', :Enter + tmux.send_keys 'env FZF_TMUX=1 fish', :Enter tmux.send_keys 'function fish_prompt; end; clear', :Enter tmux.until { |lines| lines.empty? } end