diff options
-rw-r--r-- | Makefile | 2 | ||||
-rw-r--r-- | README.md | 38 | ||||
-rw-r--r-- | src/choices.c | 2 | ||||
-rw-r--r-- | src/fzy.c | 11 | ||||
-rw-r--r-- | src/tty.c | 12 | ||||
-rw-r--r-- | src/tty.h | 3 | ||||
-rw-r--r-- | src/tty_interface.c | 69 | ||||
-rw-r--r-- | test/acceptance/acceptance_test.rb | 191 |
8 files changed, 256 insertions, 72 deletions
@@ -22,7 +22,7 @@ test/fzytest: $(TESTOBJECTS) $(CC) $(CFLAGS) $(CCFLAGS) -Isrc -o $@ $(TESTOBJECTS) $(LIBS) acceptance: fzy - cd test/acceptance && bundle && bundle exec ruby acceptance_test.rb + cd test/acceptance && bundle --quiet && bundle exec ruby acceptance_test.rb test: check check: test/fzytest @@ -3,7 +3,7 @@ **fzy** is a fast, simple fuzzy text selector for the terminal with an advanced [scoring algorithm](#sorting). -![](http://i.hawth.ca/u/fzy2.gif) +![](http://i.hawth.ca/u/fzy_animated_demo.svg) <blockquote> It's been kind of life-changing. @@ -41,30 +41,19 @@ Rather than clearing the screen, fzy displays its interface directly below the c The `PREFIX` environment variable can be used to specify the install location, the default is `/usr/local`. -### MacOS - -Using Homebrew - - brew install fzy - -Using MacPorts - - sudo port install fzy +### Arch Linux/MSYS2 -### Ubuntu/Debian 64-bit +fzy is available in the `community` repo. - wget https://github.com/jhawthorn/fzy/releases/download/0.9/fzy_0.9-1_amd64.deb - sudo dpkg -i fzy_0.9-1_amd64.deb + sudo pacman -S fzy ### Fedora/Redhat/CentOS sudo yum install https://github.com/jhawthorn/fzy/releases/download/0.9/fzy-0.9-1.x86_64.rpm -### Arch Linux/MSYS2 - -fzy is available in the `community` repo. +### FreeBSD - sudo pacman -S fzy + pkg install fzy ### Gentoo Linux @@ -72,6 +61,21 @@ fzy is available in the main repo. emerge -av app-shells/fzy +### macOS + +Using Homebrew + + brew install fzy + +Using MacPorts + + sudo port install fzy + +### Ubuntu/Debian 64-bit + + wget https://github.com/jhawthorn/fzy/releases/download/0.9/fzy_0.9-1_amd64.deb + sudo dpkg -i fzy_0.9-1_amd64.deb + ### pkgsrc (NetBSD and others) sudo pkgin install fzy diff --git a/src/choices.c b/src/choices.c index 64f9a8b..a8c24b6 100644 --- a/src/choices.c +++ b/src/choices.c @@ -21,7 +21,7 @@ static int cmpchoice(const void *_idx1, const void *_idx2) { if (a->score == b->score) { /* To ensure a stable sort, we must also sort by the string - * pointers. We can do this since we know all the stings are + * pointers. We can do this since we know all the strings are * from a contiguous memory segment (buffer in choices_t). */ if (a->str < b->str) { @@ -3,6 +3,7 @@ #include <stdlib.h> #include <ctype.h> #include <limits.h> +#include <unistd.h> #include "match.h" #include "tty.h" @@ -20,16 +21,17 @@ int main(int argc, char *argv[]) { choices_t choices; choices_init(&choices, &options); - choices_fread(&choices, stdin); if (options.benchmark) { if (!options.filter) { fprintf(stderr, "Must specify -e/--show-matches with --benchmark\n"); exit(EXIT_FAILURE); } + choices_fread(&choices, stdin); for (int i = 0; i < options.benchmark; i++) choices_search(&choices, options.filter); } else if (options.filter) { + choices_fread(&choices, stdin); choices_search(&choices, options.filter); for (size_t i = 0; i < choices_available(&choices); i++) { if (options.show_scores) @@ -38,9 +40,16 @@ int main(int argc, char *argv[]) { } } else { /* interactive */ + + if (isatty(STDIN_FILENO)) + choices_fread(&choices, stdin); + tty_t tty; tty_init(&tty, options.tty_filename); + if (!isatty(STDIN_FILENO)) + choices_fread(&choices, stdin); + if (options.num_lines > choices.size) options.num_lines = choices.size; @@ -111,11 +111,23 @@ void tty_setinvert(tty_t *tty) { tty_sgr(tty, 7); } +void tty_setunderline(tty_t *tty) { + tty_sgr(tty, 4); +} + void tty_setnormal(tty_t *tty) { tty_sgr(tty, 0); tty->fgcolor = 9; } +void tty_setnowrap(tty_t *tty) { + tty_printf(tty, "%c%c?7l", 0x1b, '['); +} + +void tty_setwrap(tty_t *tty) { + tty_printf(tty, "%c%c?7h", 0x1b, '['); +} + void tty_newline(tty_t *tty) { tty_printf(tty, "%c%cK\n", 0x1b, '['); } @@ -21,7 +21,10 @@ int tty_input_ready(tty_t *tty, int pending); void tty_setfg(tty_t *tty, int fg); void tty_setinvert(tty_t *tty); +void tty_setunderline(tty_t *tty); void tty_setnormal(tty_t *tty); +void tty_setnowrap(tty_t *tty); +void tty_setwrap(tty_t *tty); #define TTY_COLOR_BLACK 0 #define TTY_COLOR_RED 1 diff --git a/src/tty_interface.c b/src/tty_interface.c index 8081f44..f6aeef0 100644 --- a/src/tty_interface.c +++ b/src/tty_interface.c @@ -7,6 +7,14 @@ #include "tty_interface.h" #include "../config.h" +static int isprint_unicode(char c) { + return isprint(c) || c & (1 << 7); +} + +static int is_boundary(char c) { + return ~c & (1 << 7) || c & (1 << 6); +} + static void clear(tty_interface_t *state) { tty_t *tty = state->tty; @@ -34,34 +42,32 @@ static void draw_match(tty_interface_t *state, const char *choice, int selected) score_t score = match_positions(search, choice, &positions[0]); - size_t maxwidth = tty_getwidth(tty); - - if (options->show_scores && maxwidth >= 9) { + if (options->show_scores) { if (score == SCORE_MIN) { tty_printf(tty, "( ) "); } else { tty_printf(tty, "(%5.2f) ", score); } - maxwidth -= 8; } if (selected) +#ifdef TTY_SELECTION_UNDERLINE + tty_setunderline(tty); +#else tty_setinvert(tty); +#endif + tty_setnowrap(tty); for (size_t i = 0, p = 0; choice[i] != '\0'; i++) { - if (i + 1 < maxwidth) { - if (positions[p] == i) { - tty_setfg(tty, TTY_COLOR_HIGHLIGHT); - p++; - } else { - tty_setfg(tty, TTY_COLOR_NORMAL); - } - tty_printf(tty, "%c", choice[i]); + if (positions[p] == i) { + tty_setfg(tty, TTY_COLOR_HIGHLIGHT); + p++; } else { - tty_printf(tty, "$"); - break; + tty_setfg(tty, TTY_COLOR_NORMAL); } + tty_printf(tty, "%c", choice[i]); } + tty_setwrap(tty); tty_setnormal(tty); } @@ -95,7 +101,10 @@ static void draw(tty_interface_t *state) { tty_moveup(tty, num_lines); } - tty_setcol(tty, strlen(options->prompt) + state->cursor); + tty_setcol(tty, 0); + fputs(options->prompt, tty->fout); + for (size_t i = 0; i < state->cursor; i++) + fputc(state->search[i], tty->fout); tty_flush(tty); } @@ -135,12 +144,16 @@ static void action_emit(tty_interface_t *state) { static void action_del_char(tty_interface_t *state) { if (*state->search) { size_t length = strlen(state->search); - if(state->cursor == 0) { + if (state->cursor == 0) { return; } + size_t original_cursor = state->cursor; state->cursor--; - memmove(&state->search[state->cursor], &state->search[state->cursor + 1], length - state->cursor); + while (!is_boundary(state->search[state->cursor]) && state->cursor) + state->cursor--; + + memmove(&state->search[state->cursor], &state->search[original_cursor], length - original_cursor + 1); } } @@ -169,7 +182,7 @@ static void action_prev(tty_interface_t *state) { } static void action_ignore(tty_interface_t *state) { - (void) state; + (void)state; } static void action_next(tty_interface_t *state) { @@ -178,13 +191,19 @@ static void action_next(tty_interface_t *state) { } static void action_left(tty_interface_t *state) { - if (state->cursor > 0) + if (state->cursor > 0) { state->cursor--; + while (!is_boundary(state->search[state->cursor]) && state->cursor) + state->cursor--; + } } static void action_right(tty_interface_t *state) { - if (state->cursor < strlen(state->search)) + if (state->cursor < strlen(state->search)) { state->cursor++; + while (!is_boundary(state->search[state->cursor])) + state->cursor++; + } } static void action_beginning(tty_interface_t *state) { @@ -197,13 +216,13 @@ static void action_end(tty_interface_t *state) { static void action_pageup(tty_interface_t *state) { update_state(state); - for(size_t i = 0; i < state->options->num_lines && state->choices->selection > 0; i++) + for (size_t i = 0; i < state->options->num_lines && state->choices->selection > 0; i++) choices_prev(state->choices); } static void action_pagedown(tty_interface_t *state) { update_state(state); - for(size_t i = 0; i < state->options->num_lines && state->choices->selection < state->choices->available-1; i++) + for (size_t i = 0; i < state->options->num_lines && state->choices->selection < state->choices->available - 1; i++) choices_next(state->choices); } @@ -273,8 +292,8 @@ static const keybinding_t keybindings[] = {{"\x1b", action_exit}, /* ESC * {KEY_CTRL('M'), action_emit}, /* CR */ {KEY_CTRL('P'), action_prev}, /* C-P */ {KEY_CTRL('N'), action_next}, /* C-N */ - {KEY_CTRL('K'), action_prev}, /* C-J */ - {KEY_CTRL('J'), action_next}, /* C-K */ + {KEY_CTRL('K'), action_prev}, /* C-K */ + {KEY_CTRL('J'), action_next}, /* C-J */ {KEY_CTRL('A'), action_beginning}, /* C-A */ {KEY_CTRL('E'), action_end}, /* C-E */ @@ -335,7 +354,7 @@ static void handle_input(tty_interface_t *state, const char *s, int handle_ambig /* No matching keybinding, add to search */ for (int i = 0; input[i]; i++) - if (isprint(input[i])) + if (isprint_unicode(input[i])) append_search(state, input[i]); /* We have processed the input, so clear it */ diff --git a/test/acceptance/acceptance_test.rb b/test/acceptance/acceptance_test.rb index 4a11cac..52c6af0 100644 --- a/test/acceptance/acceptance_test.rb +++ b/test/acceptance/acceptance_test.rb @@ -5,8 +5,11 @@ require 'ttytest' class FzyTest < Minitest::Test FZY_PATH = File.expand_path('../../../fzy', __FILE__) + LEFT = "\e[D" + RIGHT = "\e[C" + def test_empty_list - @tty = TTYtest.new_terminal(%{echo placeholder;echo -n "" | #{FZY_PATH}}) + @tty = interactive_fzy(input: %w[], before: "placeholder") @tty.assert_cursor_position(y: 1, x: 2) @tty.assert_matches <<~TTY placeholder @@ -36,7 +39,7 @@ class FzyTest < Minitest::Test end def test_one_item - @tty = TTYtest.new_terminal(%{echo placeholder;echo -n "test" | #{FZY_PATH}}) + @tty = interactive_fzy(input: %w[test], before: "placeholder") @tty.assert_matches <<~TTY placeholder > @@ -68,7 +71,7 @@ class FzyTest < Minitest::Test end def test_two_items - @tty = TTYtest.new_terminal(%{echo placeholder;echo -n "test\nfoo" | #{FZY_PATH}}) + @tty = interactive_fzy(input: %w[test foo], before: "placeholder") @tty.assert_cursor_position(y: 1, x: 2) @tty.assert_matches <<~TTY placeholder @@ -105,7 +108,7 @@ class FzyTest < Minitest::Test end def test_editing - @tty = TTYtest.new_terminal(%{echo placeholder;echo -n "test\nfoo" | #{FZY_PATH}}) + @tty = interactive_fzy(input: %w[test foo], before: "placeholder") @tty.assert_cursor_position(y: 1, x: 2) @tty.assert_matches <<~TTY placeholder @@ -146,7 +149,7 @@ class FzyTest < Minitest::Test end def test_ctrl_d - @tty = TTYtest.new_terminal(%{echo -n "foo\nbar" | #{FZY_PATH}}) + @tty = interactive_fzy(input: %w[foo bar]) @tty.assert_matches ">\nfoo\nbar" @tty.send_keys('foo') @@ -158,7 +161,7 @@ class FzyTest < Minitest::Test end def test_ctrl_c - @tty = TTYtest.new_terminal(%{echo -n "foo\nbar" | #{FZY_PATH}}) + @tty = interactive_fzy(input: %w[foo bar]) @tty.assert_matches ">\nfoo\nbar" @tty.send_keys('foo') @@ -170,25 +173,25 @@ class FzyTest < Minitest::Test end def test_down_arrow - @tty = TTYtest.new_terminal(%{echo -n "foo\nbar" | #{FZY_PATH}}) + @tty = interactive_fzy(input: %w[foo bar]) @tty.assert_matches ">\nfoo\nbar" @tty.send_keys("\e[A\r") @tty.assert_matches "bar" - @tty = TTYtest.new_terminal(%{echo -n "foo\nbar" | #{FZY_PATH}}) + @tty = interactive_fzy(input: %w[foo bar]) @tty.assert_matches ">\nfoo\nbar" @tty.send_keys("\eOA\r") @tty.assert_matches "bar" end def test_up_arrow - @tty = TTYtest.new_terminal(%{echo -n "foo\nbar" | #{FZY_PATH}}) + @tty = interactive_fzy(input: %w[foo bar]) @tty.assert_matches ">\nfoo\nbar" @tty.send_keys("\e[A") # first down @tty.send_keys("\e[B\r") # and back up @tty.assert_matches "foo" - @tty = TTYtest.new_terminal(%{echo -n "foo\nbar" | #{FZY_PATH}}) + @tty = interactive_fzy(input: %w[foo bar]) @tty.assert_matches ">\nfoo\nbar" @tty.send_keys("\eOA") # first down @tty.send_keys("\e[B\r") # and back up @@ -196,45 +199,48 @@ class FzyTest < Minitest::Test end def test_lines - @tty = TTYtest.new_terminal(%{seq 10 | #{FZY_PATH}}) + input10 = (1..10).map(&:to_s) + input20 = (1..20).map(&:to_s) + + @tty = interactive_fzy(input: input10) @tty.assert_matches ">\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10" - @tty = TTYtest.new_terminal(%{seq 20 | #{FZY_PATH}}) + @tty = interactive_fzy(input: input20) @tty.assert_matches ">\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10" - @tty = TTYtest.new_terminal(%{seq 10 | #{FZY_PATH} -l 5}) + @tty = interactive_fzy(input: input10, args: "-l 5") @tty.assert_matches ">\n1\n2\n3\n4\n5" - @tty = TTYtest.new_terminal(%{seq 10 | #{FZY_PATH} --lines=5}) + @tty = interactive_fzy(input: input10, args: "--lines=5") @tty.assert_matches ">\n1\n2\n3\n4\n5" end def test_prompt - @tty = TTYtest.new_terminal(%{echo -n "" | #{FZY_PATH}}) + @tty = interactive_fzy @tty.send_keys("foo") @tty.assert_matches '> foo' - @tty = TTYtest.new_terminal(%{echo -n "" | #{FZY_PATH} -p 'C:\\'}) + @tty = interactive_fzy(args: "-p 'C:\\'") @tty.send_keys("foo") @tty.assert_matches 'C:\foo' - @tty = TTYtest.new_terminal(%{echo -n "" | #{FZY_PATH} --prompt="foo bar "}) + @tty = interactive_fzy(args: "--prompt=\"foo bar \"") @tty.send_keys("baz") @tty.assert_matches "foo bar baz" end def test_show_scores expected_score = '( inf)' - @tty = TTYtest.new_terminal(%{echo -n "foo\nbar" | #{FZY_PATH} -s}) + @tty = interactive_fzy(input: %w[foo bar], args: "-s") @tty.send_keys('foo') @tty.assert_matches "> foo\n#{expected_score} foo" - @tty = TTYtest.new_terminal(%{echo -n "foo\nbar" | #{FZY_PATH} --show-scores}) + @tty = interactive_fzy(input: %w[foo bar], args: "--show-scores") @tty.send_keys('foo') @tty.assert_matches "> foo\n#{expected_score} foo" expected_score = '( 0.89)' - @tty = TTYtest.new_terminal(%{echo -n "foo\nbar" | #{FZY_PATH} -s}) + @tty = interactive_fzy(input: %w[foo bar], args: "-s") @tty.send_keys('f') @tty.assert_matches "> f\n#{expected_score} foo" end @@ -252,7 +258,7 @@ class FzyTest < Minitest::Test end def test_worker_count - @tty = TTYtest.new_terminal(%{echo -n "foo\nbar" | #{FZY_PATH} -j1}) + @tty = interactive_fzy(input: %w[foo bar], args: "-j1") @tty.send_keys('foo') @tty.assert_matches "> foo\nfoo" @@ -266,29 +272,29 @@ class FzyTest < Minitest::Test end def test_initial_query - @tty = TTYtest.new_terminal(%{echo -n "foo\nbar" | #{FZY_PATH} -q fo}) + @tty = interactive_fzy(input: %w[foo bar], args: "-q fo") @tty.assert_matches "> fo\nfoo" @tty.send_keys("o") @tty.assert_matches "> foo\nfoo" @tty.send_keys("o") @tty.assert_matches "> fooo" - @tty = TTYtest.new_terminal(%{echo -n "foo\nbar" | #{FZY_PATH} -q asdf}) + @tty = interactive_fzy(input: %w[foo bar], args: "-q asdf") @tty.assert_matches "> asdf" end def test_non_interactive - @tty = TTYtest.new_terminal(%{echo before; echo -n "foo\nbar" | #{FZY_PATH} -e foo; echo after}) + @tty = interactive_fzy(input: %w[foo bar], args: "-e foo", before: "before", after: "after") @tty.assert_matches "before\nfoo\nafter" end def test_moving_text_cursor - @tty = TTYtest.new_terminal(%{echo -n "foo\nbar" | #{FZY_PATH}; echo after}) + @tty = interactive_fzy(input: %w[foo bar]) @tty.send_keys("br") @tty.assert_matches "> br\nbar" @tty.assert_cursor_position(y: 0, x: 4) - @tty.send_keys("\e[D") # left + @tty.send_keys(LEFT) @tty.assert_cursor_position(y: 0, x: 3) @tty.assert_matches "> br\nbar" @tty.send_keys("a") @@ -314,12 +320,132 @@ class FzyTest < Minitest::Test # https://github.com/jhawthorn/fzy/issues/42 # https://cirw.in/blog/bracketed-paste def test_bracketed_paste_characters - @tty = TTYtest.new_terminal(%{echo -n "foo\nbar" | #{FZY_PATH}}) + @tty = interactive_fzy(input: %w[foo bar]) @tty.assert_matches ">\nfoo\nbar" @tty.send_keys("\e[200~foo\e[201~") @tty.assert_matches "> foo\nfoo" end + # https://github.com/jhawthorn/fzy/issues/81 + def test_slow_stdin_fast_user + @tty = TTYtest.new_terminal(%{(sleep 0.5; echo aa; echo bc; echo bd) | #{FZY_PATH}}) + + # Before input has all come in, but wait for fzy to at least start + sleep 0.1 + + @tty.send_keys("b\r") + @tty.assert_matches "bc" + end + + def test_unicode + @tty = interactive_fzy(input: %w[English Français 日本語]) + @tty.assert_matches <<~TTY + > + English + Français + 日本語 + TTY + @tty.assert_cursor_position(y: 0, x: 2) + + @tty.send_keys("ç") + @tty.assert_matches <<~TTY + > ç + Français + TTY + @tty.assert_cursor_position(y: 0, x: 3) + + @tty.send_keys("\r") + @tty.assert_matches "Français" + end + + def test_unicode_backspace + @tty = interactive_fzy + @tty.send_keys "Français" + @tty.assert_matches "> Français" + @tty.assert_cursor_position(y: 0, x: 10) + + @tty.send_keys(ctrl('H') * 3) + @tty.assert_matches "> Franç" + @tty.assert_cursor_position(y: 0, x: 7) + + @tty.send_keys(ctrl('H')) + @tty.assert_matches "> Fran" + @tty.assert_cursor_position(y: 0, x: 6) + + @tty.send_keys('ce') + @tty.assert_matches "> France" + + @tty = interactive_fzy + @tty.send_keys "日本語" + @tty.assert_matches "> 日本語" + @tty.send_keys(ctrl('H')) + @tty.assert_matches "> 日本" + @tty.send_keys(ctrl('H')) + @tty.assert_matches "> 日" + @tty.send_keys(ctrl('H')) + @tty.assert_matches "> " + @tty.assert_cursor_position(y: 0, x: 2) + end + + def test_unicode_delete_word + @tty = interactive_fzy + @tty.send_keys "Je parle Français" + @tty.assert_matches "> Je parle Français" + @tty.assert_cursor_position(y: 0, x: 19) + + @tty.send_keys(ctrl('W')) + @tty.assert_matches "> Je parle" + @tty.assert_cursor_position(y: 0, x: 11) + + @tty = interactive_fzy + @tty.send_keys "日本語" + @tty.assert_matches "> 日本語" + @tty.send_keys(ctrl('W')) + @tty.assert_matches "> " + @tty.assert_cursor_position(y: 0, x: 2) + end + + def test_unicode_cursor_movement + @tty = interactive_fzy + @tty.send_keys "Français" + @tty.assert_cursor_position(y: 0, x: 10) + + @tty.send_keys(LEFT*5) + @tty.assert_cursor_position(y: 0, x: 5) + + @tty.send_keys(RIGHT*3) + @tty.assert_cursor_position(y: 0, x: 8) + + @tty = interactive_fzy + @tty.send_keys "日本語" + @tty.assert_matches "> 日本語" + @tty.assert_cursor_position(y: 0, x: 8) + @tty.send_keys(LEFT) + @tty.assert_cursor_position(y: 0, x: 6) + @tty.send_keys(LEFT) + @tty.assert_cursor_position(y: 0, x: 4) + @tty.send_keys(LEFT) + @tty.assert_cursor_position(y: 0, x: 2) + @tty.send_keys(LEFT) + @tty.assert_cursor_position(y: 0, x: 2) + @tty.send_keys(RIGHT*3) + @tty.assert_cursor_position(y: 0, x: 8) + @tty.send_keys(RIGHT) + @tty.assert_cursor_position(y: 0, x: 8) + end + + def test_long_strings + ascii = "LongStringOfText" * 6 + unicode = "LongStringOfText" * 3 + + @tty = interactive_fzy(input: [ascii, unicode]) + @tty.assert_matches <<~TTY + > + LongStringOfTextLongStringOfTextLongStringOfTextLongStringOfTextLongStringOfText + LongStringOfTextLongStringOfTextLongStri + TTY + end + def test_help @tty = TTYtest.new_terminal(%{#{FZY_PATH} --help}) @tty.assert_matches <<TTY @@ -335,4 +461,15 @@ Usage: fzy [OPTION]... -v, --version Output version information and exit TTY end + + private + + def interactive_fzy(input: [], before: nil, after: nil, args: "") + cmd = [] + cmd << %{echo "#{before}"} if before + cmd << %{printf "#{input.join("\\n")}" | #{FZY_PATH} #{args}} + cmd << %{echo "#{after}"} if after + cmd = cmd.join("; ") + TTYtest.new_terminal(cmd) + end end |