From b8dc423f9becf697f53af2e94d41e99e12cd5940 Mon Sep 17 00:00:00 2001 From: LoricAndre <57358788+LoricAndre@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:28:41 +0100 Subject: [PATCH] feat!(ui): ratatui migration (#864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Breaking changes ## Binds - execute(...) will still run the command if no item is selected. To get the previous behavior back, use if-non-matched()+execute(...) - field expansion in execute and preview will no longer support arbitrary spaces, for instance { } will not get expanded as {} - interactive mode will not use stdin/skim default command when starting up. - expect bind is deprecated - interactive mode will now expand like other commands, except that {} will keep expanding to the current query. * back to compiling * work on item list & return the results * add debounce to matcher * feat: readd actions and binds * clippy * wip: use options in ui * wip: ratatui * feat: bring perf close to tuikit version * chore: use list widget * feat: working debounce on item reading * chore: migrate to crossbeam channels * chore: clippy & fmt * chore: allow different backend for Tui * feat: add matcher polling * feat: page scrolling * wip: statusline * feat: working statusline * tmp * feat: 56/158 e2e passing * feat: 67/158 e2e passing * chore: generate completions & manpage * feat: 72/158 e2e passing * Claude/pr 864 e2e tests 011 c uyyt et2b q5n e drn aaf2 s (#873) * docs(vim): convert FIXME to descriptive Note comment Replace FIXME comment with a Note that accurately describes the working directory restoration heuristic. The current implementation is intentional and handles most use cases correctly. The comment now documents the behavior rather than implying it needs fixing. * feat(tui): replace todo!() panics with no-op stubs Replace all todo!() macro panics with no-op implementations or basic stubs for unimplemented ratatui features. This prevents crashes when these actions are triggered during testing. Changes: - History navigation (NextHistory, PreviousHistory): no-op stubs - Preview scrolling (Up/Down/Left/Right/PageUp/PageDown): no-op stubs - Command/mode controls (RefreshCmd, RotateMode): no-op stubs - Horizontal scrolling (ScrollLeft/Right): no-op stubs - Preview toggles (TogglePreviewWrap, ToggleSort): no-op stubs - Preview with position variants: basic implementations using existing preview methods These features still need full implementation but won't panic now. * test(e2e): increase wait timeout to fix slow test startup Increased the wait timeout from 1 second (200 * 5ms) to 10 seconds (400 * 25ms) to accommodate slower test startup times. This fixes failures in tests that were timing out waiting for sk to initialize, particularly the binds test suite which now passes all 6 tests. * feat(tui): implement query history navigation Implemented PreviousHistory and NextHistory actions for navigating through query history using Ctrl-P and Ctrl-N. - Added query_history, history_index, and saved_input fields to App struct - Load history from options.query_history on initialization - PreviousHistory (Ctrl-P): Navigate backward through history (newer to older) - NextHistory (Ctrl-N): Navigate forward through history (older to newer) - Saves current input when entering history, restores when returning Manual testing confirms history navigation works correctly. * feat(tui): implement preview scrolling Implemented all preview scrolling actions for navigating preview content. - Added scroll_y and scroll_x fields to Preview struct to track scroll position - Implemented scroll_up, scroll_down, scroll_left, scroll_right methods - Implemented page_up and page_down for full-page scrolling - Updated render function to use scroll offsets via Paragraph::scroll() - Preview content resets scroll position when content changes - Implemented PreviewUp, PreviewDown, PreviewLeft, PreviewRight actions in App - Implemented PreviewPageUp and PreviewPageDown actions in App * fix(history): prevent duplicate history entries Fixed critical bug where init_histories() was being called twice, causing history entries to be duplicated. The issue was that parse_args() called .build() and then main also called .build() on the result, leading to init_histories() running twice. Changed parse_args() to return unparsed options, letting main.rs call .build() only once. This ensures history is loaded exactly once without duplicates. Before: history file would contain "a\nb\nc\na\nb\nc\nnew_query" After: history file correctly contains "a\nb\nc\nnew_query" * feat(tui): implement interactive mode Implemented full interactive mode (-i flag) support for the ratatui migration. **Key Features:** - Command prompt ("c>") instead of query prompt (">") in interactive mode - Separate command history navigation using cmd_history - Command execution on history navigation via Event::Reload - Support for --cmd-query initial command - SkimOutput returns user's command in interactive mode **Implementation Details:** - Added cmd_history, cmd_history_index, and saved_cmd_input fields to App - Modified Input initialization to use cmd_prompt and cmd_query in interactive mode - Updated PreviousHistory/NextHistory to use cmd_history when options.interactive is true - In interactive mode, history navigation triggers Event::Reload to execute commands - Modified SkimOutput to return app.input as cmd in interactive mode **Manual Testing:** All interactive mode functionality verified working: - Prompt displays "c>" correctly - Ctrl-P/Ctrl-N navigate through command history (c→b→a→b) - Typing updates command (b→bn) - History file written correctly on exit * fix(statusline): render space when spinner not shown for e2e tests When the spinner is not displayed (reading and matching complete), the status line needs to maintain its layout by rendering a space in the spinner area. This ensures the status line format is " N/N" (two spaces) rather than " N/N" (one space), which is what the e2e tests expect. Also simplified show_progress_indicators logic to check reading || matcher_running directly instead of using time-based thresholds. This fixes all previously failing basic tests (defaults, binds, case, history, tmux) which were timing out because they couldn't find the expected status line format. Test results after fix: - binds: 6/6 passed - case: 10/10 passed - defaults: 4/4 passed - history: 2/2 passed - tmux: 2/2 passed * fix(input): implement Yank action and fix BackwardKillWord - Add insert_str() method to Input for inserting strings - Fix Yank action to paste from yank_register instead of storing to it - Fix delete_backward_word() to stop at non-word characters (alphanumeric only) instead of just whitespace, matching standard word deletion behavior Test improvements: - keys_ctrl_y: ✓ PASSED - keys_alt_bspace: ✓ PASSED Remaining failures: keys_ctrl_d, keys_ctrl_w, keys_ctrl_arrows, keys_tab, keys_btab * fix(input): fix delete and word movement actions - Fix delete() to use actual cursor position, not display position - Change DeleteChar and DeleteCharEOF to use offset 0 (delete at cursor) - Split word deletion into two functions: - delete_backward_word(): Uses alphanumeric boundaries (for Alt+Backspace) - delete_backward_to_whitespace(): Uses whitespace boundaries (for Ctrl+W) - Update word movement to use alphanumeric word boundaries Test improvements: - keys_ctrl_d: ✓ PASSED (DeleteChar) - keys_ctrl_w: ✓ PASSED (UnixWordRubout) - keys_alt_bspace: ✓ STILL PASSING (BackwardKillWord) - keys_ctrl_y: ✓ STILL PASSING (Yank) Remaining: keys_ctrl_arrows needs adjustment for compound words * fix(item_list): fix selection rendering to show only current item marker Fixed the item list rendering to only show ">" for the current item, not for selected items. This matches the expected behavior when not using --multi flag. Changes: - Removed highlight_symbol from List widget (was adding extra space) - Manually add ">" marker only for current item - Add space after marker for consistent formatting ("> item" or " item") - Apply current item style only to current item Test improvements: - keys_tab: ✓ PASSED - keys_btab: ✓ PASSED Keys test suite: 21/22 passing (95%) Remaining: keys_ctrl_arrows (compound word navigation) * fix(input): separate word boundaries for deletion vs cursor movement Split word boundary logic to handle two different behaviors: - Alphanumeric boundaries for deletion (Alt+D, Alt+Backspace) - Whitespace boundaries for cursor movement (Ctrl+Right, Ctrl+Left) This allows compound words like "foo-bar" to be treated as: - Single unit for cursor navigation (Ctrl+Right moves past entire word) - Multiple words for deletion (Alt+D deletes only "foo") Changes: - find_next_word_end(): Uses alphanumeric boundaries for deletion - find_compound_word_end(): Uses whitespace boundaries for movement - move_cursor_forward_word(): Now uses compound word boundaries Fixes keys_alt_d and keys_ctrl_arrows tests. All 22 keys tests now passing. * fix(item_list): respect multi-select mode for selection markers Only show selection markers (">") in multi-select mode (-m flag). In single-select mode, items should not display selection markers even if they exist in the selection HashSet. Changes: - Added multi_select field to ItemList struct - Set multi_select from options.multi in with_options() - Only render selection marker when multi_select && is_selected - Updated both normal and debug render functions Fixes: - bind_append_and_select: Shows ">>" in multi-select mode - keys_tab/keys_btab: Shows only current marker in single-select mode All 22 keys tests passing (100%). * feat(interactive): fix interactive mode to not filter items on typing In interactive mode, the input is a command to execute, not a filter query. Items should be displayed without filtering until a command is executed. Changes: - Skip restart_matcher when typing/editing in interactive mode - AddChar, BackwardDeleteChar, BackwardKillWord, DeleteChar, DeleteCharEOF - KillWord, UnixLineDiscard, UnixWordRubout, Yank - Use empty query for matcher in interactive mode - matcher.run() now uses empty Input in interactive mode - All items are shown regardless of what user types - Typing only updates the command, doesn't filter items This fixes all 22 keys_interactive tests. Now works correctly with piped stdin in interactive mode. * test(interactive): add tests for command execution on typing Added two tests to verify interactive mode command execution behavior: 1. keys_interactive.rs::interactive_command_execution() - Tests typing commands in interactive mode - Verifies "echo foo" executes and shows "foo" - Verifies clearing and typing "echo bar" shows "bar" 2. defaults.rs::interactive_mode_command_execution() - Same test in defaults suite for baseline behavior - Tests command execution without piped input These tests currently fail as interactive mode doesn't execute commands as you type - they need Event::Reload on each keystroke. * fix(test): correct interactive mode tests to use --cmd with {} expansion Fixed the interactive mode tests to properly test the actual behavior: - Interactive mode executes the command passed via --cmd - The {} placeholder in the command gets replaced with typed input - Command re-executes automatically as you type Test changes: - Use --cmd "echo 'foo {}'" to provide the command template - Typing "bar" should execute "echo 'foo bar'" and show "foo bar" - Typing more or deleting triggers re-execution with new substitution This is the correct interactive mode behavior, not executing arbitrary typed commands. * feat(interactive): implement command execution with {} expansion in interactive mode In interactive mode with --cmd, the typed input now expands the {} placeholder in the command and re-executes it on every keystroke (AddChar, BackwardDeleteChar, BackwardKillWord, DeleteChar, DeleteCharEOF, KillWord, UnixLineDiscard, UnixWordRubout, Yank). Key changes: - Modified expand_cmd() to use simple {} replacement in interactive mode - Added Event::Reload handling to clear item_pool, item_list, and drain rx channel - Interactive mode with --cmd now starts with no-op command (":") instead of executing the command initially - Added drain_rx() method to ItemList to clear pending matches from channel - Only execute commands on keystroke when both interactive mode AND --cmd are active Added test for interactive mode command execution that verifies {} expansion works correctly as the user types. Fixes command execution in interactive mode to properly expand {} with typed input. * fix(reload): don't clear displayed items during reload to avoid blank screen When handling Event::Reload, keep the old items visible until new ones arrive from the matcher. This prevents a flash of blank space and ensures tests that check for immediate output updates work correctly. The item_pool is still cleared to ensure the matcher processes only new items, but item_list.items stays populated with the previous results until the new matcher sends updated results through the rx channel. Fixes binds tests that were timing out due to unexpected blank lines. --------- Co-authored-by: Claude * feat(e2e): use nextest to simplify e2e tests * Claude/continue ratatui work 011 cv2 d vpg29 cp7 w3 z nfy djw (#874) * fix(tui): add cancellation token support to event loop The event loop task was not checking the cancellation token, causing it to continue running even after the TUI was stopped. This resulted in tests hanging indefinitely. Changes: - Clone cancellation token in start() method - Add cancellation check as first branch in tokio::select! - Replace unwrap() with _ = for send() calls to avoid panics - Break out of loop when cancellation token is triggered This fix resolves the hanging tests and allows proper cleanup. * fix(interactive): execute command with initial query substitution In interactive mode with --cmd, the command should execute immediately with {} replaced by the initial query value (empty string or --query value). Previously, it was using ':' as a no-op placeholder, which prevented any results from showing up initially. This fixes most of the interactive_mode_command_execution test. * feat(tests): improve tmux capture and add line padding for trailing spaces - Add -J flag to tmux capture-pane to preserve line structure - Add debug logging for item rendering to trace data flow - Implement line padding in item list to full area width The interactive_mode_command_execution test expects trailing spaces to be preserved (e.g., 'foo ' not 'foo'). However, ratatui doesn't write trailing whitespace to terminals unless there's content after it, and tmux doesn't capture whitespace that isn't written. This is a known limitation of terminal rendering. The data structures correctly contain 'foo ' with trailing space (verified by trace logs), but it's lost in the terminal -> tmux -> capture pipeline. All other integration tests pass successfully (50/53 e2e tests). * fix(test): use starts_with for item matching to handle trailing space stripping Terminal rendering doesn't preserve trailing whitespace, so use starts_with() instead of exact equality for item text assertions. This allows the test to pass while still validating the correct content appears on screen. All e2e tests now pass (54/54 integration tests). * refactor: remove debug logging and line padding from item_list Remove temporary debugging code and line padding logic that was added during investigation of trailing space rendering. The test fix using starts_with() is sufficient, so these changes are no longer needed. --------- Co-authored-by: Claude * chore: add interactive mode init to breaking changes * fix(tui): pass tiebreak options to matcher and sort items correctly (#875) This fixes the tiebreak end-to-end tests by ensuring tiebreak options are properly used in the ratatui implementation: 1. Pass RankBuilder with tiebreak criteria to matcher factory 2. Sort matched items by rank in ascending order (correct for how ranks are calculated with negative scores for better matches) 3. Apply sorting in both render methods when receiving new items All 10 tiebreak tests now pass (previously 9/10 were timing out). Co-authored-by: Claude * docs: use nextest for all tests in AGENTS.md * Add SkimWidget trait with from_options and render methods (#876) * Add SkimWidget trait with from_options and render methods - Create SkimWidget trait with: - from_options(options: &SkimOptions, theme: Arc) -> Self - render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender - Create SkimRender struct with items_updated boolean field - Implement SkimWidget for all TUI widgets: - App, ItemList, Input, StatusLine, Header, Preview - Remove ratatui Widget trait implementations - All widgets now initialize using SkimWidget::from_options - Add Clone derive to SkimOptions, Input, and Preview - Fix AppendAndSelect to use input.value instead of input All widgets now use the custom SkimWidget trait instead of ratatui's Widget trait, with centralized initialization through from_options. * Remove Clone derive from SkimOptions - Remove Clone derive from SkimOptions struct - Remove SkimWidget trait implementation from App - Add render_skim method to App that returns SkimRender - Update App rendering to use render_skim instead of SkimWidget::render App doesn't implement SkimWidget because it needs to own SkimOptions rather than construct from a reference. The existing App::from_options method takes ownership of SkimOptions as needed. * Implement ratatui Widget trait for App instead of custom render method Changed App to implement ratatui's Widget trait for &mut App<'_> instead of having a custom render_skim method. This allows using f.render_widget() directly in the draw closure. App does not implement SkimWidget since it needs to own SkimOptions rather than just reference it. * tests: better logging * chore: fmt * fix: remove clone derive from input widget * fix: compact render syntax * chore: rename with_options to from_options for Reader * Replace with_options with from_options across all widgets - Updated App::from_options to use SkimWidget::from_options for all widgets - Removed with_options method definitions from Header, Input, StatusLine, and ItemList - All widgets now exclusively use the SkimWidget trait's from_options method - Removed empty impl block from StatusLine --------- Co-authored-by: Claude * tests: add fail-fast and retries to default profile * Claude/fix ratatui tests 011 cv4gj exnq rd p4 al zg593o (#877) * fix: update examples for ratatui migration - Change Skim::run_with() to accept owned SkimOptions instead of &SkimOptions - Update .bind() to accept KeyMap (from string) instead of Vec - Restructure option_builder.rs to avoid cloning SkimOptions - Update all affected examples: cmd_collector, custom_item, custom_keybinding_actions, downcast, nth, option_builder, sample, selector * test: fix failing unit tests for Rust behavior changes - Update size tests to expect InvalidDigit instead of NegOverflow This aligns with current Rust standard library behavior when parsing negative numbers into unsigned integer types (u16) - Fix printf test to expect spaces instead of newlines The implementation joins items with spaces, so the test expectation should match this behavior - Update percent_neg test to expect full input string "-10%" in error --------- Co-authored-by: Claude * feat: only 33 tests remaining * fix: preview tests passing * chore: generate completions & manpage * fix: fix printf test after adding quotes * fix: fix with_nth tests * fix: opt_multi tests * feat: all tests but issue 361 passing * chore: generate completions & manpage * feat: all tests passing * feat: all tests passing * feat: compile without cli feature & cleanup * chore: generate completions & manpage * feat: update deps * fix: update examples for ratatui - Remove tuikit example (incompatible with ratatui, examples exist in skim-tuikit) - Fix preview_callback example: remove & from Skim::run_with call - Add PreviewCallback to prelude exports * fix: update tests for rand 0.9 API - Fix rand import: use rand::distr::Alphanumeric instead of rand::distributions - Update random string generation to use sample_iter for rand 0.9 compatibility All 204 tests now pass (1 flaky test passed on retry) * fix: add missing preview_fn field in non-cli Default implementation Ensures SkimOptions compiles with --no-default-features * chore: fmt & clippy * feat(ci): use nextest in CI * chore(ci): increase timeout * chore(ci): run tests in release mode * refactor: consolidate workspace and inline skim-common - Remove skim-tuikit (replaced by ratatui) - Remove async-ratatui (experimental, not needed) - Remove skim-common and move spinlock.rs directly into skim - Update workspace to only include skim and xtask - Simplify project structure for ratatui-based implementation All tests still passing (207/207) * fix(interactive): clear old items when reloading in interactive mode - Add clear() method to ItemList to reset items, selection, cursor, and offset - Drain item channel before clearing to prevent stale items from appearing - Call item_list.clear() when handling Reload event This ensures that when the input changes in interactive mode, old items from the previous command are fully cleared before new results appear. * chore(tests): more robust tests * chore: fmt & clippy * chore: fmt * tests: fix remaining flakies hopefully * chore(ci): add cache to build without cli job * test(ci): test without env vars * chore: use dev tty for crossterm input, to fix macos e2e panicing * feat: better perf * chore: generate completions & manpage * feat: performance increase & ansi handling * fix: lint * chore: fmt * fix(test): flaki bind_if_non_matched * fix: fzf-lua & perf * chore: docs * chore: fmt * chore: generate completions & manpage * chore: bring fuzzy-matcher over from skim * feat: perf equivalent to FZF for find / * feat: better bench * feat: better bench * feat: insane perf * feat: header tabstop * chore: remove useless TODO comments * fix: missing TODOs and binding overrides * chore: generate completions & manpage * chore: fmt & clippy * fix: skip-to-pattern and scrolls * chore: fmt & clippy * chore: fmt * chore: copilot review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: generate completions & manpage * docs: better contributing guide * chore: cleanup * fix: output selection in order * chore: add pre-commit hook * docs: add git hook to contributing guide * fix: completely clear UI before exiting inline mode (#880) * fix(tmux): allow early exit (#878) * chore: add receiver_multi example (#848) * feat: add preview scrolling with mouse (#849) * chore: remove install script (closes #607) * docs: ansi is a no-op in lib usage (#476) * chore: generate completions & manpage * chore: add CommandCollector and FuzzyEngine to prelude (closes #477) * chore: generate completions & manpage * feat: tac & change bind * chore: generate completions & manpage * fix: correctly init matcher (#524) * fix: collect all items in filter mode & apply tac (#385) * fix: glitches when starting with \\0 (fixes #547) * chore: add test macro * feat: use printf for interactive mode command expansion * chore: migrate tests to new macro syntax * chore: migrate remaining tests & stabilize some flakies * chore: cleanup * fix: with-nth broken when using null delimiter * fix: do not override keymaps if unknown or empty action * chore: generate completions & manpage * docs: add ratatui badge to the README * fix(tmux): show items even if no data got sent when the popup opens * chore: PR review part 1 * fix: unicode chars handling in input * feat: add man page generation to main binary * chore: generate completions & manpage * fix: build without cli feature * fix: do not use eyre for clap errors * feat: collect stderr with --show-cmd-error * chore: remove breaking changes file --------- Co-authored-by: Skim bot Co-authored-by: Claude Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .cargo/config.toml | 1 - .config/nextest.toml | 13 + .dockerignore | 4 +- .githooks/pre-commit | 4 + .github/CONTRIBUTING.md | 41 +- .github/release-please/config.json | 8 +- .github/release-please/manifest.json | 2 - .github/release-plz.toml | 10 - .github/workflows/test.yml | 58 +- .gitignore | 3 - AGENTS.md | 19 +- Cargo.lock | 1276 ++++++++++++++------ Cargo.toml | 14 +- README.md | 19 +- bench.sh | 170 +++ bin/sk-tmux | 3 +- e2e.dockerfile | 9 - e2e/Cargo.toml | 9 - e2e/src/lib.rs | 210 ---- e2e/tests/binds.rs | 97 -- e2e/tests/case.rs | 83 -- e2e/tests/defaults.rs | 68 -- e2e/tests/issues.rs | 34 - e2e/tests/keys.rs | 251 ---- e2e/tests/keys_interactive.rs | 251 ---- e2e/tests/options.rs | 948 --------------- e2e/tests/preview.rs | 55 - e2e/tests/tiebreak.rs | 88 -- install | 71 -- man/man1/sk.1 | 579 ++++----- plugin/skim.vim | 42 +- rust-toolchain.toml | 4 + shell/completion.bash | 12 +- shell/completion.fish | 28 +- shell/completion.zsh | 28 +- skim-common/Cargo.toml | 14 - skim-common/src/lib.rs | 1 - skim-tuikit/.gitignore | 3 - skim-tuikit/Cargo.lock | 266 ---- skim-tuikit/Cargo.toml | 24 - skim-tuikit/README.md | 142 --- skim-tuikit/examples/256color.rs | 31 - skim-tuikit/examples/256color_on_screen.rs | 48 - skim-tuikit/examples/custom-event.rs | 24 - skim-tuikit/examples/get_keys.rs | 25 - skim-tuikit/examples/hello-world.rs | 30 - skim-tuikit/examples/split.rs | 59 - skim-tuikit/examples/stack.rs | 83 -- skim-tuikit/examples/term_size.rs | 8 - skim-tuikit/examples/termbox.rs | 73 -- skim-tuikit/examples/true_color.rs | 80 -- skim-tuikit/examples/win.rs | 63 - skim-tuikit/src/attr.rs | 103 -- skim-tuikit/src/canvas.rs | 116 -- skim-tuikit/src/cell.rs | 65 - skim-tuikit/src/color.rs | 34 - skim-tuikit/src/draw.rs | 43 - skim-tuikit/src/error.rs | 81 -- skim-tuikit/src/event.rs | 18 - skim-tuikit/src/input.rs | 600 --------- skim-tuikit/src/key.rs | 283 ----- skim-tuikit/src/lib.rs | 82 -- skim-tuikit/src/macros.rs | 23 - skim-tuikit/src/output.rs | 410 ------- skim-tuikit/src/prelude.rs | 11 - skim-tuikit/src/raw.rs | 131 -- skim-tuikit/src/screen.rs | 387 ------ skim-tuikit/src/sys/file.rs | 36 - skim-tuikit/src/sys/mod.rs | 27 - skim-tuikit/src/sys/signal.rs | 68 -- skim-tuikit/src/sys/size.rs | 21 - skim-tuikit/src/term.rs | 838 ------------- skim-tuikit/src/widget/align.rs | 119 -- skim-tuikit/src/widget/mod.rs | 137 --- skim-tuikit/src/widget/split.rs | 1267 ------------------- skim-tuikit/src/widget/stack.rs | 222 ---- skim-tuikit/src/widget/util.rs | 65 - skim-tuikit/src/widget/win.rs | 736 ----------- skim/Cargo.toml | 25 +- skim/examples/ansi.rs | 30 + skim/examples/cmd_collector.rs | 6 +- skim/examples/custom_item.rs | 6 +- skim/examples/custom_keybinding_actions.rs | 15 +- skim/examples/downcast.rs | 4 +- skim/examples/fuzzy_matcher_fz.rs | 66 + skim/examples/nth.rs | 2 +- skim/examples/option_builder.rs | 17 +- skim/examples/preview_callback.rs | 2 +- skim/examples/receiver_multi.rs | 19 + skim/examples/sample.rs | 2 +- skim/examples/selector.rs | 2 +- skim/examples/tuikit.rs | 30 - skim/src/ansi.rs | 662 ---------- skim/src/bin/main.rs | 161 ++- skim/src/binds.rs | 239 ++++ skim/src/engine/factory.rs | 12 + skim/src/engine/fuzzy.rs | 28 +- skim/src/event.rs | 151 --- skim/src/field.rs | 58 +- skim/src/fuzzy_matcher/clangd.rs | 507 ++++++++ skim/src/fuzzy_matcher/mod.rs | 31 + skim/src/fuzzy_matcher/skim.rs | 1219 +++++++++++++++++++ skim/src/fuzzy_matcher/util.rs | 132 ++ skim/src/global.rs | 46 - skim/src/header.rs | 142 --- skim/src/helper/item.rs | 850 ++++++++++++- skim/src/helper/item_reader.rs | 100 +- skim/src/helper/selector.rs | 4 + skim/src/input.rs | 253 ---- skim/src/item.rs | 129 +- skim/src/lib.rs | 514 ++++---- skim/src/matcher.rs | 46 +- skim/src/model/mod.rs | 923 -------------- skim/src/model/options.rs | 29 - skim/src/model/status.rs | 143 --- skim/src/options.rs | 401 +++--- skim/src/orderedvec.rs | 278 ----- skim/src/output.rs | 6 +- skim/src/prelude.rs | 19 +- skim/src/previewer.rs | 673 ----------- skim/src/query.rs | 616 ---------- skim/src/reader.rs | 59 +- skim/src/selection.rs | 617 ---------- skim/src/skim_item.rs | 95 ++ {skim-common => skim}/src/spinlock.rs | 6 + skim/src/theme.rs | 312 +++-- skim/src/tmux.rs | 89 +- skim/src/tui/app.rs | 1227 +++++++++++++++++++ skim/src/tui/backend.rs | 192 +++ skim/src/tui/event.rs | 280 +++++ skim/src/tui/header.rs | 87 ++ skim/src/tui/input.rs | 315 +++++ skim/src/tui/item_list.rs | 705 +++++++++++ skim/src/tui/mod.rs | 173 +++ skim/src/tui/options.rs | 159 +++ skim/src/tui/preview.rs | 237 ++++ skim/src/tui/statusline.rs | 186 +++ skim/src/tui/widget.rs | 22 + skim/src/util.rs | 660 +++------- skim/tests/ansi.rs | 40 + skim/tests/binds.rs | 96 ++ skim/tests/case.rs | 55 + skim/tests/common/mod.rs | 695 +++++++++++ skim/tests/defaults.rs | 65 + skim/tests/highlighting.rs | 50 + {e2e => skim}/tests/history.rs | 11 +- skim/tests/issues.rs | 42 + skim/tests/keys.rs | 190 +++ skim/tests/keys_interactive.rs | 181 +++ skim/tests/options.rs | 623 ++++++++++ skim/tests/preview.rs | 43 + skim/tests/tiebreak.rs | 75 ++ {e2e => skim}/tests/tmux.rs | 19 +- test.dockerfile | 10 + 154 files changed, 11812 insertions(+), 15811 deletions(-) create mode 100644 .config/nextest.toml create mode 100755 .githooks/pre-commit create mode 100755 bench.sh delete mode 100644 e2e.dockerfile delete mode 100644 e2e/Cargo.toml delete mode 100644 e2e/src/lib.rs delete mode 100644 e2e/tests/binds.rs delete mode 100644 e2e/tests/case.rs delete mode 100644 e2e/tests/defaults.rs delete mode 100644 e2e/tests/issues.rs delete mode 100644 e2e/tests/keys.rs delete mode 100644 e2e/tests/keys_interactive.rs delete mode 100644 e2e/tests/options.rs delete mode 100644 e2e/tests/preview.rs delete mode 100644 e2e/tests/tiebreak.rs delete mode 100755 install create mode 100644 rust-toolchain.toml delete mode 100644 skim-common/Cargo.toml delete mode 100644 skim-common/src/lib.rs delete mode 100644 skim-tuikit/.gitignore delete mode 100644 skim-tuikit/Cargo.lock delete mode 100644 skim-tuikit/Cargo.toml delete mode 100644 skim-tuikit/README.md delete mode 100644 skim-tuikit/examples/256color.rs delete mode 100644 skim-tuikit/examples/256color_on_screen.rs delete mode 100644 skim-tuikit/examples/custom-event.rs delete mode 100644 skim-tuikit/examples/get_keys.rs delete mode 100644 skim-tuikit/examples/hello-world.rs delete mode 100644 skim-tuikit/examples/split.rs delete mode 100644 skim-tuikit/examples/stack.rs delete mode 100644 skim-tuikit/examples/term_size.rs delete mode 100644 skim-tuikit/examples/termbox.rs delete mode 100644 skim-tuikit/examples/true_color.rs delete mode 100644 skim-tuikit/examples/win.rs delete mode 100644 skim-tuikit/src/attr.rs delete mode 100644 skim-tuikit/src/canvas.rs delete mode 100644 skim-tuikit/src/cell.rs delete mode 100644 skim-tuikit/src/color.rs delete mode 100644 skim-tuikit/src/draw.rs delete mode 100644 skim-tuikit/src/error.rs delete mode 100644 skim-tuikit/src/event.rs delete mode 100644 skim-tuikit/src/input.rs delete mode 100644 skim-tuikit/src/key.rs delete mode 100644 skim-tuikit/src/lib.rs delete mode 100644 skim-tuikit/src/macros.rs delete mode 100644 skim-tuikit/src/output.rs delete mode 100644 skim-tuikit/src/prelude.rs delete mode 100644 skim-tuikit/src/raw.rs delete mode 100644 skim-tuikit/src/screen.rs delete mode 100644 skim-tuikit/src/sys/file.rs delete mode 100644 skim-tuikit/src/sys/mod.rs delete mode 100644 skim-tuikit/src/sys/signal.rs delete mode 100644 skim-tuikit/src/sys/size.rs delete mode 100644 skim-tuikit/src/term.rs delete mode 100644 skim-tuikit/src/widget/align.rs delete mode 100644 skim-tuikit/src/widget/mod.rs delete mode 100644 skim-tuikit/src/widget/split.rs delete mode 100644 skim-tuikit/src/widget/stack.rs delete mode 100644 skim-tuikit/src/widget/util.rs delete mode 100644 skim-tuikit/src/widget/win.rs create mode 100644 skim/examples/ansi.rs create mode 100644 skim/examples/fuzzy_matcher_fz.rs create mode 100644 skim/examples/receiver_multi.rs delete mode 100644 skim/examples/tuikit.rs delete mode 100644 skim/src/ansi.rs create mode 100644 skim/src/binds.rs delete mode 100644 skim/src/event.rs create mode 100644 skim/src/fuzzy_matcher/clangd.rs create mode 100644 skim/src/fuzzy_matcher/mod.rs create mode 100644 skim/src/fuzzy_matcher/skim.rs create mode 100644 skim/src/fuzzy_matcher/util.rs delete mode 100644 skim/src/global.rs delete mode 100644 skim/src/header.rs delete mode 100644 skim/src/input.rs delete mode 100644 skim/src/model/mod.rs delete mode 100644 skim/src/model/options.rs delete mode 100644 skim/src/model/status.rs delete mode 100644 skim/src/orderedvec.rs delete mode 100644 skim/src/previewer.rs delete mode 100644 skim/src/query.rs delete mode 100644 skim/src/selection.rs create mode 100644 skim/src/skim_item.rs rename {skim-common => skim}/src/spinlock.rs (90%) create mode 100644 skim/src/tui/app.rs create mode 100644 skim/src/tui/backend.rs create mode 100644 skim/src/tui/event.rs create mode 100644 skim/src/tui/header.rs create mode 100644 skim/src/tui/input.rs create mode 100644 skim/src/tui/item_list.rs create mode 100644 skim/src/tui/mod.rs create mode 100644 skim/src/tui/options.rs create mode 100644 skim/src/tui/preview.rs create mode 100644 skim/src/tui/statusline.rs create mode 100644 skim/src/tui/widget.rs create mode 100644 skim/tests/ansi.rs create mode 100644 skim/tests/binds.rs create mode 100644 skim/tests/case.rs create mode 100644 skim/tests/common/mod.rs create mode 100644 skim/tests/defaults.rs create mode 100644 skim/tests/highlighting.rs rename {e2e => skim}/tests/history.rs (92%) create mode 100644 skim/tests/issues.rs create mode 100644 skim/tests/keys.rs create mode 100644 skim/tests/keys_interactive.rs create mode 100644 skim/tests/options.rs create mode 100644 skim/tests/preview.rs create mode 100644 skim/tests/tiebreak.rs rename {e2e => skim}/tests/tmux.rs (91%) create mode 100644 test.dockerfile diff --git a/.cargo/config.toml b/.cargo/config.toml index 5d3fc5ab..35049cbc 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,2 @@ [alias] xtask = "run --package xtask --" -e2e = "test --package e2e" diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 00000000..088a1fae --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,13 @@ +experimental = ["setup-scripts"] + +[scripts.setup.stop-tmux] +command = "sh -c 'tmux kill-session -t skim_e2e || true'" +[scripts.setup.start-tmux] +command = "tmux new-session -d -s skim_e2e" + +[profile.default] +fail-fast = false +retries = 9 +[[profile.default.scripts]] +platform = "cfg(unix)" +setup = ["stop-tmux", "start-tmux"] \ No newline at end of file diff --git a/.dockerignore b/.dockerignore index 962e47ce..0392632c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,4 +3,6 @@ target/ bin/ plugin/ man/ -shell/ \ No newline at end of file +shell/ +*.md +install \ No newline at end of file diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..0b3596e8 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,4 @@ +set -xeuo pipefail + +cargo fmt --check --all +cargo clippy --all \ No newline at end of file diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 2eee2888..659ebb30 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,28 +1,29 @@ -# contributor guide +# Contributor Guide ## Running tests -The unit tests are simply run with `cargo test`. +All tests can be run by using [cargo-nextest](https://nexte.st/), which can be installed using `cargo install cargo-nextest` of following the instructions on the website. +You will need `tmux` to run the integration tests. -The E2E tests can be ran in a docker container: -1. Build the image: `docker build . -t skim-e2e -f e2e.dockerfile` -2. Run the tests: `docker run --rm -it skim-e2e` +You can then run `cargo nextest run --release`, which should automatically build a release binary, run the unit tests and the integration tests. -However, if you want to run the E2E tests on your host, you need `tmux` & `zsh` installed, and then run: -```bash -cargo build --release -tmux new-session -d -s skim_e2e -cargo e2e -j8 -``` +Note: you can run the tests without `--release`, but expect more flaky tests since the timings will be looser. I would advise testing manually any debug test failure if you have doubts. -The end-to-end test will use tmux to run skim, send keys and capture its output. +Note2: A dockerfile is available if you want to run the tests inside docker. There is little to no cache, so the test will need to rebuild most of the application after each change. +To use it, build the image with `docker build -f test.dockerfile . -t skim-test` then run it using `docker run --rm -it skim-test`. -## GPT/chatbot +## Submitting code -Though we tolereate GPT-assisted dev (e.g. github copilot), - it is accepted but will be judged as strictly as human-only coding: -- Please avoid generating PRs, PRs comments and issue with a chatbot. - Please avoid generating PRs, PR comments, and issues with a chatbot. -- do not submit code which you don't understand. - Avoid submitting code you do not fully understand. - Additionally, extensive refactoring is discouraged as it takes significant time for maintainers to review. +To avoid using up CI minutes uselessly, make sure that : +- You run `cargo clippy` and `cargo fmt` before pushing any code to an open PR. +- Your PR's title respects [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). + +Not respecting these guidelines could end up consuming all our minutes and preventing us from testing and releasing any new code until the end of the month. + +Note: a git pre-commit hook is available in .githooks/pre-commit which will make the clippy & fmt checks. To use it, run `git config core.hooksPath ".githooks"`. + +## Vibe Coding guidelines + +Any code generated partially or completely using LLMs will be treated the same way as if you wrote it yourself. + +This means that you are expected to understand if fully and are responsible for it. diff --git a/.github/release-please/config.json b/.github/release-please/config.json index 55154e65..f13207ed 100644 --- a/.github/release-please/config.json +++ b/.github/release-please/config.json @@ -25,15 +25,11 @@ "xtask": { "skip-github-release": true }, - "e2e": { - "skip-github-release": true - }, + "common": { "skip-github-release": true }, - "tuikit": { - "skip-github-release": true - }, + "shell": { "release-type": "simple", "skip-github-release": true, diff --git a/.github/release-please/manifest.json b/.github/release-please/manifest.json index daff9827..97f2e4fd 100644 --- a/.github/release-please/manifest.json +++ b/.github/release-please/manifest.json @@ -1,9 +1,7 @@ { ".": "1.16.2", "skim": "1.16.2", - "e2e": "0.1.0", "xtask": "0.1.1", - "tuikit": "0.6.0", "common": "0.1.0", "shell": "0.1.0" } diff --git a/.github/release-plz.toml b/.github/release-plz.toml index c27adb71..cce657ed 100644 --- a/.github/release-plz.toml +++ b/.github/release-plz.toml @@ -20,12 +20,10 @@ publish_no_verify = false changelog_include = [ "skim", "skim-common", - "skim-tuikit", "shell", "plugin", "bin", "xtask", - "e2e" ] changelog_path = "./CHANGELOG.md" changelog_update = true @@ -50,14 +48,6 @@ git_tag_enable = true git_tag_name = "common-v{{ version }}" [[package]] -name = "skim-tuikit" -publish = true -publish_no_verify = false -git_tag_enable = true -git_tag_name = "tuikit-v{{ version }}" - -[[package]] -name = "e2e" release = false publish = false [[package]] diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dac6ab7d..4cfde8e3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,36 +12,7 @@ concurrency: cancel-in-progress: true jobs: - unittests: - runs-on: ${{matrix.os}} - strategy: - matrix: - build: [linux, macos] - include: - - build: linux - os: ubuntu-latest - rust: stable - target: x86_64-unknown-linux-musl - - build: macos - os: macos-latest - rust: stable - target: x86_64-apple-darwin - steps: - - name: Checkout repository - uses: actions/checkout@v2 - with: - fetch-depth: 1 - - name: Install correct toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: ${{ matrix.rust }} - target: ${{ matrix.target }} - - name: Cache - uses: Swatinem/rust-cache@v2 - - name: Run unit tests - run: cargo test -p skim -p skim-common -p skim-tuikit - - e2e: + nextest: runs-on: ${{matrix.os}} strategy: matrix: @@ -79,13 +50,13 @@ jobs: with: toolchain: ${{ matrix.rust }} target: ${{ matrix.target }} + - uses: taiki-e/install-action@v2 + with: + tool: nextest@0.9 - name: Cache uses: Swatinem/rust-cache@v2 - - name: Run end-to-end tests - run: | - cargo build --release - tmux new-session -d - cargo e2e -j8 + - name: Run tests + run: cargo nextest run --release --all-targets env: LC_ALL: en_US.UTF-8 TERM: xterm-256color @@ -122,3 +93,20 @@ jobs: - name: Check formatting run: | cargo fmt --all -- --check + + build-no-default-features: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + components: rustfmt + - name: Cache + uses: Swatinem/rust-cache@v2 + - name: Build without cli feature + run: | + cargo build --no-default-features \ No newline at end of file diff --git a/.gitignore b/.gitignore index aa84800c..0ef3ff58 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,3 @@ /bin/sk .idea/ .ropeproject/ - -# E2E -__pycache__ diff --git a/AGENTS.md b/AGENTS.md index efb8d90a..19f88023 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,9 +3,9 @@ ## Build/Test/Lint Commands - Build: `cargo build [--release]` - Run: `cargo run [--release]` -- Test (all): `cargo test` -- Test (single): `cargo test test_name` or `cargo test -- test_name` -- E2E tests: `cargo test -p e2e` +- Test (all): `cargo nextest` +- Test (single): `cargo nextest test_name` +- Integration/E2E tests: `cargo nextest --tests` (will need tmux under the hood) - Lint: `cargo clippy` - Format: `cargo fmt` (check only: `cargo fmt --check`) @@ -22,7 +22,14 @@ ## Project Structure - Core functionality in `skim/src/` -- UI toolkit in `skim-tuikit/` - Common utilities in `skim-common/` -- End-to-end tests in `e2e/` -- Task automation in `xtask/` \ No newline at end of file +- Task automation in `xtask/` + + +## Testing + +This application can be tested by : +- creating a new `tmux` session in the background (`tmux new-session -s -d`) +- creating a new named tmux window in that session : `tmux new-window -d -P -F '#I' -n -t ` and configuring the pane naming using `tmux set-window-option -t pane-base-index 0` +- sending the command to run and input using `tmux send-keys -t ` +- when ready, capturing the window using `tmux capture-pane -b -t .0` and then saving the capture to a file using `tmux save-buffer -b ` \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 01c2510a..c9ff4887 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,19 +3,34 @@ version = 4 [[package]] -name = "aho-corasick" -version = "1.1.3" +name = "addr2line" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] -name = "android-tzdata" -version = "0.1.1" +name = "allocator-api2" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" @@ -27,10 +42,23 @@ dependencies = [ ] [[package]] -name = "anstream" -version = "0.6.20" +name = "ansi-to-tui" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +checksum = "67555e1f1ece39d737e28c8a017721287753af3f93225e4a445b29ccb0f5912c" +dependencies = [ + "nom", + "ratatui", + "simdutf8", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -43,9 +71,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.11" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" @@ -58,22 +86,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -88,6 +116,21 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + [[package]] name = "beef" version = "0.5.2" @@ -96,21 +139,15 @@ checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" [[package]] name = "bitflags" -version = "1.3.2" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "bstr" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", "regex-automata", @@ -124,19 +161,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] -name = "cc" -version = "1.2.32" +name = "bytes" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2352e5597e9c544d5e6d9c95190d5d27738ade584fa8db0a16e130e5c2b5296e" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +dependencies = [ + "find-msvc-tools", "shlex", ] [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -146,11 +205,10 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", @@ -160,9 +218,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.43" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50fd97c9dc2399518aa331917ac6f274280ec5eb34e555dd291899745c48ec6f" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", "clap_derive", @@ -170,9 +228,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.43" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c35b5830294e1fa0462034af85cc95225a4cb07092c088c55bda3147cfcd8f65" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", @@ -182,9 +240,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.56" +version = "4.5.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67e4efcbb5da11a92e8a609233aa1e8a7d91e38de0be865f016d14700d45a7fd" +checksum = "39615915e2ece2550c0149addac32fb5bd312c657f43845bb9088cb9c8a7c992" dependencies = [ "clap", ] @@ -201,9 +259,9 @@ dependencies = [ [[package]] name = "clap_complete_nushell" -version = "4.5.8" +version = "4.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a0c951694691e65bf9d421d597d68416c22de9632e884c28412cb8cd8b73dce" +checksum = "685bc86fd34b7467e0532a4f8435ab107960d69a243785ef0275e571b35b641a" dependencies = [ "clap", "clap_complete", @@ -211,9 +269,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.41" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "anstyle", "heck", @@ -225,45 +283,73 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "clap_mangen" -version = "0.2.29" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b4c3c54b30f0d9adcb47f25f61fcce35c4dd8916638c6b82fbd5f4fb4179e2" +checksum = "439ea63a92086df93893164221ad4f24142086d535b3a0957b9b9bea2dc86301" dependencies = [ "clap", "roff", ] +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "crossbeam" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-epoch", - "crossbeam-queue", - "crossbeam-utils", -] - [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -292,21 +378,40 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "filedescriptor", + "futures-core", + "libc", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "darling" version = "0.20.11" @@ -354,9 +459,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", ] @@ -392,36 +497,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dirs-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] - -[[package]] -name = "dirs-sys-next" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - -[[package]] -name = "e2e" -version = "0.1.0" -dependencies = [ - "rand", - "tempfile", - "which", -] - [[package]] name = "either" version = "1.15.0" @@ -430,9 +505,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "env_filter" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" dependencies = [ "log", "regex", @@ -465,12 +540,22 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", ] [[package]] @@ -479,6 +564,23 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + [[package]] name = "fnv" version = "1.0.7" @@ -486,42 +588,134 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "fuzzy-matcher" -version = "0.3.7" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ - "thread_local", + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", ] [[package]] name = "getrandom" -version = "0.2.16" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasip2", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" @@ -531,9 +725,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -560,39 +754,82 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] -name = "indexmap" -version = "2.10.0" +name = "indenter" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", ] [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jiff" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" dependencies = [ "jiff-static", "log", "portable-atomic", "portable-atomic-util", - "serde", + "serde_core", ] [[package]] name = "jiff-static" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" dependencies = [ "proc-macro2", "quote", @@ -601,9 +838,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -617,48 +854,99 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.174" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] -name = "libredox" -version = "0.1.9" +name = "linux-raw-sys" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "bitflags 2.9.1", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", "libc", ] [[package]] -name = "linux-raw-sys" -version = "0.9.4" +name = "nom" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" - -[[package]] -name = "log" -version = "0.4.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" - -[[package]] -name = "memchr" -version = "2.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" - -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "cfg_aliases", - "libc", + "memchr", + "minimal-lexical", ] [[package]] @@ -676,6 +964,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -684,9 +981,56 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "portable-atomic" @@ -720,9 +1064,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] @@ -733,16 +1077,16 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" dependencies = [ - "bitflags 2.9.1", + "bitflags", "memchr", "unicase", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -779,14 +1123,35 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.3", + "getrandom", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", ] [[package]] name = "rayon" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -794,30 +1159,28 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", ] [[package]] -name = "redox_users" -version = "0.4.6" +name = "redox_syscall" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "getrandom 0.2.16", - "libredox", - "thiserror", + "bitflags", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", @@ -827,9 +1190,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -838,9 +1201,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "roff" @@ -849,16 +1212,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" [[package]] -name = "rustix" -version = "1.0.8" +name = "rustc-demangle" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.1", + "bitflags", "errno", "libc", - "linux-raw-sys", - "windows-sys 0.60.2", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", ] [[package]] @@ -868,25 +1250,55 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] -name = "serde" -version = "1.0.219" +name = "ryu" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shell-quote" version = "0.7.2" @@ -902,54 +1314,97 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "skim" version = "0.20.5" dependencies = [ + "ansi-to-tui", "beef", - "bitflags 1.3.2", + "bitflags", "chrono", "clap", "clap_complete", - "crossbeam", + "clap_mangen", + "color-eyre", + "crossterm", "defer-drop", "derive_builder", "env_logger", - "fuzzy-matcher", + "futures", "indexmap", "log", "nix", "rand", + "ratatui", "rayon", "regex", "shell-quote", "shlex", - "skim-common", - "skim-tuikit", + "tempfile", + "thiserror 2.0.17", + "thread_local", "time", "timer", - "unicode-width", + "tokio", + "tokio-util", + "unicode-width 0.2.0", "vte", "which", ] [[package]] -name = "skim-common" -version = "0.2.0" +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] -name = "skim-tuikit" -version = "0.6.6" -dependencies = [ - "bitflags 1.3.2", - "env_logger", - "lazy_static", - "log", - "nix", - "skim-common", - "term", - "unicode-width", -] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "strsim" @@ -958,10 +1413,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "syn" -version = "2.0.104" +name = "strum" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", @@ -970,26 +1447,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.20.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom", "once_cell", - "rustix", - "windows-sys 0.59.0", -] - -[[package]] -name = "term" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" -dependencies = [ - "dirs-next", - "rustversion", - "winapi", + "rustix 1.1.2", + "windows-sys 0.61.2", ] [[package]] @@ -998,7 +1464,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", ] [[package]] @@ -1012,6 +1487,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -1023,9 +1509,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", "num-conv", @@ -1036,9 +1522,9 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "timer" @@ -1049,6 +1535,81 @@ dependencies = [ "chrono", ] +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + [[package]] name = "unicase" version = "2.8.1" @@ -1057,15 +1618,38 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] [[package]] name = "unicode-width" -version = "0.2.1" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "utf8parse" @@ -1073,6 +1657,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vte" version = "0.15.0" @@ -1090,45 +1680,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1136,35 +1713,34 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] [[package]] name = "which" -version = "7.0.3" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" dependencies = [ - "either", "env_home", - "rustix", + "rustix 1.1.2", "winsafe", ] @@ -1192,9 +1768,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" -version = "0.61.2" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", @@ -1205,9 +1781,9 @@ dependencies = [ [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -1216,9 +1792,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", @@ -1227,24 +1803,24 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.3" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-result" -version = "0.3.4" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] @@ -1255,16 +1831,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-targets 0.53.3", + "windows-link", ] [[package]] @@ -1273,31 +1849,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -1306,96 +1865,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" - [[package]] name = "winsafe" version = "0.0.19" @@ -1403,13 +1914,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.1", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "xtask" @@ -1425,18 +1933,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.26" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.26" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 47a676d9..e1472973 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,7 @@ [workspace] members = [ - "e2e", "skim", - "skim-tuikit", - "skim-common", - "xtask" + "xtask", ] resolver = "2" default-members = ["skim"] @@ -14,31 +11,24 @@ lto = true [workspace.dependencies] beef = "0.5.2" -bitflags = "1.3.2" chrono = "0.4.40" clap = "4.5.41" clap_complete = "4.5.55" clap_complete_fig = "4.5.2" clap_complete_nushell = "4.5.8" clap_mangen = "0.2.29" -crossbeam = "0.8.2" defer-drop = "1.3.0" derive_builder = "0.20.2" env_logger = "0.11.6" -fuzzy-matcher = "0.3.7" indexmap = "2.8.0" -lazy_static = "1.2.0" log = "0.4.27" -nix = { version = "0.29.0", default-features = false, features = ["fs"]} rand = "0.9.0" rayon = "1.5.3" regex = "1.6.0" shell-quote = "0.7.2" shlex = "1.1.0" tempfile = "3.20.0" -term = "0.7" time = "0.3.41" timer = "0.2.0" -unicode-width = "0.2.1" +unicode-width = "0.2.0" vte = "0.15.0" -which = "7.0.2" diff --git a/README.md b/README.md index b53cf170..40900849 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ Skim Discord + + Built with Ratatui +

> Life is short, skim! @@ -52,7 +55,6 @@ Skim provides a single executable called `sk`. Think of it as a smarter alternat + [How does it work?](#how-does-it-work-1) * [Fields support](#fields-support) * [Use as a library](#use-as-a-library) - * [Tuikit](#tuikit) - [FAQ](#faq) * [How to ignore files?](#how-to-ignore-files) * [Some files are not shown in Vim plugin](#some-files-are-not-shown-in-vim-plugin) @@ -66,8 +68,7 @@ Skim provides a single executable called `sk`. Think of it as a smarter alternat The skim project contains several components: 1. `sk` executable - the core program -2. `sk-tmux` - a script for launching `sk` in a tmux pane -3. Vim/Nvim plugin - to call `sk` inside Vim/Nvim. Check [skim.vim](https://github.com/skim-rs/skim/blob/master/plugin/skim.vim) for Vim support. +2. Vim/Nvim plugin - to call `sk` inside Vim/Nvim. Check [skim.vim](https://github.com/skim-rs/skim/blob/master/plugin/skim.vim) for Vim support. ## Package Managers @@ -114,8 +115,10 @@ interface for running commands. Via vim-plug (recommended): +Install skim, then : + ```vim -Plug 'skim-rs/skim', { 'dir': '~/.skim', 'do': './install' } +Plug 'skim-rs/skim' ``` @@ -544,12 +547,6 @@ so that you could deal with strings or files easily. Check out more examples under the [examples/](https://github.com/skim-rs/skim/tree/master/skim/examples) directory. -## Tuikit - -`tuikit` is the TUI framework used in `skim`. It is available from the library as `skim::tuikit`. - -Check [the README](./skim-tuikit/README.md) for more details. - # FAQ ## How to ignore files? @@ -603,6 +600,8 @@ or have any ideas. Pull requests are warmly welcomed. # Troubleshooting +To troubleshoot what's happening, you can set the environment variable `RUST_LOG` to either `debug` or even `trace`, and set `--log-file` to a path. You can then read those logs during or after the execution to better understand what's happening. Don't hesitate to add those logs to an issue if you need help. + ## No line feed issues with nix, FreeBSD, termux If you encounter display issues like: diff --git a/bench.sh b/bench.sh new file mode 100755 index 00000000..8424e7d8 --- /dev/null +++ b/bench.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash + +# Benchmark script to measure ingestion + matching rate in skim interactive mode +# This measures how fast skim can ingest items and display matched results + +set -e +export SHELL="/bin/sh" +unset HISTFILE + +# Parse arguments +BINARY_PATH=${1:-"./target/release/sk"} +NUM_ITEMS=${2:-1000000} +QUERY=${3:-"test"} + +echo "=== Skim Ingestion + Matching Benchmark ===" +echo "Binary: $BINARY_PATH | Items: $NUM_ITEMS | Query: '$QUERY'" + +# Generate test data +TMP_FILE=$(mktemp) +STATUS_FILE=$(mktemp) +SESSION_NAME="skim_bench_$$" +trap "rm -f $TMP_FILE $STATUS_FILE; tmux kill-session -t $SESSION_NAME 2>/dev/null || true" EXIT + +# Generate random path-like strings with 2-10 words separated by slashes +awk -v num="$NUM_ITEMS" 'BEGIN { + srand() + words[1]="home"; words[2]="usr"; words[3]="etc"; words[4]="var"; words[5]="opt" + words[6]="tmp"; words[7]="dev"; words[8]="proc"; words[9]="sys"; words[10]="lib" + words[11]="bin"; words[12]="sbin"; words[13]="boot"; words[14]="mnt"; words[15]="media" + words[16]="src"; words[17]="test"; words[18]="config"; words[19]="data"; words[20]="logs" + words[21]="cache"; words[22]="backup"; words[23]="docs"; words[24]="images"; words[25]="videos" + words[26]="audio"; words[27]="downloads"; words[28]="uploads"; words[29]="temp"; words[30]="shared" + + for (i = 1; i <= num; i++) { + depth = int(rand() * 9) + 2 # 2-10 depth + path = "" + for (j = 1; j <= depth; j++) { + word_idx = int(rand() * 30) + 1 + path = path words[word_idx] + if (j < depth) path = path "/" + } + print path "_" i + } +}' > "$TMP_FILE" + +# Create a new tmux session in the background +tmux new-session -s "$SESSION_NAME" -d + +# Prepare to capture the start time as close to data ingestion as possible +# Run skim with the query already set, and measure until matcher completes +tmux send-keys -t "$SESSION_NAME" "cat $TMP_FILE | $BINARY_PATH --query '$QUERY'" Enter + +# Record start time +START=$(date +%s%N) + +# Wait a bit for skim to actually start +sleep 0.2 + +# Find skim PID for resource monitoring +SK_PID="" +for i in 1 2 3 4 5; do + sleep 0.5 + SK_PID=$(pgrep -lf "$BINARY_PATH" | grep -E "sk|fzf" | head -1 | cut -d' ' -f1) + if [ -n "$SK_PID" ]; then + break + fi +done + +if [ -n "$SK_PID" ]; then + # Start background monitoring of CPU and RAM + MONITOR_LOG="/tmp/skim-monitor-$SK_PID.log" + rm -f "$MONITOR_LOG" + ( + PEAK_MEM=0 + PEAK_CPU=0 + while kill -0 "$SK_PID" 2>/dev/null; do + MEM=$(ps -p "$SK_PID" -o rss= 2>/dev/null | tr -d ' ') + CPU=$(ps -p "$SK_PID" -o %cpu= 2>/dev/null | tr -d ' ') + if [ -n "$MEM" ] && [ "$MEM" -gt "$PEAK_MEM" ]; then + PEAK_MEM=$MEM + fi + if [ -n "$CPU" ]; then + CPU_INT=$(echo "$CPU" | cut -d. -f1) + PEAK_CPU_INT=$(echo "$PEAK_CPU" | cut -d. -f1) + if [ "$CPU_INT" -gt "$PEAK_CPU_INT" ]; then + PEAK_CPU=$CPU + fi + fi + echo "$MEM $CPU" >> "$MONITOR_LOG" + sleep 0.1 + done + echo "PEAK:$PEAK_MEM:$PEAK_CPU" >> "$MONITOR_LOG" + ) & + MONITOR_PID=$! +else + MONITOR_PID="" +fi + +# Monitor for matcher completion by checking status line +COMPLETED=0 +MATCHED_COUNT=0 +TOTAL_INGESTED=0 +MAX_WAIT=60 +ELAPSED=0 + +while [ $ELAPSED -lt $MAX_WAIT ]; do + sleep 1 + ELAPSED=$((ELAPSED + 1)) + + # Capture and check status using bench.sh's method + tmux capture-pane -b "status-$SESSION_NAME" -t "$SESSION_NAME" 2>/dev/null || true + tmux save-buffer -b "status-$SESSION_NAME" "$STATUS_FILE" 2>/dev/null || true + + if [ -f "$STATUS_FILE" ]; then + # Skim status line format is typically: " > query matched/total" + # We need to find the last occurrence of the pattern matched/total + # The first number is matched items, second is total ingested items + STATUS_LINE=$(grep -oE '[0-9]+/[0-9]+' "$STATUS_FILE" 2>/dev/null | head -1 || echo "") + if [ -n "$STATUS_LINE" ]; then + MATCHED_COUNT=$(echo "$STATUS_LINE" | cut -d'/' -f1) + TOTAL_INGESTED=$(echo "$STATUS_LINE" | cut -d'/' -f2) + + # Check if ingestion is complete + if [ "$TOTAL_INGESTED" = "$NUM_ITEMS" ]; then + COMPLETED=1 + break + fi + fi + fi +done + +END=$(date +%s%N) + +# Exit skim +tmux send-keys -t "$SESSION_NAME" Escape +sleep 0.1 + +# Wait for monitor to finish if it was started +if [ -n "$MONITOR_PID" ]; then + wait "$MONITOR_PID" 2>/dev/null || true +fi + +# Clean up +tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true + +ELAPSED_NS=$((END - START)) +ELAPSED=$(awk "BEGIN {printf \"%.3f\", $ELAPSED_NS / 1000000000}") +RATE=$(awk "BEGIN {printf \"%.0f\", $NUM_ITEMS / $ELAPSED}") + +# Extract peak CPU and RAM usage +PEAK_MEM=0 +PEAK_CPU=0 +if [ -n "$MONITOR_PID" ] && [ -f "$MONITOR_LOG" ]; then + PEAK_LINE=$(grep "^PEAK:" "$MONITOR_LOG" 2>/dev/null || echo "") + if [ -n "$PEAK_LINE" ]; then + PEAK_MEM=$(echo "$PEAK_LINE" | cut -d: -f2) + PEAK_CPU=$(echo "$PEAK_LINE" | cut -d: -f3) + fi + rm -f "$MONITOR_LOG" +fi + +echo "=== Results ===" +echo "Status: $(if [ $COMPLETED -eq 1 ]; then echo 'COMPLETED'; else echo 'TIMEOUT'; fi)" +echo "Items matched: $MATCHED_COUNT / $NUM_ITEMS" +echo "Total time: ${ELAPSED}s" +echo "Items/second: ${RATE}" +if [ -n "$PEAK_MEM" ] && [ "$PEAK_MEM" -gt 0 ]; then + echo "Peak memory usage: $((PEAK_MEM / 1024)) MB" + echo "Peak CPU usage: ${PEAK_CPU}%" +fi \ No newline at end of file diff --git a/bin/sk-tmux b/bin/sk-tmux index e7cc9ec4..83e83277 100755 --- a/bin/sk-tmux +++ b/bin/sk-tmux @@ -27,7 +27,8 @@ # sk-tmux: starts sk in a tmux pane # usage: sk-tmux [LAYOUT OPTIONS] [--] [SK OPTIONS] -# echo "[WRN] This script is deprecated in favor or \`sk --tmux\` and will be removed in a later release" >&2 + +echo "[WRN] This script is deprecated in favor or \`sk --tmux\` and will be removed in a later release" >&2 fail() { >&2 echo "$1" diff --git a/e2e.dockerfile b/e2e.dockerfile deleted file mode 100644 index ec8bd2df..00000000 --- a/e2e.dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM rust:1.88-slim - -RUN apt-get update && apt-get install -y tmux bsdmainutils && apt-get clean - -COPY . . -RUN cargo build --release && cargo build --package e2e --tests - -ENTRYPOINT ["sh"] -CMD ["-c", "tmux new-session -d && cargo e2e -j8"] \ No newline at end of file diff --git a/e2e/Cargo.toml b/e2e/Cargo.toml deleted file mode 100644 index d66d3905..00000000 --- a/e2e/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "e2e" -version = "0.1.0" -edition = "2024" - -[dependencies] -rand = { workspace = true } -tempfile = { workspace = true } -which = { workspace = true } diff --git a/e2e/src/lib.rs b/e2e/src/lib.rs deleted file mode 100644 index 8fa10f5b..00000000 --- a/e2e/src/lib.rs +++ /dev/null @@ -1,210 +0,0 @@ -use std::{ - fmt::{Display, Formatter}, - fs::File, - io::{BufReader, Error, ErrorKind, Read, Result}, - path::Path, - process::Command, - thread::sleep, - time::Duration, -}; - -use rand::distr::{Alphanumeric, SampleString as _}; -use tempfile::{NamedTempFile, TempDir, tempdir}; -use which::which; - -pub static SK: &str = "SKIM_DEFAULT_OPTIONS= SKIM_DEFAULT_COMMAND= cargo run --package skim --release --"; - -pub fn sk(outfile: &str, opts: &[&str]) -> String { - format!( - "{} {} > {}.part; mv {}.part {}", - SK, - opts.join(" "), - outfile, - outfile, - outfile - ) -} - -fn wait(pred: F) -> Result -where - F: Fn() -> Result, -{ - for _ in 1..200 { - if let Ok(t) = pred() { - return Ok(t); - } - sleep(Duration::from_millis(50)); - } - Err(Error::new(ErrorKind::TimedOut, "wait timed out")) -} - -pub enum Keys<'a> { - Str(&'a str), - Key(char), - Ctrl(&'a Keys<'a>), - Alt(&'a Keys<'a>), - Enter, - Tab, - BTab, - Left, - Right, - BSpace, -} - -impl Display for Keys<'_> { - fn fmt(&self, f: &mut Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { - use Keys::*; - match self { - Str(s) => write!(f, "{}", s), - Key(c) => write!(f, "{}", c), - Ctrl(k) => write!(f, "C-{}", k), - Alt(k) => write!(f, "M-{}", k), - Enter => write!(f, "Enter"), - Tab => write!(f, "Tab"), - BTab => write!(f, "BTab"), - Left => write!(f, "Left"), - Right => write!(f, "Right"), - BSpace => write!(f, "BSpace"), - } - } -} - -pub struct TmuxController { - window: String, - pub tempdir: TempDir, -} - -impl TmuxController { - pub fn run(args: &[&str]) -> Result> { - println!("Running {:?}", args); - let output = Command::new(which("tmux").expect("Please install tmux to $PATH")) - .args(args) - .output()? - .stdout - .split(|c| *c == b'\n') - .map(|bytes| String::from_utf8(bytes.to_vec()).expect("Failed to parse bytes as UTF8 string")) - .collect::>(); - sleep(Duration::from_millis(50)); - Ok(output[0..output.len() - 1].to_vec()) - } - - pub fn new() -> Result { - let unset_cmd = "unset SKIM_DEFAULT_COMMAND SKIM_DEFAULT_OPTIONS PS1 PROMPT_COMMAND"; - - let shell_cmd = "bash --rcfile None"; - - let name = Alphanumeric.sample_string(&mut rand::rng(), 16); - - Self::run(&[ - "new-window", - "-d", - "-P", - "-F", - "#I", - "-n", - &name, - &format!("{}; {}", unset_cmd, shell_cmd), - ])?; - - Self::run(&["set-window-option", "-t", &name, "pane-base-index", "0"])?; - - Ok(Self { - window: name, - tempdir: tempdir()?, - }) - } - - pub fn send_keys(&self, keys: &[Keys]) -> Result<()> { - for key in keys { - Self::run(&["send-keys", "-t", &self.window, &key.to_string()])?; - } - Ok(()) - } - - pub fn tempfile(&self) -> Result { - Ok(NamedTempFile::new_in(&self.tempdir)? - .path() - .to_str() - .unwrap() - .to_string()) - } - - // Returns the lines in reverted order - pub fn capture(&self) -> Result> { - let tempfile = wait(|| { - let tempfile = self.tempfile()?; - Self::run(&["capture-pane", "-b", &self.window, "-t", &format!("{}.0", self.window)])?; - Self::run(&["save-buffer", "-b", &self.window, &tempfile])?; - Ok(tempfile) - })?; - - let mut string_lines = String::new(); - BufReader::new(File::open(tempfile)?).read_to_string(&mut string_lines)?; - - let str_lines = string_lines.trim(); - Ok(str_lines - .split("\n") - .map(|s| s.to_string()) - .collect::>() - .into_iter() - .rev() - .collect()) - } - - pub fn until(&self, pred: F) -> Result<()> - where - F: Fn(&[String]) -> bool, - { - match wait(|| { - let lines = self.capture()?; - if pred(&lines) { - return Ok(true); - } - Err(Error::new(ErrorKind::Other, "pred not matched")) - }) { - Ok(true) => Ok(()), - Ok(false) => Err(Error::new(ErrorKind::Other, self.capture()?.join("\n"))), - _ => Err(Error::new(ErrorKind::TimedOut, self.capture()?.join("\n"))), - } - } - - pub fn output(&self, outfile: &str) -> Result> { - wait(|| { - if Path::new(outfile).exists() { - Ok(()) - } else { - Err(Error::new(ErrorKind::NotFound, "oufile does not exist yet")) - } - })?; - let mut string_lines = String::new(); - println!("{}", Path::new(outfile).exists()); - println!("Reading file {outfile}"); - BufReader::new(File::open(outfile)?).read_to_string(&mut string_lines)?; - - let str_lines = string_lines.trim(); - Ok(str_lines - .split("\n") - .map(|s| s.to_string()) - .collect::>() - .into_iter() - .rev() - .collect()) - } - - pub fn start_sk(&self, stdin_cmd: Option<&str>, opts: &[&str]) -> Result { - let outfile = self.tempfile()?; - let sk_cmd = sk(&outfile, opts); - let cmd = match stdin_cmd { - Some(s) => format!("{} | {}", s, sk_cmd), - None => sk_cmd, - }; - self.send_keys(&[Keys::Str(&cmd), Keys::Enter])?; - Ok(outfile) - } -} - -impl Drop for TmuxController { - fn drop(&mut self) { - let _ = Self::run(&["kill-window", "-t", &self.window]); - } -} diff --git a/e2e/tests/binds.rs b/e2e/tests/binds.rs deleted file mode 100644 index 5e305b33..00000000 --- a/e2e/tests/binds.rs +++ /dev/null @@ -1,97 +0,0 @@ -use e2e::Keys::*; -use e2e::TmuxController; -use e2e::sk; -use std::io::Result; - -fn setup(input: &str, opts: &[&str]) -> Result { - let tmux = TmuxController::new()?; - let _ = tmux.start_sk(Some(&format!("echo -n -e '{input}'")), opts)?; - tmux.until(|l| l[0].starts_with(">"))?; - Ok(tmux) -} - -#[test] -fn bind_execute_0_results() -> Result<()> { - let tmux = TmuxController::new()?; - let outfile = tmux.start_sk(Some("echo -n ''"), &["--bind", "'ctrl-f:execute(echo foo{})'"])?; - tmux.until(|l| l[0] == ">")?; - - tmux.send_keys(&[Ctrl(&Key('f')), Enter])?; - tmux.until(|l| l[0] != ">")?; - - let output = tmux.output(&outfile)?; - assert_eq!(output[0], ""); - - Ok(()) -} - -#[test] -fn bind_execute_0_results_noref() -> Result<()> { - let tmux = TmuxController::new()?; - let outfile = tmux.start_sk(Some("echo -n ''"), &["--bind", "'ctrl-f:execute(echo foo)'"])?; - tmux.until(|l| l[0] == ">")?; - - tmux.send_keys(&[Ctrl(&Key('f')), Enter])?; - tmux.until(|l| l[0] != ">")?; - - let output = tmux.output(&outfile)?; - assert_eq!(output[0], "foo"); - - Ok(()) -} - -#[test] -fn bind_if_non_matched() -> Result<()> { - let tmux = setup( - "a\nb", - &["--bind", "'enter:if-non-matched(backward-delete-char)'", "-q", "ab"], - )?; - - tmux.until(|l| l[0].starts_with(">"))?; - tmux.until(|l| l[0].starts_with("> ab"))?; - - tmux.send_keys(&[Enter])?; - tmux.until(|l| l[0] == "> a")?; - - tmux.send_keys(&[Enter, Key('c')])?; - tmux.until(|l| l[0].starts_with("> ac"))?; - - Ok(()) -} - -#[test] -fn bind_append_and_select() -> Result<()> { - let tmux = setup("a\\n\\nb\\nc", &["-m", "--bind", "'ctrl-f:append-and-select'"])?; - - tmux.send_keys(&[Str("xyz"), Ctrl(&Key('f'))])?; - tmux.until(|l| l.len() > 2 && l[2] == ">>xyz")?; - - Ok(()) -} - -#[test] -fn bind_reload_no_arg() -> Result<()> { - let tmux = TmuxController::new()?; - - let outfile = tmux.tempfile()?; - let sk_cmd = sk(&outfile, &["--bind", "'ctrl-a:reload'"]) - .replace("SKIM_DEFAULT_COMMAND=", "SKIM_DEFAULT_COMMAND='echo hello'"); - tmux.send_keys(&[Str(&sk_cmd), Enter])?; - tmux.until(|l| l[0].starts_with(">"))?; - - tmux.send_keys(&[Ctrl(&Key('a'))])?; - tmux.until(|l| l.len() > 2 && l[2] == "> hello")?; - - Ok(()) -} - -#[test] -fn bind_reload_cmd() -> Result<()> { - let tmux = setup("a\\n\\nb\\nc", &["--bind", "'ctrl-a:reload(echo hello)'"])?; - - tmux.until(|l| l.len() > 2 && l[2] == "> a")?; - tmux.send_keys(&[Ctrl(&Key('a'))])?; - tmux.until(|l| l.len() > 2 && l[2] == "> hello")?; - - Ok(()) -} diff --git a/e2e/tests/case.rs b/e2e/tests/case.rs deleted file mode 100644 index cd342161..00000000 --- a/e2e/tests/case.rs +++ /dev/null @@ -1,83 +0,0 @@ -use e2e::Keys::*; -use e2e::TmuxController; -use std::io::Result; - -fn setup(case: &str) -> Result { - let tmux = TmuxController::new()?; - let _ = tmux.start_sk(Some(&format!("echo -n -e 'aBcDeF'")), &["--case", case])?; - tmux.until(|l| l[0].starts_with(">"))?; - Ok(tmux) -} - -#[test] -fn case_smart_lower() -> Result<()> { - let tmux = setup("smart")?; - - tmux.send_keys(&[Str("abc")])?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1")) -} -#[test] -fn case_smart_exact() -> Result<()> { - let tmux = setup("smart")?; - - tmux.send_keys(&[Str("aBc")])?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1")) -} -#[test] -fn case_smart_no_match() -> Result<()> { - let tmux = setup("smart")?; - - tmux.send_keys(&[Str("Abc")])?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1")) -} - -#[test] -fn case_ignore_lower() -> Result<()> { - let tmux = setup("ignore")?; - - tmux.send_keys(&[Str("abc")])?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1")) -} -#[test] -fn case_ignore_exact() -> Result<()> { - let tmux = setup("ignore")?; - - tmux.send_keys(&[Str("aBc")])?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1")) -} -#[test] -fn case_ignore_different() -> Result<()> { - let tmux = setup("ignore")?; - - tmux.send_keys(&[Str("Abc")])?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1")) -} -#[test] -fn case_ignore_no_match() -> Result<()> { - let tmux = setup("ignore")?; - - tmux.send_keys(&[Str("z")])?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1")) -} - -#[test] -fn case_respect_lower() -> Result<()> { - let tmux = setup("respect")?; - - tmux.send_keys(&[Str("abc")])?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1")) -} -#[test] -fn case_respect_exact() -> Result<()> { - let tmux = setup("respect")?; - - tmux.send_keys(&[Str("aBc")])?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1")) -} -#[test] -fn case_respect_no_match() -> Result<()> { - let tmux = setup("respect")?; - - tmux.send_keys(&[Str("Abc")])?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1")) -} diff --git a/e2e/tests/defaults.rs b/e2e/tests/defaults.rs deleted file mode 100644 index 81271c1b..00000000 --- a/e2e/tests/defaults.rs +++ /dev/null @@ -1,68 +0,0 @@ -use e2e::{Keys, TmuxController, sk}; -use std::io::Result; - -#[test] -fn vanilla() -> Result<()> { - let tmux = TmuxController::new()?; - let _ = tmux.start_sk(Some("seq 1 100000"), &[]); - tmux.until(|l| l[0].starts_with(">") && l[1].starts_with(" 100000"))?; - let lines = tmux.capture()?; - assert_eq!(lines[3], " 2"); - assert_eq!(lines[2], "> 1"); - assert!(lines[1].starts_with(" 100000/100000")); - assert!(lines[1].ends_with("0/0")); - assert_eq!(lines[0], ">"); - - Ok(()) -} - -#[test] -fn default_command() -> Result<()> { - let tmux = TmuxController::new()?; - - let outfile = tmux.tempfile()?; - let sk_cmd = sk(&outfile, &[]).replace("SKIM_DEFAULT_COMMAND=", "SKIM_DEFAULT_COMMAND='echo hello'"); - tmux.send_keys(&[Keys::Str(&sk_cmd), Keys::Enter])?; - tmux.until(|l| l[0].starts_with(">"))?; - tmux.until(|l| l.len() > 1 && l[1].starts_with(" 1/1"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> hello")?; - - tmux.send_keys(&[Keys::Enter])?; - tmux.until(|l| !l[0].starts_with(">"))?; - - let output = tmux.output(&outfile)?; - - assert_eq!(output[0], "hello"); - - Ok(()) -} - -#[test] -fn version_long() -> Result<()> { - let tmux = TmuxController::new()?; - - let outfile = tmux.tempfile()?; - let sk_cmd = sk(&outfile, &["--version"]); - tmux.send_keys(&[Keys::Str(&sk_cmd), Keys::Enter])?; - - let output = tmux.output(&outfile)?; - - assert!(output[0].starts_with("sk ")); - - Ok(()) -} - -#[test] -fn version_short() -> Result<()> { - let tmux = TmuxController::new()?; - - let outfile = tmux.tempfile()?; - let sk_cmd = sk(&outfile, &["-V"]); - tmux.send_keys(&[Keys::Str(&sk_cmd), Keys::Enter])?; - - let output = tmux.output(&outfile)?; - - assert!(output[0].starts_with("sk ")); - - Ok(()) -} diff --git a/e2e/tests/issues.rs b/e2e/tests/issues.rs deleted file mode 100644 index 4a4df696..00000000 --- a/e2e/tests/issues.rs +++ /dev/null @@ -1,34 +0,0 @@ -use e2e::Keys::*; -use e2e::TmuxController; -use std::io::Result; - -#[test] -fn issue_359_multi_regex_unicode() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk(Some("echo 'ああa'"), &["--regex", "-q", "'a'"])?; - tmux.until(|l| l[0] == "> a")?; - - tmux.until(|l| l.len() > 2 && l[2] == "> ああa")?; - - Ok(()) -} - -#[test] -fn issue_361_literal_space_control() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk(Some("echo -ne 'foo bar\\nfoo bar'"), &["-q", "'foo\\ bar'"])?; - tmux.until(|l| l[0].starts_with(">"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> foo bar")?; - - Ok(()) -} -#[test] -fn issue_361_literal_space_invert() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.send_keys(&[Str("set +o histexpand"), Enter])?; - tmux.start_sk(Some("echo -ne 'foo bar\\nfoo bar'"), &["-q", "'!foo\\ bar'"])?; - tmux.until(|l| l[0].starts_with(">"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> foo bar")?; - - Ok(()) -} diff --git a/e2e/tests/keys.rs b/e2e/tests/keys.rs deleted file mode 100644 index e1e5d489..00000000 --- a/e2e/tests/keys.rs +++ /dev/null @@ -1,251 +0,0 @@ -use e2e::{Keys::*, TmuxController}; -use std::io::Result; - -fn setup() -> Result { - let tmux = TmuxController::new()?; - let _ = tmux.start_sk(None, &["-q", "'foo bar foo-bar'"]); - tmux.until(|l| l[0].starts_with(">"))?; - Ok(tmux) -} - -#[test] -fn keys_basic() -> Result<()> { - let tmux = TmuxController::new()?; - let _ = tmux.start_sk(Some("seq 1 100000"), &[]); - tmux.until(|l| l[0].starts_with(">") && l[1].starts_with(" 100000"))?; - tmux.send_keys(&[Str("99")])?; - tmux.until(|l| l[0] == "> 99")?; - tmux.until(|l| l.len() > 1 && l[1].starts_with(" 8146/100000"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> 99")?; - - Ok(()) -} - -// Input navigation keys -// -#[test] -fn keys_arrows() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Left, Key('|')])?; - tmux.until(|l| l[0] == "> foo bar foo-ba|r")?; - tmux.send_keys(&[Right, Key('|')])?; - tmux.until(|l| l[0] == "> foo bar foo-ba|r|")?; - - Ok(()) -} - -#[test] -fn keys_ctrl_arrows() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Left), Key('|')])?; - tmux.until(|l| l[0] == "> foo bar foo-|bar")?; - tmux.send_keys(&[Ctrl(&Left), Key('|')])?; - tmux.until(|l| l[0] == "> foo bar |foo-|bar")?; - tmux.send_keys(&[Ctrl(&Right), Key('|')])?; - tmux.until(|l| l[0] == "> foo bar |foo-|bar|")?; - - Ok(()) -} - -#[test] -fn keys_ctrl_a() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('a')), Key('|')])?; - tmux.until(|l| l[0] == "> |foo bar foo-bar")?; - - Ok(()) -} - -#[test] -fn keys_ctrl_b() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('a')), Key('|')])?; - tmux.until(|l| l[0] == "> |foo bar foo-bar")?; - tmux.send_keys(&[Ctrl(&Key('f')), Key('|')])?; - tmux.until(|l| l[0] == "> |f|oo bar foo-bar")?; - - Ok(()) -} - -#[test] -fn keys_ctrl_e() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('a')), Key('|')])?; - tmux.until(|l| l[0] == "> |foo bar foo-bar")?; - tmux.send_keys(&[Ctrl(&Key('e')), Key('|')])?; - tmux.until(|l| l[0] == "> |foo bar foo-bar|")?; - - Ok(()) -} - -#[test] -fn keys_ctrl_f() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('a')), Key('|')])?; - tmux.until(|l| l[0] == "> |foo bar foo-bar")?; - tmux.send_keys(&[Ctrl(&Key('f')), Key('|')])?; - tmux.until(|l| l[0] == "> |f|oo bar foo-bar")?; - - Ok(()) -} - -#[test] -fn keys_ctrl_h() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('h')), Key('|')])?; - tmux.until(|l| l[0] == "> foo bar foo-ba|")?; - - Ok(()) -} - -#[test] -fn keys_alt_b() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Alt(&Key('b')), Key('|')])?; - tmux.until(|l| l[0] == "> foo bar foo-|bar")?; - - Ok(()) -} - -#[test] -fn keys_alt_f() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('a')), Key('|')])?; - tmux.until(|l| l[0] == "> |foo bar foo-bar")?; - tmux.send_keys(&[Alt(&Key('f')), Key('|')])?; - tmux.until(|l| l[0] == "> |foo| bar foo-bar")?; - - Ok(()) -} - -// Input manipulation keys -// -#[test] -fn keys_bspace() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[BSpace, Key('|')])?; - tmux.until(|l| l[0] == "> foo bar foo-ba|")?; - - Ok(()) -} -#[test] -fn keys_ctrl_d() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('a')), Key('|')])?; - tmux.until(|l| l[0] == "> |foo bar foo-bar")?; - tmux.send_keys(&[Ctrl(&Key('d')), Key('|')])?; - tmux.until(|l| l[0] == "> ||oo bar foo-bar")?; - - Ok(()) -} -#[test] -fn keys_ctrl_u() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('u')), Key('|')])?; - tmux.until(|l| l[0] == "> |")?; - - Ok(()) -} -#[test] -fn keys_ctrl_w() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('w')), Key('|')])?; - tmux.until(|l| l[0] == "> foo bar |")?; - - Ok(()) -} -#[test] -fn keys_ctrl_y() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Alt(&BSpace), Key('|')])?; - tmux.until(|l| l[0] == "> foo bar foo-|")?; - tmux.send_keys(&[Ctrl(&Key('y')), Key('|')])?; - tmux.until(|l| l[0] == "> foo bar foo-|bar|")?; - - Ok(()) -} -#[test] -fn keys_alt_d() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Left), Key('|')])?; - tmux.until(|l| l[0] == "> foo bar foo-|bar")?; - tmux.send_keys(&[Ctrl(&Left), Key('|')])?; - tmux.until(|l| l[0] == "> foo bar |foo-|bar")?; - tmux.send_keys(&[Alt(&Key('d')), Key('|')])?; - tmux.until(|l| l[0] == "> foo bar ||-|bar")?; - - Ok(()) -} -#[test] -fn keys_alt_bspace() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Alt(&BSpace), Key('|')])?; - tmux.until(|l| l[0] == "> foo bar foo-|")?; - - Ok(()) -} - -// Results navigation keys -// -#[test] -fn keys_ctrl_k() -> Result<()> { - let tmux = TmuxController::new()?; - let _ = tmux.start_sk(Some("seq 1 100000"), &[]); - tmux.until(|l| l[0].starts_with(">") && l[1].starts_with(" 100000"))?; - tmux.send_keys(&[Ctrl(&Key('k'))])?; - tmux.until(|l| l.len() > 2 && l[2] == " 1")?; - tmux.until(|l| l.len() > 3 && l[3] == "> 2")?; - - Ok(()) -} - -#[test] -fn keys_tab() -> Result<()> { - let tmux = TmuxController::new()?; - let _ = tmux.start_sk(Some("seq 1 100000"), &[]); - tmux.until(|l| l[0].starts_with(">") && l[1].starts_with(" 100000"))?; - tmux.send_keys(&[Ctrl(&Key('k'))])?; - tmux.until(|l| l.len() > 2 && l[2] == " 1")?; - tmux.until(|l| l.len() > 3 && l[3] == "> 2")?; - - tmux.send_keys(&[Tab])?; - tmux.until(|l| l.len() > 2 && l[2] == "> 1")?; - tmux.until(|l| l.len() > 3 && l[3] == " 2")?; - - Ok(()) -} - -#[test] -fn keys_btab() -> Result<()> { - let tmux = TmuxController::new()?; - let _ = tmux.start_sk(Some("seq 1 100000"), &[]); - tmux.until(|l| l[0].starts_with(">") && l[1].starts_with(" 100000"))?; - tmux.send_keys(&[BTab])?; - tmux.until(|l| l.len() > 2 && l[2] == " 1")?; - tmux.until(|l| l.len() > 3 && l[3] == "> 2")?; - - Ok(()) -} - -#[test] -fn keys_enter() -> Result<()> { - let tmux = TmuxController::new()?; - let outfile = tmux.start_sk(Some("seq 1 100000"), &[])?; - tmux.until(|l| l[0].starts_with(">") && l[1].starts_with(" 100000"))?; - tmux.send_keys(&[Enter])?; - tmux.until(|l| !l[0].starts_with(">"))?; - let res = tmux.output(&outfile)?; - assert_eq!(res[0], "1"); - Ok(()) -} -#[test] -fn keys_ctrl_m() -> Result<()> { - let tmux = TmuxController::new()?; - let outfile = tmux.start_sk(Some("seq 1 100000"), &[])?; - tmux.until(|l| l[0].starts_with(">") && l[1].starts_with(" 100000"))?; - tmux.send_keys(&[Ctrl(&Key('m'))])?; - tmux.until(|l| !l[0].starts_with(">"))?; - let res = tmux.output(&outfile)?; - assert_eq!(res[0], "1"); - Ok(()) -} diff --git a/e2e/tests/keys_interactive.rs b/e2e/tests/keys_interactive.rs deleted file mode 100644 index e50c444b..00000000 --- a/e2e/tests/keys_interactive.rs +++ /dev/null @@ -1,251 +0,0 @@ -use e2e::{Keys::*, TmuxController}; -use std::io::Result; - -fn setup() -> Result { - let tmux = TmuxController::new()?; - let _ = tmux.start_sk(None, &["-i", "--cmd-query", "'foo bar foo-bar'"]); - tmux.until(|l| l[0].starts_with("c>"))?; - Ok(tmux) -} - -#[test] -fn keys_interactive_basic() -> Result<()> { - let tmux = TmuxController::new()?; - let _ = tmux.start_sk(Some("seq 1 100000"), &["-i"]); - tmux.until(|l| l[0].starts_with("c>") && l[1].starts_with(" 100000"))?; - tmux.send_keys(&[Str("99")])?; - tmux.until(|l| l[0] == "c> 99")?; - tmux.until(|l| l.len() > 1 && l[1].starts_with(" 100000/100000"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> 1")?; - - Ok(()) -} - -// Input navigation keys -// -#[test] -fn keys_interactive_arrows() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Left, Key('|')])?; - tmux.until(|l| l[0] == "c> foo bar foo-ba|r")?; - tmux.send_keys(&[Right, Key('|')])?; - tmux.until(|l| l[0] == "c> foo bar foo-ba|r|")?; - - Ok(()) -} - -#[test] -fn keys_interactive_ctrl_arrows() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Left), Key('|')])?; - tmux.until(|l| l[0] == "c> foo bar foo-|bar")?; - tmux.send_keys(&[Ctrl(&Left), Key('|')])?; - tmux.until(|l| l[0] == "c> foo bar |foo-|bar")?; - tmux.send_keys(&[Ctrl(&Right), Key('|')])?; - tmux.until(|l| l[0] == "c> foo bar |foo-|bar|")?; - - Ok(()) -} - -#[test] -fn keys_interactive_ctrl_a() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('a')), Key('|')])?; - tmux.until(|l| l[0] == "c> |foo bar foo-bar")?; - - Ok(()) -} - -#[test] -fn keys_interactive_ctrl_b() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('a')), Key('|')])?; - tmux.until(|l| l[0] == "c> |foo bar foo-bar")?; - tmux.send_keys(&[Ctrl(&Key('f')), Key('|')])?; - tmux.until(|l| l[0] == "c> |f|oo bar foo-bar")?; - - Ok(()) -} - -#[test] -fn keys_interactive_ctrl_e() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('a')), Key('|')])?; - tmux.until(|l| l[0] == "c> |foo bar foo-bar")?; - tmux.send_keys(&[Ctrl(&Key('e')), Key('|')])?; - tmux.until(|l| l[0] == "c> |foo bar foo-bar|")?; - - Ok(()) -} - -#[test] -fn keys_interactive_ctrl_f() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('a')), Key('|')])?; - tmux.until(|l| l[0] == "c> |foo bar foo-bar")?; - tmux.send_keys(&[Ctrl(&Key('f')), Key('|')])?; - tmux.until(|l| l[0] == "c> |f|oo bar foo-bar")?; - - Ok(()) -} - -#[test] -fn keys_interactive_ctrl_h() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('h')), Key('|')])?; - tmux.until(|l| l[0] == "c> foo bar foo-ba|")?; - - Ok(()) -} - -#[test] -fn keys_interactive_alt_b() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Alt(&Key('b')), Key('|')])?; - tmux.until(|l| l[0] == "c> foo bar foo-|bar")?; - - Ok(()) -} - -#[test] -fn keys_interactive_alt_f() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('a')), Key('|')])?; - tmux.until(|l| l[0] == "c> |foo bar foo-bar")?; - tmux.send_keys(&[Alt(&Key('f')), Key('|')])?; - tmux.until(|l| l[0] == "c> |foo| bar foo-bar")?; - - Ok(()) -} - -// Input manipulation keys -// -#[test] -fn keys_interactive_bspace() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[BSpace, Key('|')])?; - tmux.until(|l| l[0] == "c> foo bar foo-ba|")?; - - Ok(()) -} -#[test] -fn keys_interactive_ctrl_d() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('a')), Key('|')])?; - tmux.until(|l| l[0] == "c> |foo bar foo-bar")?; - tmux.send_keys(&[Ctrl(&Key('d')), Key('|')])?; - tmux.until(|l| l[0] == "c> ||oo bar foo-bar")?; - - Ok(()) -} -#[test] -fn keys_interactive_ctrl_u() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('u')), Key('|')])?; - tmux.until(|l| l[0] == "c> |")?; - - Ok(()) -} -#[test] -fn keys_interactive_ctrl_w() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Key('w')), Key('|')])?; - tmux.until(|l| l[0] == "c> foo bar |")?; - - Ok(()) -} -#[test] -fn keys_interactive_ctrl_y() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Alt(&BSpace), Key('|')])?; - tmux.until(|l| l[0] == "c> foo bar foo-|")?; - tmux.send_keys(&[Ctrl(&Key('y')), Key('|')])?; - tmux.until(|l| l[0] == "c> foo bar foo-|bar|")?; - - Ok(()) -} -#[test] -fn keys_interactive_alt_d() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Ctrl(&Left), Key('|')])?; - tmux.until(|l| l[0] == "c> foo bar foo-|bar")?; - tmux.send_keys(&[Ctrl(&Left), Key('|')])?; - tmux.until(|l| l[0] == "c> foo bar |foo-|bar")?; - tmux.send_keys(&[Alt(&Key('d')), Key('|')])?; - tmux.until(|l| l[0] == "c> foo bar ||-|bar")?; - - Ok(()) -} -#[test] -fn keys_interactive_alt_bspace() -> Result<()> { - let tmux = setup()?; - tmux.send_keys(&[Alt(&BSpace), Key('|')])?; - tmux.until(|l| l[0] == "c> foo bar foo-|")?; - - Ok(()) -} - -// Results navigation keys -// -#[test] -fn keys_interactive_ctrl_k() -> Result<()> { - let tmux = TmuxController::new()?; - let _ = tmux.start_sk(Some("seq 1 100000"), &["-i"]); - tmux.until(|l| l[0].starts_with("c>") && l[1].starts_with(" 100000"))?; - tmux.send_keys(&[Ctrl(&Key('k'))])?; - tmux.until(|l| l.len() > 2 && l[2] == " 1")?; - tmux.until(|l| l.len() > 3 && l[3] == "> 2")?; - - Ok(()) -} - -#[test] -fn keys_interactive_tab() -> Result<()> { - let tmux = TmuxController::new()?; - let _ = tmux.start_sk(Some("seq 1 100000"), &["-i"]); - tmux.until(|l| l[0].starts_with("c>") && l[1].starts_with(" 100000"))?; - tmux.send_keys(&[Ctrl(&Key('k'))])?; - tmux.until(|l| l.len() > 2 && l[2] == " 1")?; - tmux.until(|l| l.len() > 3 && l[3] == "> 2")?; - - tmux.send_keys(&[Tab])?; - tmux.until(|l| l.len() > 2 && l[2] == "> 1")?; - tmux.until(|l| l.len() > 3 && l[3] == " 2")?; - - Ok(()) -} - -#[test] -fn keys_interactive_btab() -> Result<()> { - let tmux = TmuxController::new()?; - let _ = tmux.start_sk(Some("seq 1 100000"), &["-i"]); - tmux.until(|l| l[0].starts_with("c>") && l[1].starts_with(" 100000"))?; - tmux.send_keys(&[BTab])?; - tmux.until(|l| l.len() > 2 && l[2] == " 1")?; - tmux.until(|l| l.len() > 3 && l[3] == "> 2")?; - - Ok(()) -} - -#[test] -fn keys_interactive_enter() -> Result<()> { - let tmux = TmuxController::new()?; - let outfile = tmux.start_sk(Some("seq 1 100000"), &["-i"])?; - tmux.until(|l| l[0].starts_with("c>") && l[1].starts_with(" 100000"))?; - tmux.send_keys(&[Enter])?; - tmux.until(|l| !l[0].starts_with("c>"))?; - let res = tmux.output(&outfile)?; - assert_eq!(res[0], "1"); - Ok(()) -} -#[test] -fn keys_interactive_ctrl_m() -> Result<()> { - let tmux = TmuxController::new()?; - let outfile = tmux.start_sk(Some("seq 1 100000"), &["-i"])?; - tmux.until(|l| l[0].starts_with("c>") && l[1].starts_with(" 100000"))?; - tmux.send_keys(&[Ctrl(&Key('m'))])?; - tmux.until(|l| !l[0].starts_with("c>"))?; - let res = tmux.output(&outfile)?; - assert_eq!(res[0], "1"); - Ok(()) -} diff --git a/e2e/tests/options.rs b/e2e/tests/options.rs deleted file mode 100644 index 479b6af7..00000000 --- a/e2e/tests/options.rs +++ /dev/null @@ -1,948 +0,0 @@ -use e2e::Keys::*; -use e2e::TmuxController; -use std::io::Result; -use std::io::Write; -use tempfile::NamedTempFile; - -fn setup(input: &str, opts: &[&str]) -> Result<(TmuxController, String)> { - let tmux = TmuxController::new()?; - let outfile = tmux.start_sk(Some(&format!("echo -n -e '{input}'")), opts)?; - tmux.until(|l| l[0].starts_with(">"))?; - Ok((tmux, outfile)) -} - -#[test] -fn opt_read0() -> Result<()> { - let (tmux, _) = setup("a\\0b\\0c", &["--read0"])?; - let lines = tmux.capture()?; - - assert!(lines[1].starts_with(" 3/3")); - assert_eq!(lines[2].trim(), "> a"); - assert_eq!(lines[3].trim(), "b"); - assert_eq!(lines[4].trim(), "c"); - - Ok(()) -} - -#[test] -fn opt_print0() -> Result<()> { - let (tmux, outfile) = setup("a\\nb\\nc", &["-m", "--print0"])?; - tmux.send_keys(&[BTab, BTab, Enter])?; - tmux.until(|l| !l[0].starts_with(">"))?; - - let lines = tmux.output(&outfile)?; - - assert_eq!(lines, vec!["a\0b\0"]); - - Ok(()) -} - -#[test] -fn opt_with_nth_preview() -> Result<()> { - let (tmux, _) = setup( - "f1,f2,f3,f4", - &["--delimiter", ",", "--with-nth", "2..", "--preview", "'echo X{1}Y'"], - )?; - - tmux.until(|l| l.iter().any(|s| s.contains("Xf1Y")))?; - - Ok(()) -} - -#[test] -fn opt_min_query_length() -> Result<()> { - let (tmux, _) = setup("line1\nline2\nline3", &["--min-query-length", "3"])?; - - // With empty query, no results should be shown - let lines = tmux.capture()?; - assert!(!lines.iter().any(|s| s.contains("line"))); - - // Type 'li' (2 chars), still no results should be shown - tmux.send_keys(&[Key('l'), Key('i')])?; - tmux.until(|l| l[0].starts_with("> li"))?; - let lines = tmux.capture()?; - assert!(!lines.iter().any(|s| s.contains("line"))); - - // Type 'n' (3rd char), now results should appear - tmux.send_keys(&[Key('n')])?; - tmux.until(|l| l[0].starts_with("> lin"))?; - let lines = tmux.capture()?; - assert!(lines.iter().any(|s| s.contains("line"))); - - Ok(()) -} - -#[test] -fn opt_min_query_length_interactive() -> Result<()> { - // This test validates the fix for min-query-length in interactive mode - // focusing on the regular (non-command) mode which is working correctly - - // Part 1: Test with query length BELOW min-query-length - let tmux = TmuxController::new()?; - let _ = tmux.start_sk( - Some("echo -e 'aaa\nbbb\nccc'"), - &["-i", "--min-query-length", "2", "--cmd-query", "a"], - )?; - - // Wait for the UI to initialize - tmux.until(|l| l[0].starts_with("c> a"))?; - std::thread::sleep(std::time::Duration::from_millis(300)); - - // Verify that with insufficient query length, no results are shown - let lines = tmux.capture()?; - assert!( - !lines.iter().any(|s| s.contains("aaa")), - "No items should be displayed when query length is below min-query-length" - ); - - // Part 2: Test with query length MEETING min-query-length - let tmux = TmuxController::new()?; - let _ = tmux.start_sk( - Some("echo -e 'aaa\nbbb\nccc'"), - &["-i", "--min-query-length", "2", "--cmd-query", "aa"], - )?; - - // Wait for the UI to initialize - tmux.until(|l| l[0].starts_with("c> aa"))?; - std::thread::sleep(std::time::Duration::from_millis(300)); - - // Verify that with sufficient query length, results are shown - let lines = tmux.capture()?; - assert!( - lines.iter().any(|s| s.contains("aaa")), - "Items should be displayed when query length meets min-query-length" - ); - - Ok(()) -} - -#[test] -fn opt_min_query_length_interactive_cmd_mode() -> Result<()> { - // This test specifically validates the command mode part of the fix - // Command mode is when query starts with ":" - - // Test command mode with query length MEETING min-query-length - let tmux = TmuxController::new()?; - let _ = tmux.start_sk( - Some("echo -e 'aaa\nbbb\nccc'"), - &["-i", "--min-query-length", "2", "--cmd-query", ":bb"], - )?; - - // Wait for the UI to initialize in command mode - tmux.until(|l| l[0].starts_with("c> :bb"))?; - std::thread::sleep(std::time::Duration::from_millis(300)); - - // Verify that with sufficient command query length, results are shown - let lines = tmux.capture()?; - assert!( - lines.iter().any(|s| s.contains("bbb")), - "Items should be displayed when command mode query meets min-query-length" - ); - - Ok(()) -} - -#[test] -fn opt_with_nth_1() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "1"])?; - - tmux.until(|l| l.len() > 2 && l[2] == "> f1,")?; - - Ok(()) -} -#[test] -fn opt_with_nth_2() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "2"])?; - - tmux.until(|l| l.len() > 2 && l[2] == "> f2,")?; - - Ok(()) -} -#[test] -fn opt_with_nth_4() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "4"])?; - - tmux.until(|l| l.len() > 2 && l[2] == "> f4")?; - - Ok(()) -} -#[test] -fn opt_with_nth_oob() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "5"])?; - - tmux.until(|l| l.len() > 2 && l[2] == ">")?; - - Ok(()) -} -#[test] -fn opt_with_nth_neg_1() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--with-nth=-1"])?; - - tmux.until(|l| l.len() > 2 && l[2] == "> f4")?; - - Ok(()) -} -#[test] -fn opt_with_nth_neg_2() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--with-nth=-2"])?; - - tmux.until(|l| l.len() > 2 && l[2] == "> f3,")?; - - Ok(()) -} -#[test] -fn opt_with_nth_neg_4() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--with-nth=-4"])?; - - tmux.until(|l| l.len() > 2 && l[2] == "> f1,")?; - - Ok(()) -} -#[test] -fn opt_with_nth_neg_oob() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--with-nth=-5"])?; - - tmux.until(|l| l.len() > 2 && l[2] == ">")?; - - Ok(()) -} -#[test] -fn opt_with_nth_range_to_end() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "2.."])?; - - tmux.until(|l| l.len() > 2 && l[2] == "> f2,f3,f4")?; - - Ok(()) -} -#[test] -fn opt_with_nth_range_from_start() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "..3"])?; - - tmux.until(|l| l.len() > 2 && l[2] == "> f1,f2,f3,")?; - - Ok(()) -} -#[test] -fn opt_with_nth_range_closed() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "2..3"])?; - - tmux.until(|l| l.len() > 2 && l[2] == "> f2,f3,")?; - - Ok(()) -} -#[test] -fn opt_with_nth_range_dec() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "3..2"])?; - - tmux.until(|l| l.len() > 2 && l[2] == ">")?; - - Ok(()) -} - -#[test] -fn opt_nth_1() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--nth", "1"])?; - - tmux.send_keys(&[Str("1")])?; - tmux.until(|l| l[0] == "> 1")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> f1,f2,f3,f4")?; - - tmux.send_keys(&[Ctrl(&Key('w'))])?; - tmux.until(|l| l[0] == ">")?; - - tmux.send_keys(&[Str("2")])?; - tmux.until(|l| l[0] == "> 2")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1"))?; - - Ok(()) -} -#[test] -fn opt_nth_2() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--nth", "2"])?; - - tmux.send_keys(&[Str("2")])?; - tmux.until(|l| l[0] == "> 2")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> f1,f2,f3,f4")?; - - tmux.send_keys(&[Ctrl(&Key('w'))])?; - tmux.until(|l| l[0] == ">")?; - - tmux.send_keys(&[Str("1")])?; - tmux.until(|l| l[0] == "> 1")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1"))?; - - Ok(()) -} -#[test] -fn opt_nth_4() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--nth", "4"])?; - - tmux.send_keys(&[Str("4")])?; - tmux.until(|l| l[0] == "> 4")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> f1,f2,f3,f4")?; - - tmux.send_keys(&[Ctrl(&Key('w'))])?; - tmux.until(|l| l[0] == ">")?; - - tmux.send_keys(&[Str("1")])?; - tmux.until(|l| l[0] == "> 1")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1"))?; - - Ok(()) -} -#[test] -fn opt_nth_oob() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--nth", "5"])?; - - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> f1,f2,f3,f4")?; - - tmux.send_keys(&[Str("1")])?; - tmux.until(|l| l[0] == "> 1")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1"))?; - - Ok(()) -} -#[test] -fn opt_nth_neg_1() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--nth=-1"])?; - - tmux.send_keys(&[Str("4")])?; - tmux.until(|l| l[0] == "> 4")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> f1,f2,f3,f4")?; - - tmux.send_keys(&[Ctrl(&Key('w'))])?; - tmux.until(|l| l[0] == ">")?; - - tmux.send_keys(&[Str("1")])?; - tmux.until(|l| l[0] == "> 1")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1"))?; - - Ok(()) -} -#[test] -fn opt_nth_neg_2() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--nth=-2"])?; - - tmux.send_keys(&[Str("3")])?; - tmux.until(|l| l[0] == "> 3")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> f1,f2,f3,f4")?; - - tmux.send_keys(&[Ctrl(&Key('w'))])?; - tmux.until(|l| l[0] == ">")?; - - tmux.send_keys(&[Str("1")])?; - tmux.until(|l| l[0] == "> 1")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1"))?; - - Ok(()) -} -#[test] -fn opt_nth_neg_4() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--nth=-4"])?; - - tmux.send_keys(&[Str("1")])?; - tmux.until(|l| l[0] == "> 1")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> f1,f2,f3,f4")?; - - tmux.send_keys(&[Ctrl(&Key('w'))])?; - tmux.until(|l| l[0] == ">")?; - - tmux.send_keys(&[Str("2")])?; - tmux.until(|l| l[0] == "> 2")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1"))?; - - Ok(()) -} -#[test] -fn opt_nth_neg_oob() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--nth=-5"])?; - - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> f1,f2,f3,f4")?; - - tmux.send_keys(&[Str("1")])?; - tmux.until(|l| l[0] == "> 1")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1"))?; - - Ok(()) -} -#[test] -fn opt_nth_range_to_end() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--nth", "2.."])?; - - tmux.send_keys(&[Str("3")])?; - tmux.until(|l| l[0] == "> 3")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> f1,f2,f3,f4")?; - - tmux.send_keys(&[Ctrl(&Key('w'))])?; - tmux.until(|l| l[0] == ">")?; - - tmux.send_keys(&[Str("1")])?; - tmux.until(|l| l[0] == "> 1")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1"))?; - - Ok(()) -} -#[test] -fn opt_nth_range_from_start() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--nth", "..3"])?; - - tmux.send_keys(&[Str("1")])?; - tmux.until(|l| l[0] == "> 1")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> f1,f2,f3,f4")?; - - tmux.send_keys(&[Ctrl(&Key('w'))])?; - tmux.until(|l| l[0] == ">")?; - - tmux.send_keys(&[Str("4")])?; - tmux.until(|l| l[0] == "> 4")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1"))?; - - Ok(()) -} -#[test] -fn opt_nth_range_closed() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--nth", "2..3"])?; - - tmux.send_keys(&[Str("2")])?; - tmux.until(|l| l[0] == "> 2")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> f1,f2,f3,f4")?; - - tmux.send_keys(&[Ctrl(&Key('w'))])?; - tmux.until(|l| l[0] == ">")?; - - tmux.send_keys(&[Str("3")])?; - tmux.until(|l| l[0] == "> 3")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> f1,f2,f3,f4")?; - - tmux.send_keys(&[Ctrl(&Key('w'))])?; - tmux.until(|l| l[0] == ">")?; - - tmux.send_keys(&[Str("1")])?; - tmux.until(|l| l[0] == "> 1")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1"))?; - - tmux.send_keys(&[Ctrl(&Key('w'))])?; - tmux.until(|l| l[0] == ">")?; - - tmux.send_keys(&[Str("4")])?; - tmux.until(|l| l[0] == "> 4")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1"))?; - Ok(()) -} -#[test] -fn opt_nth_range_dec() -> Result<()> { - let (tmux, _) = setup("f1,f2,f3,f4", &["--delimiter", ",", "--nth", "3..2"])?; - - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("1/1"))?; - tmux.until(|l| l.len() > 2 && l[2] == "> f1,f2,f3,f4")?; - - tmux.send_keys(&[Str("1")])?; - tmux.until(|l| l[0] == "> 1")?; - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/1"))?; - - Ok(()) -} - -#[test] -fn opt_print_query() -> Result<()> { - let (tmux, outfile) = setup("10\\n20\\n30", &["-q", "2", "--print-query"])?; - tmux.send_keys(&[Enter])?; - tmux.until(|l| !l[0].starts_with(">"))?; - let output = tmux.output(&outfile)?; - - assert_eq!(output[0], "20"); - assert_eq!(output[1], "2"); - - Ok(()) -} -#[test] -fn opt_print_cmd() -> Result<()> { - let (tmux, outfile) = setup("1\\n2\\n3", &["--cmd-query", "cmd", "--print-cmd"])?; - tmux.send_keys(&[Enter])?; - tmux.until(|l| !l[0].starts_with(">"))?; - let output = tmux.output(&outfile)?; - - assert_eq!(output[0], "1"); - assert_eq!(output[1], "cmd"); - - Ok(()) -} -#[test] -fn opt_print_cmd_and_query() -> Result<()> { - let (tmux, outfile) = setup( - "10\\n20\\n30", - &["--cmd-query", "cmd", "--print-cmd", "-q", "2", "--print-query"], - )?; - tmux.send_keys(&[Enter])?; - tmux.until(|l| !l[0].starts_with(">"))?; - let output = tmux.output(&outfile)?; - - assert_eq!(output[0], "20"); - assert_eq!(output[1], "cmd"); - assert_eq!(output[2], "2"); - - Ok(()) -} - -#[test] -fn opt_hscroll_begin() -> Result<()> { - let (tmux, _) = setup(&format!("b{}", &["a"; 1000].join("")), &["-q", "b"])?; - - tmux.until(|l| l.len() > 2 && l[2].ends_with("..")) -} -#[test] -fn opt_hscroll_middle() -> Result<()> { - let (tmux, _) = setup( - &format!("{}b{}", &["a"; 1000].join(""), &["a"; 1000].join("")), - &["-q", "b"], - )?; - - tmux.until(|l| l.len() > 2 && l[2].ends_with(".."))?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> ..")) -} -#[test] -fn opt_hscroll_end() -> Result<()> { - let (tmux, _) = setup(&format!("{}b", &["a"; 1000].join("")), &["-q", "b"])?; - - tmux.until(|l| l.len() > 2 && l[2].starts_with("> ..")) -} - -#[test] -fn opt_no_hscroll() -> Result<()> { - let (tmux, _) = setup(&format!("{}b", &["a"; 1000].join("")), &["-q", "b", "--no-hscroll"])?; - - tmux.until(|l| l.len() > 2 && !l[2].starts_with("> .."))?; - tmux.until(|l| l.len() > 2 && l[2].ends_with("..")) -} - -#[test] -fn opt_tabstop_default() -> Result<()> { - let (tmux, _) = setup("a\\tb", &[])?; - - tmux.until(|l| l.len() > 2 && l[2].trim() == "> a b") -} -#[test] -fn opt_tabstop_1() -> Result<()> { - let (tmux, _) = setup("a\\tb", &["--tabstop", "1"])?; - - tmux.until(|l| l.len() > 2 && l[2].trim() == "> a b") -} -#[test] -fn opt_tabstop_3() -> Result<()> { - let (tmux, _) = setup("aa\\tb", &["--tabstop", "3"])?; - - tmux.until(|l| l.len() > 2 && l[2].trim() == "> aa b") -} - -#[test] -fn opt_info_control() -> Result<()> { - let (tmux, _) = setup("a\\nb\\nc", &[])?; - - tmux.until(|l| l[0].starts_with(">"))?; - tmux.until(|l| l[1].starts_with(" 3/3") && l[1].ends_with("0/0"))?; - - tmux.send_keys(&[Key('a')])?; - tmux.until(|l| l[1].starts_with(" 1/3") && l[1].ends_with("0/0")) -} -#[test] -fn opt_info_default() -> Result<()> { - let (tmux, _) = setup("a\\nb\\nc", &["--info", "default"])?; - - tmux.until(|l| l[0].starts_with(">"))?; - tmux.until(|l| l[1].starts_with(" 3/3") && l[1].ends_with("0/0"))?; - - tmux.send_keys(&[Key('a')])?; - tmux.until(|l| l[1].starts_with(" 1/3") && l[1].ends_with("0/0")) -} -#[test] -fn opt_no_info() -> Result<()> { - let (tmux, _) = setup("a\\nb\\nc", &["--no-info"])?; - - tmux.until(|l| l[0].starts_with(">"))?; - let cap = tmux.capture()?; - - assert_eq!(cap[0], ">"); - assert_eq!(cap[1], "> a"); - - Ok(()) -} -#[test] -fn opt_info_hidden() -> Result<()> { - let (tmux, _) = setup("a\\nb\\nc", &["--info", "hidden"])?; - - tmux.until(|l| l[0].starts_with(">"))?; - let cap = tmux.capture()?; - - assert_eq!(cap[0], ">"); - assert_eq!(cap[1], "> a"); - - Ok(()) -} -#[test] -fn opt_info_inline() -> Result<()> { - let (tmux, _) = setup("a\\nb\\nc", &["--info", "inline"])?; - - tmux.until(|l| l[0].starts_with("> < 3/3") && l[0].ends_with("0/0"))?; - - tmux.send_keys(&[Key('a')])?; - tmux.until(|l| l[0].starts_with("> a < 1/3") && l[0].ends_with("0/0")) -} -#[test] -fn opt_inline_info() -> Result<()> { - let (tmux, _) = setup("a\\nb\\nc", &["--inline-info"])?; - - tmux.until(|l| l[0].starts_with("> < 3/3") && l[0].ends_with("0/0"))?; - - tmux.send_keys(&[Key('a')])?; - tmux.until(|l| l[0].starts_with("> a < 1/3") && l[0].ends_with("0/0")) -} - -#[test] -fn opt_header_only() -> Result<()> { - let (tmux, _) = setup("a\\nb\\nc", &["--header", "test_header"])?; - - tmux.until(|l| l.len() > 2 && l[2].trim() == "test_header") -} -#[test] -fn opt_header_inline_info() -> Result<()> { - let (tmux, _) = setup("a\\nb\\nc", &["--header", "test_header", "--inline-info"])?; - - tmux.until(|l| l.len() > 1 && l[1].trim() == "test_header") -} -#[test] -fn opt_header_reverse() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk( - Some("echo -e -n 'a\\nb\\nc'"), - &["--header", "test_header", "--reverse"], - )?; - - tmux.until(|l| l[l.len() - 1].starts_with(">"))?; - - tmux.until(|l| l[l.len() - 3].trim() == "test_header") -} -#[test] -fn opt_header_reverse_inline_info() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk( - Some("echo -e -n 'a\\nb\\nc'"), - &["--header", "test_header", "--reverse", "--inline-info"], - )?; - - tmux.until(|l| l[l.len() - 1].starts_with(">"))?; - - tmux.until(|l| l[l.len() - 2].trim() == "test_header") -} - -#[test] -fn opt_header_lines_1() -> Result<()> { - let (tmux, _) = setup("a\\nb\\nc", &["--header-lines", "1"])?; - - tmux.until(|l| !l[2].starts_with(">") && l[2].trim() == "a")?; - tmux.until(|l| l.len() > 3 && l[3].starts_with(">")) -} -#[test] -fn opt_header_lines_all() -> Result<()> { - let (tmux, _) = setup("a\\nb\\nc", &["--header-lines", "4"])?; - - let lines = tmux.capture()?; - - assert_eq!(lines[2].trim(), "a"); - assert_eq!(lines[3].trim(), "b"); - assert_eq!(lines[4].trim(), "c"); - - Ok(()) -} -#[test] -fn opt_header_lines_inline_info() -> Result<()> { - let (tmux, _) = setup("a\\nb\\nc", &["--header-lines", "1", "--inline-info"])?; - - tmux.until(|l| !l[1].starts_with(">") && l[1].trim() == "a") -} -#[test] -fn opt_header_lines_reverse() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk(Some("echo -e -n 'a\\nb\\nc'"), &["--header-lines", "1", "--reverse"])?; - - tmux.until(|l| l[l.len() - 1].starts_with(">"))?; - - tmux.until(|l| l[l.len() - 3].trim() == "a")?; - tmux.until(|l| l[l.len() - 4].trim() == "> b") -} -#[test] -fn opt_header_lines_reverse_inline_info() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk( - Some("echo -e -n 'a\\nb\\nc'"), - &["--header-lines", "1", "--reverse", "--inline-info"], - )?; - - tmux.until(|l| l[l.len() - 1].starts_with(">"))?; - - tmux.until(|l| l[l.len() - 2].trim() == "a")?; - tmux.until(|l| l[l.len() - 3].trim() == "> b") -} - -#[test] -fn opt_reserved_options() -> Result<()> { - let reserved_options = [ - "--extended", - "--literal", - "--no-mouse", - "--cycle", - "--hscroll-off=10", - "--filepath-word", - "--jump-labels=CHARS", - "--border", - "--inline-info", - "--header=STR", - "--header-lines=1", - "--no-bold", - "--history-size=10", - "--sync", - "--no-sort", - "--select-1", - "-1", - "--exit-0", - "-0", - ]; - - for option in reserved_options { - println!("Starting sk with opt {}", option); - setup("a\\nb", &[option])?; - } - - Ok(()) -} - -#[test] -fn opt_multiple_flags_basic() -> Result<()> { - let basic_flags = [ - "--bind=ctrl-a:cancel --bind ctrl-b:cancel", - "--expect=ctrl-a --expect=ctrl-v", - "--tiebreak=begin --tiebreak=score", - "--cmd asdf --cmd find", - "--query asdf -q xyz", - "--delimiter , --delimiter . -d ,", - "--nth 1,2 --nth=1,3 -n 1,3", - "--with-nth 1,2 --with-nth=1,3", - "-I {} -I XX", - "--color base --color light", - "--margin 30% --margin 0", - "--min-height 30% --min-height 10", - "--height 30% --height 10", - "--preview 'ls {}' --preview 'cat {}'", - "--preview-window up --preview-window down", - "--multi -m", - "--no-multi --no-multi", - "--tac --tac", - "--ansi --ansi", - "--exact -e", - "--regex --regex", - "--literal --literal", - "--no-mouse --no-mouse", - "--cycle --cycle", - "--no-hscroll --no-hscroll", - "--filepath-word --filepath-word", - "--border --border", - "--inline-info --inline-info", - "--no-bold --no-bold", - "--print-query --print-query", - "--print-cmd --print-cmd", - "--print0 --print0", - "--sync --sync", - "--extended --extended", - "--no-sort --no-sort", - "--select-1 --select-1", - "--exit-0 --exit-0", - ]; - - for cmd_flags in basic_flags { - setup("a\\nb", &[cmd_flags])?; - } - - Ok(()) -} -#[test] -fn opt_multiple_flags_prompt() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk(None, &["--prompt a", "--prompt b", "-p c"])?; - - tmux.until(|l| l[0].starts_with("c"))?; - - Ok(()) -} -#[test] -fn opt_multiple_flags_cmd_prompt() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk(None, &["-i", "--cmd-prompt a", "--cmd-prompt c"])?; - - tmux.until(|l| l[0].starts_with("c"))?; - - Ok(()) -} -#[test] -fn opt_multiple_flags_cmd_query() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk(None, &["-i", "--cmd-query a", "--cmd-query b"])?; - - tmux.until(|l| l[0].starts_with("c> b"))?; - - Ok(()) -} -#[test] -fn opt_multiple_flags_interactive() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk(None, &["-i", "--interactive", "--interactive"])?; - - tmux.until(|l| l[0].starts_with("c>"))?; - - Ok(()) -} -#[test] -fn opt_multiple_flags_reverse() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk(None, &["--reverse", "--reverse"])?; - - tmux.until(|l| l[l.len() - 1].starts_with(">"))?; - - Ok(()) -} - -#[test] -fn opt_mutliple_flags_combined_expect_first() -> Result<()> { - let (tmux, outfile) = setup("a\\nb", &["--expect", "ctrl-a,ctrl-b"])?; - tmux.send_keys(&[Ctrl(&Key('a'))])?; - let output = tmux.output(&outfile)?; - assert_eq!(output[0], "a"); - assert_eq!(output[1], "ctrl-a"); - Ok(()) -} -#[test] -fn opt_mutliple_flags_combined_expect_second() -> Result<()> { - let (tmux, outfile) = setup("a\\nb", &["--expect", "ctrl-a,ctrl-b"])?; - tmux.send_keys(&[Ctrl(&Key('b'))])?; - let output = tmux.output(&outfile)?; - assert_eq!(output[0], "a"); - assert_eq!(output[1], "ctrl-b"); - Ok(()) -} - -#[test] -fn opt_multiple_flags_combined_nth() -> Result<()> { - let (tmux, _) = setup("a b c\\nd e f", &["--nth 1,2"])?; - - tmux.send_keys(&[Key('c')])?; - tmux.until(|l| l.len() > 1 && l[1].contains("0/2")) -} -#[test] -fn opt_multiple_flags_combined_with_nth() -> Result<()> { - let (tmux, _) = setup("a b c\\nd e f", &["--with-nth 1,2"])?; - - tmux.until(|l| l.len() > 2 && l[2].ends_with("a b") && l[3].ends_with("d e")) -} - -#[test] -fn opt_ansi_null() -> Result<()> { - let (tmux, outfile) = setup("a\\0b", &["--ansi"])?; - - tmux.send_keys(&[Enter])?; - - let output = tmux.output(&outfile)?; - println!("{:?}", output[0].as_bytes()); - assert_eq!(output[0].as_bytes(), &[97, 0, 98]); - Ok(()) -} - -#[test] -fn opt_skip_to_pattern() -> Result<()> { - let (tmux, _) = setup("a/b/c", &["--skip-to-pattern", "'[^/]*$'"])?; - - tmux.until(|l| l.len() > 2 && l[2] == "> ..c") -} - -#[test] -fn opt_multi() -> Result<()> { - let (tmux, outfile) = setup("a\\nb\\nc", &["--multi"])?; - - tmux.send_keys(&[BTab, BTab])?; - tmux.until(|l| l.len() > 2 && l[2] == " >a" && l[3] == " >b")?; - tmux.send_keys(&[Enter])?; - - let output = tmux.output(&outfile)?; - - assert_eq!(output[0], "b"); - assert_eq!(output[1], "a"); - - Ok(()) -} - -#[test] -fn opt_pre_select_n() -> Result<()> { - let (tmux, _) = setup("a\\nb\\nc", &["-m", "--pre-select-n", "2"])?; - tmux.until(|l| l.len() > 2 && l[2] == ">>a" && l[3] == " >b") -} - -#[test] -fn opt_pre_select_items() -> Result<()> { - let (tmux, _) = setup("a\\nb\\nc", &["-m", "--pre-select-items", "$'b\\nc'"])?; - tmux.until(|l| l.len() > 2 && l[2] == "> a" && l[3].trim() == ">b" && l[4].trim() == ">c") -} - -#[test] -fn opt_pre_select_pat() -> Result<()> { - let (tmux, _) = setup("a\\nb\\nc", &["-m", "--pre-select-pat", "'[b|c]'"])?; - tmux.until(|l| l.len() > 2 && l[2] == "> a" && l[3].trim() == ">b" && l[4].trim() == ">c") -} - -#[test] -fn opt_pre_select_file() -> Result<()> { - let mut pre_select_file = NamedTempFile::new()?; - pre_select_file.write(b"b\nc")?; - let (tmux, _) = setup( - "a\\nb\\nc", - &["-m", "--pre-select-file", pre_select_file.path().to_str().unwrap()], - )?; - tmux.until(|l| l.len() > 2 && l[2] == "> a" && l[3].trim() == ">b" && l[4].trim() == ">c") -} - -#[test] -fn opt_no_clear_if_empty() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk( - Some("echo -ne 'a\\nb\\nc'"), - &["-i", "--no-clear-if-empty", "-c", "'cat {}'"], - )?; - tmux.until(|l| l[0] == "c>")?; - - tmux.send_keys(&[Str("xxxx")])?; - tmux.until(|l| l[0] == "c> xxxx")?; - - tmux.until(|l| l.len() > 1 && l[1].trim().starts_with("0/0"))?; - tmux.until(|l| l.len() > 2 && l[2].trim() == "> a")?; - tmux.until(|l| l.len() > 3 && l[3].trim() == "b")?; - Ok(()) -} - -#[test] -fn opt_accept_arg() -> Result<()> { - let (tmux, outfile) = setup("a\\nb", &["--bind", "ctrl-a:accept:hello"])?; - tmux.send_keys(&[Ctrl(&Key('a'))])?; - - let output = tmux.output(&outfile)?; - assert_eq!(output[0], "a"); - assert_eq!(output[1], "hello"); - Ok(()) -} diff --git a/e2e/tests/preview.rs b/e2e/tests/preview.rs deleted file mode 100644 index fe88337a..00000000 --- a/e2e/tests/preview.rs +++ /dev/null @@ -1,55 +0,0 @@ -use e2e::TmuxController; -use std::io::Result; - -#[test] -fn preview_preserve_quotes() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk(Some("echo \"'\\\"ABC\\\"'\""), &["--preview", "\"echo X{}X\""])?; - - tmux.until(|l| l.iter().any(|s| s.contains("X'\"ABC\"'"))) -} - -#[test] -fn preview_nul_char() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk(Some("echo -ne 'a\\0b'"), &["--preview", "'echo -en {} | hexdump -C'"])?; - tmux.until(|l| l[0].starts_with(">"))?; - tmux.until(|l| l.iter().any(|s| s.contains("61 00 62"))) -} - -#[test] -fn preview_offset_fixed() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk( - Some("echo -ne 'a\\nb'"), - &["--preview", "'seq 1000'", "--preview-window", "left:+123"], - )?; - tmux.until(|l| l[l.len() - 1].starts_with("123"))?; - tmux.until(|l| l[l.len() - 1].contains("123/1000"))?; - - Ok(()) -} -#[test] -fn preview_offset_expr() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk( - Some("echo -ne '123 321'"), - &["--preview", "'seq 1000'", "--preview-window", "left:+{2}"], - )?; - tmux.until(|l| l[l.len() - 1].starts_with("321"))?; - tmux.until(|l| l[l.len() - 1].contains("321/1000"))?; - - Ok(()) -} -#[test] -fn preview_offset_fiexd_and_expr() -> Result<()> { - let tmux = TmuxController::new()?; - tmux.start_sk( - Some("echo -ne '123 321'"), - &["--preview", "'seq 1000'", "--preview-window", "left:+{2}-2"], - )?; - tmux.until(|l| l[l.len() - 1].starts_with("319"))?; - tmux.until(|l| l[l.len() - 1].contains("319/1000"))?; - - Ok(()) -} diff --git a/e2e/tests/tiebreak.rs b/e2e/tests/tiebreak.rs deleted file mode 100644 index e5dc4256..00000000 --- a/e2e/tests/tiebreak.rs +++ /dev/null @@ -1,88 +0,0 @@ -use e2e::Keys::*; -use e2e::TmuxController; -use std::io::Result; - -fn setup(input: &str, tiebreak: &str) -> Result { - let tmux = TmuxController::new()?; - tmux.start_sk( - Some(&format!("echo -en '{input}'")), - &[&format!("--tiebreak='{tiebreak}'")], - )?; - tmux.until(|l| l[0].starts_with(">"))?; - Ok(tmux) -} - -#[test] -fn tiebreak_default() -> Result<()> { - let tmux = setup("a\\nc\\nab\\nac\\nb", "score,begin,end")?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> a"))?; - tmux.send_keys(&[Key('b')])?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> b")) -} -#[test] -fn tiebreak_neg_score() -> Result<()> { - let tmux = setup("a\\nb\\nc\\nab\\nac", "-score")?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> a"))?; - tmux.send_keys(&[Key('b')])?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> ab")) -} - -#[test] -fn tiebreak_index() -> Result<()> { - let tmux = setup("a\\nc\\nab\\nac\\nb", "index,score")?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> a"))?; - tmux.send_keys(&[Key('b')])?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> ab")) -} -#[test] -fn tiebreak_neg_index() -> Result<()> { - let tmux = setup("a\\nb\\nc\\nab\\nac", "-index,score")?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> a"))?; - tmux.send_keys(&[Key('b')])?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> ab")) -} - -#[test] -fn tiebreak_begin() -> Result<()> { - let tmux = setup("aaba\\nb\\nc\\naba\\nac", "begin,score")?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> aaba"))?; - tmux.send_keys(&[Str("ba")])?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> aba")) -} -#[test] -fn tiebreak_neg_begin() -> Result<()> { - let tmux = setup("aba\\nb\\nc\\naaba\\nac", "-begin,score")?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> a"))?; - tmux.send_keys(&[Key('b')])?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> aaba")) -} - -#[test] -fn tiebreak_end() -> Result<()> { - let tmux = setup("aaba\\nb\\nc\\naba\\nac", "end,score")?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> aaba"))?; - tmux.send_keys(&[Str("ba")])?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> aba")) -} -#[test] -fn tiebreak_neg_end() -> Result<()> { - let tmux = setup("aba\\nb\\nc\\naaba\\nac", "-end,score")?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> a"))?; - tmux.send_keys(&[Str("ba")])?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> aaba")) -} - -#[test] -fn tiebreak_length() -> Result<()> { - let tmux = setup("aaba\\nb\\nc\\naba\\nac", "length,score")?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> b"))?; - tmux.send_keys(&[Str("ba")])?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> aba")) -} -#[test] -fn tiebreak_neg_length() -> Result<()> { - let tmux = setup("aaba\\nb\\nc\\naba\\nac", "-length,score")?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> aaba"))?; - tmux.send_keys(&[Key('c')])?; - tmux.until(|l| l.len() > 2 && l[2].starts_with("> ac")) -} diff --git a/install b/install deleted file mode 100755 index adc1aa93..00000000 --- a/install +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash - -# This script trys to download the correct version of binary from github. -# You can download it manually and put it(i.e. `sk`) under `bin/`. -# -# If you know rust or have rust installed, you can build it with -# `cargo build --release` - -set -u - -cd "$(dirname "${BASH_SOURCE[0]}")" -skim_base="$(pwd)" - -version=$(curl -s "https://api.github.com/repos/skim-rs/skim/releases/latest" | grep tag_name | grep -o "[.0-9]*") - -check_binary() { - echo -n " - Checking skim executable ... " - local output - output=$("$skim_base"/bin/sk --version | grep -o "[.0-9]*" 2>&1) - if [ $? -ne 0 ]; then - echo "Error: $output" - elif [ "$version" != "$output" ]; then - echo "$output != $version" - else - echo "Existing version is already the latest: $output" - exit 0 - fi - rm -f "$skim_base"/bin/sk - return 1 -} - - -# download the latest skim -download() { - echo "Downloading bin/sk ..." - mkdir -p "$skim_base"/bin && cd "$skim_base"/bin - if [ $? -ne 0 ]; then - binary_error="Failed to create bin directory" - return - fi - - check_binary - - local url=https://github.com/skim-rs/skim/releases/download/v$version/${1}.tgz - echo "Downloading: $url" - if command -v curl > /dev/null; then - curl -fL $url | tar xz - elif command -v wget > /dev/null; then - wget -O - $url | tar xz - else - binary_error="curl or wget not found" - return - fi - - if [ ! -f $1 ]; then - binary_error="Failed to download ${1}" - return - fi -} - -archi=$(uname -sm) -case "$archi" in - Darwin\ x86_64) download skim-${binary_arch:-x86_64}-apple-darwin;; - Darwin\ arm64) download skim-${binary_arch:-aarch64}-apple-darwin;; - Linux\ x86_64) download skim-${binary_arch:-x86_64}-unknown-linux-musl;; - Linux\ armv7l) download skim-${binary_arch:-armv7}-unknown-linux-musleabi;; - Linux\ aarch64) download skim-${binary_arch:-aarch64}-unknown-linux-musl;; - *) echo "No binaries available for '$archi' yet. Try: 'cargo install skim'";; -esac - -echo "Done :)" diff --git a/man/man1/sk.1 b/man/man1/sk.1 index 17c1c820..f730fc36 100644 --- a/man/man1/sk.1 +++ b/man/man1/sk.1 @@ -2,65 +2,80 @@ .el .ds Aq ' .TH sk 1 "sk 0.20.5" .SH NAME -sk \- sk \- fuzzy finder in Rust +sk \- Fuzzy Finder in rust! .SH SYNOPSIS -\fBsk\fR [\fB\-\-tac\fR] [\fB\-\-min\-query\-length\fR] [\fB\-\-no\-sort\fR] [\fB\-t\fR|\fB\-\-tiebreak\fR] [\fB\-n\fR|\fB\-\-nth\fR] [\fB\-\-with\-nth\fR] [\fB\-d\fR|\fB\-\-delimiter\fR] [\fB\-e\fR|\fB\-\-exact\fR] [\fB\-\-regex\fR] [\fB\-\-algo\fR] [\fB\-\-case\fR] [\fB\-b\fR|\fB\-\-bind\fR] [\fB\-m\fR|\fB\-\-multi\fR] [\fB\-\-no\-multi\fR] [\fB\-\-no\-mouse\fR] [\fB\-c\fR|\fB\-\-cmd\fR] [\fB\-i\fR|\fB\-\-interactive\fR] [\fB\-I \fR] [\fB\-\-color\fR] [\fB\-\-no\-hscroll\fR] [\fB\-\-keep\-right\fR] [\fB\-\-skip\-to\-pattern\fR] [\fB\-\-no\-clear\-if\-empty\fR] [\fB\-\-no\-clear\-start\fR] [\fB\-\-no\-clear\fR] [\fB\-\-show\-cmd\-error\fR] [\fB\-\-layout\fR] [\fB\-\-reverse\fR] [\fB\-\-height\fR] [\fB\-\-no\-height\fR] [\fB\-\-min\-height\fR] [\fB\-\-margin\fR] [\fB\-p\fR|\fB\-\-prompt\fR] [\fB\-\-cmd\-prompt\fR] [\fB\-\-ansi\fR] [\fB\-\-tabstop\fR] [\fB\-\-info\fR] [\fB\-\-no\-info\fR] [\fB\-\-inline\-info\fR] [\fB\-\-header\fR] [\fB\-\-header\-lines\fR] [\fB\-\-history\fR] [\fB\-\-history\-size\fR] [\fB\-\-cmd\-history\fR] [\fB\-\-cmd\-history\-size\fR] [\fB\-\-preview\fR] [\fB\-\-preview\-window\fR] [\fB\-q\fR|\fB\-\-query\fR] [\fB\-\-cmd\-query\fR] [\fB\-\-expect\fR] [\fB\-\-read0\fR] [\fB\-\-print0\fR] [\fB\-\-print\-query\fR] [\fB\-\-print\-cmd\fR] [\fB\-\-print\-score\fR] [\fB\-1\fR|\fB\-\-select\-1\fR] [\fB\-0\fR|\fB\-\-exit\-0\fR] [\fB\-\-sync\fR] [\fB\-\-pre\-select\-n\fR] [\fB\-\-pre\-select\-pat\fR] [\fB\-\-pre\-select\-items\fR] [\fB\-\-pre\-select\-file\fR] [\fB\-f\fR|\fB\-\-filter\fR] [\fB\-\-shell\fR] [\fB\-\-tmux\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] +\fBsk\fR [\fB\-\-tac\fR] [\fB\-\-min\-query\-length\fR] [\fB\-\-no\-sort\fR] [\fB\-t\fR|\fB\-\-tiebreak\fR] [\fB\-n\fR|\fB\-\-nth\fR] [\fB\-\-with\-nth\fR] [\fB\-d\fR|\fB\-\-delimiter\fR] [\fB\-e\fR|\fB\-\-exact\fR] [\fB\-\-regex\fR] [\fB\-\-algo\fR] [\fB\-\-case\fR] [\fB\-b\fR|\fB\-\-bind\fR] [\fB\-m\fR|\fB\-\-multi\fR] [\fB\-\-no\-multi\fR] [\fB\-\-no\-mouse\fR] [\fB\-c\fR|\fB\-\-cmd\fR] [\fB\-i\fR|\fB\-\-interactive\fR] [\fB\-I \fR] [\fB\-\-color\fR] [\fB\-\-no\-hscroll\fR] [\fB\-\-keep\-right\fR] [\fB\-\-skip\-to\-pattern\fR] [\fB\-\-no\-clear\-if\-empty\fR] [\fB\-\-no\-clear\-start\fR] [\fB\-\-no\-clear\fR] [\fB\-\-show\-cmd\-error\fR] [\fB\-\-layout\fR] [\fB\-\-reverse\fR] [\fB\-\-height\fR] [\fB\-\-no\-height\fR] [\fB\-\-min\-height\fR] [\fB\-\-margin\fR] [\fB\-p\fR|\fB\-\-prompt\fR] [\fB\-\-cmd\-prompt\fR] [\fB\-\-ansi\fR] [\fB\-\-tabstop\fR] [\fB\-\-info\fR] [\fB\-\-no\-info\fR] [\fB\-\-inline\-info\fR] [\fB\-\-header\fR] [\fB\-\-header\-lines\fR] [\fB\-\-border\fR] [\fB\-\-history\fR] [\fB\-\-history\-size\fR] [\fB\-\-cmd\-history\fR] [\fB\-\-cmd\-history\-size\fR] [\fB\-\-preview\fR] [\fB\-\-preview\-window\fR] [\fB\-q\fR|\fB\-\-query\fR] [\fB\-\-cmd\-query\fR] [\fB\-\-read0\fR] [\fB\-\-print0\fR] [\fB\-\-print\-query\fR] [\fB\-\-print\-cmd\fR] [\fB\-\-print\-score\fR] [\fB\-1\fR|\fB\-\-select\-1\fR] [\fB\-0\fR|\fB\-\-exit\-0\fR] [\fB\-\-sync\fR] [\fB\-\-pre\-select\-n\fR] [\fB\-\-pre\-select\-pat\fR] [\fB\-\-pre\-select\-items\fR] [\fB\-\-pre\-select\-file\fR] [\fB\-f\fR|\fB\-\-filter\fR] [\fB\-\-shell\fR] [\fB\-\-man\fR] [\fB\-\-tmux\fR] [\fB\-\-log\-file\fR] [\fB\-\-expect\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] .SH DESCRIPTION sk \- fuzzy finder in Rust .PP sk is a general purpose command\-line fuzzy finder. .PP -ENVIRONMENT VARIABLES .PP -NO_COLOR +# ENVIRONMENT VARIABLES +.PP + NO_COLOR +.PP + If set and not empty, sk will not use any colors in the output. +.PP + SKIM_DEFAULT_COMMAND .PP If set and not empty, sk will not use any colors in the output. .PP -SKIM_DEFAULT_COMMAND +## SKIM_DEFAULT_COMMAND .PP -Default command to use when input is tty. On *nix systems, sk runs the command with sh \-c, so make sure that it\*(Aqs POSIX\-compliant. +Default command to use when input is tty. On *nix systems, sk runs the command with sh \-c, so make sure that +it\*(Aqs POSIX\-compliant. .PP -SKIM_DEFAULT_OPTIONS +## SKIM_DEFAULT_OPTIONS .PP -Default options. e.g. export SKIM_DEFAULT_OPTIONS="\-\-multi" +Default options. e.g. `export SKIM_DEFAULT_OPTIONS="\-\-multi"` .PP -EXTENDED SEARCH MODE +# EXTENDED SEARCH MODE .PP -Unless specified otherwise, sk will start in "extended\-search mode". In this mode, you can specify multiple patterns delimited by spaces, such as: \*(Aqwild ^music .mp3$ sbtrkt !rmx +Unless specified otherwise, sk will start in "extended\-search mode". In this mode, you can specify multiple patterns +delimited by spaces, such as: \*(Aqwild ^music .mp3$ sbtrkt !rmx .PP You can prepend a backslash to a space (\\ ) to match a literal space character. .PP -Exact\-match (quoted) +## Exact\-match (quoted) .PP -A term that is prefixed by a single\-quote character (\*(Aq) is interpreted as an "exact\-match" (or "non\-fuzzy") term. sk will search for the exact occurrences of the string. +A term that is prefixed by a single\-quote character (\*(Aq) is interpreted as an "exact\-match" (or "non\-fuzzy") term. sk +will search for the exact occurrences of the string. .PP -Anchored\-match +## Anchored\-match .PP -A term can be prefixed by ^, or suffixed by $ to become an anchored\-match term. Then sk will search for the lines that start with or end with the given string. An anchored\-match term is also an exact\-match term. +A term can be prefixed by ^, or suffixed by $ to become an anchored\-match term. Then sk will search for the lines +that start with or end with the given string. An anchored\-match term is also an exact\-match term. .PP -Negation +## Negation .PP -If a term is prefixed by !, sk will exclude the lines that satisfy the term from the result. In this case, sk per‐ forms exact match by default. +If a term is prefixed by !, sk will exclude the lines that satisfy the term from the result. In this case, `sk` per‐ +forms exact match by default. .PP -Exact\-match by default +## Exact\-match by default .PP -If you don\*(Aqt prefer fuzzy matching and do not wish to "quote" (prefixing with \*(Aq) every word, start sk with \-e or \-\-exact option. Note that when \-\-exact is set, \*(Aq\-prefix "unquotes" the term. +If you don\*(Aqt prefer fuzzy matching and do not wish to "quote" (prefixing with \*(Aq) every word, +start `sk` with `\-e` or +`\-\-exact` option. Note that when `\-\-exact` is set, \*(Aq\-prefix "unquotes" the term. .PP -OR operator +## OR operator .PP -A single bar character term acts as an OR operator. For example, the following query matches entries that start with core and end with either go, rb, or py. + A single bar character term acts as an OR operator. For example, the following query matches entries that start with +core and end with either go, rb, or py. .PP -Example: ^core go$ | rb$ | py$ .PP -EXIT STATUS +**Example**: `^core go$ | rb$ | py$` .PP -\- 0: Normal exit .PP -\- 1: No match +# EXIT STATUS .PP -\- 2: Error +* 0: Normal exit .PP -\- 130: Interrupted with CTRL\-C or ESC +* 1: No match +.PP +* 2: Error +.PP +* 130: Interrupted with CTRL\-C or ESC .SH OPTIONS .TP \fB\-h\fR, \fB\-\-help\fR @@ -83,28 +98,15 @@ Only show results when the query is at least this many characters long \fB\-\-no\-sort\fR Do not sort the results -Often used in combination with \-\-tac - -Example: history | sk \-\-tac \-\-no\-sort +Often used in combination with \-\-tac Example: history | sk \-\-tac \-\-no\-sort .TP \fB\-t\fR, \fB\-\-tiebreak\fR \fI\fR [default: score,begin,end] Comma\-separated list of sort criteria to apply when the scores are tied. -\- score: Score of the fuzzy match algorithm +* **score**: Score of the fuzzy match algorithm -\- index: Prefers line that appeared earlier in the input stream - -\- begin: Prefers line with matched substring closer to the beginning - -\- end: Prefers line with matched substring closer to the end - -\- length: Prefers line with shorter length - -Notes: - -\- Each criterion could be negated, e.g. (\-index) - -\- Each criterion should appear only once in the list + \- Each criterion could be negated, e.g. (\-index) + \- Each criterion should appear only once in the list .br .br @@ -113,25 +115,18 @@ Notes: \fB\-n\fR, \fB\-\-nth\fR \fI\fR [default: ] Fields to be matched -A field index expression can be a non\-zero integer or a range expression ([BEGIN]..[END]). \-\-nth and \-\-with\-nth take a comma\-separated list of field index expressions. +A field index expression can be a non\-zero integer or a range expression (`[BEGIN]..[END]`). +`\-\-nth` and `\-\-with\-nth` take a comma\-separated list of field index expressions. -Examples: - -\- 1: The 1st field - -\- 2: The 2nd field - -\- \-1: The last field - -\- \-2: The 2nd to last field - -\- 3..5: From the 3rd field to the 5th field - -\- 2..: From the 2nd field to the last field - -\- ..\-3: From the 1st field to the 3rd to the last field - -\- ..: All the fields +**Examples:** + 1 The 1st field + 2 The 2nd field + \-1 The last field + \-2 The 2nd to last field + 3..5 From the 3rd field to the 5th field + 2.. From the 2nd field to the last field + ..\-3 From the 1st field to the 3rd to the last field + .. All the fields .TP \fB\-\-with\-nth\fR \fI\fR [default: ] Fields to be transformed @@ -141,7 +136,7 @@ See nth for the details \fB\-d\fR, \fB\-\-delimiter\fR \fI\fR [default: [\\t\\n ]+] Delimiter between fields -In regex format, default to AWK\-style +In regex format, default to AWK\-style. Escape sequences like \\x00, \\t, \\n are supported. .TP \fB\-e\fR, \fB\-\-exact\fR Run in exact mode @@ -152,15 +147,21 @@ Start in regex mode instead of fuzzy\-match \fB\-\-algo\fR \fI\fR [default: skim_v2] Fuzzy matching algorithm -\- skim_v2: Latest skim algorithm, should be better in almost any case - -\- skim_v1: Legacy skim algorithm - -\- clangd: Used in clangd for keyword completion +skim_v2 Latest skim algorithm, should be better in almost any case +skim_v1 Legacy skim algorithm +clangd Used in clangd for keyword completion .br .br -[\fIpossible values: \fRskim_v1, skim_v2, clangd] +\fIPossible values:\fR +.RS 14 +.IP \(bu 2 +skim_v1: Original skim fuzzy matching algorithm (v1) +.IP \(bu 2 +skim_v2: Improved skim fuzzy matching algorithm (v2, default) +.IP \(bu 2 +clangd: Clangd fuzzy matching algorithm +.RE .TP \fB\-\-case\fR \fI\fR [default: smart] Case sensitivity @@ -169,231 +170,268 @@ Determines whether or not to ignore case while matching .br .br -[\fIpossible values: \fRrespect, ignore, smart] +\fIPossible values:\fR +.RS 14 +.IP \(bu 2 +respect: Case\-sensitive matching +.IP \(bu 2 +ignore: Case\-insensitive matching +.IP \(bu 2 +smart: Smart case: case\-insensitive unless query contains uppercase +.RE .SH INTERFACE .TP -\fB\-b\fR, \fB\-\-bind\fR \fI\fR +\fB\-b\fR, \fB\-\-bind\fR [\fI...\fR] [default: ] Comma separated list of bindings -You can customize key bindings of sk with \-\-bind option which takes a comma\-separated list of key binding expressions. Each key binding expression follows the following format: : +You can customize key bindings of sk with `\-\-bind` option which takes a comma\-separated list of +key binding expressions. Each key binding expression follows the following format: `:` -Example: sk \-\-bind=ctrl\-j:accept,ctrl\-k:kill\-line +**Example**: `sk \-\-bind=ctrl\-j:accept,ctrl\-k:kill\-line` -AVAILABLE KEYS: (SYNONYMS) +## AVAILABLE KEYS: (SYNONYMS) -\- ctrl\-[a\-z] +* ctrl\-[a\-z] -\- ctrl\-space +* ctrl\-space -\- ctrl\-alt\-[a\-z] +* ctrl\-alt\-[a\-z] -\- alt\-[a\-zA\-Z] +* alt\-[a\-zA\-Z] -\- alt\-[0\-9] +* alt\-[0\-9] -\- f[1\-12] +* f[1\-12] -\- enter (ctrl\-m) +* enter (ctrl\-m) -\- space +* space -\- bspace (bs) +* bspace (bs) -\- alt\-up +* alt\-up -\- alt\-down +* alt\-down -\- alt\-left +* alt\-left -\- alt\-right +* alt\-right -\- alt\-enter (alt\-ctrl\-m) +* alt\-enter (alt\-ctrl\-m) -\- alt\-space +* alt\-space -\- alt\-bspace (alt\-bs) +* alt\-bspace (alt\-bs) -\- alt\-/ +* alt\-/ -\- tab +* tab -\- btab (shift\-tab) +* btab (shift\-tab) -\- esc +* esc -\- del +* del -\- up +* up -\- down +* down -\- left +* left -\- right +* right -\- home +* home -\- end +* end -\- pgup (page\-up) +* pgup (page\-up) -\- pgdn (page\-down) +* pgdn (page\-down) -\- shift\-up +* shift\-up -\- shift\-down +* shift\-down -\- shift\-left +* shift\-left -\- shift\-right +* shift\-right -\- alt\-shift\-up +* alt\-shift\-up -\- alt\-shift\-down +* alt\-shift\-down -\- alt\-shift\-left +* alt\-shift\-left -\- alt\-shift\-right +* alt\-shift\-right -\- any single character +* any single character -ACTION: DEFAULT BINDINGS [NOTES] +## ACTION: DEFAULT BINDINGS [NOTES] -\- abort: ctrl\-c ctrl\-q esc +* abort: ctrl\-c ctrl\-q esc -\- accept(...): enter the argument will be printed when the binding is triggered +* accept(...): enter *the argument will be printed when the binding is triggered* -\- append\-and\-select: +* append\-and\-select(c): append c to the query -\- backward\-char: ctrl\-b left +* append\-and\-select: -\- backward\-delete\-char: ctrl\-h bspace +* backward\-char: ctrl\-b left -\- backward\-kill\-word: alt\-bs +* backward\-delete\-char: ctrl\-h bspace -\- backward\-word: alt\-b shift\-left +* backward\-delete\-char/eof: -\- beginning\-of\-line: ctrl\-a home +* backward\-kill\-word: alt\-bs -\- clear\-screen: ctrl\-l +* backward\-word: alt\-b shift\-left -\- delete\-char: del +* beginning\-of\-line: ctrl\-a home -\- delete\-charEOF: ctrl\-d +* clear\-screen: ctrl\-l -\- deselect\-all: +* delete\-char: del -\- down: ctrl\-j ctrl\-n down +* delete\-char/eof: ctrl\-d -\- end\-of\-line: ctrl\-e end +* deselect\-all: -\- execute(...): see below for the details +* down: ctrl\-j ctrl\-n down -\- execute\-silent(...): see below for the details +* end\-of\-line: ctrl\-e end -\- forward\-char: ctrl\-f right +* execute(...): *see below for the details* -\- forward\-word: alt\-f shift\-right +* execute\-silent(...): *see below for the details* -\- if\-non\-matched: +* forward\-char: ctrl\-f right -\- if\-query\-empty: +* forward\-word: alt\-f shift\-right -\- if\-query\-not\-empty: +* if\-non\-matched: -\- ignore: +* if\-query\-empty: -\- kill\-line: +* if\-query\-not\-empty: -\- kill\-word: alt\-d +* ignore: -\- next\-history: ctrl\-n with \-\-history or \-\-cmd\-history +* kill\-line: -\- page\-down: pgdn +* kill\-word: alt\-d -\- page\-up: pgup +* next\-history: ctrl\-n with `\-\-history` or `\-\-cmd\-history` -\- half\-page\-down: +* page\-down: pgdn -\- half\-page\-up: +* page\-up: pgup -\- preview\-up: shift\-up +* half\-page\-down: -\- preview\-down: shift\-down +* half\-page\-up: -\- preview\-left: +* preview\-up: shift\-up -\- preview\-right: +* preview\-down: shift\-down -\- preview\-page\-down: +* preview\-left: -\- preview\-page\-up: +* preview\-right: -\- previous\-history: ctrl\-p with \-\-history or \-\-cmd\-history +* preview\-page\-down: -\- reload(...): +* preview\-page\-up: -\- select\-all: +* previous\-history: ctrl\-p with `\-\-history` or `\-\-cmd\-history` -\- toggle: +* redraw: -\- toggle\-all: +* refresh\-cmd: -\- toggle+down: ctrl\-i tab +* refresh\-preview: -\- toggle\-in: (\-\-layout=reverse ? toggle+up: toggle+down) +* reload(...): -\- toggle\-out: (\-\-layout=reverse ? toggle+down: toggle+up) +* select\-all: -\- toggle\-preview: +* select\-row: -\- toggle\-preview\-wrap: +* toggle: -\- toggle\-sort: +* toggle\-all: -\- toggle+up: btab shift\-tab +* toggle+down: ctrl\-i tab -\- unix\-line\-discard: ctrl\-u +* toggle\-in: (\-\-layout=reverse ? toggle+up: toggle+down) -\- unix\-word\-rubout: ctrl\-w +* toggle\-interactive: -\- up: ctrl\-k ctrl\-p up +* toggle\-out: (\-\-layout=reverse ? toggle+down: toggle+up) -\- yank: ctrl\-y +* toggle\-preview: -Multiple actions can be chained using + separator. +* toggle\-preview\-wrap: -Example: sk \-\-bind \*(Aqctrl\-a:select\-all+accept\*(Aq +* toggle\-sort: -Special behaviors +* toggle+up: btab shift\-tab -With execute(...) and reload(...) action, you can execute arbitrary commands without leaving sk. For example, you can turn sk into a simple file browser by binding enter key to less command like follows: +* top: - sk \-\-bind "enter:execute(less {})" +* unix\-line\-discard: ctrl\-u + +* unix\-word\-rubout: ctrl\-w + +* up: ctrl\-k ctrl\-p up + +* yank: ctrl\-y + +## Multiple actions can be chained using + separator. + +**Example**: `sk \-\-bind \*(Aqctrl\-a:select\-all+accept\*(Aq` + +# Special behaviors + +With `execute(...)` and `reload(...)` action, you can execute arbitrary commands without leaving sk. +For example, you can turn sk into a simple file browser by binding enter key to less command like follows: + +```bash +sk \-\-bind "enter:execute(less {})" +``` Note: if no argument is supplied to reload, the default command is run. You can use the same placeholder expressions as in \-\-preview. -If the command contains parentheses, sk may fail to parse the expression. In that case, you can use any of the following alternative notations to avoid parse errors. +If the command contains parentheses, sk may fail to parse the expression. In that case, you can +use any of the following alternative notations to avoid parse errors. -\- execute[...] +* `execute[...]` -\- execute\*(Aq...\*(Aq +* `execute\*(Aq...\*(Aq` -\- execute"..." +* `execute"..."` -\- execute:... +* `execute:...` -This is the special form that frees you from parse errors as it does not expect the clos‐ ing character. The catch is that it should be the last one in the comma\-separated list of key\-action pairs. +This is the special form that frees you from parse errors as it does not expect the clos‐ +ing character. The catch is that it should be the last one in the comma\-separated list of +key\-action pairs. -sk switches to the alternate screen when executing a command. However, if the command is ex‐ pected to complete quickly, and you are not interested in its output, you might want to use exe‐ cute\-silent instead, which silently executes the command without the switching. Note that sk will not be responsive until the command is complete. For asynchronous execution, start your command as a background process (i.e. appending &). +sk switches to the alternate screen when executing a command. However, if the command is ex‐ +pected to complete quickly, and you are not interested in its output, you might want to use exe‐ +cute\-silent instead, which silently executes the command without the switching. Note that sk +will not be responsive until the command is complete. For asynchronous execution, start your +command as a background process (i.e. appending &). -With if\-query\-empty and if\-query\-not\-empty action, you could specify the action to execute de‐ pends on the query condition. For example: +With if\-query\-empty and if\-query\-not\-empty action, you could specify the action to execute de‐ +pends on the query condition. For example: -sk \-\-bind \*(Aqctrl\-d:if\-query\-empty(abort)+delete\-char\*(Aq +`sk \-\-bind \*(Aqctrl\-d:if\-query\-empty(abort)+delete\-char\*(Aq` -If the query is empty, skim will execute abort action, otherwise execute delete\-char action. It is equal to ‘delete\-char/eof‘. +If the query is empty, skim will execute abort action, otherwise execute delete\-char action. It +is equal to ‘delete\-char/eof‘. .TP \fB\-m\fR, \fB\-\-multi\fR Enable multiple selection @@ -420,51 +458,7 @@ Replace replstr with the selected item in commands \fB\-\-color\fR \fI\fR Set color theme -Use \-\-color to customize the color scheme of skim. The format is: - -Format: [BASE_SCHEME][,COLOR:ANSI_VALUE] - -Base Color Schemes - -\- dark: Default 256\-color dark theme (default) -\- light: 256\-color light theme -\- 16: Basic 16\-color theme -\- bw: Minimal black & white theme (no colors, just styles) -\- none: Minimal black & white theme (no colors, no styles). Default when NO_COLOR is set -\- molokai: Molokai\-inspired 256\-color theme - -Color Customization - -Colors can be specified in two ways: - -\- ANSI color code (0\-255): \-\-color=fg:232,bg:255 -\- RGB hex values: \-\-color=fg:#FF0000 (red text) - -Customizable UI Elements - -\- fg: Normal text foreground color -\- bg: Normal text background color -\- matched (or hl): Matched text in search results -\- matched_bg: Background of matched text -\- current (or fg+): Current line foreground color -\- current_bg (or bg+): Current line background color -\- current_match (or hl+): Matched text in current line -\- current_match_bg: Background of matched text in current line -\- spinner: Progress indicator color -\- info: Information line color -\- prompt: Prompt color -\- cursor (or pointer): Cursor color -\- selected (or marker): Selected item marker color -\- header: Header text color -\- border: Border color for preview/layout - -Examples - -\- \-\-color=light: Use light color scheme -\- \-\-color=dark,fg:232,bg:255: Use dark scheme with custom colors -\- \-\-color=current_bg:24: Default scheme with custom current line background -\- \-\-color=dark,matched:#00FF00: Green matched text on dark theme -\- \-\-color=fg:#FFFFFF,bg:#000000: Custom white\-on\-black color scheme +Format: [BASE][,COLOR:ANSI] .TP \fB\-\-no\-hscroll\fR Disable horizontal scroll @@ -477,16 +471,20 @@ Effective only when the query string is empty \fB\-\-skip\-to\-pattern\fR \fI\fR Show the matched pattern at the line start -Line will start with the start of the matched pattern. Effective only when the query string is empty. Was designed to skip showing starts of paths of rg/grep results. +Line will start with the start of the matched pattern. Effective only when the query +string is empty. Was designed to skip showing starts of paths of rg/grep results. -Example: sk \-i \-c "rg {} \-\-color=always" \-\-skip\-to\-pattern \*(Aq[^/]*:\*(Aq \-\-ansi +e.g. sk \-i \-c "rg {} \-\-color=always" \-\-skip\-to\-pattern \*(Aq[^/]*:\*(Aq \-\-ansi .TP \fB\-\-no\-clear\-if\-empty\fR Do not clear previous line if the command returns an empty result -Do not clear previous items if new command returns empty result. This might be useful to reduce flickering when typing new commands and the half\-complete commands are not valid. +Do not clear previous items if new command returns empty result. This might be useful to +reduce flickering when typing new commands and the half\-complete commands are not valid. -This is not the default behavior because similar use cases for grep and rg have already been op‐ timized where empty query results actually mean "empty" and previous results should be cleared. +This is not the default behavior because similar use cases for grep and rg have already been op‐ +timized where empty query results actually mean "empty" and previous results should be +cleared. .TP \fB\-\-no\-clear\-start\fR Do not clear items on start @@ -502,16 +500,18 @@ Show error message if command fails .TP \fB\-\-layout\fR \fI\fR [default: default] Set layout - -*default: Display from the bottom of the screen - -*reverse: Display from the top of the screen - -*reverse\-list: Display from the top of the screen, prompt at the bottom .br .br -[\fIpossible values: \fRdefault, reverse, reverse\-list] +\fIPossible values:\fR +.RS 14 +.IP \(bu 2 +default: Display from the bottom of the screen +.IP \(bu 2 +reverse: Display from the top of the screen +.IP \(bu 2 +reverse\-list: Display from the top of the screen, prompt at the bottom +.RE .TP \fB\-\-reverse\fR Shorthand for reverse layout @@ -522,13 +522,12 @@ Height of skim\*(Aqs window Can either be a row count or a percentage .TP \fB\-\-no\-height\fR -Disable height feature +Disable height (force full screen) .TP \fB\-\-min\-height\fR \fI\fR [default: 10] Minimum height of skim\*(Aqs window Useful when the height is set as a percentage - Ignored when \-\-height is not specified .TP \fB\-\-margin\fR \fI\fR [default: 0] @@ -537,15 +536,10 @@ Screen margin For each side, can be either a row count or a percentage of the terminal size Format can be one of: - -\- TRBL - -\- TB,RL - -\- T,RL,B - -\- T,R,B,L - + \- TRBL + \- TB,RL + \- T,RL,B + \- T,R,B,L Example: 1,10% .TP \fB\-p\fR, \fB\-\-prompt\fR \fI\fR [default: > ] @@ -557,6 +551,19 @@ Set prompt in command mode .TP \fB\-\-ansi\fR Parse ANSI color codes in input strings + +When using skim as a library, this has no effect and ansi parsing should be enabled by manually injecting a cmd_collector like so: + + use skim::prelude::*; + + let _options = SkimOptionsBuilder::default() + .cmd(ls \-\-color) + .cmd_collector(Rc::new(RefCell::new(SkimItemReader::new( + SkimItemReaderOption::default().ansi(true), + ))) as Rc>) + .build() + .unwrap() + .TP \fB\-\-tabstop\fR \fI\fR [default: 8] Number of spaces that make up a tab @@ -564,9 +571,9 @@ Number of spaces that make up a tab \fB\-\-info\fR \fI\fR [default: default] Set matching result count display position -\- hidden: do not display info -\- inline: display info in the same row as the input -\- default: display info in a dedicated row above the input + hidden: do not display info + inline: display info in the same row as the input + default: display info in a dedicated row above the input .br .br @@ -588,20 +595,17 @@ Number of lines of the input treated as header The first N lines of the input are treated as the sticky header. When \-\-with\-nth is set, the lines are transformed just like the other lines that follow. .TP +\fB\-\-border\fR +Draw borders around the UI components +.TP \fB\-\-tmux\fR [\fI...\fR] Run in a tmux popup -Format: sk \-\-tmux [,SIZE[%]][,SIZE[%]] +Format: `sk \-\-tmux [,SIZE[%]][,SIZE[%]]` Depending on the direction, the order and behavior of the sizes varies: -\- center: (width, height) or (size, size) if only one is provided - -\- top | bottom: (height, width) or height = size, width = 100% if only one is provided - -\- left | right: (width, height) or height = 100%, width = size if only one is provided - -Note: env vars are only passed to the tmux command if they are either PATH or prefixed with RUST or SKIM +Default: center,50% .SH HISTORY .TP \fB\-\-history\fR \fI\fR @@ -628,27 +632,36 @@ Maximum number of query history entries to keep \fB\-\-preview\fR \fI\fR Preview command -Execute the given command for the current line and display the result on the preview window. {} in the command is the placeholder that is replaced to the single\-quoted string of the current line. To transform the replace‐ ment string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details). +Execute the given command for the current line and display the result on the preview window. {} in the command +is the placeholder that is replaced to the single\-quoted string of the current line. To transform the replace‐ +ment string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details). -Examples: +**Examples**: - sk \-\-preview=\*(Aqhead \-$LINES {}\*(Aq - ls \-l | sk \-\-preview="echo user={3} when={\-4..\-2}; cat {\-1}" \-\-header\-lines=1 +```bash +sk \-\-preview=\*(Aqhead \-$LINES {}\*(Aq +ls \-l | sk \-\-preview="echo user={3} when={\-4..\-2}; cat {\-1}" \-\-header\-lines=1 +``` sk overrides $LINES and $COLUMNS so that they represent the exact size of the preview window. -A placeholder expression starting with + flag will be replaced to the space\-separated list of the selected lines (or the current line if no selection was made) individually quoted. +A placeholder expression starting with + flag will be replaced to the space\-separated list of the selected +lines (or the current line if no selection was made) individually quoted. -Examples: - - sk \-\-multi \-\-preview=\*(Aqhead \-10 {+}\*(Aq - git log \-\-oneline | sk \-\-multi \-\-preview \*(Aqgit show {+1}\*(Aq +**Examples**: +```bash +sk \-\-multi \-\-preview=\*(Aqhead \-10 {+}\*(Aq +git log \-\-oneline | sk \-\-multi \-\-preview \*(Aqgit show {+1}\*(Aq +``` Note that you can escape a placeholder pattern by prepending a backslash. -Also, {q} is replaced to the current query string. {cq} is replaced to the current command query string. {n} is replaced to zero\-based ordinal index of the line. Use {+n} if you want all index numbers when multiple lines are selected +Also, `{q}` is replaced to the current query string. `{cq}` is replaced to the current command query string. +`{n}` is replaced to zero\-based ordinal index of the line. Use `{+n}` if you want all index numbers when multiple +lines are selected -Preview window will be updated even when there is no match for the current query if any of the placeholder ex‐ pressions evaluates to a non\-empty string. +Preview window will be updated even when there is no match for the current query if any of the placeholder ex‐ +pressions evaluates to a non\-empty string. .TP \fB\-\-preview\-window\fR \fI\fR [default: right:50%] Preview window layout @@ -672,12 +685,11 @@ Examples: git grep \-\-line\-number \*(Aq\*(Aq | sk \-\-delimiter: \-\-preview \*(Aqnl {1}\*(Aq \-\-preview\-window +{2}\-5 - # Preview with bat, matching line in the middle of the window (\-/2) - git grep \-\-line\-number \*(Aq\*(Aq | - sk \-\-delimiter: \\ - \-\-preview \*(Aqbat \-\-style=numbers \-\-color=always \-\-highlight\-line {2} {1}\*(Aq \\ - \-\-preview\-window +{2}\-/2 - + # Preview with bat, matching line in the middle of the window (\-/2) + git grep \-\-line\-number \*(Aq\*(Aq | + sk \-\-delimiter : \\ + \-\-preview \*(Aqbat \-\-style=numbers \-\-color=always \-\-highlight\-line {2} {1}\*(Aq \\ + \-\-preview\-window +{2}\-/2 .SH SCRIPTING .TP \fB\-q\fR, \fB\-\-query\fR \fI\fR @@ -686,13 +698,6 @@ Initial query \fB\-\-cmd\-query\fR \fI\fR Initial query in interactive mode .TP -\fB\-\-expect\fR \fI\fR -[Deprecated: Use \-\-bind=:accept() instead] Comma separated list of keys used to complete skim - -Comma\-separated list of keys that can be used to complete sk in addition to the default enter key. When this option is set, sk will print the name of the key pressed as the first line of its output (or as the second line if \-\-print\-query is also used). No line will be printed if sk is completed with the default enter key. If \-\-expect option is specified multiple times, sk will expect the union of the keys. \-\-no\-expect will clear the list. - -Example: sk \-\-expect=ctrl\-v,ctrl\-t,alt\-s \-\-expect=f1,f2,~,@ -.TP \fB\-\-read0\fR Read input delimited by ASCII NUL(\\0) characters .TP @@ -719,7 +724,7 @@ Synchronous search for multi\-staged filtering Synchronous search for multi\-staged filtering. If specified, skim will launch ncurses finder only after the input stream is complete. -Example: sk \-\-multi | sk \-\-sync + e.g. sk \-\-multi | sk \-\-sync .TP \fB\-\-pre\-select\-n\fR \fI\fR [default: 0] Pre\-select the first n items in multi\-selection mode @@ -732,7 +737,7 @@ Check the doc for the detailed syntax: https://docs.rs/regex/1.4.1/regex/ \fB\-\-pre\-select\-items\fR \fI\fR [default: ] Pre\-select the items separated by newline character -Example: item1\\nitem2 +Example: \*(Aqitem1\\nitem2\*(Aq .TP \fB\-\-pre\-select\-file\fR \fI\fR Pre\-select the items read from this file @@ -752,5 +757,15 @@ Note: While PowerShell completions are supported, Windows is not supported for n .br [\fIpossible values: \fRbash, elvish, fish, powershell, zsh] +.TP +\fB\-\-man\fR +Generate man page and output it to stdout +.TP +\fB\-\-log\-file\fR \fI\fR +Pipe log output to a file +.SH DEPRECATED +.TP +\fB\-\-expect\fR \fI\fR [default: ] +Deprecated, kept for compatibility purposes. See accept() bind instead .SH VERSION v0.20.5 diff --git a/plugin/skim.vim b/plugin/skim.vim index 1507c4b7..10435917 100644 --- a/plugin/skim.vim +++ b/plugin/skim.vim @@ -136,38 +136,12 @@ function! s:default_layout() \ : { 'down': '~40%' } endfunction -function! skim#install() - if s:is_win && !has('win32unix') - let script = s:base_dir.'/install.ps1' - if !filereadable(script) - throw script.' not found' - endif - let script = 'powershell -ExecutionPolicy Bypass -file ' . script - else - let script = s:base_dir.'/install' - if !executable(script) - throw script.' not found' - endif - let script .= ' --bin' - endif - - call s:warn('Running skim installer ...') - call system(script) - if v:shell_error - throw 'Failed to download skim: '.script - endif -endfunction - function! skim#exec() if !exists('s:exec') if executable(s:skim_rs) let s:exec = s:skim_rs elseif executable('sk') let s:exec = 'sk' - elseif input('skim executable not found. Download binary? (y/n) ') =~? '^y' - redraw - call skim#install() - return skim#exec() else redraw throw 'skim executable not found' @@ -518,21 +492,17 @@ function! s:dopopd() return endif - " FIXME: We temporarily change the working directory to 'dir' entry + " Note: We temporarily change the working directory to 'dir' entry " of options dictionary (set to the current working directory if not given) " before running skim. " " e.g. call skim#run({'dir': '/tmp', 'source': 'ls', 'sink': 'e'}) " - " After processing the sink function, we have to restore the current working - " directory. But doing so may not be desirable if the function changed the - " working directory on purpose. - " - " So how can we tell if we should do it or not? A simple heuristic we use - " here is that we change directory only if the current working directory - " matches 'dir' entry. However, it is possible that the sink function did - " change the directory to 'dir'. In that case, the user will have an - " unexpected result. + " After processing the sink function, we restore the current working + " directory using a heuristic: we only change directory if the current + " working directory matches 'dir' entry. This handles most cases correctly, + " though it may not restore the directory if the sink function explicitly + " changed to the 'dir' path. if s:skim_getcwd() ==# w:skim_pushd.dir && (!&autochdir || w:skim_pushd.bufname ==# bufname('')) execute w:skim_pushd.command s:escape(w:skim_pushd.origin) endif diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..1b6d19c4 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +profile = "default" +components = ["rust-analyzer"] \ No newline at end of file diff --git a/shell/completion.bash b/shell/completion.bash index 0481616f..3267398b 100644 --- a/shell/completion.bash +++ b/shell/completion.bash @@ -23,7 +23,7 @@ _sk() { case "${cmd}" in sk) - opts="-t -n -d -e -b -m -c -i -I -p -q -1 -0 -f -x -h -V --tac --min-query-length --no-sort --tiebreak --nth --with-nth --delimiter --exact --regex --algo --case --bind --multi --no-multi --no-mouse --cmd --interactive --color --no-hscroll --keep-right --skip-to-pattern --no-clear-if-empty --no-clear-start --no-clear --show-cmd-error --layout --reverse --height --no-height --min-height --margin --prompt --cmd-prompt --ansi --tabstop --info --no-info --inline-info --header --header-lines --history --history-size --cmd-history --cmd-history-size --preview --preview-window --query --cmd-query --expect --read0 --print0 --print-query --print-cmd --print-score --select-1 --exit-0 --sync --pre-select-n --pre-select-pat --pre-select-items --pre-select-file --filter --shell --tmux --extended --literal --cycle --hscroll-off --filepath-word --jump-labels --border --no-bold --pointer --marker --phony --help --version" + opts="-t -n -d -e -b -m -c -i -I -p -q -1 -0 -f -x -h -V --tac --min-query-length --no-sort --tiebreak --nth --with-nth --delimiter --exact --regex --algo --case --bind --multi --no-multi --no-mouse --cmd --interactive --color --no-hscroll --keep-right --skip-to-pattern --no-clear-if-empty --no-clear-start --no-clear --show-cmd-error --layout --reverse --height --no-height --min-height --margin --prompt --cmd-prompt --ansi --tabstop --info --no-info --inline-info --header --header-lines --border --history --history-size --cmd-history --cmd-history-size --preview --preview-window --query --cmd-query --read0 --print0 --print-query --print-cmd --print-score --select-1 --exit-0 --sync --pre-select-n --pre-select-pat --pre-select-items --pre-select-file --filter --shell --man --tmux --log-file --extended --literal --cycle --hscroll-off --filepath-word --jump-labels --no-bold --pointer --marker --phony --expect --help --version" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -177,10 +177,6 @@ _sk() { COMPREPLY=($(compgen -f "${cur}")) return 0 ;; - --expect) - COMPREPLY=($(compgen -f "${cur}")) - return 0 - ;; --pre-select-n) COMPREPLY=($(compgen -f "${cur}")) return 0 @@ -213,6 +209,10 @@ _sk() { COMPREPLY=($(compgen -f "${cur}")) return 0 ;; + --log-file) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --hscroll-off) COMPREPLY=($(compgen -f "${cur}")) return 0 @@ -221,7 +221,7 @@ _sk() { COMPREPLY=($(compgen -f "${cur}")) return 0 ;; - --border) + --expect) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; diff --git a/shell/completion.fish b/shell/completion.fish index a9f15763..9f283d6c 100644 --- a/shell/completion.fish +++ b/shell/completion.fish @@ -1,5 +1,5 @@ complete -c sk -l min-query-length -d 'Minimum query length to start showing results' -r -complete -c sk -s t -l tiebreak -d 'Comma-separated list of sort criteria to apply when the scores are tied' -r -f -a "score\t'' +complete -c sk -s t -l tiebreak -d 'Comma-separated list of sort criteria to apply when the scores are tied.' -r -f -a "score\t'' -score\t'' begin\t'' -begin\t'' @@ -12,20 +12,20 @@ index\t'' complete -c sk -s n -l nth -d 'Fields to be matched' -r complete -c sk -l with-nth -d 'Fields to be transformed' -r complete -c sk -s d -l delimiter -d 'Delimiter between fields' -r -complete -c sk -l algo -d 'Fuzzy matching algorithm' -r -f -a "skim_v1\t'' -skim_v2\t'' -clangd\t''" -complete -c sk -l case -d 'Case sensitivity' -r -f -a "respect\t'' -ignore\t'' -smart\t''" +complete -c sk -l algo -d 'Fuzzy matching algorithm' -r -f -a "skim_v1\t'Original skim fuzzy matching algorithm (v1)' +skim_v2\t'Improved skim fuzzy matching algorithm (v2, default)' +clangd\t'Clangd fuzzy matching algorithm'" +complete -c sk -l case -d 'Case sensitivity' -r -f -a "respect\t'Case-sensitive matching' +ignore\t'Case-insensitive matching' +smart\t'Smart case: case-insensitive unless query contains uppercase'" complete -c sk -s b -l bind -d 'Comma separated list of bindings' -r complete -c sk -s c -l cmd -d 'Command to invoke dynamically in interactive mode' -r complete -c sk -s I -d 'Replace replstr with the selected item in commands' -r complete -c sk -l color -d 'Set color theme' -r complete -c sk -l skip-to-pattern -d 'Show the matched pattern at the line start' -r -complete -c sk -l layout -d 'Set layout' -r -f -a "default\t'' -reverse\t'' -reverse-list\t''" +complete -c sk -l layout -d 'Set layout' -r -f -a "default\t'Display from the bottom of the screen' +reverse\t'Display from the top of the screen' +reverse-list\t'Display from the top of the screen, prompt at the bottom'" complete -c sk -l height -d 'Height of skim\'s window' -r complete -c sk -l min-height -d 'Minimum height of skim\'s window' -r complete -c sk -l margin -d 'Screen margin' -r @@ -45,7 +45,6 @@ complete -c sk -l preview -d 'Preview command' -r complete -c sk -l preview-window -d 'Preview window layout' -r complete -c sk -s q -l query -d 'Initial query' -r complete -c sk -l cmd-query -d 'Initial query in interactive mode' -r -complete -c sk -l expect -d '[Deprecated: Use --bind=:accept() instead] Comma separated list of keys used to complete skim' -r complete -c sk -l pre-select-n -d 'Pre-select the first n items in multi-selection mode' -r complete -c sk -l pre-select-pat -d 'Pre-select the matched items in multi-selection mode' -r complete -c sk -l pre-select-items -d 'Pre-select the items separated by newline character' -r @@ -57,9 +56,10 @@ fish\t'' powershell\t'' zsh\t''" complete -c sk -l tmux -d 'Run in a tmux popup' -r +complete -c sk -l log-file -d 'Pipe log output to a file' -r complete -c sk -l hscroll-off -d 'Reserved for later use' -r complete -c sk -l jump-labels -d 'Reserved for later use' -r -complete -c sk -l border -d 'Reserved for later use' -r +complete -c sk -l expect -d 'Deprecated, kept for compatibility purposes. See accept() bind instead' -r complete -c sk -l tac -d 'Show results in reverse order' complete -c sk -l no-sort -d 'Do not sort the results' complete -c sk -s e -l exact -d 'Run in exact mode' @@ -75,10 +75,11 @@ complete -c sk -l no-clear-start -d 'Do not clear items on start' complete -c sk -l no-clear -d 'Do not clear screen on exit' complete -c sk -l show-cmd-error -d 'Show error message if command fails' complete -c sk -l reverse -d 'Shorthand for reverse layout' -complete -c sk -l no-height -d 'Disable height feature' +complete -c sk -l no-height -d 'Disable height (force full screen)' complete -c sk -l ansi -d 'Parse ANSI color codes in input strings' complete -c sk -l no-info -d 'Alias for --info=hidden' complete -c sk -l inline-info -d 'Alias for --info=inline' +complete -c sk -l border -d 'Draw borders around the UI components' complete -c sk -l read0 -d 'Read input delimited by ASCII NUL(\\0) characters' complete -c sk -l print0 -d 'Print output delimited by ASCII NUL(\\0) characters' complete -c sk -l print-query -d 'Print the query as the first line' @@ -87,6 +88,7 @@ complete -c sk -l print-score -d 'Print the command as the first line (after pri complete -c sk -s 1 -l select-1 -d 'Automatically select the match if there is only one' complete -c sk -s 0 -l exit-0 -d 'Automatically exit when no match is left' complete -c sk -l sync -d 'Synchronous search for multi-staged filtering' +complete -c sk -l man -d 'Generate man page and output it to stdout' complete -c sk -s x -l extended -d 'Reserved for later use' complete -c sk -l literal -d 'Reserved for later use' complete -c sk -l cycle -d 'Reserved for later use' diff --git a/shell/completion.zsh b/shell/completion.zsh index 7c891a16..c050ee54 100644 --- a/shell/completion.zsh +++ b/shell/completion.zsh @@ -16,23 +16,29 @@ _sk() { local context curcontext="$curcontext" state line _arguments "${_arguments_options[@]}" : \ '--min-query-length=[Minimum query length to start showing results]:MIN_QUERY_LENGTH:_default' \ -'*-t+[Comma-separated list of sort criteria to apply when the scores are tied]:TIEBREAK:(score -score begin -begin end -end length -length index -index)' \ -'*--tiebreak=[Comma-separated list of sort criteria to apply when the scores are tied]:TIEBREAK:(score -score begin -begin end -end length -length index -index)' \ +'*-t+[Comma-separated list of sort criteria to apply when the scores are tied.]:TIEBREAK:(score -score begin -begin end -end length -length index -index)' \ +'*--tiebreak=[Comma-separated list of sort criteria to apply when the scores are tied.]:TIEBREAK:(score -score begin -begin end -end length -length index -index)' \ '*-n+[Fields to be matched]:NTH:_default' \ '*--nth=[Fields to be matched]:NTH:_default' \ '*--with-nth=[Fields to be transformed]:WITH_NTH:_default' \ '-d+[Delimiter between fields]:DELIMITER:_default' \ '--delimiter=[Delimiter between fields]:DELIMITER:_default' \ -'--algo=[Fuzzy matching algorithm]:ALGORITHM:(skim_v1 skim_v2 clangd)' \ -'--case=[Case sensitivity]:CASE:(respect ignore smart)' \ -'*-b+[Comma separated list of bindings]:BIND:_default' \ -'*--bind=[Comma separated list of bindings]:BIND:_default' \ +'--algo=[Fuzzy matching algorithm]:ALGORITHM:((skim_v1\:"Original skim fuzzy matching algorithm (v1)" +skim_v2\:"Improved skim fuzzy matching algorithm (v2, default)" +clangd\:"Clangd fuzzy matching algorithm"))' \ +'--case=[Case sensitivity]:CASE:((respect\:"Case-sensitive matching" +ignore\:"Case-insensitive matching" +smart\:"Smart case\: case-insensitive unless query contains uppercase"))' \ +'*-b+[Comma separated list of bindings]' \ +'*--bind=[Comma separated list of bindings]' \ '-c+[Command to invoke dynamically in interactive mode]:CMD:_default' \ '--cmd=[Command to invoke dynamically in interactive mode]:CMD:_default' \ '-I+[Replace replstr with the selected item in commands]:REPLSTR:_default' \ '--color=[Set color theme]:COLOR:_default' \ '--skip-to-pattern=[Show the matched pattern at the line start]:SKIP_TO_PATTERN:_default' \ -'--layout=[Set layout]:LAYOUT:(default reverse reverse-list)' \ +'--layout=[Set layout]:LAYOUT:((default\:"Display from the bottom of the screen" +reverse\:"Display from the top of the screen" +reverse-list\:"Display from the top of the screen, prompt at the bottom"))' \ '--height=[Height of skim'\''s window]:HEIGHT:_default' \ '--min-height=[Minimum height of skim'\''s window]:MIN_HEIGHT:_default' \ '--margin=[Screen margin]:MARGIN:_default' \ @@ -52,7 +58,6 @@ _sk() { '-q+[Initial query]:QUERY:_default' \ '--query=[Initial query]:QUERY:_default' \ '--cmd-query=[Initial query in interactive mode]:CMD_QUERY:_default' \ -'*--expect=[\[Deprecated\: Use --bind=\:accept() instead\] Comma separated list of keys used to complete skim]:EXPECT:_default' \ '--pre-select-n=[Pre-select the first n items in multi-selection mode]:PRE_SELECT_N:_default' \ '--pre-select-pat=[Pre-select the matched items in multi-selection mode]:PRE_SELECT_PAT:_default' \ '--pre-select-items=[Pre-select the items separated by newline character]:PRE_SELECT_ITEMS:_default' \ @@ -61,9 +66,10 @@ _sk() { '--filter=[Query for filter mode]:FILTER:_default' \ '--shell=[Generate shell completion script]:SHELL:(bash elvish fish powershell zsh)' \ '--tmux=[Run in a tmux popup]' \ +'--log-file=[Pipe log output to a file]:LOG_FILE:_default' \ '--hscroll-off=[Reserved for later use]:HSCROLL_OFF:_default' \ '--jump-labels=[Reserved for later use]:JUMP_LABELS:_default' \ -'--border=[Reserved for later use]' \ +'--expect=[Deprecated, kept for compatibility purposes. See accept() bind instead]:EXPECT:_default' \ '--tac[Show results in reverse order]' \ '--no-sort[Do not sort the results]' \ '-e[Run in exact mode]' \ @@ -82,10 +88,11 @@ _sk() { '--no-clear[Do not clear screen on exit]' \ '--show-cmd-error[Show error message if command fails]' \ '--reverse[Shorthand for reverse layout]' \ -'--no-height[Disable height feature]' \ +'--no-height[Disable height (force full screen)]' \ '--ansi[Parse ANSI color codes in input strings]' \ '--no-info[Alias for --info=hidden]' \ '--inline-info[Alias for --info=inline]' \ +'--border[Draw borders around the UI components]' \ '--read0[Read input delimited by ASCII NUL(\\0) characters]' \ '--print0[Print output delimited by ASCII NUL(\\0) characters]' \ '--print-query[Print the query as the first line]' \ @@ -96,6 +103,7 @@ _sk() { '-0[Automatically exit when no match is left]' \ '--exit-0[Automatically exit when no match is left]' \ '--sync[Synchronous search for multi-staged filtering]' \ +'--man[Generate man page and output it to stdout]' \ '-x[Reserved for later use]' \ '--extended[Reserved for later use]' \ '--literal[Reserved for later use]' \ diff --git a/skim-common/Cargo.toml b/skim-common/Cargo.toml deleted file mode 100644 index 46ec6a4a..00000000 --- a/skim-common/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "skim-common" -version = "0.2.0" -edition = "2024" -authors = ["Zhang Jinzhou ", "Loric Andre"] -description = "Fuzzy Finder in rust!" -documentation = "https://docs.rs/skim" -homepage = "https://github.com/skim-rs/skim" -repository = "https://github.com/skim-rs/skim" -readme = "../README.md" -keywords = ["util"] -license = "MIT" - -[dependencies] diff --git a/skim-common/src/lib.rs b/skim-common/src/lib.rs deleted file mode 100644 index a74af510..00000000 --- a/skim-common/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod spinlock; diff --git a/skim-tuikit/.gitignore b/skim-tuikit/.gitignore deleted file mode 100644 index 2bb8d253..00000000 --- a/skim-tuikit/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -/target -**/*.rs.bk -.idea diff --git a/skim-tuikit/Cargo.lock b/skim-tuikit/Cargo.lock deleted file mode 100644 index 8e3c1564..00000000 --- a/skim-tuikit/Cargo.lock +++ /dev/null @@ -1,266 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "aho-corasick" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" -dependencies = [ - "memchr", -] - -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi", - "libc", - "winapi", -] - -[[package]] -name = "bitflags" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" - -[[package]] -name = "cfg-if" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "dirs-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" -dependencies = [ - "cfg-if 1.0.0", - "dirs-sys-next", -] - -[[package]] -name = "dirs-sys-next" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - -[[package]] -name = "env_logger" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aafcde04e90a5226a6443b7aabdb016ba2f8307c847d524724bd9b346dd1a2d3" -dependencies = [ - "atty", - "humantime", - "log", - "regex", - "termcolor", -] - -[[package]] -name = "getrandom" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9495705279e7140bf035dde1f6e750c162df8b625267cd52cc44e0b156732c8" -dependencies = [ - "cfg-if 1.0.0", - "libc", - "wasi", -] - -[[package]] -name = "hermit-abi" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aca5565f760fb5b220e499d72710ed156fdb74e631659e99377d9ebfbd13ae8" -dependencies = [ - "libc", -] - -[[package]] -name = "humantime" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f" -dependencies = [ - "quick-error", -] - -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - -[[package]] -name = "libc" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5916d2ae698f6de9bfb891ad7a8d65c09d232dc58cc4ac433c7da3b2fd84bc2b" - -[[package]] -name = "log" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fabed175da42fed1fa0746b0ea71f412aa9d35e76e95e59b192c64b9dc2bf8b" -dependencies = [ - "cfg-if 0.1.10", -] - -[[package]] -name = "memchr" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" - -[[package]] -name = "nix" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f17df307904acd05aa8e32e97bb20f2a0df1728bbc2d771ae8f9a90463441e9" -dependencies = [ - "bitflags", - "cfg-if 1.0.0", - "libc", -] - -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - -[[package]] -name = "redox_syscall" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94341e4e44e24f6b591b59e47a8a027df12e008d73fd5672dbea9cc22f4507d9" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_users" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" -dependencies = [ - "getrandom", - "redox_syscall", -] - -[[package]] -name = "regex" -version = "1.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.6.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64" - -[[package]] -name = "rustversion" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5d2a036dc6d2d8fd16fde3498b04306e29bd193bf306a57427019b823d5acd" - -[[package]] -name = "term" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" -dependencies = [ - "dirs-next", - "rustversion", - "winapi", -] - -[[package]] -name = "termcolor" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb6bfa289a4d7c5766392812c0a1f4c1ba45afa1ad47803c11e1f407d846d75f" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "tuikit" -version = "0.5.0" -dependencies = [ - "bitflags", - "env_logger", - "lazy_static", - "log", - "nix", - "term", - "unicode-width", -] - -[[package]] -name = "unicode-width" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" - -[[package]] -name = "wasi" -version = "0.10.2+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" -dependencies = [ - "winapi", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/skim-tuikit/Cargo.toml b/skim-tuikit/Cargo.toml deleted file mode 100644 index afcf3bfe..00000000 --- a/skim-tuikit/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "skim-tuikit" -version = "0.6.6" -authors = ["Jinzhou Zhang "] -description = "Toolkit for writing TUI applications" -documentation = "https://docs.rs/skim-tuikit" -homepage = "https://github.com/skim-rs/skim" -repository = "https://github.com/skim-rs/skim" -readme = "README.md" -keywords = ["tui", "terminal", "tty", "color"] -license = "MIT" -edition = "2024" - -[dependencies] -bitflags = { workspace = true } -skim-common = { path = "../skim-common/", version = "0.2.0" } -lazy_static = { workspace = true } -log = { workspace = true } -nix = { workspace = true, default-features = false, features = ["fs", "poll", "signal", "term"] } -term = { workspace = true } -unicode-width = { workspace = true } - -[dev-dependencies] -env_logger = { workspace = true } diff --git a/skim-tuikit/README.md b/skim-tuikit/README.md deleted file mode 100644 index a3f824d0..00000000 --- a/skim-tuikit/README.md +++ /dev/null @@ -1,142 +0,0 @@ -[![Crates.io](https://img.shields.io/crates/v/skim-tuikit.svg)](https://crates.io/crates/skim-tuikit) - -## Tuikit - -Tuikit is a TUI library for writing terminal UI applications. Highlights: - -- Thread safe. -- Support non-fullscreen mode as well as fullscreen mode. -- Support `Alt` keys, mouse events, etc. -- Buffering for efficient rendering. - -Tuikit is modeld after [termbox](https://github.com/nsf/termbox) which views the -terminal as a table of fixed-size cells and input being a stream of structured -messages. - -**WARNING**: The library is not stable yet, the API might change. - -## Usage - -In your `Cargo.toml` add the following: - -```toml -[dependencies] -skim-tuikit = "*" -``` - - -Here is an example (could also be run by `cargo run --example hello-world`): - -```rust -use skim_tuikit::prelude::*; -use std::cmp::{min, max}; - -fn main() { - let term: Term<()> = Term::with_height(TermHeight::Percent(30)).unwrap(); - let mut row = 1; - let mut col = 0; - - let _ = term.print(0, 0, "press arrow key to move the text, (q) to quit"); - let _ = term.present(); - - while let Ok(ev) = term.poll_event() { - let _ = term.clear(); - let _ = term.print(0, 0, "press arrow key to move the text, (q) to quit"); - - let (width, height) = term.term_size().unwrap(); - match ev { - Event::Key(Key::ESC) | Event::Key(Key::Char('q')) => break, - Event::Key(Key::Up) => row = max(row-1, 1), - Event::Key(Key::Down) => row = min(row+1, height-1), - Event::Key(Key::Left) => col = max(col, 1)-1, - Event::Key(Key::Right) => col = min(col+1, width-1), - _ => {} - } - - let attr = Attr{ fg: Color::RED, ..Attr::default() }; - let _ = term.print_with_attr(row, col, "Hello World! 你好!今日は。", attr); - let _ = term.set_cursor(row, col); - let _ = term.present(); - } -} -``` - -## Layout - -`tuikit` provides `HSplit`, `VSplit` and `Win` for managing layouts: - -1. `HSplit` allow you to split area horizontally into pieces. -2. `VSplit` works just like `HSplit` but splits vertically. -3. `Win` do not split, it could have margin, padding and border. - -For example: - -```rust -use skim_tuikit::prelude::*; - -struct Model(String); - -impl Draw for Model { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - let (width, height) = canvas.size()?; - let message_width = self.0.len(); - let left = (width - message_width) / 2; - let top = height / 2; - let _ = canvas.print(top, left, &self.0); - Ok(()) - } -} - -impl Widget for Model{} - -fn main() { - let term: Term<()> = Term::with_height(TermHeight::Percent(50)).unwrap(); - let model = Model("middle!".to_string()); - - while let Ok(ev) = term.poll_event() { - if let Event::Key(Key::Char('q')) = ev { - break; - } - let _ = term.print(0, 0, "press 'q' to exit"); - - let hsplit = HSplit::default() - .split( - VSplit::default() - .basis(Size::Percent(30)) - .split(Win::new(&model).border(true).basis(Size::Percent(30))) - .split(Win::new(&model).border(true).basis(Size::Percent(30))) - ) - .split(Win::new(&model).border(true)); - - let _ = term.draw(&hsplit); - let _ = term.present(); - } -} -``` - -The split algorithm is simple: - -1. Both `HSplit` and `VSplit` will take several `Split` where a `Split` would - contains: - 1. basis, the original size - 2. grow, the factor to grow if there is still enough room - 3. shrink, the factor to shrink if there is not enough room -2. `HSplit/VSplit` will count the total width/height(basis) of the split items -3. Judge if the current width/height is enough or not for the split items -4. shrink/grow the split items according to their grow/shrink: `factor / sum(factors)` -5. If still not enough room, the last one(s) would be set width/height 0 - -## References - -`Tuikit` borrows ideas from lots of other projects: - -- [rustyline](https://github.com/kkawakam/rustyline) Readline Implementation in Rust. - - How to enter the raw mode. - - Part of the keycode parsing logic. -- [termion](https://gitlab.redox-os.org/redox-os/termion) A bindless library for controlling terminals/TTY. - - How to parse mouse events. - - How to enter raw mode. -- [rustbox](https://github.com/gchp/rustbox) and [termbox](https://github.com/nsf/termbox) - - The idea of viewing terminal as table of fixed cells. -- [termfest](https://github.com/agatan/termfest) Easy TUI library written in Rust - - The buffering idea. diff --git a/skim-tuikit/examples/256color.rs b/skim-tuikit/examples/256color.rs deleted file mode 100644 index dae0ebbd..00000000 --- a/skim-tuikit/examples/256color.rs +++ /dev/null @@ -1,31 +0,0 @@ -use skim_tuikit::attr::Color; -use skim_tuikit::output::Output; -use std::io; - -fn main() { - let mut output = Output::new(Box::new(io::stdout())).unwrap(); - - for fg in 0..=255 { - output.set_fg(Color::AnsiValue(fg)); - output.write(format!("{:5}", fg).as_str()); - if fg % 16 == 15 { - output.reset_attributes(); - output.write("\n"); - output.flush() - } - } - - output.reset_attributes(); - - for bg in 0..=255 { - output.set_bg(Color::AnsiValue(bg)); - output.write(format!("{:5}", bg).as_str()); - if bg % 16 == 15 { - output.reset_attributes(); - output.write("\n"); - output.flush() - } - } - - output.flush() -} diff --git a/skim-tuikit/examples/256color_on_screen.rs b/skim-tuikit/examples/256color_on_screen.rs deleted file mode 100644 index 493b0a49..00000000 --- a/skim-tuikit/examples/256color_on_screen.rs +++ /dev/null @@ -1,48 +0,0 @@ -use skim_tuikit::attr::{Attr, Color}; -use skim_tuikit::canvas::Canvas; -use skim_tuikit::output::Output; -use skim_tuikit::screen::Screen; -use std::io; - -fn main() { - let mut output = Output::new(Box::new(io::stdout())).unwrap(); - let (width, height) = output.terminal_size().unwrap(); - let mut screen = Screen::new(width, height); - - for fg in 0..=255 { - let _ = screen.print_with_attr( - fg / 16, - (fg % 16) * 5, - format!("{:5}", fg).as_str(), - Color::AnsiValue(fg as u8).into(), - ); - } - - let _ = screen.set_cursor(15, 80); - let commands = screen.present(); - - commands.into_iter().for_each(|cmd| output.execute(cmd)); - output.flush(); - - let _ = screen.print_with_attr(0, 78, "HELLO WORLD", Attr::default()); - let commands = screen.present(); - - commands.into_iter().for_each(|cmd| output.execute(cmd)); - output.flush(); - - for bg in 0..=255 { - let _ = screen.print_with_attr( - bg / 16, - (bg % 16) * 5, - format!("{:5}", bg).as_str(), - Attr { - bg: Color::AnsiValue(bg as u8), - ..Attr::default() - }, - ); - } - let commands = screen.present(); - commands.into_iter().for_each(|cmd| output.execute(cmd)); - output.reset_attributes(); - output.flush() -} diff --git a/skim-tuikit/examples/custom-event.rs b/skim-tuikit/examples/custom-event.rs deleted file mode 100644 index 9873e9ca..00000000 --- a/skim-tuikit/examples/custom-event.rs +++ /dev/null @@ -1,24 +0,0 @@ -use bitflags::_core::result::Result::Ok; - -use skim_tuikit::prelude::*; - -fn main() { - let term: Term = Term::with_height(TermHeight::Percent(30)).expect("term creation error"); - let _ = term.print(0, 0, "Press 'q' or 'Ctrl-c' to quit!"); - while let Ok(ev) = term.poll_event() { - match ev { - Event::Key(Key::Char('q')) | Event::Key(Key::Ctrl('c')) => break, - Event::Key(key) => { - let _ = term.print(1, 0, format!("get key: {:?}", key).as_str()); - let _ = term.send_event(Event::User(format!("key: {:?}", key))); - } - Event::User(ev_str) => { - let _ = term.print(2, 0, format!("user event: {}", &ev_str).as_str()); - } - _ => { - let _ = term.print(3, 0, format!("event: {:?}", ev).as_str()); - } - } - let _ = term.present(); - } -} diff --git a/skim-tuikit/examples/get_keys.rs b/skim-tuikit/examples/get_keys.rs deleted file mode 100644 index 6d518d29..00000000 --- a/skim-tuikit/examples/get_keys.rs +++ /dev/null @@ -1,25 +0,0 @@ -use skim_tuikit::input::KeyBoard; -use skim_tuikit::key::Key; -use skim_tuikit::output::Output; -use skim_tuikit::raw::IntoRawMode; -use std::time::Duration; - -fn main() { - let _stdout = std::io::stdout().into_raw_mode().unwrap(); - let mut output = Output::new(Box::new(_stdout)).unwrap(); - output.enable_mouse_support(); - output.flush(); - - println!("program will exit on pressing `q` or wait 5 seconds"); - - // let mut keyboard = KeyBoard::new(Box::new(std::io::stdin())); - let mut keyboard = KeyBoard::new_with_tty(); - while let Ok(key) = keyboard.next_key_timeout(Duration::from_secs(5)) { - if key == Key::Char('q') { - break; - } - println!("print: {:?}", key); - } - output.disable_mouse_support(); - output.flush(); -} diff --git a/skim-tuikit/examples/hello-world.rs b/skim-tuikit/examples/hello-world.rs deleted file mode 100644 index c1148b13..00000000 --- a/skim-tuikit/examples/hello-world.rs +++ /dev/null @@ -1,30 +0,0 @@ -use skim_tuikit::prelude::*; -use std::cmp::{max, min}; - -fn main() { - let term: Term<()> = Term::with_height(TermHeight::Percent(30)).unwrap(); - let mut row = 1; - let mut col = 0; - - let _ = term.print(0, 0, "press arrow key to move the text, (q) to quit"); - let _ = term.present(); - - while let Ok(ev) = term.poll_event() { - let _ = term.clear(); - let _ = term.print(0, 0, "press arrow key to move the text, (q) to quit"); - - let (width, height) = term.term_size().unwrap(); - match ev { - Event::Key(Key::ESC) | Event::Key(Key::Char('q')) | Event::Key(Key::Ctrl('c')) => break, - Event::Key(Key::Up) => row = max(row - 1, 1), - Event::Key(Key::Down) => row = min(row + 1, height - 1), - Event::Key(Key::Left) => col = max(col, 1) - 1, - Event::Key(Key::Right) => col = min(col + 1, width - 1), - _ => {} - } - - let _ = term.print_with_attr(row, col, "Hello World! 你好!今日は。", Color::RED); - let _ = term.set_cursor(row, col); - let _ = term.present(); - } -} diff --git a/skim-tuikit/examples/split.rs b/skim-tuikit/examples/split.rs deleted file mode 100644 index c3783884..00000000 --- a/skim-tuikit/examples/split.rs +++ /dev/null @@ -1,59 +0,0 @@ -use skim_tuikit::prelude::*; - -struct Fit(String); - -impl Draw for Fit { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - let (_width, height) = canvas.size()?; - let top = height / 2; - let _ = canvas.print(top, 0, &self.0); - Ok(()) - } -} -impl Widget for Fit { - fn size_hint(&self) -> (Option, Option) { - (Some(self.0.len()), None) - } -} - -struct Model(String); - -impl Draw for Model { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - let (width, height) = canvas.size()?; - let message_width = self.0.len(); - let left = (width - message_width) / 2; - let top = height / 2; - let _ = canvas.print_with_attr(0, left, "press 'q' to exit", Effect::UNDERLINE.into()); - let _ = canvas.print(top, left, &self.0); - Ok(()) - } -} - -impl Widget for Model {} - -fn main() { - let term: Term<()> = Term::with_height(TermHeight::Percent(50)).unwrap(); - let model = Model("Hey, I'm in middle!".to_string()); - let fit = Fit("Short Text That Fits".to_string()); - - while let Ok(ev) = term.poll_event() { - match ev { - Event::Key(Key::Char('q')) | Event::Key(Key::Ctrl('c')) => break, - _ => (), - } - - let hsplit = HSplit::default() - .split( - VSplit::default() - .shrink(0) - .grow(0) - .split(Win::new(&fit).border(true)) - .split(Win::new(&fit).border(true)), - ) - .split(Win::new(&model).border(true)); - - let _ = term.draw(&hsplit); - let _ = term.present(); - } -} diff --git a/skim-tuikit/examples/stack.rs b/skim-tuikit/examples/stack.rs deleted file mode 100644 index d2c1e254..00000000 --- a/skim-tuikit/examples/stack.rs +++ /dev/null @@ -1,83 +0,0 @@ -use skim_tuikit::prelude::*; - -struct Model { - win: String, -} - -impl Draw for Model { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - let (width, height) = canvas.size()?; - let _ = canvas.clear(); - let message_width = self.win.len(); - let left = (width - message_width) / 2; - let top = height / 2; - let _ = canvas.print(top, left, &self.win); - Ok(()) - } -} - -impl Widget for Model { - fn on_event(&self, event: Event, _rect: Rectangle) -> Vec { - if let Event::Key(Key::SingleClick(_, _, _)) = event { - vec![format!("{} clicked", self.win)] - } else { - vec![] - } - } -} - -fn main() { - let term = Term::with_options(TermOptions::default().mouse_enabled(true)).unwrap(); - let (mut width, mut height) = term.term_size().unwrap(); - - while let Ok(ev) = term.poll_event() { - match ev { - Event::Key(Key::Char('q')) | Event::Key(Key::Ctrl('c')) => break, - Event::Key(Key::MouseRelease(_, _)) => continue, - Event::Resize { width: w, height: h } => { - width = w; - height = h; - } - _ => (), - } - let stack = Stack::::new() - .top( - Win::new(Model { - win: "win floating on top".to_string(), - }) - .border(true) - .margin(Size::Percent(30)), - ) - .bottom( - HSplit::default() - .split( - Win::new(Model { - win: String::from("left"), - }) - .border(true), - ) - .split( - Win::new(Model { - win: String::from("right"), - }) - .border(true), - ), - ); - - let message = stack.on_event( - ev, - Rectangle { - width, - height, - top: 0, - left: 0, - }, - ); - let click_message = if message.is_empty() { "" } else { &message[0] }; - let _ = term.draw(&stack); - let _ = term.print(1, 1, "press 'q' to exit, try clicking on windows"); - let _ = term.print(2, 1, &(String::from(click_message) + " ")); - let _ = term.present(); - } - let _ = term.show_cursor(false); -} diff --git a/skim-tuikit/examples/term_size.rs b/skim-tuikit/examples/term_size.rs deleted file mode 100644 index 634837a1..00000000 --- a/skim-tuikit/examples/term_size.rs +++ /dev/null @@ -1,8 +0,0 @@ -use skim_tuikit::output::Output; -use std::io; - -fn main() { - let output = Output::new(Box::new(io::stdout())).unwrap(); - let (width, height) = output.terminal_size().unwrap(); - println!("width: {}, height: {}", width, height); -} diff --git a/skim-tuikit/examples/termbox.rs b/skim-tuikit/examples/termbox.rs deleted file mode 100644 index 99de5802..00000000 --- a/skim-tuikit/examples/termbox.rs +++ /dev/null @@ -1,73 +0,0 @@ -use skim_tuikit::prelude::*; -use std::sync::Arc; -use std::thread; -use std::time::{Duration, Instant}; - -extern crate env_logger; - -/// This example is testing tuikit with multi-threads. - -const COL: usize = 4; - -fn main() { - env_logger::init(); - let term = Arc::new(Term::with_height(TermHeight::Fixed(10)).unwrap()); - let _ = term.enable_mouse_support(); - let now = Instant::now(); - - print_banner(&term); - - let th = thread::spawn(move || { - while let Ok(ev) = term.poll_event() { - match ev { - Event::Key(Key::Char('q')) | Event::Key(Key::Ctrl('c')) => break, - Event::Key(Key::Char('r')) => { - let term = term.clone(); - thread::spawn(move || { - let _ = term.pause(); - println!("restart in 2 seconds"); - thread::sleep(Duration::from_secs(2)); - let _ = term.restart(); - let _ = term.clear(); - }); - } - _ => (), - } - - print_banner(&term); - print_event(&term, ev, &now); - } - }); - let _ = th.join(); -} - -fn print_banner(term: &Term) { - let (_, height) = term.term_size().unwrap_or((5, 5)); - for row in 0..height { - let _ = term.print(row, 0, format!("{} ", row).as_str()); - } - let attr = Attr { - fg: Color::GREEN, - effect: Effect::UNDERLINE, - ..Attr::default() - }; - let _ = term.print_with_attr(0, COL, "How to use: (q)uit, (r)estart", attr); - let _ = term.present(); -} - -fn print_event(term: &Term, ev: Event, now: &Instant) { - let elapsed = now.elapsed(); - let (_, height) = term.term_size().unwrap_or((5, 5)); - let _ = term.print(1, COL, format!("{:?}", ev).as_str()); - let _ = term.print( - height - 1, - COL, - format!( - "time elapsed since program start: {}s + {}ms", - elapsed.as_secs(), - elapsed.subsec_millis() - ) - .as_str(), - ); - let _ = term.present(); -} diff --git a/skim-tuikit/examples/true_color.rs b/skim-tuikit/examples/true_color.rs deleted file mode 100644 index c4de1ce1..00000000 --- a/skim-tuikit/examples/true_color.rs +++ /dev/null @@ -1,80 +0,0 @@ -use skim_tuikit::attr::Color; -use skim_tuikit::output::Output; -use std::io; - -// ported from: https://github.com/gnachman/iTerm2/blob/master/tests/24-bit-color.sh -// should be run in terminals that supports true color - -// given a color idx/22 along HSV, return (r, g, b) -fn rainbow_color(idx: u8) -> (u8, u8, u8) { - let h = idx / 43; - let f = idx - 43 * h; - let t = ((f as i32 * 255) / 43) as u8; - let q = 255 - t; - - match h { - 0 => (255, t, 0), - 1 => (q, 255, 0), - 2 => (0, 255, t), - 3 => (0, q, 255), - 4 => (t, 0, 255), - 5 => (255, 0, q), - _ => unreachable!(), - } -} - -fn try_background(output: &mut Output, r: u8, g: u8, b: u8) { - output.set_bg(Color::Rgb(r, g, b)); - output.write(" ") -} - -fn reset_output(output: &mut Output) { - output.reset_attributes(); - output.write("\n"); - output.flush(); -} - -fn main() { - let mut output = Output::new(Box::new(io::stdout())).unwrap(); - for i in 0..=127 { - try_background(&mut output, i, 0, 0); - } - reset_output(&mut output); - - for i in (128..=255).rev() { - try_background(&mut output, i, 0, 0); - } - reset_output(&mut output); - - for i in 0..=127 { - try_background(&mut output, 0, i, 0); - } - reset_output(&mut output); - - for i in (128..=255).rev() { - try_background(&mut output, 0, i, 0); - } - reset_output(&mut output); - - for i in 0..=127 { - try_background(&mut output, 0, 0, i); - } - reset_output(&mut output); - - for i in (128..=255).rev() { - try_background(&mut output, 0, 0, i); - } - reset_output(&mut output); - - for i in 0..=127 { - let (r, g, b) = rainbow_color(i); - try_background(&mut output, r, g, b); - } - reset_output(&mut output); - - for i in (128..=255).rev() { - let (r, g, b) = rainbow_color(i); - try_background(&mut output, r, g, b); - } - reset_output(&mut output); -} diff --git a/skim-tuikit/examples/win.rs b/skim-tuikit/examples/win.rs deleted file mode 100644 index 75ec8ce5..00000000 --- a/skim-tuikit/examples/win.rs +++ /dev/null @@ -1,63 +0,0 @@ -use skim_tuikit::prelude::*; - -struct Model(String); - -impl Draw for Model { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - let (width, height) = canvas.size()?; - let message_width = self.0.len(); - let left = (width - message_width) / 2; - let top = height / 2; - let _ = canvas.print(top, left, &self.0); - Ok(()) - } -} - -impl Widget for Model {} - -fn main() { - let term: Term<()> = Term::with_options( - TermOptions::default() - .height(TermHeight::Percent(50)) - .disable_alternate_screen(true) - .clear_on_start(false), - ) - .unwrap(); - let model = Model("Hey, I'm in middle!".to_string()); - - while let Ok(ev) = term.poll_event() { - match ev { - Event::Key(Key::Char('q')) | Event::Key(Key::Ctrl('c')) => break, - _ => (), - } - let _ = term.print(0, 0, "press 'q' to exit"); - - let inner_win = Win::new(&model) - .fn_draw_header(Box::new(|canvas| { - let _ = canvas.print(0, 0, "header printed with function"); - Ok(()) - })) - .border(true); - - let win_bottom_title = Win::new(&inner_win) - .title_align(HorizontalAlign::Center) - .title("Title (at bottom) center aligned") - .right_prompt("Right Prompt stays") - .title_on_top(false) - .border_bottom(true); - - let win = Win::new(&win_bottom_title) - .margin(Size::Percent(10)) - .padding(1) - .title("Window Title") - .right_prompt("Right Prompt") - .border(true) - .border_top_attr(Color::BLUE) - .border_right_attr(Color::YELLOW) - .border_bottom_attr(Color::RED) - .border_left_attr(Color::GREEN); - - let _ = term.draw(&win); - let _ = term.present(); - } -} diff --git a/skim-tuikit/src/attr.rs b/skim-tuikit/src/attr.rs deleted file mode 100644 index 232fdeea..00000000 --- a/skim-tuikit/src/attr.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! attr modules defines the attributes(colors, effects) of a terminal cell - -use bitflags::bitflags; - -pub use crate::color::Color; - -/// `Attr` is a rendering attribute that contains fg color, bg color and text effect. -/// -/// ``` -/// use skim_tuikit::attr::{Attr, Effect, Color}; -/// -/// let attr = Attr { fg: Color::RED, effect: Effect::BOLD, ..Attr::default() }; -/// ``` -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Attr { - pub fg: Color, - pub bg: Color, - pub effect: Effect, -} - -impl Default for Attr { - fn default() -> Self { - Attr { - fg: Color::default(), - bg: Color::default(), - effect: Effect::empty(), - } - } -} - -impl Attr { - /// extend the properties with the new attr's if the properties in new attr is not default. - /// ``` - /// use skim_tuikit::attr::{Attr, Color, Effect}; - /// - /// let default = Attr{fg: Color::BLUE, bg: Color::YELLOW, effect: Effect::BOLD}; - /// let new = Attr{fg: Color::Default, bg: Color::WHITE, effect: Effect::REVERSE}; - /// let extended = default.extend(new); - /// - /// assert_eq!(Color::BLUE, extended.fg); - /// assert_eq!(Color::WHITE, extended.bg); - /// assert_eq!(Effect::BOLD | Effect::REVERSE, extended.effect); - /// ``` - pub fn extend(&self, new_attr: Self) -> Attr { - Attr { - fg: if new_attr.fg != Color::default() { - new_attr.fg - } else { - self.fg - }, - bg: if new_attr.bg != Color::default() { - new_attr.bg - } else { - self.bg - }, - effect: self.effect | new_attr.effect, - } - } - - pub fn fg(mut self, fg: Color) -> Self { - self.fg = fg; - self - } - - pub fn bg(mut self, bg: Color) -> Self { - self.bg = bg; - self - } - - pub fn effect(mut self, effect: Effect) -> Self { - self.effect = effect; - self - } -} - -bitflags! { - /// `Effect` is the effect of a text - pub struct Effect: u8 { - const BOLD = 0b00000001; - const DIM = 0b00000010; - const UNDERLINE = 0b00000100; - const BLINK = 0b00001000; - const REVERSE = 0b00010000; - } -} - -impl From for Attr { - fn from(fg: Color) -> Self { - Attr { - fg, - ..Default::default() - } - } -} - -impl From for Attr { - fn from(effect: Effect) -> Self { - Attr { - effect, - ..Default::default() - } - } -} diff --git a/skim-tuikit/src/canvas.rs b/skim-tuikit/src/canvas.rs deleted file mode 100644 index bf007bf7..00000000 --- a/skim-tuikit/src/canvas.rs +++ /dev/null @@ -1,116 +0,0 @@ -use crate::Result; -/// A canvas is a trait defining the draw actions -use crate::attr::Attr; -use crate::cell::Cell; -use unicode_width::UnicodeWidthChar; - -pub trait Canvas { - /// Get the canvas size (width, height) - fn size(&self) -> Result<(usize, usize)>; - - /// clear the canvas - fn clear(&mut self) -> Result<()>; - - /// change a cell of position `(row, col)` to `cell` - /// if `(row, col)` is out of boundary, `Ok` is returned, but no operation is taken - /// return the width of the character/cell - fn put_cell(&mut self, row: usize, col: usize, cell: Cell) -> Result; - - /// just like put_cell, except it accept (char & attr) - /// return the width of the character/cell - fn put_char_with_attr(&mut self, row: usize, col: usize, ch: char, attr: Attr) -> Result { - self.put_cell(row, col, Cell { ch, attr }) - } - - /// print `content` starting with position `(row, col)` with `attr` - /// - /// - canvas should NOT wrap to y+1 if the content is too long - /// - canvas should handle wide characters - /// - /// returns the printed width of the content - fn print_with_attr(&mut self, row: usize, col: usize, content: &str, attr: Attr) -> Result { - let mut cell = Cell { - attr, - ..Cell::default() - }; - - let mut width = 0; - for ch in content.chars() { - cell.ch = ch; - width += self.put_cell(row, col + width, cell)?; - } - Ok(width) - } - - /// print `content` starting with position `(row, col)` with default attribute - fn print(&mut self, row: usize, col: usize, content: &str) -> Result { - self.print_with_attr(row, col, content, Attr::default()) - } - - /// move cursor position (row, col) and show cursor - fn set_cursor(&mut self, row: usize, col: usize) -> Result<()>; - - /// show/hide cursor, set `show` to `false` to hide the cursor - fn show_cursor(&mut self, show: bool) -> Result<()>; -} - -/// A sub-area of a canvas. -/// It will handle the adjustments of cursor movement, so that you could write -/// to for example (0, 0) and BoundedCanvas will adjust it to real position. -pub struct BoundedCanvas<'a> { - canvas: &'a mut dyn Canvas, - top: usize, - left: usize, - width: usize, - height: usize, -} - -impl<'a> BoundedCanvas<'a> { - pub fn new(top: usize, left: usize, width: usize, height: usize, canvas: &'a mut dyn Canvas) -> Self { - Self { - canvas, - top, - left, - width, - height, - } - } -} - -impl Canvas for BoundedCanvas<'_> { - fn size(&self) -> Result<(usize, usize)> { - Ok((self.width, self.height)) - } - - fn clear(&mut self) -> Result<()> { - for row in self.top..(self.top + self.height) { - for col in self.left..(self.left + self.width) { - let _ = self.canvas.put_cell(row, col, Cell::empty()); - } - } - - Ok(()) - } - - fn put_cell(&mut self, row: usize, col: usize, cell: Cell) -> Result { - if row >= self.height || col >= self.width { - // do nothing - Ok(cell.ch.width().unwrap_or(2)) - } else { - self.canvas.put_cell(row + self.top, col + self.left, cell) - } - } - - fn set_cursor(&mut self, row: usize, col: usize) -> Result<()> { - if row >= self.height || col >= self.width { - // do nothing - Ok(()) - } else { - self.canvas.set_cursor(row + self.top, col + self.left) - } - } - - fn show_cursor(&mut self, show: bool) -> Result<()> { - self.canvas.show_cursor(show) - } -} diff --git a/skim-tuikit/src/cell.rs b/skim-tuikit/src/cell.rs deleted file mode 100644 index d5e4fce9..00000000 --- a/skim-tuikit/src/cell.rs +++ /dev/null @@ -1,65 +0,0 @@ -/// `Cell` is a cell of the terminal. -/// It has a display character and an attribute (fg and bg color, effects). -use crate::attr::{Attr, Color, Effect}; - -const EMPTY_CHAR: char = '\0'; - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Cell { - pub ch: char, - pub attr: Attr, -} - -impl Default for Cell { - fn default() -> Self { - Self { - ch: ' ', - attr: Attr::default(), - } - } -} - -impl Cell { - pub fn empty() -> Self { - Self::default().ch(EMPTY_CHAR) - } - - pub fn ch(mut self, ch: char) -> Self { - self.ch = ch; - self - } - - pub fn fg(mut self, fg: Color) -> Self { - self.attr.fg = fg; - self - } - - pub fn bg(mut self, bg: Color) -> Self { - self.attr.bg = bg; - self - } - - pub fn effect(mut self, effect: Effect) -> Self { - self.attr.effect = effect; - self - } - - pub fn attribute(mut self, attr: Attr) -> Self { - self.attr = attr; - self - } - - /// check if a cell is empty - pub fn is_empty(self) -> bool { - self.ch == EMPTY_CHAR && self.attr == Attr::default() - } -} - -impl From for Cell { - fn from(ch: char) -> Self { - Cell { - ch, - attr: Attr::default(), - } - } -} diff --git a/skim-tuikit/src/color.rs b/skim-tuikit/src/color.rs deleted file mode 100644 index 5e8ba938..00000000 --- a/skim-tuikit/src/color.rs +++ /dev/null @@ -1,34 +0,0 @@ -/// Color of a character, could be 8 bit(256 color) or RGB color -/// -/// ``` -/// use skim_tuikit::attr::Color; -/// Color::RED; // predefined values -/// Color::Rgb(255, 0, 0); // RED -/// ``` -#[derive(Debug, Clone, Copy, PartialEq, Default)] -#[non_exhaustive] -pub enum Color { - #[default] - Default, - AnsiValue(u8), - Rgb(u8, u8, u8), -} - -impl Color { - pub const BLACK: Color = Color::AnsiValue(0); - pub const RED: Color = Color::AnsiValue(1); - pub const GREEN: Color = Color::AnsiValue(2); - pub const YELLOW: Color = Color::AnsiValue(3); - pub const BLUE: Color = Color::AnsiValue(4); - pub const MAGENTA: Color = Color::AnsiValue(5); - pub const CYAN: Color = Color::AnsiValue(6); - pub const WHITE: Color = Color::AnsiValue(7); - pub const LIGHT_BLACK: Color = Color::AnsiValue(8); - pub const LIGHT_RED: Color = Color::AnsiValue(9); - pub const LIGHT_GREEN: Color = Color::AnsiValue(10); - pub const LIGHT_YELLOW: Color = Color::AnsiValue(11); - pub const LIGHT_BLUE: Color = Color::AnsiValue(12); - pub const LIGHT_MAGENTA: Color = Color::AnsiValue(13); - pub const LIGHT_CYAN: Color = Color::AnsiValue(14); - pub const LIGHT_WHITE: Color = Color::AnsiValue(15); -} diff --git a/skim-tuikit/src/draw.rs b/skim-tuikit/src/draw.rs deleted file mode 100644 index bef104da..00000000 --- a/skim-tuikit/src/draw.rs +++ /dev/null @@ -1,43 +0,0 @@ -/// A trait defines something that could be drawn -use crate::canvas::Canvas; - -pub type DrawResult = std::result::Result>; - -/// Something that knows how to draw itself onto the canvas -#[allow(unused_variables)] -pub trait Draw { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - Ok(()) - } - fn draw_mut(&mut self, canvas: &mut dyn Canvas) -> DrawResult<()> { - self.draw(canvas) - } -} - -impl Draw for &T { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - (*self).draw(canvas) - } - fn draw_mut(&mut self, canvas: &mut dyn Canvas) -> DrawResult<()> { - (*self).draw(canvas) - } -} - -impl Draw for &mut T { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - (**self).draw(canvas) - } - fn draw_mut(&mut self, canvas: &mut dyn Canvas) -> DrawResult<()> { - (**self).draw_mut(canvas) - } -} - -impl Draw for Box { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - self.as_ref().draw(canvas) - } - - fn draw_mut(&mut self, canvas: &mut dyn Canvas) -> DrawResult<()> { - self.as_mut().draw_mut(canvas) - } -} diff --git a/skim-tuikit/src/error.rs b/skim-tuikit/src/error.rs deleted file mode 100644 index 627e1a1c..00000000 --- a/skim-tuikit/src/error.rs +++ /dev/null @@ -1,81 +0,0 @@ -use std::error::Error; -use std::fmt::{Display, Formatter}; -use std::string::FromUtf8Error; -use std::time::Duration; - -#[derive(Debug)] -pub enum TuikitError { - UnknownSequence(String), - NoCursorReportResponse, - IndexOutOfBound(usize, usize), - Timeout(Duration), - Interrupted, - TerminalNotStarted, - DrawError(Box), - SendEventError(String), - FromUtf8Error(std::string::FromUtf8Error), - ParseIntError(std::num::ParseIntError), - IOError(std::io::Error), - NixError(nix::Error), - ChannelReceiveError(std::sync::mpsc::RecvError), -} - -impl Display for TuikitError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - TuikitError::UnknownSequence(sequence) => { - write!(f, "unsupported esc sequence: {sequence}") - } - TuikitError::NoCursorReportResponse => { - write!(f, "buffer did not contain cursor position response") - } - TuikitError::IndexOutOfBound(row, col) => { - write!(f, "({row}, {col}) is out of bound") - } - TuikitError::Timeout(duration) => write!(f, "timeout with duration: {duration:?}"), - TuikitError::Interrupted => write!(f, "interrupted"), - TuikitError::TerminalNotStarted => { - write!(f, "terminal not started, call `restart` to start it") - } - TuikitError::DrawError(error) => write!(f, "draw error: {error}"), - TuikitError::SendEventError(error) => write!(f, "send event error: {error}"), - TuikitError::FromUtf8Error(error) => write!(f, "{error}"), - TuikitError::ParseIntError(error) => write!(f, "{error}"), - TuikitError::IOError(error) => write!(f, "{error}"), - TuikitError::NixError(error) => write!(f, "{error}"), - TuikitError::ChannelReceiveError(error) => write!(f, "{error}"), - } - } -} - -impl Error for TuikitError {} - -impl From for TuikitError { - fn from(error: FromUtf8Error) -> Self { - TuikitError::FromUtf8Error(error) - } -} - -impl From for TuikitError { - fn from(error: std::num::ParseIntError) -> Self { - TuikitError::ParseIntError(error) - } -} - -impl From for TuikitError { - fn from(error: nix::Error) -> Self { - TuikitError::NixError(error) - } -} - -impl From for TuikitError { - fn from(error: std::io::Error) -> Self { - TuikitError::IOError(error) - } -} - -impl From for TuikitError { - fn from(error: std::sync::mpsc::RecvError) -> Self { - TuikitError::ChannelReceiveError(error) - } -} diff --git a/skim-tuikit/src/event.rs b/skim-tuikit/src/event.rs deleted file mode 100644 index 72a69a1e..00000000 --- a/skim-tuikit/src/event.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! events a `Term` could return - -pub use crate::key::Key; - -#[derive(Eq, PartialEq, Hash, Debug, Copy, Clone)] -pub enum Event { - Key(Key), - Resize { - width: usize, - height: usize, - }, - Restarted, - /// user defined signal 1 - User(UserEvent), - - #[doc(hidden)] - __Nonexhaustive, -} diff --git a/skim-tuikit/src/input.rs b/skim-tuikit/src/input.rs deleted file mode 100644 index 66532a6f..00000000 --- a/skim-tuikit/src/input.rs +++ /dev/null @@ -1,600 +0,0 @@ -//! module to handle keystrokes -//! -//! ```no_run -//! use skim_tuikit::input::KeyBoard; -//! use skim_tuikit::key::Key; -//! use std::time::Duration; -//! let mut keyboard = KeyBoard::new_with_tty(); -//! let key = keyboard.next_key(); -//! ``` - -use std::fs::File; -use std::io::prelude::*; -use std::os::fd::AsFd as _; -use std::os::unix::io::AsRawFd; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use nix::fcntl::{FcntlArg, OFlag, fcntl}; - -use crate::Result; -use crate::error::TuikitError; -use crate::key::Key::*; -use crate::key::{Key, MouseButton}; -use crate::raw::get_tty; -use crate::spinlock::SpinLock; -use crate::sys::file::wait_until_ready; - -pub trait ReadAndAsRawFd: Read + AsRawFd + Send {} - -const KEY_WAIT: Duration = Duration::from_millis(10); -const DOUBLE_CLICK_DURATION: u128 = 300; - -impl ReadAndAsRawFd for T where T: Read + AsRawFd + Send {} - -pub struct KeyBoard { - file: File, - sig_tx: Arc>, - sig_rx: File, - // bytes will be poped from front, normally the buffer size will be small(< 10 bytes) - byte_buf: Vec, - - raw_mouse: bool, - next_key: Option>, - last_click: Key, - last_click_time: SpinLock, -} - -// https://www.xfree86.org/4.8.0/ctlseqs.html -// http://man7.org/linux/man-pages/man4/console_codes.4.html -impl KeyBoard { - pub fn new(file: File) -> Self { - // the self-pipe trick for interrupt `select` - let (rx, tx) = nix::unistd::pipe().expect("failed to set pipe"); - - // set the signal pipe to non-blocking mode - let flag = fcntl(rx.as_raw_fd(), FcntlArg::F_GETFL).expect("Get fcntl failed"); - let mut flag = OFlag::from_bits_truncate(flag); - flag.insert(OFlag::O_NONBLOCK); - let _ = fcntl(rx.as_raw_fd(), FcntlArg::F_SETFL(flag)); - - // set file to non-blocking mode - let flag = fcntl(file.as_raw_fd(), FcntlArg::F_GETFL).expect("Get fcntl failed"); - let mut flag = OFlag::from_bits_truncate(flag); - flag.insert(OFlag::O_NONBLOCK); - let _ = fcntl(file.as_raw_fd(), FcntlArg::F_SETFL(flag)); - - KeyBoard { - file, - sig_tx: Arc::new(SpinLock::new(File::from(tx))), - sig_rx: File::from(rx), - byte_buf: Vec::new(), - raw_mouse: false, - next_key: None, - last_click: Key::Null, - last_click_time: SpinLock::new(Instant::now()), - } - } - - pub fn new_with_tty() -> Self { - Self::new(get_tty().expect("KeyBoard::new_with_tty: failed to get tty")) - } - - pub fn raw_mouse(mut self, raw_mouse: bool) -> Self { - self.raw_mouse = raw_mouse; - self - } - - pub fn get_interrupt_handler(&self) -> KeyboardHandler { - KeyboardHandler { - handler: self.sig_tx.clone(), - } - } - - fn fetch_bytes(&mut self, timeout: Duration) -> Result<()> { - let mut reader_buf = [0; 1]; - - // clear interrupt signal - while self.sig_rx.read(&mut reader_buf).is_ok() {} - - wait_until_ready(self.file.as_fd(), Some(self.sig_rx.as_fd()), timeout)?; // wait timeout - - self.read_unread_bytes(); - Ok(()) - } - - fn read_unread_bytes(&mut self) { - let mut reader_buf = [0; 1]; - while self.file.read(&mut reader_buf).is_ok() { - self.byte_buf.push(reader_buf[0]); - } - } - - #[allow(dead_code)] - fn next_byte(&mut self) -> Result { - self.next_byte_timeout(Duration::new(0, 0)) - } - - fn next_byte_timeout(&mut self, timeout: Duration) -> Result { - trace!("next_byte_timeout: timeout: {timeout:?}"); - if self.byte_buf.is_empty() { - self.fetch_bytes(timeout)?; - } - - trace!("next_byte_timeout: after fetch, buf = {:?}", self.byte_buf); - Ok(self.byte_buf.remove(0)) - } - - #[allow(dead_code)] - fn next_char(&mut self) -> Result { - self.next_char_timeout(Duration::new(0, 0)) - } - - fn next_char_timeout(&mut self, timeout: Duration) -> Result { - trace!("next_char_timeout: timeout: {timeout:?}"); - if self.byte_buf.is_empty() { - self.fetch_bytes(timeout)?; - } - - trace!("get_chars: buf: {:?}", self.byte_buf); - let bytes = std::mem::take(&mut self.byte_buf); - match String::from_utf8(bytes) { - Ok(string) => { - let ret = string.chars().next().expect("failed to get next char from input"); - self.byte_buf.extend_from_slice(&string.as_bytes()[ret.len_utf8()..]); - Ok(ret) - } - Err(error) => { - let valid_up_to = error.utf8_error().valid_up_to(); - let bytes = error.into_bytes(); - let string = String::from_utf8_lossy(&bytes[..valid_up_to]); - let ret = string.chars().next().expect("failed to get next char from input"); - self.byte_buf.extend_from_slice(&bytes[ret.len_utf8()..]); - Ok(ret) - } - } - } - - fn merge_wheel(&mut self, current_key: Result) -> (Result, Option>) { - match current_key { - Ok(Key::MousePress(key @ MouseButton::WheelUp, row, col)) - | Ok(Key::MousePress(key @ MouseButton::WheelDown, row, col)) => { - let mut count = 1; - let mut o_next_key; - loop { - o_next_key = self.try_next_raw_key(); - match o_next_key { - Some(Ok(Key::MousePress(k, r, c))) if key == k && row == r && col == c => count += 1, - _ => break, - } - } - - match key { - MouseButton::WheelUp => (Ok(Key::WheelUp(row, col, count)), o_next_key), - MouseButton::WheelDown => (Ok(Key::WheelDown(row, col, count)), o_next_key), - _ => unreachable!(), - } - } - _ => (current_key, None), - } - } - - pub fn next_key(&mut self) -> Result { - self.next_key_timeout(Duration::new(0, 0)) - } - - pub fn next_key_timeout(&mut self, timeout: Duration) -> Result { - if self.raw_mouse { - return self.next_raw_key_timeout(timeout); - } - - let next_key = if self.next_key.is_some() { - self.next_key.take().unwrap() - } else { - // fetch next key - let next_key = self.next_raw_key_timeout(timeout); - let (next_key, next_next_key) = self.merge_wheel(next_key); - self.next_key = next_next_key; - next_key - }; - - // parse double click - match next_key { - Ok(key @ MousePress(..)) => { - if let MousePress(button, row, col) = key { - let ret = if key == self.last_click - && self.last_click_time.lock().elapsed().as_millis() < DOUBLE_CLICK_DURATION - { - DoubleClick(button, row, col) - } else { - self.last_click = key; - SingleClick(button, row, col) - }; - - *self.last_click_time.lock() = Instant::now(); - Ok(ret) - } else { - unreachable!(); - } - } - _ => next_key, - } - } - - #[allow(dead_code)] - fn next_raw_key(&mut self) -> Result { - self.next_raw_key_timeout(Duration::new(0, 0)) - } - - fn try_next_raw_key(&mut self) -> Option> { - match self.next_raw_key_timeout(KEY_WAIT) { - Ok(key) => Some(Ok(key)), - Err(TuikitError::Timeout(_)) => None, - Err(error) => Some(Err(error)), - } - } - - /// Wait `timeout` until next key stroke - fn next_raw_key_timeout(&mut self, timeout: Duration) -> Result { - trace!("next_raw_key_timeout: {timeout:?}"); - let ch = self.next_char_timeout(timeout)?; - match ch { - '\u{00}' => Ok(Ctrl(' ')), - '\u{01}' => Ok(Ctrl('a')), - '\u{02}' => Ok(Ctrl('b')), - '\u{03}' => Ok(Ctrl('c')), - '\u{04}' => Ok(Ctrl('d')), - '\u{05}' => Ok(Ctrl('e')), - '\u{06}' => Ok(Ctrl('f')), - '\u{07}' => Ok(Ctrl('g')), - '\u{08}' => Ok(Ctrl('h')), - '\u{09}' => Ok(Tab), - '\u{0A}' => Ok(Ctrl('j')), - '\u{0B}' => Ok(Ctrl('k')), - '\u{0C}' => Ok(Ctrl('l')), - '\u{0D}' => Ok(Enter), - '\u{0E}' => Ok(Ctrl('n')), - '\u{0F}' => Ok(Ctrl('o')), - '\u{10}' => Ok(Ctrl('p')), - '\u{11}' => Ok(Ctrl('q')), - '\u{12}' => Ok(Ctrl('r')), - '\u{13}' => Ok(Ctrl('s')), - '\u{14}' => Ok(Ctrl('t')), - '\u{15}' => Ok(Ctrl('u')), - '\u{16}' => Ok(Ctrl('v')), - '\u{17}' => Ok(Ctrl('w')), - '\u{18}' => Ok(Ctrl('x')), - '\u{19}' => Ok(Ctrl('y')), - '\u{1A}' => Ok(Ctrl('z')), - '\u{1B}' => self.escape_sequence(), - '\u{7F}' => Ok(Backspace), - ch => Ok(Char(ch)), - } - } - - fn escape_sequence(&mut self) -> Result { - let seq1 = self.next_char_timeout(KEY_WAIT).unwrap_or('\u{1B}'); - match seq1 { - '[' => self.escape_csi(), - 'O' => self.escape_o(), - _ => self.parse_alt(seq1), - } - } - - fn parse_alt(&mut self, ch: char) -> Result { - match ch { - '\u{1B}' => { - match self.next_byte_timeout(KEY_WAIT) { - Ok(b'[') => {} - Ok(c) => { - return Err(TuikitError::UnknownSequence(format!("ESC ESC {c}"))); - } - Err(_) => return Ok(ESC), - } - - match self.escape_csi() { - Ok(Up) => Ok(AltUp), - Ok(Down) => Ok(AltDown), - Ok(Left) => Ok(AltLeft), - Ok(Right) => Ok(AltRight), - Ok(PageUp) => Ok(AltPageUp), - Ok(PageDown) => Ok(AltPageDown), - _ => Err(TuikitError::UnknownSequence("ESC ESC [ ...".to_string())), - } - } - '\u{00}' => Ok(CtrlAlt(' ')), - '\u{01}' => Ok(CtrlAlt('a')), - '\u{02}' => Ok(CtrlAlt('b')), - '\u{03}' => Ok(CtrlAlt('c')), - '\u{04}' => Ok(CtrlAlt('d')), - '\u{05}' => Ok(CtrlAlt('e')), - '\u{06}' => Ok(CtrlAlt('f')), - '\u{07}' => Ok(CtrlAlt('g')), - '\u{08}' => Ok(CtrlAlt('h')), - '\u{09}' => Ok(AltTab), - '\u{0A}' => Ok(CtrlAlt('j')), - '\u{0B}' => Ok(CtrlAlt('k')), - '\u{0C}' => Ok(CtrlAlt('l')), - '\u{0D}' => Ok(AltEnter), - '\u{0E}' => Ok(CtrlAlt('n')), - '\u{0F}' => Ok(CtrlAlt('o')), - '\u{10}' => Ok(CtrlAlt('p')), - '\u{11}' => Ok(CtrlAlt('q')), - '\u{12}' => Ok(CtrlAlt('r')), - '\u{13}' => Ok(CtrlAlt('s')), - '\u{14}' => Ok(CtrlAlt('t')), - '\u{15}' => Ok(CtrlAlt('u')), - '\u{16}' => Ok(CtrlAlt('v')), - '\u{17}' => Ok(CtrlAlt('w')), - '\u{18}' => Ok(CtrlAlt('x')), - '\u{19}' => Ok(AltBackTab), - '\u{1A}' => Ok(CtrlAlt('z')), - '\u{7F}' => Ok(AltBackspace), - ch => Ok(Alt(ch)), - } - } - - fn escape_csi(&mut self) -> Result { - let cursor_pos = self.parse_cursor_report(); - if cursor_pos.is_ok() { - return cursor_pos; - } - - let seq2 = self.next_byte_timeout(KEY_WAIT)?; - match seq2 { - b'0' | b'9' => Err(TuikitError::UnknownSequence(format!("ESC [ {seq2:x?}"))), - b'1'..=b'8' => self.extended_escape(seq2), - b'[' => { - // Linux Console ESC [ [ _ - let seq3 = self.next_byte_timeout(KEY_WAIT)?; - match seq3 { - b'A' => Ok(F(1)), - b'B' => Ok(F(2)), - b'C' => Ok(F(3)), - b'D' => Ok(F(4)), - b'E' => Ok(F(5)), - _ => Err(TuikitError::UnknownSequence(format!("ESC [ [ {seq3:x?}"))), - } - } - b'A' => Ok(Up), // kcuu1 - b'B' => Ok(Down), // kcud1 - b'C' => Ok(Right), // kcuf1 - b'D' => Ok(Left), // kcub1 - b'H' => Ok(Home), // khome - b'F' => Ok(End), - b'Z' => Ok(BackTab), - b'M' => { - // X10 emulation mouse encoding: ESC [ M Bxy (6 characters only) - let cb = self.next_byte_timeout(KEY_WAIT)?; - // (1, 1) are the coords for upper left. - let cx = self.next_byte_timeout(KEY_WAIT)?.saturating_sub(32) as u16 - 1; // 0 based - let cy = self.next_byte_timeout(KEY_WAIT)?.saturating_sub(32) as u16 - 1; // 0 based - match cb & 0b11 { - 0 => { - if cb & 0x40 != 0 { - Ok(MousePress(MouseButton::WheelUp, cy, cx)) - } else { - Ok(MousePress(MouseButton::Left, cy, cx)) - } - } - 1 => { - if cb & 0x40 != 0 { - Ok(MousePress(MouseButton::WheelDown, cy, cx)) - } else { - Ok(MousePress(MouseButton::Middle, cy, cx)) - } - } - 2 => Ok(MousePress(MouseButton::Right, cy, cx)), - 3 => Ok(MouseRelease(cy, cx)), - _ => Err(TuikitError::UnknownSequence(format!("ESC M {cb:?}{cx:?}{cy:?}"))), - } - } - b'<' => { - // xterm mouse encoding: - // ESC [ < Cb ; Cx ; Cy ; (M or m) - self.read_unread_bytes(); - if !self.byte_buf.contains(&b'm') && !self.byte_buf.contains(&b'M') { - return Err(TuikitError::UnknownSequence( - "ESC [ < (not ending with m/M)".to_string(), - )); - } - - let mut str_buf = String::new(); - let mut c = self.next_char_timeout(KEY_WAIT)?; - while c != 'm' && c != 'M' { - str_buf.push(c); - c = self.next_char_timeout(KEY_WAIT)?; - } - let nums = &mut str_buf.split(';'); - - let cb = nums.next().unwrap().parse::().unwrap(); - let cx = nums.next().unwrap().parse::().unwrap() - 1; // 0 based - let cy = nums.next().unwrap().parse::().unwrap() - 1; // 0 based - - match cb { - 0..=2 | 64..=65 => { - let button = match cb { - 0 => MouseButton::Left, - 1 => MouseButton::Middle, - 2 => MouseButton::Right, - 64 => MouseButton::WheelUp, - 65 => MouseButton::WheelDown, - _ => { - return Err(TuikitError::UnknownSequence(format!("ESC [ < {str_buf} {c}"))); - } - }; - - match c { - 'M' => Ok(MousePress(button, cy, cx)), - 'm' => Ok(MouseRelease(cy, cx)), - _ => Err(TuikitError::UnknownSequence(format!("ESC [ < {str_buf} {c}"))), - } - } - 32 => Ok(MouseHold(cy, cx)), - _ => Err(TuikitError::UnknownSequence(format!("ESC [ < {str_buf} {c}"))), - } - } - _ => Err(TuikitError::UnknownSequence(format!("ESC [ {seq2:?}"))), - } - } - - // Cursor position report (CPR): Answer is ESC [ y ; x R, where x,y is the cursor location. - fn parse_cursor_report(&mut self) -> Result { - self.read_unread_bytes(); - let pos_semi = self.byte_buf.iter().position(|&b| b == b';'); - let pos_r = self.byte_buf.iter().position(|&b| b == b'R'); - - if let Some(pos_semi) = pos_semi - && let Some(pos_r) = pos_r - { - if pos_r > pos_semi { - let remain = self.byte_buf.split_off(pos_r + 1); - let mut col_str = self.byte_buf.split_off(pos_semi + 1); - let mut row_str = std::mem::replace(&mut self.byte_buf, remain); - - row_str.pop(); // remove the ';' character - col_str.pop(); // remove the 'R' character - let row = String::from_utf8(row_str)?; - let col = String::from_utf8(col_str)?; - - let row_num = row.parse::()?; - let col_num = col.parse::()?; - - return Ok(CursorPos(row_num - 1, col_num - 1)); - } - } - - Err(TuikitError::NoCursorReportResponse) - } - - fn extended_escape(&mut self, seq2: u8) -> Result { - let seq3 = self.next_byte_timeout(KEY_WAIT)?; - if seq3 == b'~' { - match seq2 { - b'1' | b'7' => Ok(Home), // tmux, xrvt - b'2' => Ok(Insert), - b'3' => Ok(Delete), // kdch1 - b'4' | b'8' => Ok(End), // tmux, xrvt - b'5' => Ok(PageUp), // kpp - b'6' => Ok(PageDown), // knp - _ => Err(TuikitError::UnknownSequence(format!("ESC [ {seq2} ~"))), - } - } else if seq3.is_ascii_digit() { - let mut str_buf = String::new(); - str_buf.push(seq2 as char); - str_buf.push(seq3 as char); - - let mut seq_last = self.next_byte_timeout(KEY_WAIT)?; - while seq_last != b'M' && seq_last != b'~' { - str_buf.push(seq_last as char); - seq_last = self.next_byte_timeout(KEY_WAIT)?; - } - - match seq_last { - b'M' => { - // rxvt mouse encoding: - // ESC [ Cb ; Cx ; Cy ; M - let mut nums = str_buf.split(';'); - - let cb = nums.next().unwrap().parse::().unwrap(); - let cx = nums.next().unwrap().parse::().unwrap() - 1; // 0 based - let cy = nums.next().unwrap().parse::().unwrap() - 1; // 0 based - - match cb { - 32 => Ok(MousePress(MouseButton::Left, cy, cx)), - 33 => Ok(MousePress(MouseButton::Middle, cy, cx)), - 34 => Ok(MousePress(MouseButton::Right, cy, cx)), - 35 => Ok(MouseRelease(cy, cx)), - 64 => Ok(MouseHold(cy, cx)), - 96 | 97 => Ok(MousePress(MouseButton::WheelUp, cy, cx)), - _ => Err(TuikitError::UnknownSequence(format!("ESC [ {str_buf} M"))), - } - } - b'~' => { - let num: u8 = str_buf.parse().unwrap(); - match num { - v @ 11..=15 => Ok(F(v - 10)), - v @ 17..=21 => Ok(F(v - 11)), - v @ 23..=24 => Ok(F(v - 12)), - 200 => Ok(BracketedPasteStart), - 201 => Ok(BracketedPasteEnd), - _ => Err(TuikitError::UnknownSequence(format!("ESC [ {str_buf} ~"))), - } - } - _ => unreachable!(), - } - } else if seq3 == b';' { - let seq4 = self.next_byte_timeout(KEY_WAIT)?; - if seq4.is_ascii_digit() { - let seq5 = self.next_byte_timeout(KEY_WAIT)?; - if seq2 == b'1' { - match (seq4, seq5) { - (b'5', b'A') => Ok(CtrlUp), - (b'5', b'B') => Ok(CtrlDown), - (b'5', b'C') => Ok(CtrlRight), - (b'5', b'D') => Ok(CtrlLeft), - (b'4', b'A') => Ok(AltShiftUp), - (b'4', b'B') => Ok(AltShiftDown), - (b'4', b'C') => Ok(AltShiftRight), - (b'4', b'D') => Ok(AltShiftLeft), - (b'3', b'H') => Ok(AltHome), - (b'3', b'F') => Ok(AltEnd), - (b'2', b'A') => Ok(ShiftUp), - (b'2', b'B') => Ok(ShiftDown), - (b'2', b'C') => Ok(ShiftRight), - (b'2', b'D') => Ok(ShiftLeft), - _ => Err(TuikitError::UnknownSequence(format!("ESC [ 1 ; {seq4:x?} {seq5:x?}"))), - } - } else { - Err(TuikitError::UnknownSequence(format!( - "ESC [ {seq2:x?} ; {seq4:x?} {seq5:x?}" - ))) - } - } else { - Err(TuikitError::UnknownSequence(format!("ESC [ {seq2:x?} ; {seq4:x?}"))) - } - } else { - match (seq2, seq3) { - (b'5', b'A') => Ok(CtrlUp), - (b'5', b'B') => Ok(CtrlDown), - (b'5', b'C') => Ok(CtrlRight), - (b'5', b'D') => Ok(CtrlLeft), - _ => Err(TuikitError::UnknownSequence(format!("ESC [ {seq2:x?} {seq3:x?}"))), - } - } - } - - // SSS3 - fn escape_o(&mut self) -> Result { - let seq2 = self.next_byte_timeout(KEY_WAIT)?; - match seq2 { - b'A' => Ok(Up), // kcuu1 - b'B' => Ok(Down), // kcud1 - b'C' => Ok(Right), // kcuf1 - b'D' => Ok(Left), // kcub1 - b'F' => Ok(End), // kend - b'H' => Ok(Home), // khome - b'P' => Ok(F(1)), // kf1 - b'Q' => Ok(F(2)), // kf2 - b'R' => Ok(F(3)), // kf3 - b'S' => Ok(F(4)), // kf4 - b'a' => Ok(CtrlUp), - b'b' => Ok(CtrlDown), - b'c' => Ok(CtrlRight), // rxvt - b'd' => Ok(CtrlLeft), // rxvt - _ => Err(TuikitError::UnknownSequence(format!("ESC O {seq2:x?}"))), - } - } -} - -pub struct KeyboardHandler { - handler: Arc>, -} - -impl KeyboardHandler { - pub fn interrupt(&self) { - let mut handler = self.handler.lock(); - let _ = handler.write_all(b"x"); - let _ = handler.flush(); - } -} diff --git a/skim-tuikit/src/key.rs b/skim-tuikit/src/key.rs deleted file mode 100644 index 7abcf0cf..00000000 --- a/skim-tuikit/src/key.rs +++ /dev/null @@ -1,283 +0,0 @@ -//! Defines all the keys `tuikit` recognizes. - -// http://ascii-table.com/ansi-escape-sequences.php -/// Single key -#[non_exhaustive] -#[rustfmt::skip] -#[derive(Eq, PartialEq, Hash, Debug, Copy, Clone)] -pub enum Key { - Null, - ESC, - - Ctrl(char), - Tab, // Ctrl-I - Enter, // Ctrl-M - - BackTab, Backspace, AltBackTab, - - Up, Down, Left, Right, Home, End, Insert, Delete, PageUp, PageDown, - CtrlUp, CtrlDown, CtrlLeft, CtrlRight, - ShiftUp, ShiftDown, ShiftLeft, ShiftRight, - AltUp, AltDown, AltLeft, AltRight, AltHome, AltEnd, AltPageUp, AltPageDown, - AltShiftUp, AltShiftDown, AltShiftLeft, AltShiftRight, - - F(u8), - - CtrlAlt(char), // chars are lower case - AltEnter, - AltBackspace, - AltTab, - Alt(char), // chars could be lower or upper case - Char(char), // chars could be lower or upper case - CursorPos(u16, u16), // row, col - - // raw mouse events, will only generated if raw mouse mode is enabled - MousePress(MouseButton, u16, u16), // row, col - MouseRelease(u16, u16), // row, col - MouseHold(u16, u16), // row, col - - // parsed mouse events, will be generated if raw mouse mode is disabled - SingleClick(MouseButton, u16, u16), // row, col - DoubleClick(MouseButton, u16, u16), // row, col, will only record left button double click - WheelUp(u16, u16, u16), // row, col, number of scroll - WheelDown(u16, u16, u16), // row, col, number of scroll - - BracketedPasteStart, - BracketedPasteEnd, -} - -/// A mouse button. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -pub enum MouseButton { - /// The left mouse button. - Left, - /// The right mouse button. - Right, - /// The middle mouse button. - Middle, - /// Mouse wheel is going up. - /// - /// This event is typically only used with MousePress. - WheelUp, - /// Mouse wheel is going down. - /// - /// This event is typically only used with MousePress. - WheelDown, -} - -#[rustfmt::skip] -pub fn from_keyname(keyname: &str) -> Option { - use self::Key::*; - match keyname.to_lowercase().as_ref() { - "ctrl-space" | "ctrl-`" | "ctrl-@" => Some(Ctrl(' ')), - "ctrl-a" => Some(Ctrl('a')), - "ctrl-b" => Some(Ctrl('b')), - "ctrl-c" => Some(Ctrl('c')), - "ctrl-d" => Some(Ctrl('d')), - "ctrl-e" => Some(Ctrl('e')), - "ctrl-f" => Some(Ctrl('f')), - "ctrl-g" => Some(Ctrl('g')), - "ctrl-h" => Some(Ctrl('h')), - "tab" | "ctrl-i" => Some(Tab), - "ctrl-j" => Some(Ctrl('j')), - "ctrl-k" => Some(Ctrl('k')), - "ctrl-l" => Some(Ctrl('l')), - "enter" | "return" | "ctrl-m" => Some(Enter), - "ctrl-n" => Some(Ctrl('n')), - "ctrl-o" => Some(Ctrl('o')), - "ctrl-p" => Some(Ctrl('p')), - "ctrl-q" => Some(Ctrl('q')), - "ctrl-r" => Some(Ctrl('r')), - "ctrl-s" => Some(Ctrl('s')), - "ctrl-t" => Some(Ctrl('t')), - "ctrl-u" => Some(Ctrl('u')), - "ctrl-v" => Some(Ctrl('v')), - "ctrl-w" => Some(Ctrl('w')), - "ctrl-x" => Some(Ctrl('x')), - "ctrl-y" => Some(Ctrl('y')), - "ctrl-z" => Some(Ctrl('z')), - "ctrl-up" => Some(CtrlUp), - "ctrl-down" => Some(CtrlDown), - "ctrl-left" => Some(CtrlLeft), - "ctrl-right" => Some(CtrlRight), - - "ctrl-alt-space" => Some(Ctrl(' ')), - "ctrl-alt-a" => Some(CtrlAlt('a')), - "ctrl-alt-b" => Some(CtrlAlt('b')), - "ctrl-alt-c" => Some(CtrlAlt('c')), - "ctrl-alt-d" => Some(CtrlAlt('d')), - "ctrl-alt-e" => Some(CtrlAlt('e')), - "ctrl-alt-f" => Some(CtrlAlt('f')), - "ctrl-alt-g" => Some(CtrlAlt('g')), - "ctrl-alt-h" => Some(CtrlAlt('h')), - "ctrl-alt-j" => Some(CtrlAlt('j')), - "ctrl-alt-k" => Some(CtrlAlt('k')), - "ctrl-alt-l" => Some(CtrlAlt('l')), - "ctrl-alt-n" => Some(CtrlAlt('n')), - "ctrl-alt-o" => Some(CtrlAlt('o')), - "ctrl-alt-p" => Some(CtrlAlt('p')), - "ctrl-alt-q" => Some(CtrlAlt('q')), - "ctrl-alt-r" => Some(CtrlAlt('r')), - "ctrl-alt-s" => Some(CtrlAlt('s')), - "ctrl-alt-t" => Some(CtrlAlt('t')), - "ctrl-alt-u" => Some(CtrlAlt('u')), - "ctrl-alt-v" => Some(CtrlAlt('v')), - "ctrl-alt-w" => Some(CtrlAlt('w')), - "ctrl-alt-x" => Some(CtrlAlt('x')), - "ctrl-alt-y" => Some(CtrlAlt('y')), - "ctrl-alt-z" => Some(CtrlAlt('z')), - - "esc" => Some(ESC), - "btab" | "shift-tab" => Some(BackTab), - "bspace" | "bs" => Some(Backspace), - "ins" | "insert" => Some(Insert), - "del" => Some(Delete), - "pgup" | "page-up" => Some(PageUp), - "pgdn" | "page-down" => Some(PageDown), - "up" => Some(Up), - "down" => Some(Down), - "left" => Some(Left), - "right" => Some(Right), - "home" => Some(Home), - "end" => Some(End), - "shift-up" => Some(ShiftUp), - "shift-down" => Some(ShiftDown), - "shift-left" => Some(ShiftLeft), - "shift-right" => Some(ShiftRight), - - "f1" => Some(F(1)), - "f2" => Some(F(2)), - "f3" => Some(F(3)), - "f4" => Some(F(4)), - "f5" => Some(F(5)), - "f6" => Some(F(6)), - "f7" => Some(F(7)), - "f8" => Some(F(8)), - "f9" => Some(F(9)), - "f10" => Some(F(10)), - "f11" => Some(F(11)), - "f12" => Some(F(12)), - - "alt-a" => Some(Alt('a')), - "alt-b" => Some(Alt('b')), - "alt-c" => Some(Alt('c')), - "alt-d" => Some(Alt('d')), - "alt-e" => Some(Alt('e')), - "alt-f" => Some(Alt('f')), - "alt-g" => Some(Alt('g')), - "alt-h" => Some(Alt('h')), - "alt-i" => Some(Alt('i')), - "alt-j" => Some(Alt('j')), - "alt-k" => Some(Alt('k')), - "alt-l" => Some(Alt('l')), - "alt-m" => Some(Alt('m')), - "alt-n" => Some(Alt('n')), - "alt-o" => Some(Alt('o')), - "alt-p" => Some(Alt('p')), - "alt-q" => Some(Alt('q')), - "alt-r" => Some(Alt('r')), - "alt-s" => Some(Alt('s')), - "alt-t" => Some(Alt('t')), - "alt-u" => Some(Alt('u')), - "alt-v" => Some(Alt('v')), - "alt-w" => Some(Alt('w')), - "alt-x" => Some(Alt('x')), - "alt-y" => Some(Alt('y')), - "alt-z" => Some(Alt('z')), - "alt-/" => Some(Alt('/')), - - "shift-a" => Some(Char('A')), - "shift-b" => Some(Char('B')), - "shift-c" => Some(Char('C')), - "shift-d" => Some(Char('D')), - "shift-e" => Some(Char('E')), - "shift-f" => Some(Char('F')), - "shift-g" => Some(Char('G')), - "shift-h" => Some(Char('H')), - "shift-i" => Some(Char('I')), - "shift-j" => Some(Char('J')), - "shift-k" => Some(Char('K')), - "shift-l" => Some(Char('L')), - "shift-m" => Some(Char('M')), - "shift-n" => Some(Char('N')), - "shift-o" => Some(Char('O')), - "shift-p" => Some(Char('P')), - "shift-q" => Some(Char('Q')), - "shift-r" => Some(Char('R')), - "shift-s" => Some(Char('S')), - "shift-t" => Some(Char('T')), - "shift-u" => Some(Char('U')), - "shift-v" => Some(Char('V')), - "shift-w" => Some(Char('W')), - "shift-x" => Some(Char('X')), - "shift-y" => Some(Char('Y')), - "shift-z" => Some(Char('Z')), - - "alt-shift-a" => Some(Alt('A')), - "alt-shift-b" => Some(Alt('B')), - "alt-shift-c" => Some(Alt('C')), - "alt-shift-d" => Some(Alt('D')), - "alt-shift-e" => Some(Alt('E')), - "alt-shift-f" => Some(Alt('F')), - "alt-shift-g" => Some(Alt('G')), - "alt-shift-h" => Some(Alt('H')), - "alt-shift-i" => Some(Alt('I')), - "alt-shift-j" => Some(Alt('J')), - "alt-shift-k" => Some(Alt('K')), - "alt-shift-l" => Some(Alt('L')), - "alt-shift-m" => Some(Alt('M')), - "alt-shift-n" => Some(Alt('N')), - "alt-shift-o" => Some(Alt('O')), - "alt-shift-p" => Some(Alt('P')), - "alt-shift-q" => Some(Alt('Q')), - "alt-shift-r" => Some(Alt('R')), - "alt-shift-s" => Some(Alt('S')), - "alt-shift-t" => Some(Alt('T')), - "alt-shift-u" => Some(Alt('U')), - "alt-shift-v" => Some(Alt('V')), - "alt-shift-w" => Some(Alt('W')), - "alt-shift-x" => Some(Alt('X')), - "alt-shift-y" => Some(Alt('Y')), - "alt-shift-z" => Some(Alt('Z')), - - "alt-btab" | "alt-shift-tab" => Some(AltBackTab), - "alt-bspace" | "alt-bs" => Some(AltBackspace), - "alt-pgup" | "alt-page-up" => Some(AltPageUp), - "alt-pgdn" | "alt-page-down" => Some(AltPageDown), - "alt-up" => Some(AltUp), - "alt-down" => Some(AltDown), - "alt-left" => Some(AltLeft), - "alt-right" => Some(AltRight), - "alt-home" => Some(AltHome), - "alt-end" => Some(AltEnd), - "alt-shift-up" => Some(AltShiftUp), - "alt-shift-down" => Some(AltShiftDown), - "alt-shift-left" => Some(AltShiftLeft), - "alt-shift-right" => Some(AltShiftRight), - "alt-enter" | "alt-ctrl-m" => Some(AltEnter), - "alt-tab" | "alt-ctrl-i" => Some(AltTab), - - "space" => Some(Char(' ')), - "alt-space" => Some(Alt(' ')), - - ch if ch.chars().count() == 1 => { - Some(Char(ch.chars().next().expect("input:parse_key: no key is specified"))) - }, - _ => None, - } -} - -#[cfg(test)] -mod test { - use super::Key::*; - use super::*; - - #[test] - fn bind_shift_key() { - // Without the "shift-" prefix, "from_keyname" ignores the case. - assert_eq!(from_keyname("A").unwrap(), Char('a')); - - // A correct way to refer to an uppercase char. - assert_eq!(from_keyname("shift-a").unwrap(), Char('A')); - } -} diff --git a/skim-tuikit/src/lib.rs b/skim-tuikit/src/lib.rs deleted file mode 100644 index f0bdfa9d..00000000 --- a/skim-tuikit/src/lib.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! -//! ## Tuikit -//! Tuikit is a TUI library for writing terminal UI applications. Highlights: -//! -//! - Thread safe. -//! - Support non-fullscreen mode as well as fullscreen mode. -//! - Support `Alt` keys, mouse events, etc. -//! - Buffering for efficient rendering. -//! -//! Tuikit is modeld after [termbox](https://github.com/nsf/termbox) which views the -//! terminal as a table of fixed-size cells and input being a stream of structured -//! messages. -//! -//! ## Usage -//! -//! In your `Cargo.toml` add the following: -//! -//! ```toml -//! [dependencies] -//! tuikit = "*" -//! ``` -//! -//! Here is an example: -//! -//! ```no_run -//! use skim_tuikit::attr::*; -//! use skim_tuikit::term::{Term, TermHeight}; -//! use skim_tuikit::event::{Event, Key}; -//! use std::cmp::{min, max}; -//! -//! let term: Term<()> = Term::with_height(TermHeight::Percent(30)).unwrap(); -//! let mut row = 1; -//! let mut col = 0; -//! -//! let _ = term.print(0, 0, "press arrow key to move the text, (q) to quit"); -//! let _ = term.present(); -//! -//! while let Ok(ev) = term.poll_event() { -//! let _ = term.clear(); -//! let _ = term.print(0, 0, "press arrow key to move the text, (q) to quit"); -//! -//! let (width, height) = term.term_size().unwrap(); -//! match ev { -//! Event::Key(Key::ESC) | Event::Key(Key::Char('q')) => break, -//! Event::Key(Key::Up) => row = max(row-1, 1), -//! Event::Key(Key::Down) => row = min(row+1, height-1), -//! Event::Key(Key::Left) => col = max(col, 1)-1, -//! Event::Key(Key::Right) => col = min(col+1, width-1), -//! _ => {} -//! } -//! -//! let attr = Attr{ fg: Color::RED, ..Attr::default() }; -//! let _ = term.print_with_attr(row, col, "Hello World! 你好!今日は。", attr); -//! let _ = term.set_cursor(row, col); -//! let _ = term.present(); -//! } -//! ``` -pub mod attr; -pub mod canvas; -pub mod cell; -mod color; -pub mod draw; -pub mod error; -pub mod event; -pub mod input; -pub mod key; -mod macros; -pub mod output; -pub mod prelude; -pub mod raw; -pub mod screen; -use skim_common::spinlock; -mod sys; -pub mod term; -pub mod widget; - -#[macro_use] -extern crate log; - -use crate::error::TuikitError; - -pub type Result = std::result::Result; diff --git a/skim-tuikit/src/macros.rs b/skim-tuikit/src/macros.rs deleted file mode 100644 index 33b7479c..00000000 --- a/skim-tuikit/src/macros.rs +++ /dev/null @@ -1,23 +0,0 @@ -#[macro_export] -macro_rules! ok_or_return { - ($expr:expr, $default_val:expr) => { - match $expr { - Ok(val) => val, - Err(_) => { - return $default_val; - } - } - }; -} - -#[macro_export] -macro_rules! some_or_return { - ($expr:expr, $default_val:expr) => { - match $expr { - Some(val) => val, - None => { - return $default_val; - } - } - }; -} diff --git a/skim-tuikit/src/output.rs b/skim-tuikit/src/output.rs deleted file mode 100644 index 497b8d2c..00000000 --- a/skim-tuikit/src/output.rs +++ /dev/null @@ -1,410 +0,0 @@ -//! `Output` is the output stream that deals with ANSI Escape codes. -//! normally you should not use it directly. -//! -//! ``` -//! use std::io; -//! use skim_tuikit::attr::Color; -//! use skim_tuikit::output::Output; -//! -//! let Ok(mut output) = Output::new(Box::new(io::stdout())) else { return; }; -//! output.set_fg(Color::YELLOW); -//! output.write("YELLOW\n"); -//! output.flush(); -//! -//! ``` - -use std::io; -use std::io::Write; -use std::os::unix::io::AsRawFd; - -use crate::attr::{Attr, Color, Effect}; -use crate::sys::size::terminal_size; - -use term::terminfo::TermInfo; -use term::terminfo::parm::{Param, Variables, expand}; - -// modeled after python-prompt-toolkit -// term info: https://ftp.netbsd.org/pub/NetBSD/NetBSD-release-7/src/share/terminfo/terminfo - -const DEFAULT_BUFFER_SIZE: usize = 1024; - -/// Output is an abstraction over the ANSI codes. -pub struct Output { - /// A callable which returns the `Size` of the output terminal. - buffer: Vec, - stdout: Box, - /// The terminal environment variable. (xterm, xterm-256color, linux, ...) - terminfo: TermInfo, -} - -pub trait WriteAndAsRawFdAndSend: Write + AsRawFd + Send {} - -impl WriteAndAsRawFdAndSend for T where T: Write + AsRawFd + Send {} - -impl Output { - pub fn new(stdout: Box) -> io::Result { - Result::Ok(Self { - buffer: Vec::with_capacity(DEFAULT_BUFFER_SIZE), - stdout, - terminfo: TermInfo::from_env()?, - }) - } - - fn write_cap(&mut self, cmd: &str) { - self.write_cap_with_params(cmd, &[]) - } - - fn write_cap_with_params(&mut self, cap: &str, params: &[Param]) { - if let Some(cmd) = self.terminfo.strings.get(cap) - && let Ok(s) = expand(cmd, params, &mut Variables::new()) - { - self.buffer.extend(&s); - } - } - - /// Write text (Terminal escape sequences will be removed/escaped.) - pub fn write(&mut self, data: &str) { - self.buffer.extend(data.replace("\x1b", "?").as_bytes()); - } - - /// Write raw texts to the terminal. - pub fn write_raw(&mut self, data: &[u8]) { - self.buffer.extend_from_slice(data); - } - - /// Return the encoding for this output, e.g. 'utf-8'. - /// (This is used mainly to know which characters are supported by the - /// output the data, so that the UI can provide alternatives, when - /// required.) - pub fn encoding(&self) -> &str { - unimplemented!() - } - - /// Set terminal title. - pub fn set_title(&mut self, title: &str) { - if self.terminfo.names.contains(&"linux".to_string()) - || self.terminfo.names.contains(&"eterm-color".to_string()) - { - return; - } - - let title = title.replace("\x1b", "").replace("\x07", ""); - self.write_raw(format!("\x1b]2;{title}\x07").as_bytes()); - } - - /// Clear title again. (or restore previous title.) - pub fn clear_title(&mut self) { - self.set_title(""); - } - - /// Write to output stream and flush. - pub fn flush(&mut self) { - let _ = self.stdout.write(&self.buffer); - self.buffer.clear(); - let _ = self.stdout.flush(); - } - - /// Erases the screen with the background colour and moves the cursor to home. - pub fn erase_screen(&mut self) { - self.write_cap("clear"); - } - - /// Go to the alternate screen buffer. (For full screen applications). - pub fn enter_alternate_screen(&mut self) { - self.write_cap("smcup"); - } - - /// Leave the alternate screen buffer. - pub fn quit_alternate_screen(&mut self) { - self.write_cap("rmcup"); - } - - /// Enable mouse. - pub fn enable_mouse_support(&mut self) { - self.write_raw("\x1b[?1000h".as_bytes()); - - // Enable urxvt Mouse mode. (For terminals that understand this.) - self.write_raw("\x1b[?1015h".as_bytes()); - - // Also enable Xterm SGR mouse mode. (For terminals that understand this.) - self.write_raw("\x1b[?1006h".as_bytes()); - - // Note: E.g. lxterminal understands 1000h, but not the urxvt or sgr extensions. - } - - /// Disable mouse. - pub fn disable_mouse_support(&mut self) { - self.write_raw("\x1b[?1000l".as_bytes()); - self.write_raw("\x1b[?1015l".as_bytes()); - self.write_raw("\x1b[?1006l".as_bytes()); - } - - /// Erases from the current cursor position to the end of the current line. - pub fn erase_end_of_line(&mut self) { - self.write_cap("el"); - } - - /// Erases the screen from the current line down to the bottom of the screen. - pub fn erase_down(&mut self) { - self.write_cap("ed"); - } - - /// Reset color and styling attributes. - pub fn reset_attributes(&mut self) { - self.write_cap("sgr0"); - } - - /// Set current foreground color - pub fn set_fg(&mut self, color: Color) { - match color { - Color::Default => { - self.write_raw("\x1b[39m".as_bytes()); - } - Color::AnsiValue(x) => { - self.write_cap_with_params("setaf", &[Param::Number(x as i32)]); - } - Color::Rgb(r, g, b) => { - self.write_raw(format!("\x1b[38;2;{r};{g};{b}m").as_bytes()); - } - } - } - - /// Set current background color - pub fn set_bg(&mut self, color: Color) { - match color { - Color::Default => { - self.write_raw("\x1b[49m".as_bytes()); - } - Color::AnsiValue(x) => { - self.write_cap_with_params("setab", &[Param::Number(x as i32)]); - } - Color::Rgb(r, g, b) => { - self.write_raw(format!("\x1b[48;2;{r};{g};{b}m").as_bytes()); - } - } - } - - /// Set current effect (underline, bold, etc) - pub fn set_effect(&mut self, effect: Effect) { - if effect.contains(Effect::BOLD) { - self.write_cap("bold"); - } - if effect.contains(Effect::DIM) { - self.write_cap("dim"); - } - if effect.contains(Effect::UNDERLINE) { - self.write_cap("smul"); - } - if effect.contains(Effect::BLINK) { - self.write_cap("blink"); - } - if effect.contains(Effect::REVERSE) { - self.write_cap("rev"); - } - } - - /// Set new color and styling attributes. - pub fn set_attribute(&mut self, attr: Attr) { - self.set_fg(attr.fg); - self.set_bg(attr.bg); - self.set_effect(attr.effect); - } - - /// Disable auto line wrapping. - pub fn disable_autowrap(&mut self) { - self.write_cap("rmam"); - } - - /// Enable auto line wrapping. - pub fn enable_autowrap(&mut self) { - self.write_cap("smam"); - } - - /// Move cursor position. - pub fn cursor_goto(&mut self, row: usize, column: usize) { - self.write_cap_with_params("cup", &[Param::Number(row as i32), Param::Number(column as i32)]); - } - - /// Move cursor `amount` place up. - pub fn cursor_up(&mut self, amount: usize) { - match amount { - 0 => {} - 1 => self.write_cap("cuu1"), - _ => self.write_cap_with_params("cuu", &[Param::Number(amount as i32)]), - } - } - - /// Move cursor `amount` place down. - pub fn cursor_down(&mut self, amount: usize) { - match amount { - 0 => {} - 1 => self.write_cap("cud1"), - _ => self.write_cap_with_params("cud", &[Param::Number(amount as i32)]), - } - } - - /// Move cursor `amount` place forward. - pub fn cursor_forward(&mut self, amount: usize) { - match amount { - 0 => {} - 1 => self.write_cap("cuf1"), - _ => self.write_cap_with_params("cuf", &[Param::Number(amount as i32)]), - } - } - - /// Move cursor `amount` place backward. - pub fn cursor_backward(&mut self, amount: usize) { - match amount { - 0 => {} - 1 => self.write_cap("cub1"), - _ => self.write_cap_with_params("cub", &[Param::Number(amount as i32)]), - } - } - - /// Hide cursor. - pub fn hide_cursor(&mut self) { - self.write_cap("civis"); - } - - /// Show cursor. - pub fn show_cursor(&mut self) { - self.write_cap("cnorm"); - } - - /// Asks for a cursor position report (CPR). (VT100 only.) - pub fn ask_for_cpr(&mut self) { - self.write_raw("\x1b[6n".as_bytes()); - self.flush() - } - - /// Sound bell. - pub fn bell(&mut self) { - self.write_cap("bel"); - self.flush() - } - - /// get terminal size (width, height) - pub fn terminal_size(&self) -> io::Result<(usize, usize)> { - terminal_size(self.stdout.as_raw_fd()) - } - - /// For vt100/xterm etc. - pub fn enable_bracketed_paste(&mut self) { - self.write_raw("\x1b[?2004h".as_bytes()); - } - - /// For vt100/xterm etc. - pub fn disable_bracketed_paste(&mut self) { - self.write_raw("\x1b[?2004l".as_bytes()); - } - - /// Execute the command - pub fn execute(&mut self, cmd: Command) { - match cmd { - Command::PutChar(c) => self.write(c.to_string().as_str()), - Command::Write(content) => self.write(&content), - Command::SetTitle(title) => self.set_title(&title), - Command::ClearTitle => self.clear_title(), - Command::Flush => self.flush(), - Command::EraseScreen => self.erase_screen(), - Command::AlternateScreen(enable) => { - if enable { - self.enter_alternate_screen() - } else { - self.quit_alternate_screen() - } - } - Command::MouseSupport(enable) => { - if enable { - self.enable_mouse_support(); - } else { - self.disable_mouse_support(); - } - } - Command::EraseEndOfLine => self.erase_end_of_line(), - Command::EraseDown => self.erase_down(), - Command::ResetAttributes => self.reset_attributes(), - Command::Fg(fg) => self.set_fg(fg), - Command::Bg(bg) => self.set_bg(bg), - Command::Effect(effect) => self.set_effect(effect), - Command::SetAttribute(attr) => self.set_attribute(attr), - Command::AutoWrap(enable) => { - if enable { - self.enable_autowrap(); - } else { - self.disable_autowrap(); - } - } - Command::CursorGoto { row, col } => self.cursor_goto(row, col), - Command::CursorUp(amount) => self.cursor_up(amount), - Command::CursorDown(amount) => self.cursor_down(amount), - Command::CursorLeft(amount) => self.cursor_backward(amount), - Command::CursorRight(amount) => self.cursor_forward(amount), - Command::CursorShow(show) => { - if show { - self.show_cursor() - } else { - self.hide_cursor() - } - } - Command::BracketedPaste(enable) => { - if enable { - self.enable_bracketed_paste() - } else { - self.disable_bracketed_paste() - } - } - } - } -} - -/// Instead of calling functions of `Output`, we could send commands. -#[derive(Debug, Clone)] -pub enum Command { - /// Put a char to screen - PutChar(char), - /// Write content to screen (escape codes will be escaped) - Write(String), - /// Set the title of the terminal - SetTitle(String), - /// Clear the title of the terminal - ClearTitle, - /// Flush all the buffered contents - Flush, - /// Erase the entire screen - EraseScreen, - /// Enter(true)/Quit(false) the alternate screen mode - AlternateScreen(bool), - /// Enable(true)/Disable(false) mouse support - MouseSupport(bool), - /// Erase contents to the end of current line - EraseEndOfLine, - /// Erase contents till the bottom of the screen - EraseDown, - /// Reset attributes - ResetAttributes, - /// Set the foreground color - Fg(Color), - /// Set the background color - Bg(Color), - /// Set the effect(e.g. underline, dim, bold, ...) - Effect(Effect), - /// Set the fg, bg & effect. - SetAttribute(Attr), - /// Enable(true)/Disable(false) autowrap - AutoWrap(bool), - /// move the cursor to `(row, col)` - CursorGoto { row: usize, col: usize }, - /// move cursor up `x` lines - CursorUp(usize), - /// move cursor down `x` lines - CursorDown(usize), - /// move cursor left `x` characters - CursorLeft(usize), - /// move cursor right `x` characters - CursorRight(usize), - /// Show(true)/Hide(false) cursor - CursorShow(bool), - /// Enable(true)/Disable(false) the bracketed paste mode - BracketedPaste(bool), -} diff --git a/skim-tuikit/src/prelude.rs b/skim-tuikit/src/prelude.rs deleted file mode 100644 index dd051869..00000000 --- a/skim-tuikit/src/prelude.rs +++ /dev/null @@ -1,11 +0,0 @@ -pub use crate::Result; -pub use crate::attr::{Attr, Color, Effect}; -pub use crate::canvas::Canvas; -pub use crate::cell::Cell; -pub use crate::draw::{Draw, DrawResult}; -pub use crate::event::Event; -pub use crate::key::*; -pub use crate::term::{Term, TermHeight, TermOptions}; -pub use crate::widget::{ - AlignSelf, HSplit, HorizontalAlign, Rectangle, Size, Split, Stack, VSplit, VerticalAlign, Widget, Win, -}; diff --git a/skim-tuikit/src/raw.rs b/skim-tuikit/src/raw.rs deleted file mode 100644 index 0eb51e82..00000000 --- a/skim-tuikit/src/raw.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! Managing raw mode. -//! -//! Raw mode is a particular state a TTY can have. It signifies that: -//! -//! 1. No line buffering (the input is given byte-by-byte). -//! 2. The input is not written out, instead it has to be done manually by the programmer. -//! 3. The output is not canonicalized (for example, `\n` means "go one line down", not "line -//! break"). -//! -//! # Example -//! -//! ```rust,no_run -//! use skim_tuikit::raw::IntoRawMode; -//! use std::io::{Write, stdout}; -//! -//! let mut stdout = stdout().into_raw_mode().unwrap(); -//! -//! write!(stdout, "Hey there.").unwrap(); -//! ``` - -use std::io::{self, Write}; -use std::ops; -use std::os::fd::{AsFd, AsRawFd, BorrowedFd}; - -use nix::sys::termios::{SetArg, Termios, cfmakeraw, tcgetattr, tcsetattr}; -use nix::unistd::isatty; -use std::fs; -use std::os::unix::io::RawFd; - -// taken from termion -/// Get the TTY device. -/// -/// This allows for getting stdio representing _only_ the TTY, and not other streams. -pub fn get_tty() -> io::Result { - fs::OpenOptions::new().read(true).write(true).open("/dev/tty") -} - -/// A terminal restorer, which keeps the previous state of the terminal, and restores it, when -/// dropped. -/// -/// Restoring will entirely bring back the old TTY state. -pub struct RawTerminal { - prev_ios: Termios, - output: W, -} - -impl Drop for RawTerminal { - fn drop(&mut self) { - let _ = tcsetattr(self.output.as_fd(), SetArg::TCSANOW, &self.prev_ios); - } -} - -impl ops::Deref for RawTerminal { - type Target = W; - - fn deref(&self) -> &W { - &self.output - } -} - -impl ops::DerefMut for RawTerminal { - fn deref_mut(&mut self) -> &mut W { - &mut self.output - } -} - -impl Write for RawTerminal { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.output.write(buf) - } - - fn flush(&mut self) -> io::Result<()> { - self.output.flush() - } -} - -impl AsFd for RawTerminal { - fn as_fd(&self) -> BorrowedFd<'_> { - self.output.as_fd() - } -} - -impl AsRawFd for RawTerminal { - fn as_raw_fd(&self) -> RawFd { - self.output.as_raw_fd() - } -} - -/// Types which can be converted into "raw mode". -/// -/// # Why is this type defined on writers and not readers? -/// -/// TTYs has their state controlled by the writer, not the reader. You use the writer to clear the -/// screen, move the cursor and so on, so naturally you use the writer to change the mode as well. -pub trait IntoRawMode: Write + AsFd + AsRawFd + Sized { - /// Switch to raw mode. - /// - /// Raw mode means that stdin won't be printed (it will instead have to be written manually by - /// the program). Furthermore, the input isn't canonicalised or buffered (that is, you can - /// read from stdin one byte of a time). The output is neither modified in any way. - fn into_raw_mode(self) -> io::Result>; -} - -impl IntoRawMode for W { - // modified after https://github.com/kkawakam/rustyline/blob/master/src/tty/unix.rs#L668 - // refer: https://linux.die.net/man/3/termios - fn into_raw_mode(self) -> io::Result> { - use nix::errno::Errno::ENOTTY; - use nix::sys::termios::OutputFlags; - - let istty = isatty(self.as_raw_fd()).map_err(nix_err_to_io_err)?; - if !istty { - Err(nix_err_to_io_err(ENOTTY))? - } - - let prev_ios = tcgetattr(self.as_fd()).map_err(nix_err_to_io_err)?; - let mut ios = prev_ios.clone(); - // set raw mode - cfmakeraw(&mut ios); - // enable output processing (so that '\n' will issue carriage return) - ios.output_flags |= OutputFlags::OPOST; - - tcsetattr(&self, SetArg::TCSANOW, &ios).map_err(nix_err_to_io_err)?; - - Ok(RawTerminal { prev_ios, output: self }) - } -} - -fn nix_err_to_io_err(err: nix::Error) -> io::Error { - io::Error::from(err) -} diff --git a/skim-tuikit/src/screen.rs b/skim-tuikit/src/screen.rs deleted file mode 100644 index 14ddaeb6..00000000 --- a/skim-tuikit/src/screen.rs +++ /dev/null @@ -1,387 +0,0 @@ -//! Buffering screen cells and try to optimize rendering contents -use crate::Result; -use crate::attr::Attr; -use crate::canvas::Canvas; -use crate::cell::Cell; -use crate::error::TuikitError; -use crate::output::Command; -use std::cmp::{max, min}; -use unicode_width::UnicodeWidthChar; - -// much of the code comes from https://github.com/agatan/termfest/blob/master/src/screen.rs - -/// A Screen is a table of cells to draw on. -/// It's a buffer holding the contents -#[derive(Debug)] -pub struct Screen { - width: usize, - height: usize, - cursor: Cursor, - cells: Vec, - painted_cells: Vec, - painted_cursor: Cursor, - clear_on_start: bool, -} - -impl Screen { - /// create an empty screen with size: (width, height) - pub fn new(width: usize, height: usize) -> Self { - Self { - width, - height, - cells: vec![Cell::default(); width * height], - cursor: Cursor::default(), - painted_cells: vec![Cell::default(); width * height], - painted_cursor: Cursor::default(), - clear_on_start: false, - } - } - - pub fn clear_on_start(&mut self, clear_on_start: bool) { - self.clear_on_start = clear_on_start; - } - - /// get the width of the screen - #[inline] - pub fn width(&self) -> usize { - self.width - } - - /// get the height of the screen - #[inline] - pub fn height(&self) -> usize { - self.height - } - - #[inline] - fn index(&self, row: usize, col: usize) -> Result { - if row >= self.height || col >= self.width { - Err(TuikitError::IndexOutOfBound(row, col)) - } else { - Ok(row * self.width + col) - } - } - - fn empty_canvas(&self, width: usize, height: usize) -> Vec { - vec![Cell::empty(); width * height] - } - - fn copy_cells(&self, original: &[Cell], width: usize, height: usize) -> Vec { - let mut new_cells = self.empty_canvas(width, height); - use std::cmp; - let min_height = cmp::min(height, self.height); - let min_width = cmp::min(width, self.width); - for row in 0..min_height { - let orig_start = row * self.width; - let orig_end = min_width + orig_start; - let start = row * width; - let end = min_width + start; - new_cells[start..end].copy_from_slice(&original[orig_start..orig_end]); - } - new_cells - } - - /// to resize the screen to `(width, height)` - pub fn resize(&mut self, width: usize, height: usize) { - self.cells = self.copy_cells(&self.cells, width, height); - self.painted_cells = self.empty_canvas(width, height); - self.width = width; - self.height = height; - - self.cursor.row = min(self.cursor.row, height); - self.cursor.col = min(self.cursor.col, width); - } - - /// sync internal buffer with the terminal - pub fn present(&mut self) -> Vec { - let mut commands = Vec::with_capacity(2048); - let default_attr = Attr::default(); - let mut last_attr = default_attr; - - // hide cursor && reset Attributes - commands.push(Command::CursorShow(false)); - commands.push(Command::CursorGoto { row: 0, col: 0 }); - commands.push(Command::ResetAttributes); - - let mut last_cursor = Cursor::default(); - - for row in 0..self.height { - // calculate the last col that has contents - let mut empty_col_index = 0; - for col in (0..self.width).rev() { - let index = self.index(row, col).unwrap(); - let cell = &self.cells[index]; - if cell.is_empty() { - self.painted_cells[index] = *cell; - } else { - empty_col_index = col + 1; - break; - } - } - - // compare cells and print necessary escape codes - let mut last_ch_is_wide = false; - for col in 0..empty_col_index { - let index = self.index(row, col).unwrap(); - - // advance if the last character is wide - if last_ch_is_wide { - last_ch_is_wide = false; - self.painted_cells[index] = self.cells[index]; - continue; - } - - let cell_to_paint = self.cells[index]; - let cell_painted = self.painted_cells[index]; - - // no need to paint if the content did not change - if cell_to_paint == cell_painted { - continue; - } - - // move cursor if necessary - if last_cursor.row != row || last_cursor.col != col { - commands.push(Command::CursorGoto { row, col }); - } - - if cell_to_paint.attr != last_attr { - commands.push(Command::ResetAttributes); - commands.push(Command::SetAttribute(cell_to_paint.attr)); - last_attr = cell_to_paint.attr; - } - - // correctly draw the characters - match cell_to_paint.ch { - '\n' | '\r' | '\t' | '\0' => { - commands.push(Command::PutChar(' ')); - } - _ => { - commands.push(Command::PutChar(cell_to_paint.ch)); - } - } - - let display_width = cell_to_paint.ch.width().unwrap_or(2); - - // wide character - if display_width == 2 { - last_ch_is_wide = true; - } - - last_cursor.row = row; - last_cursor.col = col + display_width; - self.painted_cells[index] = cell_to_paint; - } - - if empty_col_index != self.width { - commands.push(Command::CursorGoto { - row, - col: empty_col_index, - }); - commands.push(Command::ResetAttributes); - if self.clear_on_start { - commands.push(Command::EraseEndOfLine); - } - last_attr = Attr::default(); - } - } - - // restore cursor - commands.push(Command::CursorGoto { - row: self.cursor.row, - col: self.cursor.col, - }); - if self.cursor.visible { - commands.push(Command::CursorShow(true)); - } - - self.painted_cursor = self.cursor; - - commands - } - - /// ``` - /// use skim_tuikit::cell::Cell; - /// use skim_tuikit::canvas::Canvas; - /// use skim_tuikit::screen::Screen; - /// - /// - /// let mut screen = Screen::new(1, 1); - /// screen.put_cell(0, 0, Cell{ ch: 'a', ..Cell::default()}); - /// let mut iter = screen.iter_cell(); - /// assert_eq!(Some((0, 0, &Cell{ ch: 'a', ..Cell::default()})), iter.next()); - /// assert_eq!(None, iter.next()); - /// ``` - pub fn iter_cell(&self) -> CellIterator<'_> { - CellIterator { - width: self.width, - index: 0, - vec: &self.cells, - } - } -} - -impl Canvas for Screen { - /// Get the canvas size (width, height) - fn size(&self) -> Result<(usize, usize)> { - Ok((self.width(), self.height())) - } - - /// clear the screen buffer - fn clear(&mut self) -> Result<()> { - for cell in self.cells.iter_mut() { - *cell = Cell::empty(); - } - Ok(()) - } - - /// change a cell of position `(row, col)` to `cell` - fn put_cell(&mut self, row: usize, col: usize, cell: Cell) -> Result { - let ch_width = cell.ch.width().unwrap_or(2); - if ch_width > 1 { - let _ = self.index(row, col + 1).map(|index| { - self.cells[index - 1] = cell; - self.cells[index].ch = ' '; - }); - } else { - let _ = self.index(row, col).map(|index| { - self.cells[index] = cell; - }); - } - Ok(ch_width) - } - - /// move cursor position (row, col) and show cursor - fn set_cursor(&mut self, row: usize, col: usize) -> Result<()> { - self.cursor.row = min(row, max(self.height, 1) - 1); - self.cursor.col = min(col, max(self.width, 1) - 1); - self.cursor.visible = true; - Ok(()) - } - - /// show/hide cursor, set `show` to `false` to hide the cursor - fn show_cursor(&mut self, show: bool) -> Result<()> { - self.cursor.visible = show; - Ok(()) - } -} - -pub struct CellIterator<'a> { - width: usize, - index: usize, - vec: &'a Vec, -} - -impl<'a> Iterator for CellIterator<'a> { - type Item = (usize, usize, &'a Cell); - - fn next(&mut self) -> Option { - if self.index >= self.vec.len() { - return None; - } - - let (row, col) = (self.index / self.width, self.index % self.width); - let ret = self.vec.get(self.index).map(|cell| (row, col, cell)); - self.index += 1; - ret - } -} - -#[derive(Debug, Clone, Copy, Default)] -struct Cursor { - pub row: usize, - pub col: usize, - visible: bool, -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_cell_iterator() { - let mut screen = Screen::new(2, 2); - let _ = screen.put_cell( - 0, - 0, - Cell { - ch: 'a', - attr: Attr::default(), - }, - ); - let _ = screen.put_cell( - 0, - 1, - Cell { - ch: 'b', - attr: Attr::default(), - }, - ); - let _ = screen.put_cell( - 1, - 0, - Cell { - ch: 'c', - attr: Attr::default(), - }, - ); - let _ = screen.put_cell( - 1, - 1, - Cell { - ch: 'd', - attr: Attr::default(), - }, - ); - - let mut iter = screen.iter_cell(); - assert_eq!( - Some(( - 0, - 0, - &Cell { - ch: 'a', - attr: Attr::default() - } - )), - iter.next() - ); - assert_eq!( - Some(( - 0, - 1, - &Cell { - ch: 'b', - attr: Attr::default() - } - )), - iter.next() - ); - assert_eq!( - Some(( - 1, - 0, - &Cell { - ch: 'c', - attr: Attr::default() - } - )), - iter.next() - ); - assert_eq!( - Some(( - 1, - 1, - &Cell { - ch: 'd', - attr: Attr::default() - } - )), - iter.next() - ); - assert_eq!(None, iter.next()); - - let empty_screen = Screen::new(0, 0); - let mut empty_iter = empty_screen.iter_cell(); - assert_eq!(None, empty_iter.next()); - } -} diff --git a/skim-tuikit/src/sys/file.rs b/skim-tuikit/src/sys/file.rs deleted file mode 100644 index 0d903137..00000000 --- a/skim-tuikit/src/sys/file.rs +++ /dev/null @@ -1,36 +0,0 @@ -use crate::Result; -use std::os::fd::BorrowedFd; -use std::time::Duration; - -use crate::error::TuikitError; -use nix::sys::select; -use nix::sys::time::{TimeVal, TimeValLike}; - -fn duration_to_timeval(duration: Duration) -> TimeVal { - let sec = duration.as_secs() * 1000 + (duration.subsec_millis() as u64); - TimeVal::milliseconds(sec as i64) -} - -pub fn wait_until_ready(fd: BorrowedFd, signal_fd: Option, timeout: Duration) -> Result<()> { - let mut timeout_spec = if timeout == Duration::new(0, 0) { - None - } else { - Some(duration_to_timeval(timeout)) - }; - - let mut fdset = select::FdSet::new(); - fdset.insert(fd); - - if let Some(f) = signal_fd { - fdset.insert(f); - } - let n = select::select(None, &mut fdset, None, None, &mut timeout_spec)?; - - if n < 1 { - Err(TuikitError::Timeout(timeout)) // this error message will be used in input.rs - } else if fdset.contains(fd) { - Ok(()) - } else { - Err(TuikitError::Interrupted) - } -} diff --git a/skim-tuikit/src/sys/mod.rs b/skim-tuikit/src/sys/mod.rs deleted file mode 100644 index acc7f30b..00000000 --- a/skim-tuikit/src/sys/mod.rs +++ /dev/null @@ -1,27 +0,0 @@ -// copy from https://docs.rs/crate/termion/1.5.1/source/src/sys/unix/mod.rs -use std::io; -pub mod file; -pub mod signal; -pub mod size; - -trait IsMinusOne { - fn is_minus_one(&self) -> bool; -} - -macro_rules! impl_is_minus_one { - ($($t:ident)*) => ($(impl IsMinusOne for $t { - fn is_minus_one(&self) -> bool { - *self == -1 - } - })*) - } - -impl_is_minus_one! { i8 i16 i32 i64 isize } - -fn cvt(t: T) -> io::Result { - if t.is_minus_one() { - Err(io::Error::last_os_error()) - } else { - Ok(t) - } -} diff --git a/skim-tuikit/src/sys/signal.rs b/skim-tuikit/src/sys/signal.rs deleted file mode 100644 index d78307c4..00000000 --- a/skim-tuikit/src/sys/signal.rs +++ /dev/null @@ -1,68 +0,0 @@ -use lazy_static::lazy_static; -use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, SigmaskHow, Signal}; -use nix::sys::signal::{pthread_sigmask, sigaction}; -use std::collections::HashMap; -use std::sync::Mutex; -use std::sync::Once; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::mpsc::{Receiver, Sender, channel}; -use std::thread; - -lazy_static! { - static ref NOTIFIER_COUNTER: AtomicUsize = AtomicUsize::new(1); - static ref NOTIFIER: Mutex>> = Mutex::new(HashMap::new()); -} - -static ONCE: Once = Once::new(); - -pub fn initialize_signals() { - ONCE.call_once(listen_sigwinch); -} - -pub fn notify_on_sigwinch() -> (usize, Receiver<()>) { - let (tx, rx) = channel(); - let new_id = NOTIFIER_COUNTER.fetch_add(1, Ordering::Relaxed); - let mut notifiers = NOTIFIER.lock().unwrap(); - notifiers.entry(new_id).or_insert(tx); - (new_id, rx) -} - -pub fn unregister_sigwinch(id: usize) -> Option> { - let mut notifiers = NOTIFIER.lock().unwrap(); - notifiers.remove(&id) -} - -extern "C" fn handle_sigwiwnch(_: i32) {} - -fn listen_sigwinch() { - let (tx_sig, rx_sig) = channel(); - - // register terminal resize event, `pthread_sigmask` should be run before any thread. - let mut sigset = SigSet::empty(); - sigset.add(Signal::SIGWINCH); - let _ = pthread_sigmask(SigmaskHow::SIG_BLOCK, Some(&sigset), None); - - // SIGWINCH is ignored by mac by default, thus we need to register an empty handler - let action = SigAction::new(SigHandler::Handler(handle_sigwiwnch), SaFlags::empty(), SigSet::empty()); - - unsafe { - let _ = sigaction(Signal::SIGWINCH, &action); - } - - thread::spawn(move || { - // listen to the resize event; - loop { - let _errno = sigset.wait(); - let _ = tx_sig.send(()); - } - }); - - thread::spawn(move || { - while rx_sig.recv().is_ok() { - let notifiers = NOTIFIER.lock().unwrap(); - for (_, sender) in notifiers.iter() { - let _ = sender.send(()); - } - } - }); -} diff --git a/skim-tuikit/src/sys/size.rs b/skim-tuikit/src/sys/size.rs deleted file mode 100644 index c661e8f4..00000000 --- a/skim-tuikit/src/sys/size.rs +++ /dev/null @@ -1,21 +0,0 @@ -use std::{io, mem}; - -use super::cvt; -use nix::libc::{TIOCGWINSZ, c_int, c_ushort, ioctl}; - -#[repr(C)] -struct TermSize { - row: c_ushort, - col: c_ushort, - _x: c_ushort, - _y: c_ushort, -} - -/// Get the size of the terminal. -pub fn terminal_size(fd: c_int) -> io::Result<(usize, usize)> { - unsafe { - let mut size: TermSize = mem::zeroed(); - cvt(ioctl(fd, TIOCGWINSZ, &mut size as *mut _))?; - Ok((size.col as usize, size.row as usize)) - } -} diff --git a/skim-tuikit/src/term.rs b/skim-tuikit/src/term.rs deleted file mode 100644 index 3062b736..00000000 --- a/skim-tuikit/src/term.rs +++ /dev/null @@ -1,838 +0,0 @@ -//! Term is a thread-safe "terminal". -//! -//! It allows you to: -//! - Listen to key stroke events -//! - Output contents to the terminal -//! -//! ```no_run -//! use skim_tuikit::prelude::*; -//! -//! let term = Term::<()>::new().unwrap(); -//! -//! while let Ok(ev) = term.poll_event() { -//! if let Event::Key(Key::Char('q')) = ev { -//! break; -//! } -//! -//! term.print(0, 0, format!("got event: {:?}", ev).as_str()); -//! term.present(); -//! } -//! ``` -//! -//! Term is modeled after [termbox](https://github.com/nsf/termbox). The main idea is viewing -//! terminals as a table of fixed-size cells and input being a stream of structured messages - -use std::cmp::{max, min}; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::mpsc::{Receiver, Sender, channel}; -use std::thread; -use std::time::Duration; - -use crate::Result; -use crate::attr::Attr; -use crate::canvas::Canvas; -use crate::cell::Cell; -use crate::draw::Draw; -use crate::error::TuikitError; -use crate::event::Event; -use crate::input::{KeyBoard, KeyboardHandler}; -use crate::key::Key; -use crate::output::Command; -use crate::output::Output; -use crate::raw::{IntoRawMode, get_tty}; -use crate::screen::Screen; -use crate::spinlock::SpinLock; -use crate::sys::signal::{initialize_signals, notify_on_sigwinch, unregister_sigwinch}; - -const MIN_HEIGHT: usize = 1; -const WAIT_TIMEOUT: Duration = Duration::from_millis(300); -const POLLING_TIMEOUT: Duration = Duration::from_millis(10); - -#[derive(Debug, Copy, Clone)] -pub enum TermHeight { - Fixed(usize), - Percent(usize), -} - -pub struct Term { - components_to_stop: Arc, - keyboard_handler: SpinLock>, - resize_signal_id: Arc, - term_lock: SpinLock, - event_rx: SpinLock>>, - event_tx: Arc>>>, - raw_mouse: bool, // to produce raw mouse event or the parsed event(e.g. DoubleClick) -} - -pub struct TermOptions { - max_height: TermHeight, - min_height: TermHeight, - height: TermHeight, - clear_on_exit: bool, - clear_on_start: bool, - mouse_enabled: bool, - raw_mouse: bool, - hold: bool, // to start term or not on creation - disable_alternate_screen: bool, -} - -impl Default for TermOptions { - fn default() -> Self { - Self { - max_height: TermHeight::Percent(100), - min_height: TermHeight::Fixed(3), - height: TermHeight::Percent(100), - clear_on_exit: true, - clear_on_start: true, - mouse_enabled: false, - raw_mouse: false, - hold: false, - disable_alternate_screen: false, - } - } -} - -// Builder -impl TermOptions { - pub fn max_height(mut self, max_height: TermHeight) -> Self { - self.max_height = max_height; - self - } - - pub fn min_height(mut self, min_height: TermHeight) -> Self { - self.min_height = min_height; - self - } - pub fn height(mut self, height: TermHeight) -> Self { - self.height = height; - self - } - pub fn clear_on_exit(mut self, clear: bool) -> Self { - self.clear_on_exit = clear; - self - } - pub fn clear_on_start(mut self, clear: bool) -> Self { - self.clear_on_start = clear; - self - } - pub fn mouse_enabled(mut self, enabled: bool) -> Self { - self.mouse_enabled = enabled; - self - } - pub fn raw_mouse(mut self, enabled: bool) -> Self { - self.raw_mouse = enabled; - self - } - pub fn hold(mut self, hold: bool) -> Self { - self.hold = hold; - self - } - pub fn disable_alternate_screen(mut self, disable_alternate_screen: bool) -> Self { - self.disable_alternate_screen = disable_alternate_screen; - self - } -} - -impl Term { - /// Create a Term with height specified. - /// - /// Internally if the calculated height would fill the whole screen, `Alternate Screen` will - /// be enabled, otherwise only part of the screen will be used. - /// - /// If the preferred height is larger than the current screen, whole screen is used. - /// - /// ```no_run - /// use skim_tuikit::term::{Term, TermHeight}; - /// - /// let term: Term<()> = Term::with_height(TermHeight::Percent(30)).unwrap(); // 30% of the terminal height - /// let term: Term<()> = Term::with_height(TermHeight::Fixed(20)).unwrap(); // fixed 20 lines - /// ``` - pub fn with_height(height: TermHeight) -> Result> { - Term::with_options(TermOptions::default().height(height)) - } - - /// Create a Term (with 100% height) - /// - /// ```no_run - /// use skim_tuikit::term::{Term, TermHeight}; - /// - /// let term: Term<()> = Term::new().unwrap(); - /// let term: Term<()> = Term::with_height(TermHeight::Percent(100)).unwrap(); - /// ``` - pub fn new() -> Result> { - Term::with_options(TermOptions::default()) - } - - /// Create a Term with custom options - /// - /// ```no_run - /// use skim_tuikit::term::{Term, TermHeight, TermOptions}; - /// - /// let term: Term<()> = Term::with_options(TermOptions::default().height(TermHeight::Percent(100))).unwrap(); - /// ``` - pub fn with_options(options: TermOptions) -> Result> { - initialize_signals(); - - let (event_tx, event_rx) = channel(); - let raw_mouse = options.raw_mouse; - let ret = Term { - components_to_stop: Arc::new(AtomicUsize::new(0)), - keyboard_handler: SpinLock::new(None), - resize_signal_id: Arc::new(AtomicUsize::new(0)), - term_lock: SpinLock::new(TermLock::with_options(&options)), - event_tx: Arc::new(SpinLock::new(event_tx)), - event_rx: SpinLock::new(event_rx), - raw_mouse, - }; - if options.hold { - Ok(ret) - } else { - ret.restart().map(|_| ret) - } - } - - fn ensure_not_stopped(&self) -> Result<()> { - if self.components_to_stop.load(Ordering::SeqCst) == 2 { - Ok(()) - } else { - Err(TuikitError::TerminalNotStarted) - } - } - - fn get_cursor_pos(&self, keyboard: &mut KeyBoard, output: &mut Output) -> Result<(usize, usize)> { - output.ask_for_cpr(); - - if let Ok(Key::CursorPos(row, col)) = keyboard.next_key_timeout(WAIT_TIMEOUT) { - return Ok((row as usize, col as usize)); - } - - Ok((0, 0)) - } - - /// restart the terminal if it had been stopped - pub fn restart(&self) -> Result<()> { - let mut termlock = self.term_lock.lock(); - if self.components_to_stop.load(Ordering::SeqCst) == 2 { - return Ok(()); - } - - let ttyout = get_tty()?.into_raw_mode()?; - let mut output = Output::new(Box::new(ttyout))?; - let mut keyboard = KeyBoard::new_with_tty().raw_mouse(self.raw_mouse); - self.keyboard_handler.lock().replace(keyboard.get_interrupt_handler()); - let cursor_pos = self.get_cursor_pos(&mut keyboard, &mut output)?; - termlock.restart(output, cursor_pos)?; - - // start two listener - self.start_key_listener(keyboard); - self.start_size_change_listener(); - - // wait for components to start - while self.components_to_stop.load(Ordering::SeqCst) < 2 { - debug!( - "restart: components: {}", - self.components_to_stop.load(Ordering::SeqCst) - ); - thread::sleep(POLLING_TIMEOUT); - } - - let event_tx = self.event_tx.lock(); - let _ = event_tx.send(Event::Restarted); - - Ok(()) - } - - /// Pause the Term - /// - /// This function will cause the Term to give away the control to the terminal(such as listening - /// to the key strokes). After the Term was "paused", `poll_event` will block indefinitely and - /// recover after the Term was `restart`ed. - pub fn pause(&self) -> Result<()> { - self.pause_internal(false) - } - - fn pause_internal(&self, exiting: bool) -> Result<()> { - debug!("pause"); - let mut termlock = self.term_lock.lock(); - - if self.components_to_stop.load(Ordering::SeqCst) == 0 { - return Ok(()); - } - - // wait for the components to stop - // i.e. key_listener & size_change_listener - if let Some(h) = self.keyboard_handler.lock().take() { - h.interrupt() - } - unregister_sigwinch(self.resize_signal_id.load(Ordering::Relaxed)).map(|tx| tx.send(())); - - termlock.pause(exiting)?; - - // wait for the components to stop - while self.components_to_stop.load(Ordering::SeqCst) > 0 { - debug!("pause: components: {}", self.components_to_stop.load(Ordering::SeqCst)); - thread::sleep(POLLING_TIMEOUT); - } - - Ok(()) - } - - fn start_key_listener(&self, mut keyboard: KeyBoard) { - let event_tx_clone = self.event_tx.clone(); - let components_to_stop = self.components_to_stop.clone(); - thread::spawn(move || { - components_to_stop.fetch_add(1, Ordering::SeqCst); - debug!("key listener start"); - loop { - let next_key = keyboard.next_key(); - trace!("next key: {next_key:?}"); - match next_key { - Ok(key) => { - let event_tx = event_tx_clone.lock(); - let _ = event_tx.send(Event::Key(key)); - } - Err(TuikitError::Interrupted) => break, - _ => {} // ignored - } - } - components_to_stop.fetch_sub(1, Ordering::SeqCst); - debug!("key listener stop"); - }); - } - - fn start_size_change_listener(&self) { - let event_tx_clone = self.event_tx.clone(); - let resize_signal_id = self.resize_signal_id.clone(); - let components_to_stop = self.components_to_stop.clone(); - - thread::spawn(move || { - let (id, sigwinch_rx) = notify_on_sigwinch(); - resize_signal_id.store(id, Ordering::Relaxed); - - components_to_stop.fetch_add(1, Ordering::SeqCst); - debug!("size change listener started"); - loop { - if sigwinch_rx.recv().is_ok() { - let event_tx = event_tx_clone.lock(); - let _ = event_tx.send(Event::Resize { width: 0, height: 0 }); - } else { - break; - } - } - components_to_stop.fetch_sub(1, Ordering::SeqCst); - debug!("size change listener stop"); - }); - } - - fn filter_event(&self, event: Event) -> Event { - match event { - Event::Resize { .. } => { - { - let mut termlock = self.term_lock.lock(); - let _ = termlock.on_resize(); - } - let (width, height) = self.term_size().unwrap_or((0, 0)); - Event::Resize { width, height } - } - Event::Key(Key::MousePress(button, row, col)) => { - // adjust mouse event position - let cursor_row = self.term_lock.lock().get_term_start_row() as u16; - if row < cursor_row { - Event::__Nonexhaustive - } else { - Event::Key(Key::MousePress(button, row - cursor_row, col)) - } - } - Event::Key(Key::MouseRelease(row, col)) => { - // adjust mouse event position - let cursor_row = self.term_lock.lock().get_term_start_row() as u16; - if row < cursor_row { - Event::__Nonexhaustive - } else { - Event::Key(Key::MouseRelease(row - cursor_row, col)) - } - } - Event::Key(Key::MouseHold(row, col)) => { - // adjust mouse event position - let cursor_row = self.term_lock.lock().get_term_start_row() as u16; - if row < cursor_row { - Event::__Nonexhaustive - } else { - Event::Key(Key::MouseHold(row - cursor_row, col)) - } - } - Event::Key(Key::SingleClick(button, row, col)) => { - let cursor_row = self.term_lock.lock().get_term_start_row() as u16; - if row < cursor_row { - Event::__Nonexhaustive - } else { - Event::Key(Key::SingleClick(button, row - cursor_row, col)) - } - } - Event::Key(Key::DoubleClick(button, row, col)) => { - let cursor_row = self.term_lock.lock().get_term_start_row() as u16; - if row < cursor_row { - Event::__Nonexhaustive - } else { - Event::Key(Key::DoubleClick(button, row - cursor_row, col)) - } - } - Event::Key(Key::WheelUp(row, col, num)) => { - let cursor_row = self.term_lock.lock().get_term_start_row() as u16; - if row < cursor_row { - Event::__Nonexhaustive - } else { - Event::Key(Key::WheelUp(row - cursor_row, col, num)) - } - } - Event::Key(Key::WheelDown(row, col, num)) => { - let cursor_row = self.term_lock.lock().get_term_start_row() as u16; - if row < cursor_row { - Event::__Nonexhaustive - } else { - Event::Key(Key::WheelDown(row - cursor_row, col, num)) - } - } - ev => ev, - } - } - - /// Wait an event up to `timeout` and return it - pub fn peek_event(&self, timeout: Duration) -> Result> { - let event_rx = self.event_rx.lock(); - event_rx - .recv_timeout(timeout) - .map(|ev| self.filter_event(ev)) - .map_err(|_| TuikitError::Timeout(timeout)) - } - - /// Wait for an event indefinitely and return it - pub fn poll_event(&self) -> Result> { - let event_rx = self.event_rx.lock(); - event_rx - .recv() - .map(|ev| self.filter_event(ev)) - .map_err(TuikitError::ChannelReceiveError) - } - - /// An interface to inject event to the terminal's event queue - pub fn send_event(&self, event: Event) -> Result<()> { - let event_tx = self.event_tx.lock(); - event_tx - .send(event) - .map_err(|err| TuikitError::SendEventError(err.to_string())) - } - - /// Sync internal buffer with terminal - pub fn present(&self) -> Result<()> { - self.ensure_not_stopped()?; - let mut termlock = self.term_lock.lock(); - termlock.present() - } - - /// Return the printable size(width, height) of the term - pub fn term_size(&self) -> Result<(usize, usize)> { - self.ensure_not_stopped()?; - let termlock = self.term_lock.lock(); - termlock.term_size() - } - - /// Clear internal buffer - pub fn clear(&self) -> Result<()> { - self.ensure_not_stopped()?; - let mut termlock = self.term_lock.lock(); - termlock.clear() - } - - /// Change a cell of position `(row, col)` to `cell` - pub fn put_cell(&self, row: usize, col: usize, cell: Cell) -> Result { - self.ensure_not_stopped()?; - let mut termlock = self.term_lock.lock(); - termlock.put_cell(row, col, cell) - } - - /// Print `content` starting with position `(row, col)` - pub fn print(&self, row: usize, col: usize, content: &str) -> Result { - self.print_with_attr(row, col, content, Attr::default()) - } - - /// print `content` starting with position `(row, col)` with `attr` - pub fn print_with_attr(&self, row: usize, col: usize, content: &str, attr: impl Into) -> Result { - self.ensure_not_stopped()?; - let mut termlock = self.term_lock.lock(); - termlock.print_with_attr(row, col, content, attr) - } - - /// Set cursor position to (row, col), and show the cursor - pub fn set_cursor(&self, row: usize, col: usize) -> Result<()> { - self.ensure_not_stopped()?; - let mut termlock = self.term_lock.lock(); - termlock.set_cursor(row, col) - } - - /// show/hide cursor, set `show` to `false` to hide the cursor - pub fn show_cursor(&self, show: bool) -> Result<()> { - self.ensure_not_stopped()?; - let mut termlock = self.term_lock.lock(); - termlock.show_cursor(show) - } - - /// Enable mouse support - pub fn enable_mouse_support(&self) -> Result<()> { - self.ensure_not_stopped()?; - let mut termlock = self.term_lock.lock(); - termlock.enable_mouse_support() - } - - /// Disable mouse support - pub fn disable_mouse_support(&self) -> Result<()> { - self.ensure_not_stopped()?; - let mut termlock = self.term_lock.lock(); - termlock.disable_mouse_support() - } - - /// Whether to clear the terminal upon exiting. Defaults to true. - pub fn clear_on_exit(&self, clear: bool) -> Result<()> { - self.ensure_not_stopped()?; - let mut termlock = self.term_lock.lock(); - termlock.clear_on_exit(clear); - Ok(()) - } - - pub fn draw(&self, draw: &dyn Draw) -> Result<()> { - let mut canvas = TermCanvas { term: self }; - draw.draw(&mut canvas).map_err(TuikitError::DrawError) - } - - pub fn draw_mut(&self, draw: &mut dyn Draw) -> Result<()> { - let mut canvas = TermCanvas { term: self }; - draw.draw_mut(&mut canvas).map_err(TuikitError::DrawError) - } -} - -impl Drop for Term { - fn drop(&mut self) { - let _ = self.pause_internal(true); - } -} - -pub struct TermCanvas<'a, UserEvent: Send + 'static> { - term: &'a Term, -} - -impl Canvas for TermCanvas<'_, UserEvent> { - fn size(&self) -> Result<(usize, usize)> { - self.term.term_size() - } - - fn clear(&mut self) -> Result<()> { - self.term.clear() - } - - fn put_cell(&mut self, row: usize, col: usize, cell: Cell) -> Result { - self.term.put_cell(row, col, cell) - } - - fn print_with_attr(&mut self, row: usize, col: usize, content: &str, attr: Attr) -> Result { - self.term.print_with_attr(row, col, content, attr) - } - - fn set_cursor(&mut self, row: usize, col: usize) -> Result<()> { - self.term.set_cursor(row, col) - } - - fn show_cursor(&mut self, show: bool) -> Result<()> { - self.term.show_cursor(show) - } -} - -struct TermLock { - prefer_height: TermHeight, - max_height: TermHeight, - min_height: TermHeight, - // keep bottom intact when resize? - bottom_intact: bool, - clear_on_exit: bool, - clear_on_start: bool, - mouse_enabled: bool, - alternate_screen: bool, - disable_alternate_screen: bool, - cursor_row: usize, - screen_height: usize, - screen_width: usize, - screen: Screen, - output: Option, -} - -impl Default for TermLock { - fn default() -> Self { - Self { - prefer_height: TermHeight::Percent(100), - max_height: TermHeight::Percent(100), - min_height: TermHeight::Fixed(3), - bottom_intact: false, - alternate_screen: false, - disable_alternate_screen: false, - cursor_row: 0, - screen_height: 0, - screen_width: 0, - screen: Screen::new(0, 0), - output: None, - clear_on_exit: true, - clear_on_start: true, - mouse_enabled: false, - } - } -} - -impl TermLock { - pub fn with_options(options: &TermOptions) -> Self { - let mut term = TermLock::default(); - term.prefer_height = options.height; - term.max_height = options.max_height; - term.min_height = options.min_height; - term.clear_on_exit = options.clear_on_exit; - term.clear_on_start = options.clear_on_start; - term.screen.clear_on_start(options.clear_on_start); - term.disable_alternate_screen = options.disable_alternate_screen; - term.mouse_enabled = options.mouse_enabled; - term - } - - /// Present the content to the terminal - pub fn present(&mut self) -> Result<()> { - let output = self.output.as_mut().ok_or(TuikitError::TerminalNotStarted)?; - let mut commands = self.screen.present(); - - let cursor_row = self.cursor_row; - // add cursor_row to all CursorGoto commands - for cmd in commands.iter_mut() { - if let Command::CursorGoto { row, col } = *cmd { - *cmd = Command::CursorGoto { - row: row + cursor_row, - col, - } - } - } - - for cmd in commands.into_iter() { - output.execute(cmd); - } - output.flush(); - Ok(()) - } - - /// Resize the internal buffer to according to new terminal size - pub fn on_resize(&mut self) -> Result<()> { - let output = self.output.as_mut().ok_or(TuikitError::TerminalNotStarted)?; - let (screen_width, screen_height) = output.terminal_size().expect("term:restart get terminal size failed"); - self.screen_height = screen_height; - self.screen_width = screen_width; - - let width = screen_width; - let height = - Self::calc_preferred_height(&self.min_height, &self.max_height, &self.prefer_height, screen_height); - - // update the cursor position - if self.cursor_row + height >= screen_height { - self.bottom_intact = true; - } - - if self.bottom_intact { - self.cursor_row = screen_height - height; - } - - // clear the screen - output.cursor_goto(self.cursor_row, 0); - if self.clear_on_start { - output.erase_down(); - } - - // clear the screen buffer - self.screen.resize(width, height); - Ok(()) - } - - fn calc_height(height_spec: &TermHeight, actual_height: usize) -> usize { - match *height_spec { - TermHeight::Fixed(h) => h, - TermHeight::Percent(p) => actual_height * min(p, 100) / 100, - } - } - - fn calc_preferred_height( - min_height: &TermHeight, - max_height: &TermHeight, - prefer_height: &TermHeight, - height: usize, - ) -> usize { - let max_height = Self::calc_height(max_height, height); - let min_height = Self::calc_height(min_height, height); - let prefer_height = Self::calc_height(prefer_height, height); - - // ensure the calculated height is in range (MIN_HEIGHT, height) - let max_height = max(min(max_height, height), MIN_HEIGHT); - let min_height = max(min(min_height, height), MIN_HEIGHT); - max(min(prefer_height, max_height), min_height) - } - - /// Pause the terminal - fn pause(&mut self, exiting: bool) -> Result<()> { - self.disable_mouse()?; - if let Some(mut output) = self.output.take() { - output.show_cursor(); - if self.clear_on_exit || !exiting { - // clear drawn contents - if !self.disable_alternate_screen { - output.quit_alternate_screen(); - } else { - output.cursor_goto(self.cursor_row, 0); - output.erase_down(); - } - } else { - output.cursor_goto(self.cursor_row + self.screen.height(), 0); - if self.bottom_intact { - output.write("\n"); - } - } - output.flush(); - } - Ok(()) - } - - /// ensure the screen had enough height - /// If the prefer height is full screen, it will enter alternate screen - /// otherwise it will ensure there are enough lines at the bottom - fn ensure_height(&mut self, cursor_pos: (usize, usize)) -> Result<()> { - let output = self.output.as_mut().ok_or(TuikitError::TerminalNotStarted)?; - - // initialize - - let (screen_width, screen_height) = output - .terminal_size() - .expect("termlock:ensure_height get terminal size failed"); - let height_to_be = - Self::calc_preferred_height(&self.min_height, &self.max_height, &self.prefer_height, screen_height); - - self.alternate_screen = false; - let (mut cursor_row, cursor_col) = cursor_pos; - if height_to_be >= screen_height { - // whole screen - self.alternate_screen = true; - self.bottom_intact = false; - self.cursor_row = 0; - if !self.disable_alternate_screen { - output.enter_alternate_screen(); - } - } else { - // only use part of the screen - - // go to a new line so that existing line won't be messed up - if cursor_col > 0 { - output.write("\n"); - cursor_row += 1; - } - - if (cursor_row + height_to_be) <= screen_height { - self.bottom_intact = false; - self.cursor_row = cursor_row; - } else { - for _ in 0..(height_to_be - 1) { - output.write("\n"); - } - self.bottom_intact = true; - self.cursor_row = min(cursor_row, screen_height - height_to_be); - } - } - - output.cursor_goto(self.cursor_row, 0); - output.flush(); - self.screen_height = screen_height; - self.screen_width = screen_width; - Ok(()) - } - - /// get the start row of the terminal - pub fn get_term_start_row(&self) -> usize { - self.cursor_row - } - - /// restart the terminal - pub fn restart(&mut self, output: Output, cursor_pos: (usize, usize)) -> Result<()> { - // ensure the output area had enough height - self.output.replace(output); - self.ensure_height(cursor_pos)?; - self.on_resize()?; - if self.mouse_enabled { - self.enable_mouse()?; - } - Ok(()) - } - - /// return the printable size(width, height) of the term - pub fn term_size(&self) -> Result<(usize, usize)> { - self.screen.size() - } - - /// clear internal buffer - pub fn clear(&mut self) -> Result<()> { - self.screen.clear() - } - - /// change a cell of position `(row, col)` to `cell` - pub fn put_cell(&mut self, row: usize, col: usize, cell: Cell) -> Result { - self.screen.put_cell(row, col, cell) - } - - /// print `content` starting with position `(row, col)` - pub fn print_with_attr(&mut self, row: usize, col: usize, content: &str, attr: impl Into) -> Result { - self.screen.print_with_attr(row, col, content, attr.into()) - } - - /// set cursor position to (row, col) - pub fn set_cursor(&mut self, row: usize, col: usize) -> Result<()> { - self.screen.set_cursor(row, col) - } - - /// show/hide cursor, set `show` to `false` to hide the cursor - pub fn show_cursor(&mut self, show: bool) -> Result<()> { - self.screen.show_cursor(show) - } - - /// Enable mouse support - pub fn enable_mouse_support(&mut self) -> Result<()> { - self.mouse_enabled = true; - self.enable_mouse() - } - - /// Disable mouse support - pub fn disable_mouse_support(&mut self) -> Result<()> { - self.mouse_enabled = false; - self.disable_mouse() - } - - pub fn clear_on_exit(&mut self, clear: bool) { - self.clear_on_exit = clear; - } - - /// Enable mouse (send ANSI codes to enable mouse) - fn enable_mouse(&mut self) -> Result<()> { - let output = self.output.as_mut().ok_or(TuikitError::TerminalNotStarted)?; - output.enable_mouse_support(); - Ok(()) - } - - /// Disable mouse (send ANSI codes to disable mouse) - fn disable_mouse(&mut self) -> Result<()> { - let output = self.output.as_mut().ok_or(TuikitError::TerminalNotStarted)?; - output.disable_mouse_support(); - Ok(()) - } -} - -impl Drop for TermLock { - fn drop(&mut self) { - let _ = self.pause(true); - } -} diff --git a/skim-tuikit/src/widget/align.rs b/skim-tuikit/src/widget/align.rs deleted file mode 100644 index fede49fd..00000000 --- a/skim-tuikit/src/widget/align.rs +++ /dev/null @@ -1,119 +0,0 @@ -pub trait AlignSelf { - /// say horizontal align, given container's (start, end) and self's size - /// Adjust the actual start position of self. - /// - /// Note that if the container's size < self_size, will return `start` - fn adjust(&self, start: usize, end_exclusive: usize, self_size: usize) -> usize; -} - -pub enum HorizontalAlign { - Left, - Center, - Right, -} - -pub enum VerticalAlign { - Top, - Middle, - Bottom, -} - -impl AlignSelf for HorizontalAlign { - fn adjust(&self, start: usize, end: usize, self_size: usize) -> usize { - if start >= end { - // wrong input - return start; - } - let container_size = end - start; - if container_size <= self_size { - return start; - } - - match self { - HorizontalAlign::Left => start, - HorizontalAlign::Center => start + (container_size - self_size) / 2, - HorizontalAlign::Right => end - self_size, - } - } -} - -impl AlignSelf for VerticalAlign { - fn adjust(&self, start: usize, end: usize, self_size: usize) -> usize { - if start >= end { - // wrong input - return start; - } - let container_size = end - start; - if container_size <= self_size { - return start; - } - - match self { - VerticalAlign::Top => start, - VerticalAlign::Middle => start + (container_size - self_size) / 2, - VerticalAlign::Bottom => end - self_size, - } - } -} - -#[cfg(test)] -mod tests { - use crate::widget::align::{AlignSelf, HorizontalAlign, VerticalAlign}; - - #[test] - fn size_lt0_return_start() { - assert_eq!(0, HorizontalAlign::Left.adjust(0, 0, 2)); - assert_eq!(0, HorizontalAlign::Center.adjust(0, 0, 2)); - assert_eq!(0, HorizontalAlign::Right.adjust(0, 0, 2)); - assert_eq!(0, VerticalAlign::Top.adjust(0, 0, 2)); - assert_eq!(0, VerticalAlign::Middle.adjust(0, 0, 2)); - assert_eq!(0, VerticalAlign::Bottom.adjust(0, 0, 2)); - - assert_eq!(2, HorizontalAlign::Left.adjust(2, 0, 2)); - assert_eq!(2, HorizontalAlign::Center.adjust(2, 0, 2)); - assert_eq!(2, HorizontalAlign::Right.adjust(2, 0, 2)); - assert_eq!(2, VerticalAlign::Top.adjust(2, 0, 2)); - assert_eq!(2, VerticalAlign::Middle.adjust(2, 0, 2)); - assert_eq!(2, VerticalAlign::Bottom.adjust(2, 0, 2)); - } - - #[test] - fn container_size_too_small_return_start() { - assert_eq!(2, HorizontalAlign::Left.adjust(2, 3, 2)); - assert_eq!(2, HorizontalAlign::Center.adjust(2, 3, 2)); - assert_eq!(2, HorizontalAlign::Right.adjust(2, 3, 2)); - assert_eq!(2, VerticalAlign::Top.adjust(2, 3, 2)); - assert_eq!(2, VerticalAlign::Middle.adjust(2, 3, 2)); - assert_eq!(2, VerticalAlign::Bottom.adjust(2, 3, 2)); - } - - #[test] - fn align_start() { - assert_eq!(2, HorizontalAlign::Left.adjust(2, 8, 2)); - assert_eq!(2, VerticalAlign::Top.adjust(2, 8, 2)); - assert_eq!(2, HorizontalAlign::Left.adjust(2, 7, 2)); - assert_eq!(2, VerticalAlign::Top.adjust(2, 7, 2)); - assert_eq!(2, HorizontalAlign::Left.adjust(2, 8, 3)); - assert_eq!(2, VerticalAlign::Top.adjust(2, 8, 3)); - } - - #[test] - fn align_end() { - assert_eq!(6, HorizontalAlign::Right.adjust(2, 8, 2)); - assert_eq!(6, VerticalAlign::Bottom.adjust(2, 8, 2)); - assert_eq!(5, HorizontalAlign::Right.adjust(2, 7, 2)); - assert_eq!(5, VerticalAlign::Bottom.adjust(2, 7, 2)); - assert_eq!(5, HorizontalAlign::Right.adjust(2, 8, 3)); - assert_eq!(5, VerticalAlign::Bottom.adjust(2, 8, 3)); - } - - #[test] - fn align_center() { - assert_eq!(4, HorizontalAlign::Center.adjust(2, 8, 2)); - assert_eq!(4, VerticalAlign::Middle.adjust(2, 8, 2)); - assert_eq!(3, HorizontalAlign::Center.adjust(2, 7, 2)); - assert_eq!(3, VerticalAlign::Middle.adjust(2, 7, 2)); - assert_eq!(3, HorizontalAlign::Center.adjust(2, 8, 3)); - assert_eq!(3, VerticalAlign::Middle.adjust(2, 8, 3)); - } -} diff --git a/skim-tuikit/src/widget/mod.rs b/skim-tuikit/src/widget/mod.rs deleted file mode 100644 index 2d5a6382..00000000 --- a/skim-tuikit/src/widget/mod.rs +++ /dev/null @@ -1,137 +0,0 @@ -pub use self::align::*; -// Various pre-defined widget that implements Draw -pub use self::split::*; -pub use self::stack::*; -pub use self::win::*; -use crate::draw::Draw; -use crate::event::Event; -use std::cmp::min; -mod align; -mod split; -mod stack; -mod util; -mod win; - -/// Whether fixed size or percentage -#[derive(Debug, Copy, Clone, Default)] -pub enum Size { - Fixed(usize), - Percent(usize), - #[default] - Default, -} - -impl Size { - pub fn calc_fixed_size(&self, total_size: usize, default_size: usize) -> usize { - match *self { - Size::Fixed(fixed) => min(total_size, fixed), - Size::Percent(percent) => min(total_size, total_size * percent / 100), - Size::Default => default_size, - } - } -} - -impl From for Size { - fn from(size: usize) -> Self { - Size::Fixed(size) - } -} - -#[derive(Copy, Clone, Debug)] -pub struct Rectangle { - pub top: usize, - pub left: usize, - pub width: usize, - pub height: usize, -} - -impl Rectangle { - /// check if the given point(row, col) lies in the rectangle - pub fn contains(&self, row: usize, col: usize) -> bool { - if row < self.top || row >= self.top + self.height { - false - } else { - !(col < self.left || col >= self.left + self.width) - } - } - - /// assume the point (row, col) lies in the rectangle, adjust the origin to the rectangle's - /// origin (top, left) - pub fn relative_to_origin(&self, row: usize, col: usize) -> (usize, usize) { - (row - self.top, col - self.left) - } - - pub fn adjust_origin(&self) -> Rectangle { - Self { - top: 0, - left: 0, - width: self.width, - height: self.height, - } - } -} - -/// A widget could be recursive nested -pub trait Widget: Draw { - /// the (width, height) of the content - /// it will be the hint for layouts to calculate the final size - fn size_hint(&self) -> (Option, Option) { - (None, None) - } - - /// given a key event, emit zero or more messages - /// typical usage is the mouse click event where containers would pass the event down - /// to their children. - fn on_event(&self, event: Event, rect: Rectangle) -> Vec { - let _ = (event, rect); // avoid warning - Vec::new() - } - - /// same as `on_event` except that the self reference is mutable - fn on_event_mut(&mut self, event: Event, rect: Rectangle) -> Vec { - let _ = (event, rect); // avoid warning - Vec::new() - } -} - -impl> Widget for &T { - fn size_hint(&self) -> (Option, Option) { - (*self).size_hint() - } - - fn on_event(&self, event: Event, rect: Rectangle) -> Vec { - (*self).on_event(event, rect) - } - - fn on_event_mut(&mut self, event: Event, rect: Rectangle) -> Vec { - (**self).on_event(event, rect) - } -} - -impl> Widget for &mut T { - fn size_hint(&self) -> (Option, Option) { - (**self).size_hint() - } - - fn on_event(&self, event: Event, rect: Rectangle) -> Vec { - (**self).on_event(event, rect) - } - - fn on_event_mut(&mut self, event: Event, rect: Rectangle) -> Vec { - (**self).on_event_mut(event, rect) - } -} - -impl + ?Sized> Widget for Box { - fn size_hint(&self) -> (Option, Option) { - self.as_ref().size_hint() - } - - fn on_event(&self, event: Event, rect: Rectangle) -> Vec { - self.as_ref().on_event(event, rect) - } - - fn on_event_mut(&mut self, event: Event, rect: Rectangle) -> Vec { - self.as_mut().on_event_mut(event, rect) - } -} diff --git a/skim-tuikit/src/widget/split.rs b/skim-tuikit/src/widget/split.rs deleted file mode 100644 index 62ff7b66..00000000 --- a/skim-tuikit/src/widget/split.rs +++ /dev/null @@ -1,1267 +0,0 @@ -use super::Size; -use super::util::adjust_event; -use super::{Rectangle, Widget}; -use crate::canvas::{BoundedCanvas, Canvas}; -use crate::draw::Draw; -use crate::draw::DrawResult; -use crate::event::Event; -use std::cmp::{Ordering, min}; - -/// A Split item would contain 3 things -/// 0. inner_size, will be used if `basis` is `Size::Default`. -/// 1. basis, the original size -/// 2. grow, the factor to grow if there is still enough room -/// 3. shrink, the factor to shrink if there is not enough room -pub trait Split: Widget { - fn get_basis(&self) -> Size; - - fn get_grow(&self) -> usize; - - fn get_shrink(&self) -> usize; - - /// get the default size of inner content, will be used if `basis` is Default - fn inner_size(&self) -> (Size, Size) { - let (width, height) = self.size_hint(); - let width = width.map(Size::Fixed).unwrap_or(Size::Default); - let height = height.map(Size::Fixed).unwrap_or(Size::Default); - (width, height) - } -} - -impl + Widget> Split for &T { - fn get_basis(&self) -> Size { - (*self).get_basis() - } - - fn get_grow(&self) -> usize { - (*self).get_grow() - } - - fn get_shrink(&self) -> usize { - (*self).get_shrink() - } - - fn inner_size(&self) -> (Size, Size) { - (*self).inner_size() - } -} - -impl + Widget> Split for &mut T { - fn get_basis(&self) -> Size { - (**self).get_basis() - } - - fn get_grow(&self) -> usize { - (**self).get_grow() - } - - fn get_shrink(&self) -> usize { - (**self).get_shrink() - } - - fn inner_size(&self) -> (Size, Size) { - (**self).inner_size() - } -} - -enum Op { - Noop, - Grow, - Shrink, -} - -enum SplitType { - Horizontal, - Vertical, -} - -trait SplitContainer<'a, Message = ()> { - fn get_splits(&self) -> &[Box + 'a>]; - - fn get_split_type(&self) -> SplitType; - - /// return the target sizes of the splits - fn retrieve_split_info(&self, actual_size: usize) -> Vec { - let split_type = self.get_split_type(); - - let split_sizes: Vec = self - .get_splits() - .iter() - .map(|split| { - let (width, height) = split.inner_size(); - let default = match &split_type { - SplitType::Horizontal => width, - SplitType::Vertical => height, - }; - - match split.get_basis() { - Size::Default => default, - basis => basis, - } - }) - .map(|size| size.calc_fixed_size(actual_size, actual_size)) - .collect(); - - let target_total_size: usize = split_sizes.iter().sum(); - - let op = match target_total_size.cmp(&actual_size) { - Ordering::Equal => Op::Noop, - Ordering::Less => Op::Grow, - Ordering::Greater => Op::Shrink, - }; - - let size_diff = match op { - Op::Noop => 0, - Op::Grow => actual_size - target_total_size, - Op::Shrink => target_total_size - actual_size, - }; - - let split_factors: Vec = self - .get_splits() - .iter() - .map(|split| match op { - Op::Noop => 0, - Op::Shrink => split.get_shrink(), - Op::Grow => split.get_grow(), - }) - .collect(); - - let total_factors: usize = split_factors.iter().sum(); - - let unit = if total_factors == 0 { - 0 - } else { - size_diff / total_factors - }; - - (0..split_sizes.len()) - .map(|idx| { - let diff = split_factors[idx] * unit; - match op { - Op::Noop => split_sizes[idx], - Op::Grow => split_sizes[idx] + diff, - Op::Shrink => split_sizes[idx] - min(split_sizes[idx], diff), - } - }) - .collect() - } -} - -/// HSplit will split the area horizontally. It will -/// 1. Count the total width(basis) of the split items it contains -/// 2. Judge if the current width is enough or not for the split items -/// 3. shrink/grow the split items according to their factors / (total factors) -/// 4. If still not enough room, the last one(s) would be set width 0 -pub struct HSplit<'a, Message = ()> { - basis: Size, - grow: usize, - shrink: usize, - splits: Vec + 'a>>, -} - -impl Default for HSplit<'_, Message> { - fn default() -> Self { - Self { - basis: Size::Default, - grow: 1, - shrink: 1, - splits: Vec::new(), - } - } -} - -impl<'a, Message> HSplit<'a, Message> { - pub fn split(mut self, split: impl Split + 'a) -> Self { - self.splits.push(Box::new(split)); - self - } - - pub fn basis(mut self, basis: impl Into) -> Self { - self.basis = basis.into(); - self - } - - pub fn grow(mut self, grow: usize) -> Self { - self.grow = grow; - self - } - - pub fn shrink(mut self, shrink: usize) -> Self { - self.shrink = shrink; - self - } -} - -impl<'a, Message> SplitContainer<'a, Message> for HSplit<'a, Message> { - fn get_splits(&self) -> &[Box + 'a>] { - &self.splits - } - - fn get_split_type(&self) -> SplitType { - SplitType::Horizontal - } -} - -impl Draw for HSplit<'_, Message> { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - let (width, height) = canvas.size()?; - let target_widths = self.retrieve_split_info(width); - - // iterate over the splits - let mut left = 0; - for (idx, split) in self.splits.iter().enumerate() { - let target_width = target_widths[idx]; - let right = min(left + target_width, width); - let mut new_canvas = BoundedCanvas::new(0, left, right - left, height, canvas); - let _ = split.draw(&mut new_canvas); - left = right; - } - - Ok(()) - } - - fn draw_mut(&mut self, canvas: &mut dyn Canvas) -> DrawResult<()> { - let (width, height) = canvas.size()?; - let target_widths = self.retrieve_split_info(width); - - // iterate over the splits - let mut left = 0; - for (idx, split) in self.splits.iter_mut().enumerate() { - let target_width = target_widths[idx]; - let right = min(left + target_width, width); - let mut new_canvas = BoundedCanvas::new(0, left, right - left, height, canvas); - let _ = split.draw_mut(&mut new_canvas); - left = right; - } - - Ok(()) - } -} - -impl Widget for HSplit<'_, Message> { - fn size_hint(&self) -> (Option, Option) { - let has_width_hint = self.splits.iter().any(|split| split.size_hint().0.is_some()); - let has_height_hint = self.splits.iter().any(|split| split.size_hint().1.is_some()); - - let width = if has_width_hint { - Some(self.splits.iter().map(|split| split.size_hint().0.unwrap_or(0)).sum()) - } else { - None - }; - - let height = if has_height_hint { - Some( - self.splits - .iter() - .map(|split| split.size_hint().1.unwrap_or(0)) - .max() - .unwrap_or(0), - ) - } else { - None - }; - - (width, height) - } - - fn on_event(&self, event: Event, rect: Rectangle) -> Vec { - // should collect events from every children - let target_widths = self.retrieve_split_info(rect.width); - let Rectangle { top, width, height, .. } = rect; - let mut messages = vec![]; - - // iterate over the splits - let mut left = 0; - for (idx, split) in self.splits.iter().enumerate() { - let target_width = target_widths[idx]; - let right = min(left + target_width, width); - let sub_rect = Rectangle { - top, - left, - width: target_width, - height, - }; - - let mut sub_message = adjust_event(event, sub_rect) - .map(|ev| split.as_ref().on_event(ev, sub_rect.adjust_origin())) - .unwrap_or_default(); - messages.append(&mut sub_message); - left = right; - } - - messages - } - - fn on_event_mut(&mut self, event: Event, rect: Rectangle) -> Vec { - // should collect events from every children - let target_widths = self.retrieve_split_info(rect.width); - let Rectangle { top, width, height, .. } = rect; - let mut messages = vec![]; - - // iterate over the splits - let mut left = 0; - for (idx, split) in self.splits.iter_mut().enumerate() { - let target_width = target_widths[idx]; - let right = min(left + target_width, width); - let sub_rect = Rectangle { - top, - left, - width: target_width, - height, - }; - - let mut sub_message = adjust_event(event, sub_rect) - .map(|ev| split.as_mut().on_event_mut(ev, sub_rect.adjust_origin())) - .unwrap_or_default(); - messages.append(&mut sub_message); - left = right; - } - - messages - } -} - -impl Split for HSplit<'_, Message> { - fn get_basis(&self) -> Size { - self.basis - } - - fn get_grow(&self) -> usize { - self.grow - } - - fn get_shrink(&self) -> usize { - self.shrink - } -} - -/// VSplit will split the area vertically. It will -/// 1. Count the total height(basis) of the split items it contains -/// 2. Judge if the current height is enough or not for the split items -/// 3. shrink/grow the split items according to their factors / (total factors) -/// 4. If still not enough room, the last one(s) would be set height 0 -pub struct VSplit<'a, Message = ()> { - basis: Size, - grow: usize, - shrink: usize, - splits: Vec + 'a>>, -} - -impl Default for VSplit<'_, Message> { - fn default() -> Self { - Self { - basis: Size::Default, - grow: 1, - shrink: 1, - splits: Vec::new(), - } - } -} - -impl<'a, Message> VSplit<'a, Message> { - pub fn split(mut self, split: impl Split + 'a) -> Self { - self.splits.push(Box::new(split)); - self - } - - pub fn basis(mut self, basis: impl Into) -> Self { - self.basis = basis.into(); - self - } - - pub fn grow(mut self, grow: usize) -> Self { - self.grow = grow; - self - } - - pub fn shrink(mut self, shrink: usize) -> Self { - self.shrink = shrink; - self - } -} - -impl<'a, Message> SplitContainer<'a, Message> for VSplit<'a, Message> { - fn get_splits(&self) -> &[Box + 'a>] { - &self.splits - } - - fn get_split_type(&self) -> SplitType { - SplitType::Vertical - } -} - -impl Draw for VSplit<'_, Message> { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - let (width, height) = canvas.size()?; - let target_heights = self.retrieve_split_info(height); - - // iterate over the splits - let mut top = 0; - for (idx, split) in self.splits.iter().enumerate() { - let target_height = target_heights[idx]; - let bottom = min(top + target_height, height); - let mut new_canvas = BoundedCanvas::new(top, 0, width, bottom - top, canvas); - let _ = split.draw(&mut new_canvas); - top = bottom; - } - - Ok(()) - } - fn draw_mut(&mut self, canvas: &mut dyn Canvas) -> DrawResult<()> { - let (width, height) = canvas.size()?; - let target_heights = self.retrieve_split_info(height); - - // iterate over the splits - let mut top = 0; - for (idx, split) in self.splits.iter_mut().enumerate() { - let target_height = target_heights[idx]; - let bottom = min(top + target_height, height); - let mut new_canvas = BoundedCanvas::new(top, 0, width, bottom - top, canvas); - let _ = split.draw_mut(&mut new_canvas); - top = bottom; - } - - Ok(()) - } -} - -impl Widget for VSplit<'_, Message> { - fn size_hint(&self) -> (Option, Option) { - let has_width_hint = self.splits.iter().any(|split| split.size_hint().0.is_some()); - let has_height_hint = self.splits.iter().any(|split| split.size_hint().1.is_some()); - - let width = if has_width_hint { - Some( - self.splits - .iter() - .map(|split| split.size_hint().0.unwrap_or(0)) - .max() - .unwrap_or(0), - ) - } else { - None - }; - - let height = if has_height_hint { - Some(self.splits.iter().map(|split| split.size_hint().1.unwrap_or(0)).sum()) - } else { - None - }; - - (width, height) - } - - fn on_event(&self, event: Event, rect: Rectangle) -> Vec { - // should collect events from every children - let target_heights = self.retrieve_split_info(rect.height); - let Rectangle { - left, width, height, .. - } = rect; - let mut messages = vec![]; - - // iterate over the splits - let mut top = 0; - for (idx, split) in self.splits.iter().enumerate() { - let target_height = target_heights[idx]; - let bottom = min(top + target_height, height); - let sub_rect = Rectangle { - top, - left, - width, - height: target_height, - }; - let mut sub_message = adjust_event(event, sub_rect) - .map(|ev| split.as_ref().on_event(ev, sub_rect.adjust_origin())) - .unwrap_or_default(); - messages.append(&mut sub_message); - top = bottom; - } - - messages - } - - fn on_event_mut(&mut self, event: Event, rect: Rectangle) -> Vec { - // should collect events from every children - let target_heights = self.retrieve_split_info(rect.height); - let Rectangle { - left, width, height, .. - } = rect; - let mut messages = vec![]; - - // iterate over the splits - let mut top = 0; - for (idx, split) in self.splits.iter_mut().enumerate() { - let target_height = target_heights[idx]; - let bottom = min(top + target_height, height); - let sub_rect = Rectangle { - top, - left, - width, - height: target_height, - }; - let mut sub_message = adjust_event(event, sub_rect) - .map(|ev| split.as_mut().on_event_mut(ev, sub_rect.adjust_origin())) - .unwrap_or_default(); - messages.append(&mut sub_message); - top = bottom; - } - - messages - } -} - -impl Split for VSplit<'_, Message> { - fn get_basis(&self) -> Size { - self.basis - } - - fn get_grow(&self) -> usize { - self.grow - } - - fn get_shrink(&self) -> usize { - self.shrink - } -} - -#[cfg(test)] -#[allow(dead_code)] -mod test { - use super::*; - use crate::Result; - use crate::cell::Cell; - use crate::key::Key; - use crate::key::Key::*; - use crate::key::MouseButton; - use std::sync::Mutex; - - struct TestCanvas { - pub width: usize, - pub height: usize, - } - - impl Canvas for TestCanvas { - fn size(&self) -> Result<(usize, usize)> { - Ok((self.width, self.height)) - } - - fn clear(&mut self) -> Result<()> { - unimplemented!() - } - - fn put_cell(&mut self, _row: usize, _col: usize, _cell: Cell) -> Result { - unimplemented!() - } - - fn set_cursor(&mut self, _row: usize, _col: usize) -> Result<()> { - unimplemented!() - } - - fn show_cursor(&mut self, _show: bool) -> Result<()> { - unimplemented!() - } - } - - struct WSplit<'a> { - pub basis: Size, - pub grow: usize, - pub shrink: usize, - pub draw: &'a dyn Draw, - } - - impl<'a> WSplit<'a> { - pub fn new(draw: &'a dyn Draw) -> Self { - Self { - basis: Size::Default, - grow: 1, - shrink: 1, - draw, - } - } - - pub fn basis(mut self, basis: impl Into) -> Self { - self.basis = basis.into(); - self - } - - pub fn grow(mut self, grow: usize) -> Self { - self.grow = grow; - self - } - - pub fn shrink(mut self, shrink: usize) -> Self { - self.shrink = shrink; - self - } - } - - impl Split for WSplit<'_> { - fn get_basis(&self) -> Size { - self.basis - } - - fn get_grow(&self) -> usize { - self.grow - } - - fn get_shrink(&self) -> usize { - self.shrink - } - } - - impl Draw for WSplit<'_> { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - self.draw.draw(canvas) - } - } - - impl Widget for WSplit<'_> {} - - #[derive(Default)] - struct SingleWindow { - pub width: usize, - pub height: usize, - } - - impl Draw for SingleWindow { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - let (width, height) = canvas.size().unwrap(); - assert_eq!(self.width, width); - assert_eq!(self.height, height); - Ok(()) - } - } - - #[test] - fn splits_should_create_on_empty_items() { - let mut canvas = TestCanvas { width: 80, height: 60 }; - let hsplit = HSplit::<()>::default(); - let vsplit = VSplit::<()>::default(); - let _ = hsplit.draw(&mut canvas); - let _ = vsplit.draw(&mut canvas); - } - - #[test] - fn single_splits_should_take_over_all_spaces() { - let width = 80; - let height = 60; - let mut canvas = TestCanvas { width, height }; - let window = SingleWindow { width, height }; - let hsplit = HSplit::default().split(WSplit::new(&window)); - let vsplit = VSplit::default().split(WSplit::new(&window)); - let _ = hsplit.draw(&mut canvas); - let _ = vsplit.draw(&mut canvas); - } - - #[test] - fn two_splits_should_take_50_percent() { - let width = 80; - let height = 60; - let mut canvas = TestCanvas { width, height }; - - let h_window = SingleWindow { - width: width / 2, - height, - }; - let v_window = SingleWindow { - width, - height: height / 2, - }; - - let hsplit = HSplit::default() - .split(WSplit::new(&h_window)) - .split(WSplit::new(&h_window)); - let vsplit = VSplit::default() - .split(WSplit::new(&v_window)) - .split(WSplit::new(&v_window)); - - let _ = hsplit.draw(&mut canvas); - let _ = vsplit.draw(&mut canvas); - } - - #[test] - fn exceeded_should_be_ignored() { - // |<-- screen width: 80 -->| - // |<-- 60 -->|<-- 60 -->| - // |<-- 60 -->|<-- | (will be cut) - - let width = 80; - let height = 80; - let mut canvas = TestCanvas { width, height }; - - let h_first = SingleWindow { width: 60, height }; - let h_second = SingleWindow { width: 20, height }; - let h_third = SingleWindow { width: 0, height }; - - let hsplit = HSplit::default() - .split(WSplit::new(&h_first).basis(60).shrink(0)) - .split(WSplit::new(&h_second).basis(60).shrink(0)) - .split(WSplit::new(&h_third).basis(60).shrink(0)); - - let _ = hsplit.draw(&mut canvas); - - let v_first = SingleWindow { width, height: 60 }; - let v_second = SingleWindow { width, height: 20 }; - let v_third = SingleWindow { width, height: 0 }; - - let vsplit = VSplit::default() - .split(WSplit::new(&v_first).basis(60).shrink(0)) - .split(WSplit::new(&v_second).basis(60).shrink(0)) - .split(WSplit::new(&v_third).basis(60).shrink(0)); - - let _ = vsplit.draw(&mut canvas); - } - - #[test] - fn grow() { - // |<-- screen width: 80 -->| - // 1. 10 (with grow: 1) => 30 - // 2. 10 (with grow: 2) => 50 - - let width = 80; - let height = 80; - let mut canvas = TestCanvas { width, height }; - - let h_first = SingleWindow { width: 30, height }; - let h_second = SingleWindow { width: 50, height }; - - let hsplit = HSplit::default() - .split(WSplit::new(&h_first).basis(10).grow(1)) - .split(WSplit::new(&h_second).basis(10).grow(2)); - - let _ = hsplit.draw(&mut canvas); - - let v_first = SingleWindow { width, height: 30 }; - let v_second = SingleWindow { width, height: 50 }; - - let vsplit = VSplit::default() - .split(WSplit::new(&v_first).basis(10).grow(1)) - .split(WSplit::new(&v_second).basis(10).grow(2)); - - let _ = vsplit.draw(&mut canvas); - } - - #[test] - fn shrink() { - // |<-- screen width: 80 -->| - // 1. 70 (with shrink: 1) => 30 - // 2. 70 (with shrink: 2) => 50 - - let width = 80; - let height = 80; - let mut canvas = TestCanvas { width, height }; - - let h_first = SingleWindow { width: 50, height }; - let h_second = SingleWindow { width: 30, height }; - - let hsplit = HSplit::default() - .split(WSplit::new(&h_first).basis(70).shrink(1)) - .split(WSplit::new(&h_second).basis(70).shrink(2)); - - let _ = hsplit.draw(&mut canvas); - - let v_first = SingleWindow { width, height: 50 }; - let v_second = SingleWindow { width, height: 30 }; - - let vsplit = VSplit::default() - .split(WSplit::new(&v_first).basis(70).shrink(1)) - .split(WSplit::new(&v_second).basis(70).shrink(2)); - - let _ = vsplit.draw(&mut canvas); - } - - struct WinHint { - pub width_hint: Option, - pub height_hint: Option, - } - - impl Draw for WinHint { - fn draw(&self, _canvas: &mut dyn Canvas) -> DrawResult<()> { - unimplemented!() - } - } - - impl Widget for WinHint { - fn size_hint(&self) -> (Option, Option) { - (self.width_hint, self.height_hint) - } - } - - impl Split for WinHint { - fn get_basis(&self) -> Size { - Size::Default - } - fn get_grow(&self) -> usize { - 0 - } - fn get_shrink(&self) -> usize { - 0 - } - } - - #[test] - fn size_hint_of_hsplit() { - let hint_none = WinHint { - width_hint: None, - height_hint: None, - }; - let hint_width_1 = WinHint { - width_hint: Some(1), - height_hint: None, - }; - let hint_width_2 = WinHint { - width_hint: Some(2), - height_hint: None, - }; - let hint_height_1 = WinHint { - width_hint: None, - height_hint: Some(1), - }; - let hint_height_2 = WinHint { - width_hint: None, - height_hint: Some(2), - }; - - // sum(width), max(height) - let split = HSplit::default() - .split(&hint_none) - .split(&hint_width_1) - .split(&hint_width_2) - .split(&hint_height_1) - .split(&hint_height_2); - - assert_eq!((Some(3), Some(2)), split.size_hint()); - - // None, max(height) - let split = HSplit::default() - .split(&hint_none) - .split(&hint_height_1) - .split(&hint_height_2); - - assert_eq!((None, Some(2)), split.size_hint()); - - // sum(width), None - let split = HSplit::default() - .split(&hint_none) - .split(&hint_width_1) - .split(&hint_width_2); - assert_eq!((Some(3), None), split.size_hint()); - - // None - let split = HSplit::default().split(&hint_none).split(&hint_none); - assert_eq!((None, None), split.size_hint()); - } - - #[test] - fn size_hint_of_vsplit() { - let hint_none = WinHint { - width_hint: None, - height_hint: None, - }; - let hint_width_1 = WinHint { - width_hint: Some(1), - height_hint: None, - }; - let hint_width_2 = WinHint { - width_hint: Some(2), - height_hint: None, - }; - let hint_height_1 = WinHint { - width_hint: None, - height_hint: Some(1), - }; - let hint_height_2 = WinHint { - width_hint: None, - height_hint: Some(2), - }; - - // max(width), sum(height) - let split = VSplit::default() - .split(&hint_none) - .split(&hint_width_1) - .split(&hint_width_2) - .split(&hint_height_1) - .split(&hint_height_2); - - assert_eq!((Some(2), Some(3)), split.size_hint()); - - // None, sum(height) - let split = VSplit::default() - .split(&hint_none) - .split(&hint_height_1) - .split(&hint_height_2); - - assert_eq!((None, Some(3)), split.size_hint()); - - // max(width), None - let split = VSplit::default() - .split(&hint_none) - .split(&hint_width_1) - .split(&hint_width_2); - assert_eq!((Some(2), None), split.size_hint()); - - // None - let split = VSplit::default().split(&hint_none).split(&hint_none); - assert_eq!((None, None), split.size_hint()); - } - - #[derive(Copy, Clone, PartialOrd, PartialEq, Debug)] - enum Message { - Window(i32), - } - - struct WindowWithId { - id: i32, - } - - impl WindowWithId { - pub fn new(id: i32) -> Self { - Self { id } - } - } - - impl Draw for WindowWithId { - fn draw(&self, _canvas: &mut dyn Canvas) -> DrawResult<()> { - unimplemented!() - } - } - - impl Widget for WindowWithId { - fn on_event(&self, _event: Event, _rect: Rectangle) -> Vec { - vec![Message::Window(self.id)] - } - fn on_event_mut(&mut self, _event: Event, _rect: Rectangle) -> Vec { - vec![Message::Window(self.id)] - } - } - - impl Split for WindowWithId { - fn get_basis(&self) -> Size { - Size::Default - } - fn get_grow(&self) -> usize { - 1 - } - fn get_shrink(&self) -> usize { - 1 - } - } - - #[test] - fn message_should_be_dispatched_correctly() { - let width = 80; - let height = 60; - let rect = Rectangle { - top: 0, - left: 0, - width, - height, - }; - - let win1 = WindowWithId::new(1); - let win2 = WindowWithId::new(2); - let win3 = WindowWithId::new(3); - let win4 = WindowWithId::new(4); - - let ev_left_1 = Event::Key(Key::MouseHold(0, 0)); - let ev_left_2 = Event::Key(Key::MouseHold(0, 39)); - let ev_right_1 = Event::Key(Key::MouseHold(20, 40)); - let ev_right_2 = Event::Key(Key::MouseHold(20, 41)); - let ev_right_3 = Event::Key(Key::MouseHold(59, 79)); - let ev_out_of_bound = Event::Key(Key::MouseHold(60, 80)); - - let hsplit = HSplit::default().split(&win1).split(&win2); - let msg = hsplit.on_event(ev_left_1, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(1), msg[0]); - let msg = hsplit.on_event(ev_left_2, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(1), msg[0]); - let msg = hsplit.on_event(ev_right_1, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(2), msg[0]); - let msg = hsplit.on_event(ev_right_2, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(2), msg[0]); - let msg = hsplit.on_event(ev_right_3, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(2), msg[0]); - let msg = hsplit.on_event(ev_out_of_bound, rect); - assert!(msg.is_empty()); - - let ev_top_1 = Event::Key(Key::MouseHold(0, 0)); - let ev_top_2 = Event::Key(Key::MouseHold(29, 39)); - let ev_bottom_1 = Event::Key(Key::MouseHold(30, 40)); - let ev_bottom_2 = Event::Key(Key::MouseHold(31, 41)); - let ev_bottom_3 = Event::Key(Key::MouseHold(59, 79)); - let ev_out_of_bound = Event::Key(Key::MouseHold(60, 80)); - - let vsplit = VSplit::default().split(&win1).split(&win2); - - let msg = vsplit.on_event(ev_top_1, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(1), msg[0]); - let msg = vsplit.on_event(ev_top_2, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(1), msg[0]); - let msg = vsplit.on_event(ev_bottom_1, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(2), msg[0]); - let msg = vsplit.on_event(ev_bottom_2, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(2), msg[0]); - let msg = vsplit.on_event(ev_bottom_3, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(2), msg[0]); - let msg = vsplit.on_event(ev_out_of_bound, rect); - assert!(msg.is_empty()); - - // 1 | 2 - // --|-- - // 3 | 4 - let nested = HSplit::default() - .split(VSplit::default().split(&win1).split(&win3)) - .split(VSplit::default().split(&win2).split(&win4)); - let row_col_event = [ - ((0, 0), Message::Window(1)), - ((0, 39), Message::Window(1)), - ((29, 0), Message::Window(1)), - ((29, 39), Message::Window(1)), - ((0, 40), Message::Window(2)), - ((0, 79), Message::Window(2)), - ((29, 40), Message::Window(2)), - ((29, 79), Message::Window(2)), - ((30, 0), Message::Window(3)), - ((30, 39), Message::Window(3)), - ((59, 0), Message::Window(3)), - ((59, 39), Message::Window(3)), - ((30, 40), Message::Window(4)), - ((30, 79), Message::Window(4)), - ((59, 40), Message::Window(4)), - ((59, 79), Message::Window(4)), - ]; - - for &((row, col), event) in row_col_event.iter() { - let ev = Event::Key(MousePress(MouseButton::Left, row, col)); - let msg = nested.on_event(ev, rect); - assert_eq!(msg[0], event); - let ev = Event::Key(MouseRelease(row, col)); - let msg = nested.on_event(ev, rect); - assert_eq!(msg[0], event); - let ev = Event::Key(MouseHold(row, col)); - let msg = nested.on_event(ev, rect); - assert_eq!(msg[0], event); - let ev = Event::Key(SingleClick(MouseButton::Left, row, col)); - let msg = nested.on_event(ev, rect); - assert_eq!(msg[0], event); - let ev = Event::Key(DoubleClick(MouseButton::Left, row, col)); - let msg = nested.on_event(ev, rect); - assert_eq!(msg[0], event); - let ev = Event::Key(Key::WheelUp(row, col, 1)); - let msg = nested.on_event(ev, rect); - assert_eq!(msg[0], event); - let ev = Event::Key(Key::WheelDown(row, col, 1)); - let msg = nested.on_event(ev, rect); - assert_eq!(msg[0], event); - } - } - - #[test] - fn message_should_be_dispatched_correctly_mut() { - let width = 80; - let height = 60; - let rect = Rectangle { - top: 0, - left: 0, - width, - height, - }; - - let mut win1 = WindowWithId::new(1); - let mut win2 = WindowWithId::new(2); - let mut win3 = WindowWithId::new(3); - let mut win4 = WindowWithId::new(4); - - let ev_left_1 = Event::Key(Key::MouseHold(0, 0)); - let ev_left_2 = Event::Key(Key::MouseHold(0, 39)); - let ev_right_1 = Event::Key(Key::MouseHold(20, 40)); - let ev_right_2 = Event::Key(Key::MouseHold(20, 41)); - let ev_right_3 = Event::Key(Key::MouseHold(59, 79)); - let ev_out_of_bound = Event::Key(Key::MouseHold(60, 80)); - - { - let mut hsplit = HSplit::default().split(&mut win1).split(&mut win2); - let msg = hsplit.on_event_mut(ev_left_1, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(1), msg[0]); - let msg = hsplit.on_event_mut(ev_left_2, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(1), msg[0]); - let msg = hsplit.on_event_mut(ev_right_1, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(2), msg[0]); - let msg = hsplit.on_event_mut(ev_right_2, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(2), msg[0]); - let msg = hsplit.on_event_mut(ev_right_3, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(2), msg[0]); - let msg = hsplit.on_event_mut(ev_out_of_bound, rect); - assert!(msg.is_empty()); - } - - let ev_top_1 = Event::Key(Key::MouseHold(0, 0)); - let ev_top_2 = Event::Key(Key::MouseHold(29, 39)); - let ev_bottom_1 = Event::Key(Key::MouseHold(30, 40)); - let ev_bottom_2 = Event::Key(Key::MouseHold(31, 41)); - let ev_bottom_3 = Event::Key(Key::MouseHold(59, 79)); - let ev_out_of_bound = Event::Key(Key::MouseHold(60, 80)); - - { - let mut vsplit = VSplit::default().split(&mut win1).split(&mut win2); - - let msg = vsplit.on_event_mut(ev_top_1, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(1), msg[0]); - let msg = vsplit.on_event_mut(ev_top_2, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(1), msg[0]); - let msg = vsplit.on_event_mut(ev_bottom_1, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(2), msg[0]); - let msg = vsplit.on_event_mut(ev_bottom_2, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(2), msg[0]); - let msg = vsplit.on_event_mut(ev_bottom_3, rect); - assert!(!msg.is_empty()); - assert_eq!(Message::Window(2), msg[0]); - let msg = vsplit.on_event_mut(ev_out_of_bound, rect); - assert!(msg.is_empty()); - } - - // 1 | 2 - // --|-- - // 3 | 4 - { - let mut nested = HSplit::default() - .split(VSplit::default().split(&mut win1).split(&mut win3)) - .split(VSplit::default().split(&mut win2).split(&mut win4)); - let row_col_event = [ - ((0, 0), Message::Window(1)), - ((0, 39), Message::Window(1)), - ((29, 0), Message::Window(1)), - ((29, 39), Message::Window(1)), - ((0, 40), Message::Window(2)), - ((0, 79), Message::Window(2)), - ((29, 40), Message::Window(2)), - ((29, 79), Message::Window(2)), - ((30, 0), Message::Window(3)), - ((30, 39), Message::Window(3)), - ((59, 0), Message::Window(3)), - ((59, 39), Message::Window(3)), - ((30, 40), Message::Window(4)), - ((30, 79), Message::Window(4)), - ((59, 40), Message::Window(4)), - ((59, 79), Message::Window(4)), - ]; - - for &((row, col), event) in row_col_event.iter() { - let ev = Event::Key(MousePress(MouseButton::Left, row, col)); - let msg = nested.on_event_mut(ev, rect); - assert_eq!(msg[0], event); - let ev = Event::Key(MouseRelease(row, col)); - let msg = nested.on_event_mut(ev, rect); - assert_eq!(msg[0], event); - let ev = Event::Key(MouseHold(row, col)); - let msg = nested.on_event_mut(ev, rect); - assert_eq!(msg[0], event); - let ev = Event::Key(SingleClick(MouseButton::Left, row, col)); - let msg = nested.on_event_mut(ev, rect); - assert_eq!(msg[0], event); - let ev = Event::Key(DoubleClick(MouseButton::Left, row, col)); - let msg = nested.on_event_mut(ev, rect); - assert_eq!(msg[0], event); - let ev = Event::Key(Key::WheelUp(row, col, 1)); - let msg = nested.on_event_mut(ev, rect); - assert_eq!(msg[0], event); - let ev = Event::Key(Key::WheelDown(row, col, 1)); - let msg = nested.on_event_mut(ev, rect); - assert_eq!(msg[0], event); - } - } - } - - #[derive(PartialEq, Debug)] - enum Called { - No, - Mut, - Immut, - } - - struct Drawn { - called: Mutex, - } - - impl Draw for Drawn { - fn draw(&self, _canvas: &mut dyn Canvas) -> DrawResult<()> { - *self.called.lock().unwrap() = Called::Immut; - Ok(()) - } - fn draw_mut(&mut self, _canvas: &mut dyn Canvas) -> DrawResult<()> { - *self.called.lock().unwrap() = Called::Mut; - Ok(()) - } - } - - impl Widget for Drawn {} - - impl Split for Drawn { - fn get_basis(&self) -> Size { - Size::Default - } - - fn get_grow(&self) -> usize { - 1 - } - - fn get_shrink(&self) -> usize { - 1 - } - } - - #[test] - fn mutable_widget() { - let mut canvas = TestCanvas { width: 80, height: 80 }; - - let mut mutable = Drawn { - called: Mutex::new(Called::No), - }; - { - let mut hsplit = HSplit::default().split(&mut mutable); - hsplit.draw_mut(&mut canvas).unwrap(); - } - assert_eq!(Called::Mut, *mutable.called.lock().unwrap()); - - let mut mutable = Drawn { - called: Mutex::new(Called::No), - }; - { - let mut vsplit = VSplit::default().split(&mut mutable); - vsplit.draw_mut(&mut canvas).unwrap(); - } - assert_eq!(Called::Mut, *mutable.called.lock().unwrap()); - - let immutable = Drawn { - called: Mutex::new(Called::No), - }; - let hsplit = HSplit::default().split(&immutable); - hsplit.draw(&mut canvas).unwrap(); - assert_eq!(Called::Immut, *immutable.called.lock().unwrap()); - let immutable = Drawn { - called: Mutex::new(Called::No), - }; - let vsplit = VSplit::default().split(&immutable); - vsplit.draw(&mut canvas).unwrap(); - assert_eq!(Called::Immut, *immutable.called.lock().unwrap()); - } -} diff --git a/skim-tuikit/src/widget/stack.rs b/skim-tuikit/src/widget/stack.rs deleted file mode 100644 index 80fd3067..00000000 --- a/skim-tuikit/src/widget/stack.rs +++ /dev/null @@ -1,222 +0,0 @@ -use crate::canvas::Canvas; -use crate::draw::{Draw, DrawResult}; -use crate::event::Event; -use crate::widget::{Rectangle, Widget}; - -/// A stack of widgets, will draw the including widgets back to front -pub struct Stack<'a, Message = ()> { - inner: Vec + 'a>>, -} - -impl Default for Stack<'_, Message> { - fn default() -> Self { - Self::new() - } -} - -impl<'a, Message> Stack<'a, Message> { - pub fn new() -> Self { - Self { inner: vec![] } - } - - pub fn top(mut self, widget: impl Widget + 'a) -> Self { - self.inner.push(Box::new(widget)); - self - } - - pub fn bottom(mut self, widget: impl Widget + 'a) -> Self { - self.inner.insert(0, Box::new(widget)); - self - } -} - -impl Draw for Stack<'_, Message> { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - for widget in self.inner.iter() { - widget.draw(canvas)? - } - - Ok(()) - } - fn draw_mut(&mut self, canvas: &mut dyn Canvas) -> DrawResult<()> { - for widget in self.inner.iter_mut() { - widget.draw_mut(canvas)? - } - - Ok(()) - } -} - -impl Widget for Stack<'_, Message> { - fn size_hint(&self) -> (Option, Option) { - // max of the inner widgets - let width = self - .inner - .iter() - .map(|widget| widget.size_hint().0) - .max() - .unwrap_or(None); - let height = self - .inner - .iter() - .map(|widget| widget.size_hint().1) - .max() - .unwrap_or(None); - (width, height) - } - - fn on_event(&self, event: Event, rect: Rectangle) -> Vec { - // like javascript's capture, from top to bottom - for widget in self.inner.iter().rev() { - let message = widget.on_event(event, rect); - if !message.is_empty() { - return message; - } - } - vec![] - } - - fn on_event_mut(&mut self, event: Event, rect: Rectangle) -> Vec { - // like javascript's capture, from top to bottom - for widget in self.inner.iter_mut().rev() { - let message = widget.on_event_mut(event, rect); - if !message.is_empty() { - return message; - } - } - vec![] - } -} - -#[cfg(test)] -#[allow(dead_code)] -mod test { - use super::*; - use crate::cell::Cell; - use std::sync::Mutex; - - struct WinHint { - pub width_hint: Option, - pub height_hint: Option, - } - - impl Draw for WinHint { - fn draw(&self, _canvas: &mut dyn Canvas) -> DrawResult<()> { - unimplemented!() - } - } - - impl Widget for WinHint { - fn size_hint(&self) -> (Option, Option) { - (self.width_hint, self.height_hint) - } - } - - #[test] - fn size_hint() { - let stack = Stack::new().top(WinHint { - width_hint: None, - height_hint: None, - }); - assert_eq!((None, None), stack.size_hint()); - - let stack = Stack::new().top(WinHint { - width_hint: Some(1), - height_hint: Some(1), - }); - assert_eq!((Some(1), Some(1)), stack.size_hint()); - - let stack = Stack::new() - .top(WinHint { - width_hint: Some(1), - height_hint: Some(2), - }) - .top(WinHint { - width_hint: Some(2), - height_hint: Some(1), - }); - assert_eq!((Some(2), Some(2)), stack.size_hint()); - - let stack = Stack::new() - .top(WinHint { - width_hint: None, - height_hint: None, - }) - .top(WinHint { - width_hint: Some(2), - height_hint: Some(1), - }); - assert_eq!((Some(2), Some(1)), stack.size_hint()); - } - - #[derive(PartialEq, Debug)] - enum Called { - No, - Mut, - Immut, - } - - struct Drawn { - called: Mutex, - } - - impl Draw for Drawn { - fn draw(&self, _canvas: &mut dyn Canvas) -> DrawResult<()> { - *self.called.lock().unwrap() = Called::Immut; - Ok(()) - } - fn draw_mut(&mut self, _canvas: &mut dyn Canvas) -> DrawResult<()> { - *self.called.lock().unwrap() = Called::Mut; - Ok(()) - } - } - - impl Widget for Drawn {} - - #[derive(Default)] - struct TestCanvas {} - - #[allow(unused_variables)] - impl Canvas for TestCanvas { - fn size(&self) -> crate::Result<(usize, usize)> { - Ok((100, 100)) - } - - fn clear(&mut self) -> crate::Result<()> { - unimplemented!() - } - - fn put_cell(&mut self, row: usize, col: usize, cell: Cell) -> crate::Result { - Ok(1) - } - - fn set_cursor(&mut self, row: usize, col: usize) -> crate::Result<()> { - unimplemented!() - } - - fn show_cursor(&mut self, show: bool) -> crate::Result<()> { - unimplemented!() - } - } - - #[test] - fn mutable_widget() { - let mut canvas = TestCanvas::default(); - - let mut mutable = Drawn { - called: Mutex::new(Called::No), - }; - { - let mut stack = Stack::new().top(&mut mutable); - stack.draw_mut(&mut canvas).unwrap(); - } - assert_eq!(Called::Mut, *mutable.called.lock().unwrap()); - - let immutable = Drawn { - called: Mutex::new(Called::No), - }; - let stack = Stack::new().top(&immutable); - stack.draw(&mut canvas).unwrap(); - assert_eq!(Called::Immut, *immutable.called.lock().unwrap()); - } -} diff --git a/skim-tuikit/src/widget/util.rs b/skim-tuikit/src/widget/util.rs deleted file mode 100644 index fcb39077..00000000 --- a/skim-tuikit/src/widget/util.rs +++ /dev/null @@ -1,65 +0,0 @@ -use crate::event::Event; -use crate::key::Key; -use crate::widget::Rectangle; - -pub fn adjust_event(event: Event, inner_rect: Rectangle) -> Option { - match event { - Event::Key(Key::MousePress(button, row, col)) => { - if inner_rect.contains(row as usize, col as usize) { - let (row, col) = inner_rect.relative_to_origin(row as usize, col as usize); - Some(Event::Key(Key::MousePress(button, row as u16, col as u16))) - } else { - None - } - } - Event::Key(Key::MouseRelease(row, col)) => { - if inner_rect.contains(row as usize, col as usize) { - let (row, col) = inner_rect.relative_to_origin(row as usize, col as usize); - Some(Event::Key(Key::MouseRelease(row as u16, col as u16))) - } else { - None - } - } - Event::Key(Key::MouseHold(row, col)) => { - if inner_rect.contains(row as usize, col as usize) { - let (row, col) = inner_rect.relative_to_origin(row as usize, col as usize); - Some(Event::Key(Key::MouseHold(row as u16, col as u16))) - } else { - None - } - } - Event::Key(Key::SingleClick(button, row, col)) => { - if inner_rect.contains(row as usize, col as usize) { - let (row, col) = inner_rect.relative_to_origin(row as usize, col as usize); - Some(Event::Key(Key::SingleClick(button, row as u16, col as u16))) - } else { - None - } - } - Event::Key(Key::DoubleClick(button, row, col)) => { - if inner_rect.contains(row as usize, col as usize) { - let (row, col) = inner_rect.relative_to_origin(row as usize, col as usize); - Some(Event::Key(Key::DoubleClick(button, row as u16, col as u16))) - } else { - None - } - } - Event::Key(Key::WheelDown(row, col, count)) => { - if inner_rect.contains(row as usize, col as usize) { - let (row, col) = inner_rect.relative_to_origin(row as usize, col as usize); - Some(Event::Key(Key::WheelDown(row as u16, col as u16, count))) - } else { - None - } - } - Event::Key(Key::WheelUp(row, col, count)) => { - if inner_rect.contains(row as usize, col as usize) { - let (row, col) = inner_rect.relative_to_origin(row as usize, col as usize); - Some(Event::Key(Key::WheelUp(row as u16, col as u16, count))) - } else { - None - } - } - ev => Some(ev), - } -} diff --git a/skim-tuikit/src/widget/win.rs b/skim-tuikit/src/widget/win.rs deleted file mode 100644 index f49009ac..00000000 --- a/skim-tuikit/src/widget/win.rs +++ /dev/null @@ -1,736 +0,0 @@ -use super::Size; -use super::split::Split; -use super::util::adjust_event; -use super::{Rectangle, Widget}; -use crate::attr::Attr; -use crate::canvas::{BoundedCanvas, Canvas}; -use crate::cell::Cell; -use crate::draw::{Draw, DrawResult}; -use crate::event::Event; -use crate::widget::align::{AlignSelf, HorizontalAlign}; -use crate::{ok_or_return, some_or_return}; -use std::cmp::max; -use unicode_width::UnicodeWidthStr; - -type FnDrawHeader = dyn Fn(&mut dyn Canvas) -> DrawResult<()>; - -/// A Win is like a div in HTML, it has its margin/padding, and border -pub struct Win<'a, Message = ()> { - margin_top: Size, - margin_right: Size, - margin_bottom: Size, - margin_left: Size, - - padding_top: Size, - padding_right: Size, - padding_bottom: Size, - padding_left: Size, - - border_top: bool, - border_right: bool, - border_bottom: bool, - border_left: bool, - - border_top_attr: Attr, - border_right_attr: Attr, - border_bottom_attr: Attr, - border_left_attr: Attr, - - fn_draw_header: Option>, - title: Option, - title_attr: Attr, - right_prompt: Option, - right_prompt_attr: Attr, - title_align: HorizontalAlign, - title_on_top: bool, - - basis: Size, - grow: usize, - shrink: usize, - - inner: Box + 'a>, -} - -// Builder -impl<'a, Message> Win<'a, Message> { - pub fn new(widget: impl Widget + 'a) -> Self { - Self { - margin_top: Default::default(), - margin_right: Default::default(), - margin_bottom: Default::default(), - margin_left: Default::default(), - padding_top: Default::default(), - padding_right: Default::default(), - padding_bottom: Default::default(), - padding_left: Default::default(), - border_top: false, - border_right: false, - border_bottom: false, - border_left: false, - border_top_attr: Default::default(), - border_right_attr: Default::default(), - border_bottom_attr: Default::default(), - border_left_attr: Default::default(), - fn_draw_header: None, - title: None, - title_attr: Default::default(), - right_prompt: None, - right_prompt_attr: Default::default(), - title_align: HorizontalAlign::Left, - title_on_top: true, - basis: Size::Default, - grow: 1, - shrink: 1, - inner: Box::new(widget), - } - } - - pub fn margin_top(mut self, margin_top: impl Into) -> Self { - self.margin_top = margin_top.into(); - self - } - - pub fn margin_right(mut self, margin_right: impl Into) -> Self { - self.margin_right = margin_right.into(); - self - } - - pub fn margin_bottom(mut self, margin_bottom: impl Into) -> Self { - self.margin_bottom = margin_bottom.into(); - self - } - - pub fn margin_left(mut self, margin_left: impl Into) -> Self { - self.margin_left = margin_left.into(); - self - } - - pub fn margin(mut self, margin: impl Into) -> Self { - let margin = margin.into(); - self.margin_top = margin; - self.margin_right = margin; - self.margin_bottom = margin; - self.margin_left = margin; - self - } - - pub fn padding_top(mut self, padding_top: impl Into) -> Self { - self.padding_top = padding_top.into(); - self - } - - pub fn padding_right(mut self, padding_right: impl Into) -> Self { - self.padding_right = padding_right.into(); - self - } - - pub fn padding_bottom(mut self, padding_bottom: impl Into) -> Self { - self.padding_bottom = padding_bottom.into(); - self - } - - pub fn padding_left(mut self, padding_left: impl Into) -> Self { - self.padding_left = padding_left.into(); - self - } - - pub fn padding(mut self, padding: impl Into) -> Self { - let padding = padding.into(); - self.padding_top = padding; - self.padding_right = padding; - self.padding_bottom = padding; - self.padding_left = padding; - self - } - - pub fn border_top(mut self, border_top: bool) -> Self { - self.border_top = border_top; - self - } - - pub fn border_right(mut self, border_right: bool) -> Self { - self.border_right = border_right; - self - } - - pub fn border_bottom(mut self, border_bottom: bool) -> Self { - self.border_bottom = border_bottom; - self - } - - pub fn border_left(mut self, border_left: bool) -> Self { - self.border_left = border_left; - self - } - - pub fn border(mut self, border: bool) -> Self { - self.border_top = border; - self.border_right = border; - self.border_bottom = border; - self.border_left = border; - self - } - - pub fn border_top_attr(mut self, border_top_attr: impl Into) -> Self { - self.border_top_attr = border_top_attr.into(); - self - } - - pub fn border_right_attr(mut self, border_right_attr: impl Into) -> Self { - self.border_right_attr = border_right_attr.into(); - self - } - - pub fn border_bottom_attr(mut self, border_bottom_attr: impl Into) -> Self { - self.border_bottom_attr = border_bottom_attr.into(); - self - } - - pub fn border_left_attr(mut self, border_left_attr: impl Into) -> Self { - self.border_left_attr = border_left_attr.into(); - self - } - - pub fn border_attr(mut self, attr: impl Into) -> Self { - let attr = attr.into(); - self.border_top_attr = attr; - self.border_right_attr = attr; - self.border_bottom_attr = attr; - self.border_left_attr = attr; - self - } - - pub fn fn_draw_header(mut self, fn_draw_header: Box) -> Self { - self.fn_draw_header = Some(fn_draw_header); - self - } - - pub fn title(mut self, title: impl Into) -> Self { - self.title = Some(title.into()); - self - } - - pub fn title_attr(mut self, title_attr: impl Into) -> Self { - self.title_attr = title_attr.into(); - self - } - - pub fn right_prompt(mut self, right_prompt: impl Into) -> Self { - self.right_prompt = Some(right_prompt.into()); - self - } - - pub fn right_prompt_attr(mut self, right_prompt_attr: impl Into) -> Self { - self.right_prompt_attr = right_prompt_attr.into(); - self - } - - pub fn title_align(mut self, align: HorizontalAlign) -> Self { - self.title_align = align; - self - } - - pub fn title_on_top(mut self, title_on_top: bool) -> Self { - self.title_on_top = title_on_top; - self - } - - pub fn basis(mut self, basis: impl Into) -> Self { - self.basis = basis.into(); - self - } - - pub fn grow(mut self, grow: usize) -> Self { - self.grow = grow; - self - } - - pub fn shrink(mut self, shrink: usize) -> Self { - self.shrink = shrink; - self - } -} - -impl<'a, Message> Win<'a, Message> { - fn rect_reserve_margin(&self, rect: Rectangle) -> DrawResult { - let Rectangle { width, height, .. } = rect; - - let margin_top = self.margin_top.calc_fixed_size(height, 0); - let margin_right = self.margin_right.calc_fixed_size(width, 0); - let margin_bottom = self.margin_bottom.calc_fixed_size(height, 0); - let margin_left = self.margin_left.calc_fixed_size(width, 0); - - if margin_top + margin_bottom >= height || margin_left + margin_right >= width { - return Err("margin takes too much screen".into()); - } - - let top = margin_top; - let left = margin_left; - let width = width - (margin_left + margin_right); - let height = height - (margin_top + margin_bottom); - Ok(Rectangle { - top, - left, - width, - height, - }) - } - - fn rect_header(&self, rect_reserve_margin: Rectangle) -> Rectangle { - let Rectangle { - top, - mut left, - width, - height, - } = rect_reserve_margin; - - let new_top = if self.title_on_top { - top - } else { - max(top + height, 1) - 1 - }; - - let height_needed = if self.title_on_top && self.border_bottom { 2 } else { 1 }; - if height_needed > height { - // not enough space, don't draw at all - return Rectangle { - top: new_top, - left, - width, - height: 0, - }; - } - - let mut width_needed = 0; - if self.border_left { - width_needed += 1; - left += 1; - } - if self.border_right { - width_needed += 1; - } - if width_needed > width { - return Rectangle { - top: new_top, - left, - width: 0, - height, - }; - } - - Rectangle { - top: new_top, - left, - width: width - width_needed, - height: 1, - } - } - - fn rect_reserve_border(&self, rect: Rectangle) -> DrawResult { - let Rectangle { - top, - left, - width, - height, - } = rect; - - // title and right prompt will be displayed on top - let border_top = - self.border_top || (self.title_on_top && (self.title.is_some() || self.right_prompt.is_some())); - let border_bottom = - self.border_bottom || (!self.title_on_top && (self.title.is_some() || self.right_prompt.is_some())); - - if (border_top || border_bottom) && ((height < 1) || (border_top && border_bottom && height < 2)) { - return Err("not enough height for border".into()); - } - - if (self.border_left || self.border_right) - && ((width < 1) || (self.border_left && self.border_right && width < 2)) - { - return Err("not enough width for border".into()); - } - - let top = if border_top { top + 1 } else { top }; - let left = if self.border_left { left + 1 } else { left }; - let width = if self.border_left { width - 1 } else { width }; - let width = if self.border_right { width - 1 } else { width }; - let height = if border_top { height - 1 } else { height }; - let height = if border_bottom { height - 1 } else { height }; - - Ok(Rectangle { - top, - left, - width, - height, - }) - } - - fn rect_reserve_padding(&self, rect: Rectangle) -> DrawResult { - let Rectangle { - top, - left, - width, - height, - } = rect; - - let padding_top = self.padding_top.calc_fixed_size(height, 0); - let padding_right = self.padding_right.calc_fixed_size(width, 0); - let padding_bottom = self.padding_bottom.calc_fixed_size(height, 0); - let padding_left = self.padding_left.calc_fixed_size(width, 0); - - if padding_top + padding_bottom >= height || padding_left + padding_right >= width { - return Err("padding takes too much screen, won't draw".into()); - } - - let top = top + padding_top; - let left = left + padding_left; - let width = width - (padding_left + padding_right); - let height = height - (padding_top + padding_bottom); - Ok(Rectangle { - top, - left, - width, - height, - }) - } - - /// Calculate the inner rectangle(inside margin, border, padding) - fn calc_inner_rect(&self, rect: Rectangle) -> DrawResult { - self.rect_reserve_padding(self.rect_reserve_border(self.rect_reserve_margin(rect)?)?) - } - - /// draw border and return the position & size of the inner canvas - /// (top, left, width, height) - fn draw_border(&self, rect: Rectangle, canvas: &mut dyn Canvas) -> DrawResult<()> { - let Rectangle { - top, - left, - width, - height, - } = rect; - - if (self.border_top || self.border_bottom) - && ((height < 1) || (self.border_top && self.border_bottom && height < 2)) - { - return Err("not enough height for border".into()); - } - - if (self.border_left || self.border_right) - && ((width < 1) || (self.border_left && self.border_right && width < 2)) - { - return Err("not enough width for border".into()); - } - - let bottom = max(top + height, 1) - 1; - let right = max(left + width, 1) - 1; - - if self.border_top { - let _ = canvas.print_with_attr(top, left, &"─".repeat(width), self.border_top_attr); - } - - if self.border_bottom { - let _ = canvas.print_with_attr(bottom, left, &"─".repeat(width), self.border_bottom_attr); - } - - if self.border_left { - for i in top..(top + height) { - let _ = canvas.print_with_attr(i, left, "│", self.border_left_attr); - } - } - - if self.border_right { - for i in top..(top + height) { - let _ = canvas.print_with_attr(i, right, "│", self.border_right_attr); - } - } - - // draw 4 corners if necessary - - if self.border_top && self.border_left { - let _ = canvas.put_cell(top, left, Cell::default().ch('┌').attribute(self.border_top_attr)); - } - - if self.border_top && self.border_right { - let _ = canvas.put_cell(top, right, Cell::default().ch('┐').attribute(self.border_top_attr)); - } - - if self.border_bottom && self.border_left { - let _ = canvas.put_cell(bottom, left, Cell::default().ch('└').attribute(self.border_bottom_attr)); - } - - if self.border_bottom && self.border_right { - let _ = canvas.put_cell( - bottom, - right, - Cell::default().ch('┘').attribute(self.border_bottom_attr), - ); - } - - Ok(()) - } - - fn draw_title_and_prompt(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - let (width, height) = canvas.size()?; - let row = if self.title_on_top { 0 } else { max(height, 1) - 1 }; - - if self.right_prompt.is_some() { - let prompt = self.right_prompt.as_ref().unwrap(); - let text_width = prompt.width_cjk(); - let left = HorizontalAlign::Right.adjust(0, width, text_width); - canvas.print_with_attr(row, left, prompt, self.right_prompt_attr)?; - } - - if self.title.is_some() { - let title = self.title.as_ref().unwrap(); - let text_width = title.width_cjk(); - let left = self.title_align.adjust(0, width, text_width); - canvas.print_with_attr(row, left, title, self.right_prompt_attr)?; - } - - Ok(()) - } - - fn draw_header(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - let (width, height) = canvas.size()?; - if width == 0 || height == 0 { - return Ok(()); - } - - if self.fn_draw_header.is_some() { - self.fn_draw_header.as_ref().unwrap()(canvas)?; - } else { - self.draw_title_and_prompt(canvas)?; - } - - Ok(()) - } - - fn draw_context(&self, canvas: &'a mut dyn Canvas) -> DrawResult> { - let (width, height) = canvas.size()?; - let outer_rect = Rectangle { - top: 0, - left: 0, - width, - height, - }; - - let rect_in_margin = self.rect_reserve_margin(outer_rect)?; - self.draw_border(rect_in_margin, canvas)?; - - let Rectangle { - top, - left, - width, - height, - } = self.rect_header(rect_in_margin); - let mut header_canvas = BoundedCanvas::new(top, left, width, height, canvas); - self.draw_header(&mut header_canvas)?; - - let Rectangle { - top, - left, - width, - height, - } = self.calc_inner_rect(outer_rect)?; - - Ok(BoundedCanvas::new(top, left, width, height, canvas)) - } -} - -impl Draw for Win<'_, Message> { - /// Reserve margin & padding, draw border. - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - let mut new_canvas = self.draw_context(canvas)?; - self.inner.draw(&mut new_canvas) - } - - fn draw_mut(&mut self, canvas: &mut dyn Canvas) -> DrawResult<()> { - let mut new_canvas = self.draw_context(canvas)?; - self.inner.draw_mut(&mut new_canvas) - } -} - -impl Widget for Win<'_, Message> { - fn size_hint(&self) -> (Option, Option) { - // plus border size - let (width, height) = self.inner.size_hint(); - let width = width.map(|mut w| { - w += if self.border_left { 1 } else { 0 }; - w += if self.border_right { 1 } else { 0 }; - w - }); - - let height = height.map(|mut h| { - h += if self.border_top { 1 } else { 0 }; - h += if self.border_bottom { 1 } else { 0 }; - h - }); - - (width, height) - } - - fn on_event(&self, event: Event, rect: Rectangle) -> Vec { - let empty = vec![]; - let inner_rect = ok_or_return!(self.calc_inner_rect(rect), empty); - let adjusted_event = some_or_return!(adjust_event(event, inner_rect), empty); - self.inner.on_event(adjusted_event, inner_rect) - } - - fn on_event_mut(&mut self, event: Event, rect: Rectangle) -> Vec { - let empty = vec![]; - let inner_rect = ok_or_return!(self.calc_inner_rect(rect), empty); - let adjusted_event = some_or_return!(adjust_event(event, inner_rect), empty); - self.inner.on_event(adjusted_event, inner_rect) - } -} - -impl Split for Win<'_, Message> { - fn get_basis(&self) -> Size { - self.basis - } - - fn get_grow(&self) -> usize { - self.grow - } - - fn get_shrink(&self) -> usize { - self.shrink - } -} - -#[cfg(test)] -#[allow(dead_code)] -mod test { - use super::*; - use std::sync::Mutex; - - struct WinHint { - pub width_hint: Option, - pub height_hint: Option, - } - - impl Draw for WinHint { - fn draw(&self, _canvas: &mut dyn Canvas) -> DrawResult<()> { - unimplemented!() - } - } - - impl Widget for WinHint { - fn size_hint(&self) -> (Option, Option) { - (self.width_hint, self.height_hint) - } - } - - #[test] - fn size_hint_for_window_should_include_border() { - let inner = WinHint { - width_hint: None, - height_hint: None, - }; - let win_border_top = Win::new(&inner).border_top(true); - assert_eq!((None, None), win_border_top.size_hint()); - let win_border_right = Win::new(&inner).border_right(true); - assert_eq!((None, None), win_border_right.size_hint()); - let win_border_bottom = Win::new(&inner).border_bottom(true); - assert_eq!((None, None), win_border_bottom.size_hint()); - let win_border_left = Win::new(&inner).border_left(true); - assert_eq!((None, None), win_border_left.size_hint()); - - let inner = WinHint { - width_hint: Some(1), - height_hint: None, - }; - let win_border_top = Win::new(&inner).border_top(true); - assert_eq!((Some(1), None), win_border_top.size_hint()); - let win_border_right = Win::new(&inner).border_right(true); - assert_eq!((Some(2), None), win_border_right.size_hint()); - let win_border_bottom = Win::new(&inner).border_bottom(true); - assert_eq!((Some(1), None), win_border_bottom.size_hint()); - let win_border_left = Win::new(&inner).border_left(true); - assert_eq!((Some(2), None), win_border_left.size_hint()); - - let inner = WinHint { - width_hint: None, - height_hint: Some(1), - }; - let win_border_top = Win::new(&inner).border_top(true); - assert_eq!((None, Some(2)), win_border_top.size_hint()); - let win_border_right = Win::new(&inner).border_right(true); - assert_eq!((None, Some(1)), win_border_right.size_hint()); - let win_border_bottom = Win::new(&inner).border_bottom(true); - assert_eq!((None, Some(2)), win_border_bottom.size_hint()); - let win_border_left = Win::new(&inner).border_left(true); - assert_eq!((None, Some(1)), win_border_left.size_hint()); - } - - #[derive(PartialEq, Debug)] - enum Called { - No, - Mut, - Immut, - } - - struct Drawn { - called: Mutex, - } - - impl Draw for Drawn { - fn draw(&self, _canvas: &mut dyn Canvas) -> DrawResult<()> { - *self.called.lock().unwrap() = Called::Immut; - Ok(()) - } - fn draw_mut(&mut self, _canvas: &mut dyn Canvas) -> DrawResult<()> { - *self.called.lock().unwrap() = Called::Mut; - Ok(()) - } - } - - impl Widget for Drawn {} - - #[derive(Default)] - struct TestCanvas {} - - #[allow(unused_variables)] - impl Canvas for TestCanvas { - fn size(&self) -> crate::Result<(usize, usize)> { - Ok((100, 100)) - } - - fn clear(&mut self) -> crate::Result<()> { - unimplemented!() - } - - fn put_cell(&mut self, row: usize, col: usize, cell: Cell) -> crate::Result { - Ok(1) - } - - fn set_cursor(&mut self, row: usize, col: usize) -> crate::Result<()> { - unimplemented!() - } - - fn show_cursor(&mut self, show: bool) -> crate::Result<()> { - unimplemented!() - } - } - - #[test] - fn mutable_widget() { - let mut canvas = TestCanvas::default(); - - let mut mutable = Drawn { - called: Mutex::new(Called::No), - }; - { - let mut win = Win::new(&mut mutable); - win.draw_mut(&mut canvas).unwrap(); - } - assert_eq!(Called::Mut, *mutable.called.lock().unwrap()); - - let immutable = Drawn { - called: Mutex::new(Called::No), - }; - let win = Win::new(&immutable); - win.draw(&mut canvas).unwrap(); - assert_eq!(Called::Immut, *immutable.called.lock().unwrap()); - } -} diff --git a/skim/Cargo.toml b/skim/Cargo.toml index f145d9d2..ce6630a4 100644 --- a/skim/Cargo.toml +++ b/skim/Cargo.toml @@ -18,34 +18,43 @@ path = "src/lib.rs" [[bin]] name = "sk" path = "src/bin/main.rs" +required-features = ["cli"] [dependencies] beef = { workspace = true } -bitflags = { workspace = true } +bitflags = "2.10.0" chrono = { workspace = true } clap = { workspace = true, optional = true, features = ["cargo", "derive", "unstable-markdown"] } clap_complete = { workspace = true, optional = true } -crossbeam = { workspace = true } +clap_mangen = { workspace = true, optional = true } defer-drop = { workspace = true } derive_builder = { workspace = true } env_logger = { workspace = true, optional = true } -fuzzy-matcher = { workspace = true } indexmap = { workspace = true } log = { workspace = true } -nix = { workspace = true } +nix = { version = "0.30.1", features = ["fs"] } rand = { workspace = true } rayon = { workspace = true } regex = { workspace = true } shell-quote = { workspace = true } shlex = { workspace = true, optional = true } -skim-common = { path = "../skim-common/", version = "0.2.0" } -skim-tuikit = { path = "../skim-tuikit/", version = "0.6.6" } time = { workspace = true } timer = { workspace = true } +tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "sync", "time", "tokio-macros"] } unicode-width = { workspace = true } vte = { workspace = true } -which = { workspace = true } +which = "8.0.0" +ratatui = "0.29.0" +color-eyre = "0.6.5" +ansi-to-tui = "7.0.0" +futures = "0.3.31" +tokio-util = "0.7.17" +thiserror = "2.0.17" +tempfile = { workspace = true } +crossterm = { version = "0.28.1", features = ["event-stream", "use-dev-tty", "libc"] } # TODO remove libc feature after ratatui upgrades to crossterm 0.29+ +thread_local = "1.1.9" [features] default = ["cli"] -cli = ["dep:clap", "dep:clap_complete", "dep:shlex", "dep:env_logger"] +cli = ["dep:clap", "dep:clap_complete", "dep:shlex", "dep:env_logger", "dep:clap_mangen"] +compact_matcher = [] diff --git a/skim/examples/ansi.rs b/skim/examples/ansi.rs new file mode 100644 index 00000000..fcea2809 --- /dev/null +++ b/skim/examples/ansi.rs @@ -0,0 +1,30 @@ +extern crate skim; +use skim::{prelude::*, reader::CommandCollector}; + +pub fn main() { + env_logger::init(); + + let glogm = Some(String::from("git log --oneline --color=always | head -n10")); + + let options = SkimOptionsBuilder::default() + .height(String::from("50%")) + .cmd(glogm) + .preview(Some(String::from("echo {}"))) + .multi(true) + .reverse(true) + .cmd_collector(Rc::new(RefCell::new(SkimItemReader::new( + SkimItemReaderOption::default().ansi(true), + ))) as Rc>) + .build() + .unwrap(); + + log::debug!("Options: ansi {}", options.ansi); + + let selected_items = Skim::run_with(options, None) + .map(|out| out.selected_items) + .unwrap_or_default(); + + for item in selected_items.iter() { + print!("selected: {}{}", item.output(), "\n"); + } +} diff --git a/skim/examples/cmd_collector.rs b/skim/examples/cmd_collector.rs index 09042f58..d820439c 100644 --- a/skim/examples/cmd_collector.rs +++ b/skim/examples/cmd_collector.rs @@ -7,7 +7,7 @@ struct BasicSkimItem { } impl SkimItem for BasicSkimItem { - fn text(&self) -> Cow { + fn text(&self) -> Cow<'_, str> { Cow::Borrowed(&self.value) } } @@ -29,7 +29,7 @@ impl CommandCollector for BasicCmdCollector { } } -pub fn main() { +fn main() { let cmd_collector = BasicCmdCollector { items: vec![String::from("foo"), String::from("bar"), String::from("baz")], }; @@ -38,7 +38,7 @@ pub fn main() { .build() .unwrap(); - let selected_items = Skim::run_with(&options, None) + let selected_items = Skim::run_with(options, None) .map(|out| out.selected_items) .unwrap_or_default(); diff --git a/skim/examples/custom_item.rs b/skim/examples/custom_item.rs index 4220dc88..b77fcdcf 100644 --- a/skim/examples/custom_item.rs +++ b/skim/examples/custom_item.rs @@ -6,7 +6,7 @@ struct MyItem { } impl SkimItem for MyItem { - fn text(&self) -> Cow { + fn text(&self) -> Cow<'_, str> { Cow::Borrowed(&self.inner) } @@ -19,7 +19,7 @@ impl SkimItem for MyItem { } } -pub fn main() { +fn main() { let options = SkimOptionsBuilder::default() .height(String::from("50%")) .multi(true) @@ -39,7 +39,7 @@ pub fn main() { })); drop(tx_item); // so that skim could know when to stop waiting for more items. - let selected_items = Skim::run_with(&options, Some(rx_item)) + let selected_items = Skim::run_with(options, Some(rx_item)) .map(|out| out.selected_items) .unwrap_or_default(); diff --git a/skim/examples/custom_keybinding_actions.rs b/skim/examples/custom_keybinding_actions.rs index 48915a18..316fb91c 100644 --- a/skim/examples/custom_keybinding_actions.rs +++ b/skim/examples/custom_keybinding_actions.rs @@ -1,4 +1,5 @@ extern crate skim; +use crossterm::event::{KeyCode, KeyModifiers}; use skim::prelude::*; // No action is actually performed on your filesystem! @@ -12,22 +13,24 @@ fn fake_create_item(item: &str) { println!("Creating a new item `{item}`..."); } -pub fn main() { +fn main() { // Note: `accept` is a keyword used define custom actions. // For full list of accepted keywords see `parse_event` in `src/event.rs`. // `delete` and `create` are arbitrary keywords used for this example. let options = SkimOptionsBuilder::default() .multi(true) - .bind(vec![String::from("bs:abort"), String::from("Enter:accept")]) + .bind(vec!["bs:abort".into(), "enter:accept".into()]) .build() .unwrap(); - if let Some(out) = Skim::run_with(&options, None) { - match out.final_key { + if let Ok(out) = Skim::run_with(options, None) { + match (out.final_key.code, out.final_key.modifiers) { // Delete each selected item - Key::Backspace => out.selected_items.iter().for_each(|i| fake_delete_item(&i.text())), + (KeyCode::Backspace, KeyModifiers::NONE) => { + out.selected_items.iter().for_each(|i| fake_delete_item(&i.text())) + } // Create a new item based on the query - Key::Enter => fake_create_item(out.query.as_ref()), + (KeyCode::Enter, KeyModifiers::NONE) => fake_create_item(out.query.as_ref()), _ => (), } }; diff --git a/skim/examples/downcast.rs b/skim/examples/downcast.rs index ba599fb2..d75f2242 100644 --- a/skim/examples/downcast.rs +++ b/skim/examples/downcast.rs @@ -11,7 +11,7 @@ struct Item { } impl SkimItem for Item { - fn text(&self) -> Cow { + fn text(&self) -> Cow<'_, str> { Cow::Borrowed(&self.text) } @@ -56,7 +56,7 @@ pub fn main() { drop(tx); - let selected_items = Skim::run_with(&options, Some(rx)) + let selected_items = Skim::run_with(options, Some(rx)) .map(|out| out.selected_items) .unwrap_or_default() .iter() diff --git a/skim/examples/fuzzy_matcher_fz.rs b/skim/examples/fuzzy_matcher_fz.rs new file mode 100644 index 00000000..54dbfa79 --- /dev/null +++ b/skim/examples/fuzzy_matcher_fz.rs @@ -0,0 +1,66 @@ +use skim::fuzzy_matcher::FuzzyMatcher; +use skim::fuzzy_matcher::clangd::ClangdMatcher; +use skim::fuzzy_matcher::skim::SkimMatcherV2; +use std::env; +use std::io::{self, BufRead}; +use std::process::exit; + +#[cfg(not(feature = "compact_matcher"))] +type IndexType = usize; +#[cfg(feature = "compact_matcher")] +type IndexType = u32; + +pub fn main() { + let args: Vec = env::args().collect(); + + // arg parsing (manually) + let mut arg_iter = args.iter().skip(1); + let mut pattern = "".to_string(); + let mut algorithm = Some("skim"); + + while let Some(arg) = arg_iter.next() { + if arg == "--algo" { + algorithm = arg_iter.next().map(String::as_ref); + } else { + pattern = arg.to_string(); + } + } + + if &pattern == "" { + eprintln!("Usage: echo | fz --algo [skim|clangd] "); + exit(1); + } + + let matcher: Box = match algorithm { + Some("skim") | Some("skim_v2") => Box::new(SkimMatcherV2::default()), + Some("clangd") => Box::new(ClangdMatcher::default()), + _ => panic!("Algorithm not supported: {:?}", algorithm), + }; + + let stdin = io::stdin(); + for line in stdin.lock().lines() { + if let Ok(line) = line { + if let Some((score, indices)) = matcher.fuzzy_indices(&line, &pattern) { + println!("{:8}: {}", score, wrap_matches(&line, &indices)); + } + } + } +} + +fn wrap_matches(line: &str, indices: &[IndexType]) -> String { + let mut ret = String::new(); + let mut peekable = indices.iter().peekable(); + let ansi_invert: &str = str::from_utf8(&[27, b'[', b'7', b'm']).unwrap(); + let ansi_reset: &str = str::from_utf8(&[27, b'[', b'0', b'm']).unwrap(); + for (idx, ch) in line.chars().enumerate() { + let next_id = **peekable.peek().unwrap_or(&&(line.len() as IndexType)); + if next_id == (idx as IndexType) { + ret.push_str(format!("{}{}{}", ansi_invert, ch, ansi_reset).as_str()); + peekable.next(); + } else { + ret.push(ch); + } + } + + ret +} diff --git a/skim/examples/nth.rs b/skim/examples/nth.rs index 975c47b0..996431d6 100644 --- a/skim/examples/nth.rs +++ b/skim/examples/nth.rs @@ -14,7 +14,7 @@ pub fn main() { let item_reader = SkimItemReader::new(SkimItemReaderOption::default().nth(vec!["2"].into_iter()).build()); let items = item_reader.of_bufread(Cursor::new(input)); - let selected_items = Skim::run_with(&options, Some(items)) + let selected_items = Skim::run_with(options, Some(items)) .map(|out| out.selected_items) .unwrap_or_default(); diff --git a/skim/examples/option_builder.rs b/skim/examples/option_builder.rs index 0654913d..bb1bb0f7 100644 --- a/skim/examples/option_builder.rs +++ b/skim/examples/option_builder.rs @@ -3,18 +3,18 @@ use skim::prelude::*; use std::io::Cursor; pub fn main() { + let item_reader = SkimItemReader::default(); + + //================================================== + // first run let options = SkimOptionsBuilder::default() .height(String::from("50%")) .multi(true) .build() .unwrap(); - let item_reader = SkimItemReader::default(); - - //================================================== - // first run let input = "aaaaa\nbbbb\nccc"; let items = item_reader.of_bufread(Cursor::new(input)); - let selected_items = Skim::run_with(&options, Some(items)) + let selected_items = Skim::run_with(options, Some(items)) .map(|out| out.selected_items) .unwrap_or_default(); @@ -24,9 +24,14 @@ pub fn main() { //================================================== // second run + let options = SkimOptionsBuilder::default() + .height(String::from("50%")) + .multi(true) + .build() + .unwrap(); let input = "11111\n22222\n333333333"; let items = item_reader.of_bufread(Cursor::new(input)); - let selected_items = Skim::run_with(&options, Some(items)) + let selected_items = Skim::run_with(options, Some(items)) .map(|out| out.selected_items) .unwrap_or_default(); diff --git a/skim/examples/preview_callback.rs b/skim/examples/preview_callback.rs index ba4efe54..681260c3 100644 --- a/skim/examples/preview_callback.rs +++ b/skim/examples/preview_callback.rs @@ -18,7 +18,7 @@ pub fn main() { let input = "aaaaa\nbbbb\nccc"; let items = item_reader.of_bufread(Cursor::new(input)); - let selected_items = Skim::run_with(&options, Some(items)) + let selected_items = Skim::run_with(options, Some(items)) .map(|out| out.selected_items) .unwrap_or_default(); diff --git a/skim/examples/receiver_multi.rs b/skim/examples/receiver_multi.rs new file mode 100644 index 00000000..c10b1329 --- /dev/null +++ b/skim/examples/receiver_multi.rs @@ -0,0 +1,19 @@ +use std::sync::Arc; + +use skim::prelude::*; + +fn main() { + let (sender, receiver) = unbounded::>(); + for num in 1..=8 { + sender.send(Arc::new(format!("Option {num}"))).unwrap(); + } + drop(sender); // bug replicates even without this + + let _ = Skim::run_with( + SkimOptions { + multi: true, + ..Default::default() + }, + Some(receiver), + ); +} diff --git a/skim/examples/sample.rs b/skim/examples/sample.rs index 527d7b9d..24e98356 100644 --- a/skim/examples/sample.rs +++ b/skim/examples/sample.rs @@ -4,7 +4,7 @@ use skim::prelude::*; pub fn main() { let options = SkimOptions::default(); - let selected_items = Skim::run_with(&options, None) + let selected_items = Skim::run_with(options, None) .map(|out| out.selected_items) .unwrap_or_default(); diff --git a/skim/examples/selector.rs b/skim/examples/selector.rs index c1a62d22..e0f5f846 100644 --- a/skim/examples/selector.rs +++ b/skim/examples/selector.rs @@ -22,7 +22,7 @@ pub fn main() { .build() .unwrap(); - let selected_items = Skim::run_with(&options, None) + let selected_items = Skim::run_with(options, None) .map(|out| out.selected_items) .unwrap_or_default(); diff --git a/skim/examples/tuikit.rs b/skim/examples/tuikit.rs deleted file mode 100644 index 0b34318e..00000000 --- a/skim/examples/tuikit.rs +++ /dev/null @@ -1,30 +0,0 @@ -use skim::tuikit::prelude::*; -use std::cmp::{max, min}; - -fn main() { - let term: Term<()> = Term::with_height(TermHeight::Percent(30)).unwrap(); - let mut row = 1; - let mut col = 0; - - let _ = term.print(0, 0, "press arrow key to move the text, (q) to quit"); - let _ = term.present(); - - while let Ok(ev) = term.poll_event() { - let _ = term.clear(); - let _ = term.print(0, 0, "press arrow key to move the text, (q) to quit"); - - let (width, height) = term.term_size().unwrap(); - match ev { - Event::Key(Key::ESC) | Event::Key(Key::Char('q')) | Event::Key(Key::Ctrl('c')) => break, - Event::Key(Key::Up) => row = max(row - 1, 1), - Event::Key(Key::Down) => row = min(row + 1, height - 1), - Event::Key(Key::Left) => col = max(col, 1) - 1, - Event::Key(Key::Right) => col = min(col + 1, width - 1), - _ => {} - } - - let _ = term.print_with_attr(row, col, "Hello World! 你好!今日は。", Color::RED); - let _ = term.set_cursor(row, col); - let _ = term.present(); - } -} diff --git a/skim/src/ansi.rs b/skim/src/ansi.rs deleted file mode 100644 index fc00f1dd..00000000 --- a/skim/src/ansi.rs +++ /dev/null @@ -1,662 +0,0 @@ -// Parse ANSI attr code -use std::default::Default; - -use beef::lean::Cow; -use skim_tuikit::prelude::*; -use std::cmp::max; -use vte::{Params, Perform}; - -/// An ANSI Parser, will parse one line at a time. -/// -/// It will cache the latest attribute used, that means if an attribute affect multiple -/// lines, the parser will recognize it. -#[derive(Debug, Default)] -pub struct ANSIParser { - partial_str: String, - last_attr: Attr, - - stripped: String, - stripped_char_count: usize, - fragments: Vec<(Attr, (u32, u32))>, // [char_index_start, char_index_end) -} - -impl Perform for ANSIParser { - fn print(&mut self, ch: char) { - self.partial_str.push(ch); - } - - fn execute(&mut self, byte: u8) { - match byte { - // \b to delete character back - 0x08 => { - self.partial_str.pop(); - } - // put back \0 \r \n \t - 0x00 | 0x0d | 0x0A | 0x09 => self.partial_str.push(byte as char), - // ignore all others - _ => trace!("AnsiParser:execute ignored {byte:?}"), - } - } - - fn hook(&mut self, params: &Params, _intermediates: &[u8], _ignore: bool, _action: char) { - trace!("AnsiParser:hook ignored {params:?}"); - } - - fn put(&mut self, byte: u8) { - trace!("AnsiParser:put ignored {byte:?}"); - } - - fn unhook(&mut self) { - trace!("AnsiParser:unhook ignored"); - } - - fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) { - trace!("AnsiParser:osc ignored {params:?}"); - } - - fn csi_dispatch(&mut self, params: &Params, _intermediates: &[u8], _ignore: bool, action: char) { - // https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters - // Only care about graphic modes, ignore all others - - if action != 'm' { - trace!("ignore: params: {params:?}, action : {action:?}"); - return; - } - - // \[[m => means reset - let mut attr = if params.is_empty() { - Attr::default() - } else { - self.last_attr - }; - - let mut iter = params.iter(); - while let Some(code) = iter.next() { - match code[0] { - 0 => attr = Attr::default(), - 1 => attr.effect |= Effect::BOLD, - 2 => attr.effect |= Effect::DIM, - 4 => attr.effect |= Effect::UNDERLINE, - 5 => attr.effect |= Effect::BLINK, - 7 => attr.effect |= Effect::REVERSE, - num @ 30..=37 => attr.fg = Color::AnsiValue((num - 30) as u8), - 38 => match iter.next() { - Some(&[2]) => { - // ESC[ 38;2;;; m Select RGB foreground color - let (r, g, b) = match (iter.next(), iter.next(), iter.next()) { - (Some(r), Some(g), Some(b)) => (r[0] as u8, g[0] as u8, b[0] as u8), - _ => { - trace!("ignore CSI {params:?} m"); - continue; - } - }; - - attr.fg = Color::Rgb(r, g, b); - } - Some(&[5]) => { - // ESC[ 38;5; m Select foreground color - let color = match iter.next() { - Some(color) => color[0] as u8, - None => { - trace!("ignore CSI {params:?} m"); - continue; - } - }; - - attr.fg = Color::AnsiValue(color); - } - _ => { - trace!("error on parsing CSI {params:?} m"); - } - }, - 39 => attr.fg = Color::Default, - num @ 40..=47 => attr.bg = Color::AnsiValue((num - 40) as u8), - 48 => match iter.next() { - Some(&[2]) => { - // ESC[ 48;2;;; m Select RGB background color - let (r, g, b) = match (iter.next(), iter.next(), iter.next()) { - (Some(r), Some(g), Some(b)) => (r[0] as u8, g[0] as u8, b[0] as u8), - _ => { - trace!("ignore CSI {params:?} m"); - continue; - } - }; - - attr.bg = Color::Rgb(r, g, b); - } - Some(&[5]) => { - // ESC[ 48;5; m Select background color - let color = match iter.next() { - Some(color) => color[0] as u8, - None => { - trace!("ignore CSI {params:?} m"); - continue; - } - }; - - attr.bg = Color::AnsiValue(color); - } - _ => { - trace!("ignore CSI {params:?} m"); - } - }, - 49 => attr.bg = Color::Default, - num @ 90..=97 => attr.fg = Color::AnsiValue((num - 82) as u8), - num @ 100..=107 => attr.bg = Color::AnsiValue((num - 92) as u8), - _ => { - trace!("ignore CSI {params:?} m"); - } - } - } - - self.attr_change(attr); - } - - fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) { - // ESC characters are replaced with \[ - self.partial_str.push('"'); - self.partial_str.push('['); - } -} - -impl ANSIParser { - /// save the partial_str into fragments with current attr - fn save_str(&mut self) { - if self.partial_str.is_empty() { - return; - } - - let string = std::mem::take(&mut self.partial_str); - let string_char_count = string.chars().count(); - self.fragments.push(( - self.last_attr, - ( - self.stripped_char_count as u32, - (self.stripped_char_count + string_char_count) as u32, - ), - )); - self.stripped_char_count += string_char_count; - self.stripped.push_str(&string); - } - - // accept a new attr - fn attr_change(&mut self, new_attr: Attr) { - if new_attr == self.last_attr { - return; - } - - self.save_str(); - self.last_attr = new_attr; - } - - pub fn parse_ansi(&mut self, text: &str) -> AnsiString<'static> { - let mut statemachine = vte::Parser::new(); - - statemachine.advance(self, text.as_bytes()); - self.save_str(); - - let stripped = std::mem::take(&mut self.stripped); - self.stripped_char_count = 0; - let fragments = std::mem::take(&mut self.fragments); - AnsiString::new_string(stripped, fragments) - } -} - -/// A String that contains ANSI state (e.g. colors) -/// -/// It is internally represented as Vec<(attr, string)> -#[derive(Clone, Debug)] -pub struct AnsiString<'a> { - stripped: Cow<'a, str>, - // attr: start, end - fragments: Option>, -} - -impl<'a> AnsiString<'a> { - pub fn new_empty() -> Self { - Self { - stripped: Cow::borrowed(""), - fragments: None, - } - } - - fn new_raw_string(string: String) -> Self { - Self { - stripped: Cow::owned(string), - fragments: None, - } - } - - fn new_raw_str(str_ref: &'a str) -> Self { - Self { - stripped: Cow::borrowed(str_ref), - fragments: None, - } - } - - /// assume the fragments are ordered by (start, end) while end is exclusive - pub fn new_str(stripped: &'a str, fragments: Vec<(Attr, (u32, u32))>) -> Self { - let fragments_empty = fragments.is_empty() || (fragments.len() == 1 && fragments[0].0 == Attr::default()); - Self { - stripped: Cow::borrowed(stripped), - fragments: if fragments_empty { None } else { Some(fragments) }, - } - } - - /// assume the fragments are ordered by (start, end) while end is exclusive - pub fn new_string(stripped: String, fragments: Vec<(Attr, (u32, u32))>) -> Self { - let fragments_empty = fragments.is_empty() || (fragments.len() == 1 && fragments[0].0 == Attr::default()); - Self { - stripped: Cow::owned(stripped), - fragments: if fragments_empty { None } else { Some(fragments) }, - } - } - - pub fn parse(raw: &'a str) -> AnsiString<'static> { - ANSIParser::default().parse_ansi(raw) - } - - #[inline] - pub fn is_empty(&self) -> bool { - self.stripped.is_empty() - } - - #[inline] - pub fn into_inner(self) -> std::borrow::Cow<'a, str> { - std::borrow::Cow::Owned(self.stripped.into_owned()) - } - - pub fn iter(&'a self) -> Box + 'a> { - if self.fragments.is_none() { - return Box::new(self.stripped.chars().map(|c| (c, Attr::default()))); - } - - Box::new(AnsiStringIterator::new( - &self.stripped, - self.fragments.as_ref().unwrap(), - )) - } - - pub fn has_attrs(&self) -> bool { - self.fragments.is_some() - } - - #[inline] - pub fn stripped(&self) -> &str { - &self.stripped - } - - pub fn override_attrs(&mut self, attrs: Vec<(Attr, (u32, u32))>) { - if attrs.is_empty() { - // pass - } else if self.fragments.is_none() { - self.fragments = Some(attrs); - } else { - let current_fragments = self.fragments.take().expect("unreachable"); - let new_fragments = merge_fragments(¤t_fragments, &attrs); - self.fragments.replace(new_fragments); - } - } -} - -impl<'a> From<&'a str> for AnsiString<'a> { - fn from(s: &'a str) -> AnsiString<'a> { - AnsiString::new_raw_str(s) - } -} - -impl From for AnsiString<'static> { - fn from(s: String) -> Self { - AnsiString::new_raw_string(s) - } -} - -// (text, indices, highlight attribute) -> AnsiString -impl<'a> From<(&'a str, &'a [usize], Attr)> for AnsiString<'a> { - fn from((text, indices, attr): (&'a str, &'a [usize], Attr)) -> Self { - let fragments = indices - .iter() - .map(|&idx| (attr, (idx as u32, 1 + idx as u32))) - .collect(); - AnsiString::new_str(text, fragments) - } -} - -impl<'a> std::ops::Add for AnsiString<'a> { - type Output = AnsiString<'a>; - - fn add(mut self, rhs: Self) -> Self::Output { - let len = self.stripped.as_ref().len() as u32; - if let Some(fragments) = rhs.fragments { - if self.fragments.is_none() { - self.fragments = Some(vec![]); - } - for (attr, (start, end)) in fragments.iter() { - self.fragments.as_mut().unwrap().push((*attr, (start + len, end + len))); - } - } - self.stripped = Cow::owned(self.stripped.into_owned() + rhs.stripped.as_ref()); - self - } -} - -/// An iterator over all the (char, attr) characters. -pub struct AnsiStringIterator<'a> { - fragments: &'a [(Attr, (u32, u32))], - fragment_idx: usize, - chars_iter: std::iter::Enumerate>, -} - -impl<'a> AnsiStringIterator<'a> { - pub fn new(stripped: &'a str, fragments: &'a [(Attr, (u32, u32))]) -> Self { - Self { - fragments, - fragment_idx: 0, - chars_iter: stripped.chars().enumerate(), - } - } -} - -impl Iterator for AnsiStringIterator<'_> { - type Item = (char, Attr); - - fn next(&mut self) -> Option { - match self.chars_iter.next() { - Some((char_idx, char)) => { - // update fragment_idx - loop { - if self.fragment_idx >= self.fragments.len() { - break; - } - - let (_attr, (_start, end)) = self.fragments[self.fragment_idx]; - if char_idx < (end as usize) { - break; - } else { - self.fragment_idx += 1; - } - } - - let (attr, (start, end)) = if self.fragment_idx >= self.fragments.len() { - (Attr::default(), (char_idx as u32, 1 + char_idx as u32)) - } else { - self.fragments[self.fragment_idx] - }; - - if (start as usize) <= char_idx && char_idx < (end as usize) { - Some((char, attr)) - } else { - Some((char, Attr::default())) - } - } - None => None, - } - } -} - -fn merge_fragments(old: &[(Attr, (u32, u32))], new: &[(Attr, (u32, u32))]) -> Vec<(Attr, (u32, u32))> { - let mut ret = vec![]; - let mut i = 0; - let mut j = 0; - let mut os = 0; - - while i < old.len() && j < new.len() { - let (oa, (o_start, oe)) = old[i]; - let (na, (ns, ne)) = new[j]; - os = max(os, o_start); - - if ns <= os && ne >= oe { - // [--old--] | [--old--] | [--old--] | [--old--] - // [----new----] | [---new---] | [---new---] | [--new--] - i += 1; // skip old - } else if ns <= os { - // [--old--] | [--old--] | [--old--] | [---old---] - // [--new--] | [--new--] | [--new--] | [--new--] - ret.push((na, (ns, ne))); - os = ne; - j += 1; - } else if ns >= oe { - // [--old--] | [--old--] - // [--new--] | [--new--] - ret.push((oa, (os, oe))); - i += 1; - } else { - // [---old---] | [---old---] | [--old--] - // [--new--] | [--new--] | [--new--] - ret.push((oa, (os, ns))); - os = ns; - } - } - - if i < old.len() { - for &(oa, (s, e)) in old[i..].iter() { - ret.push((oa, (max(os, s), e))) - } - } - if j < new.len() { - ret.extend_from_slice(&new[j..]); - } - - ret -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_ansi_iterator() { - let input = "\x1B[48;2;5;10;15m\x1B[38;2;70;130;180mhi\x1B[0m"; - let ansistring = ANSIParser::default().parse_ansi(input); - let mut it = ansistring.iter(); - let attr = Attr { - fg: Color::Rgb(70, 130, 180), - bg: Color::Rgb(5, 10, 15), - ..Attr::default() - }; - - assert_eq!(Some(('h', attr)), it.next()); - assert_eq!(Some(('i', attr)), it.next()); - assert_eq!(None, it.next()); - assert_eq!(ansistring.stripped(), "hi"); - } - - #[test] - fn test_highlight_indices() { - let text = "abc"; - let indices: Vec = vec![1]; - let attr = Attr { - fg: Color::Rgb(70, 130, 180), - bg: Color::Rgb(5, 10, 15), - ..Attr::default() - }; - - let ansistring = AnsiString::from((text, &indices as &[usize], attr)); - let mut it = ansistring.iter(); - - assert_eq!(Some(('a', Attr::default())), it.next()); - assert_eq!(Some(('b', attr)), it.next()); - assert_eq!(Some(('c', Attr::default())), it.next()); - assert_eq!(None, it.next()); - } - - #[test] - fn test_normal_string() { - let input = "ab"; - let ansistring = ANSIParser::default().parse_ansi(input); - - assert!(!ansistring.has_attrs()); - - let mut it = ansistring.iter(); - assert_eq!(Some(('a', Attr::default())), it.next()); - assert_eq!(Some(('b', Attr::default())), it.next()); - assert_eq!(None, it.next()); - - assert_eq!(ansistring.stripped(), "ab"); - } - - #[test] - fn test_multiple_attributes() { - let input = "\x1B[1;31mhi"; - let ansistring = ANSIParser::default().parse_ansi(input); - let mut it = ansistring.iter(); - let attr = Attr { - fg: Color::AnsiValue(1), - effect: Effect::BOLD, - ..Attr::default() - }; - - assert_eq!(Some(('h', attr)), it.next()); - assert_eq!(Some(('i', attr)), it.next()); - assert_eq!(None, it.next()); - assert_eq!(ansistring.stripped(), "hi"); - } - - #[test] - fn test_reset() { - let input = "\x1B[35mA\x1B[mB"; - let ansistring = ANSIParser::default().parse_ansi(input); - assert_eq!(ansistring.fragments.as_ref().map(|x| x.len()).unwrap(), 2); - assert_eq!(ansistring.stripped(), "AB"); - } - - #[test] - fn test_multi_bytes() { - let input = "中`\x1B[0m\x1B[1m\x1B[31mXYZ\x1B[0ms`"; - let ansistring = ANSIParser::default().parse_ansi(input); - let mut it = ansistring.iter(); - let default_attr = Attr::default(); - let annotated = Attr { - fg: Color::AnsiValue(1), - effect: Effect::BOLD, - ..default_attr - }; - - assert_eq!(Some(('中', default_attr)), it.next()); - assert_eq!(Some(('`', default_attr)), it.next()); - assert_eq!(Some(('X', annotated)), it.next()); - assert_eq!(Some(('Y', annotated)), it.next()); - assert_eq!(Some(('Z', annotated)), it.next()); - assert_eq!(Some(('s', default_attr)), it.next()); - assert_eq!(Some(('`', default_attr)), it.next()); - assert_eq!(None, it.next()); - } - - #[test] - fn test_merge_fragments() { - let ao = Attr::default(); - let an = Attr::default().bg(Color::BLUE); - - assert_eq!( - merge_fragments(&[(ao, (0, 1)), (ao, (1, 2))], &[]), - vec![(ao, (0, 1)), (ao, (1, 2))] - ); - - assert_eq!( - merge_fragments(&[], &[(an, (0, 1)), (an, (1, 2))]), - vec![(an, (0, 1)), (an, (1, 2))] - ); - - assert_eq!( - merge_fragments(&[(ao, (1, 3)), (ao, (5, 6)), (ao, (9, 10))], &[(an, (0, 1))]), - vec![(an, (0, 1)), (ao, (1, 3)), (ao, (5, 6)), (ao, (9, 10))] - ); - - assert_eq!( - merge_fragments(&[(ao, (1, 3)), (ao, (5, 7)), (ao, (9, 11))], &[(an, (0, 2))]), - vec![(an, (0, 2)), (ao, (2, 3)), (ao, (5, 7)), (ao, (9, 11))] - ); - - assert_eq!( - merge_fragments(&[(ao, (1, 3)), (ao, (5, 7)), (ao, (9, 11))], &[(an, (0, 3))]), - vec![(an, (0, 3)), (ao, (5, 7)), (ao, (9, 11))] - ); - - assert_eq!( - merge_fragments( - &[(ao, (1, 3)), (ao, (5, 7)), (ao, (9, 11))], - &[(an, (0, 6)), (an, (6, 7))] - ), - vec![(an, (0, 6)), (an, (6, 7)), (ao, (9, 11))] - ); - - assert_eq!( - merge_fragments(&[(ao, (1, 3)), (ao, (5, 7)), (ao, (9, 11))], &[(an, (1, 2))]), - vec![(an, (1, 2)), (ao, (2, 3)), (ao, (5, 7)), (ao, (9, 11))] - ); - - assert_eq!( - merge_fragments(&[(ao, (1, 3)), (ao, (5, 7)), (ao, (9, 11))], &[(an, (1, 3))]), - vec![(an, (1, 3)), (ao, (5, 7)), (ao, (9, 11))] - ); - - assert_eq!( - merge_fragments(&[(ao, (1, 3)), (ao, (5, 7)), (ao, (9, 11))], &[(an, (1, 4))]), - vec![(an, (1, 4)), (ao, (5, 7)), (ao, (9, 11))] - ); - - assert_eq!( - merge_fragments(&[(ao, (1, 3)), (ao, (5, 7)), (ao, (9, 11))], &[(an, (2, 3))]), - vec![(ao, (1, 2)), (an, (2, 3)), (ao, (5, 7)), (ao, (9, 11))] - ); - - assert_eq!( - merge_fragments(&[(ao, (1, 3)), (ao, (5, 7)), (ao, (9, 11))], &[(an, (2, 4))]), - vec![(ao, (1, 2)), (an, (2, 4)), (ao, (5, 7)), (ao, (9, 11))] - ); - - assert_eq!( - merge_fragments(&[(ao, (1, 3)), (ao, (5, 7)), (ao, (9, 11))], &[(an, (2, 6))]), - vec![(ao, (1, 2)), (an, (2, 6)), (ao, (6, 7)), (ao, (9, 11))] - ); - } - - #[test] - fn test_ansi_string_add() { - let default_attr = Attr::default(); - let ar = Attr::default().bg(Color::RED); - let ab = Attr::default().bg(Color::BLUE); - - let string_a = AnsiString::new_str("foo", vec![(ar, (1, 3))]); - let string_b = AnsiString::new_str("bar", vec![(ab, (0, 2))]); - let string_c = string_a + string_b; - - let mut it = string_c.iter(); - assert_eq!(Some(('f', default_attr)), it.next()); - assert_eq!(Some(('o', ar)), it.next()); - assert_eq!(Some(('o', ar)), it.next()); - assert_eq!(Some(('b', ab)), it.next()); - assert_eq!(Some(('a', ab)), it.next()); - assert_eq!(Some(('r', default_attr)), it.next()); - assert_eq!(None, it.next()); - } - - #[test] - fn test_multi_byte_359() { - // https://github.com/lotabout/skim/issues/359 - let highlight = Attr::default().effect(Effect::BOLD); - let ansistring = AnsiString::new_str("ああa", vec![(highlight, (2, 3))]); - let mut it = ansistring.iter(); - assert_eq!(Some(('あ', Attr::default())), it.next()); - assert_eq!(Some(('あ', Attr::default())), it.next()); - assert_eq!(Some(('a', highlight)), it.next()); - assert_eq!(None, it.next()); - } - - #[test] - fn test_ansi_dim() { - // https://github.com/lotabout/skim/issues/495 - let input = "\x1B[2mhi\x1b[0m"; - let ansistring = ANSIParser::default().parse_ansi(input); - let mut it = ansistring.iter(); - let attr = Attr { - effect: Effect::DIM, - ..Attr::default() - }; - - assert_eq!(Some(('h', attr)), it.next()); - assert_eq!(Some(('i', attr)), it.next()); - assert_eq!(None, it.next()); - assert_eq!(ansistring.stripped(), "hi"); - } -} diff --git a/skim/src/bin/main.rs b/skim/src/bin/main.rs index 61f3d7e6..2cbf8b4e 100644 --- a/skim/src/bin/main.rs +++ b/skim/src/bin/main.rs @@ -1,3 +1,7 @@ +//! Command-line interface for skim fuzzy finder. +//! +//! This binary provides the `sk` command-line tool for fuzzy finding and filtering. + extern crate clap; extern crate env_logger; extern crate log; @@ -5,13 +9,19 @@ extern crate shlex; extern crate skim; extern crate time; +use crate::Event; use clap::{CommandFactory, Error, Parser}; -use clap_complete::generate; - +use color_eyre::Result; +use color_eyre::eyre::eyre; use derive_builder::Builder; +use log::trace; +use skim::item::RankBuilder; +use skim::reader::CommandCollector; +use skim::tui::event::Action; use std::fs::File; use std::io::{BufReader, BufWriter, IsTerminal, Write}; use std::{env, io}; +use thiserror::Error; use skim::prelude::*; @@ -33,34 +43,14 @@ fn parse_args() -> Result { args.push(arg); } - Ok(SkimOptions::try_parse_from(args)?.build()) -} - -//------------------------------------------------------------------------------ -fn main() { - use SkMainError::{ArgError, IoError}; - - env_logger::builder().format_timestamp_nanos().init(); - match sk_main() { - Ok(exit_code) => std::process::exit(exit_code), - Err(err) => { - // if downstream pipe is closed, exit silently, see PR#279 - match err { - IoError(e) => { - if e.kind() == std::io::ErrorKind::BrokenPipe { - std::process::exit(0) - } else { - std::process::exit(2) - } - } - ArgError(e) => e.exit(), - } - } - } + SkimOptions::try_parse_from(args) } +#[derive(Error, Debug)] enum SkMainError { + #[error("I/O error {0:?}")] IoError(std::io::Error), + #[error("Argument error {0:?}")] ArgError(clap::Error), } @@ -76,25 +66,68 @@ impl From for SkMainError { } } -fn sk_main() -> Result { - let mut opts = parse_args()?; +//------------------------------------------------------------------------------ +fn main() -> Result<()> { + let mut opts = parse_args().unwrap_or_else(|e| { + e.exit(); + }); + color_eyre::install()?; + let log_target = if let Some(ref log_file) = opts.log_file { + env_logger::Target::Pipe(Box::new(File::create(log_file).expect("Failed to create log file"))) + } else { + env_logger::Target::Stdout + }; + env_logger::builder().target(log_target).format_timestamp_nanos().init(); + // Build the options after setting the log target + opts = opts.build(); + trace!("Command line: {:?}", std::env::args()); - // Handle shell completion generation if requested + // Shell completion scripts if let Some(shell) = opts.shell { // Generate completion script directly to stdout - generate(shell, &mut SkimOptions::command(), "sk", &mut io::stdout()); - return Ok(0); + clap_complete::generate(shell, &mut SkimOptions::command(), "sk", &mut io::stdout()); + return Ok(()); + } + // Man page + if opts.man { + clap_mangen::Man::new(SkimOptions::command()).render(&mut std::io::stdout())?; + return Ok(()); } - let reader_opts = SkimItemReaderOption::default() - .ansi(opts.ansi) - .delimiter(&opts.delimiter) - .with_nth(opts.with_nth.iter().map(String::as_str)) - .nth(opts.nth.iter().map(String::as_str)) - .read0(opts.read0) - .show_error(opts.show_cmd_error); + match sk_main(opts) { + Ok(exit_code) => std::process::exit(exit_code), + Err(err) => { + // if downstream pipe is closed, exit silently, see PR#279 + match err.downcast_ref::() { + Some(SkMainError::IoError(e)) => { + if e.kind() == std::io::ErrorKind::BrokenPipe { + std::process::exit(0) + } else { + Err(eyre!(err)) + } + } + Some(SkMainError::ArgError(e)) => e.exit(), + None => match err.downcast_ref::() { + Some(e) => e.exit(), + None => Err(eyre!(err)), + }, + } + } + } +} + +fn sk_main(mut opts: SkimOptions) -> Result { + let reader_opts = SkimItemReaderOption::from_options(&opts); let cmd_collector = Rc::new(RefCell::new(SkimItemReader::new(reader_opts))); - opts.cmd_collector = cmd_collector.clone(); + opts.cmd_collector = cmd_collector.clone() as Rc>; + + let cmd_history = opts.cmd_history.clone(); + let cmd_history_size = opts.cmd_history_size; + let cmd_history_file = opts.cmd_history_file.clone(); + + let query_history = opts.query_history.clone(); + let history_size = opts.history_size; + let history_file = opts.history_file.clone(); //------------------------------------------------------------------------------ let bin_options = BinOptions { filter: opts.filter.clone(), @@ -110,7 +143,7 @@ fn sk_main() -> Result { crate::tmux::run_with(&opts) } else { // read from pipe or command - let rx_item = if io::stdin().is_terminal() { + let rx_item = if io::stdin().is_terminal() || (opts.interactive && opts.cmd.is_some()) { None } else { let rx_item = cmd_collector.borrow().of_bufread(BufReader::new(std::io::stdin())); @@ -120,7 +153,7 @@ fn sk_main() -> Result { if opts.filter.is_some() { return Ok(filter(&bin_options, &opts, rx_item)); } - Skim::run_with(&opts, rx_item) + Some(Skim::run_with(opts, rx_item)?) }) else { return Ok(135); }; @@ -138,7 +171,7 @@ fn sk_main() -> Result { print!("{}{}", result.cmd, bin_options.output_ending); } - if let Event::EvActAccept(Some(accept_key)) = result.final_event { + if let Event::Action(Action::Accept(Some(accept_key))) = result.final_event { print!("{}{}", accept_key, bin_options.output_ending); } @@ -150,14 +183,14 @@ fn sk_main() -> Result { //------------------------------------------------------------------------------ // write the history with latest item - if let Some(file) = opts.history_file { - let limit = opts.history_size; - write_history_to_file(&opts.query_history, &result.query, limit, &file)?; + if let Some(file) = history_file { + let limit = history_size; + write_history_to_file(&query_history, &result.query, limit, &file)?; } - if let Some(file) = opts.cmd_history_file { - let limit = opts.cmd_history_size; - write_history_to_file(&opts.cmd_history, &result.cmd, limit, &file)?; + if let Some(file) = cmd_history_file { + let limit = cmd_history_size; + write_history_to_file(&cmd_history, &result.cmd, limit, &file)?; } Ok(i32::from(result.selected_items.is_empty())) @@ -189,7 +222,9 @@ fn write_history_to_file( Ok(()) } +/// Options specific to the binary/CLI mode #[derive(Builder)] +#[allow(missing_docs)] pub struct BinOptions { filter: Option, output_ending: String, @@ -197,6 +232,7 @@ pub struct BinOptions { print_cmd: bool, } +/// Runs skim in filter mode, matching items against a fixed query without interactive UI pub fn filter(bin_option: &BinOptions, options: &SkimOptions, source: Option) -> i32 { let default_command = match env::var("SKIM_DEFAULT_COMMAND").as_ref().map(String::as_ref) { Ok("") | Err(_) => "find .".to_owned(), @@ -219,9 +255,11 @@ pub fn filter(bin_option: &BinOptions, options: &SkimOptions, source: Option = if options.regex { Box::new(RegexEngineFactory::builder()) } else { + let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone())); let fuzzy_engine_factory = ExactOrFuzzyEngineFactory::builder() .fuzzy_algorithm(options.algorithm) .exact_mode(options.exact) + .rank_builder(rank_builder) .build(); Box::new(AndOrEngineFactory::new(fuzzy_engine_factory)) }; @@ -232,20 +270,33 @@ pub fn filter(bin_option: &BinOptions, options: &SkimOptions, source: Option = items + .iter() .filter_map(|item| engine.match_item(item.clone()).map(|result| (item, result))) - .for_each(|(item, _match_result)| { - num_matched += 1; - let _ = write!(stdout_lock, "{}{}", item.output(), bin_option.output_ending); - }); + .collect(); + + if options.tac { + matched_items.reverse(); + } + + matched_items.iter().for_each(|(item, _match_result)| { + num_matched += 1; + let _ = write!(stdout_lock, "{}{}", item.output(), bin_option.output_ending); + }); i32::from(num_matched == 0) } diff --git a/skim/src/binds.rs b/skim/src/binds.rs new file mode 100644 index 00000000..2c66b518 --- /dev/null +++ b/skim/src/binds.rs @@ -0,0 +1,239 @@ +//! Key binding configuration and parsing. +//! +//! This module provides utilities for parsing and managing keyboard shortcuts +//! and their associated actions in skim. + +use std::{ + collections::HashMap, + ops::{Deref, DerefMut}, +}; + +use color_eyre::Result; +use color_eyre::eyre::eyre; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::tui::event::{self, Action}; + +/// A map of key events to their associated actions +#[derive(Clone, Debug)] +pub struct KeyMap(pub HashMap>); + +impl Deref for KeyMap { + type Target = HashMap>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl DerefMut for KeyMap { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From<&str> for KeyMap { + fn from(value: &str) -> Self { + parse_keymaps(value.split(',')) + } +} + +impl Default for KeyMap { + fn default() -> Self { + get_default_key_map() + } +} + +impl KeyMap { + /// Adds keymaps from the source, parsing them using parse_keymap + pub fn add_keymaps<'a, T>(&mut self, source: T) + where + T: Iterator, + { + for map in source { + if let Ok((key, action_chain)) = parse_keymap(map) { + self.bind(key, action_chain) + .unwrap_or_else(|err| debug!("Failed to bind key {map}: {err}")); + } else { + debug!("Failed to parse key: {map}"); + } + } + } + fn bind(&mut self, key: &str, action_chain: Vec) -> Result<()> { + let key = parse_key(key)?; + + // remove the key for existing keymap; + let _ = self.remove(&key); + self.entry(key).or_insert(action_chain); + Ok(()) + } +} + +/// Returns the default key bindings for skim +#[rustfmt::skip] +pub fn get_default_key_map() -> KeyMap { + let mut ret = HashMap::new(); + + ret.insert(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), vec![Action::Down(1)]); + ret.insert(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), vec![Action::Up(1)]); + ret.insert(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), vec![Action::PageUp(1)]); + ret.insert(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE), vec![Action::PageDown(1)]); + ret.insert(KeyEvent::new(KeyCode::End, KeyModifiers::NONE), vec![Action::EndOfLine]); + ret.insert(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), vec![Action::BeginningOfLine]); + ret.insert(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE), vec![Action::DeleteChar]); + ret.insert(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), vec![Action::Toggle, Action::Down(1)]); + ret.insert(KeyEvent::new(KeyCode::BackTab, KeyModifiers::all()), vec![Action::Toggle, Action::Up(1)]); + ret.insert(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), vec![Action::Abort]); + ret.insert(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), vec![Action::Accept(None)]); + ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), vec![Action::BackwardChar]); + ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), vec![Action::ForwardChar]); + ret.insert(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), vec![Action::BackwardDeleteChar]); + + + ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT), vec![Action::BackwardWord]); + ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT), vec![Action::ForwardWord]); + ret.insert(KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT), vec![Action::PreviewUp(1)]); + ret.insert(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT), vec![Action::PreviewDown(1)]); + ret.insert(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT), vec![Action::Toggle, Action::Up(1)]); + ret.insert(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT), vec![Action::Toggle, Action::Up(1)]); + ret.insert(KeyEvent::new(KeyCode::Home, KeyModifiers::SHIFT), vec![Action::BeginningOfLine]); + + + ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL), vec![Action::BackwardWord]); + ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL), vec![Action::ForwardWord]); + + ret.insert(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), vec![Action::BeginningOfLine]); + ret.insert(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL), vec![Action::BackwardChar]); + ret.insert(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), vec![Action::Abort]); + ret.insert(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), vec![Action::DeleteCharEof]); + ret.insert(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), vec![Action::EndOfLine]); + ret.insert(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), vec![Action::ForwardChar]); + ret.insert(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL), vec![Action::Abort]); + ret.insert(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), vec![Action::BackwardDeleteChar]); + ret.insert(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), vec![Action::Down(1)]); + ret.insert(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), vec![Action::Up(1)]); + ret.insert(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL), vec![Action::ClearScreen]); + ret.insert(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), vec![Action::Down(1)]); + ret.insert(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), vec![Action::Up(1)]); + ret.insert(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL), vec![Action::ToggleInteractive]); + ret.insert(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), vec![Action::RotateMode]); + ret.insert(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), vec![Action::UnixLineDiscard]); + ret.insert(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), vec![Action::UnixWordRubout]); + ret.insert(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), vec![Action::Yank]); + + + ret.insert(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT), vec![Action::BackwardKillWord]); + + ret.insert(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT), vec![Action::BackwardWord]); + ret.insert(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT), vec![Action::KillWord]); + ret.insert(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT), vec![Action::ForwardWord]); + ret.insert(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::ALT), vec![Action::ScrollLeft(1)]); + ret.insert(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::ALT), vec![Action::ScrollRight(1)]); + + KeyMap(ret) +} + +/// Parses a key str into a crossterm KeyEvent +pub fn parse_key(key: &str) -> Result { + if key.is_empty() { + return Err(eyre!("Cannot parse empty key")); + } + let parts = key.split('-').collect::>(); + let mut mods = KeyModifiers::NONE; + + if parts.len() > 1 { + let mod_strs = &parts[..parts.len() - 1]; + for mod_str in mod_strs { + mods |= match *mod_str { + "ctrl" => KeyModifiers::CONTROL, + "alt" => KeyModifiers::ALT, + "shift" => KeyModifiers::SHIFT, + s => return Err(eyre!("Failed to parse {} as key modifier", s)), + } + } + } + let key = parts.last().unwrap_or(&"").to_string().to_lowercase(); + + let keycode: KeyCode; + if key.len() == 1 { + let char = key.chars().next().unwrap(); + if char.is_uppercase() { + mods |= KeyModifiers::SHIFT; + keycode = KeyCode::Char(char.to_lowercase().next().unwrap()); + } else { + keycode = KeyCode::Char(char); + } + } else if key.starts_with("f") { + let f_index = key.strip_prefix("f").unwrap().parse::()?; + keycode = KeyCode::F(f_index); + } else { + keycode = match key.as_str() { + "space" => KeyCode::Char(' '), + "enter" => KeyCode::Enter, + "bspace" | "bs" => KeyCode::Backspace, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "tab" => KeyCode::Tab, + "btab" => KeyCode::BackTab, + "esc" => KeyCode::Esc, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pgup" | "page-up" => KeyCode::PageUp, + "pgdown" | "page-down" => KeyCode::PageDown, + "change" => KeyCode::F(255), + s => return Err(eyre!("Unknown key {}", s)), + } + } + + Ok(KeyEvent::new(keycode, mods)) +} + +/// Parse an iterator of keymaps into a KeyMap +pub fn parse_keymaps<'a, T>(maps: T) -> KeyMap +where + T: Iterator, +{ + let mut res = KeyMap::default(); + res.add_keymaps(maps); + res +} + +/// Parses an action chain, separated by '+'s into the corresponding actions +pub fn parse_action_chain(action_chain: &str) -> Result> { + let mut actions: Vec = vec![]; + let mut split = action_chain.split('+'); + loop { + let opt_s = split.next(); + if opt_s.is_none() { + break; + } + let mut s = opt_s.unwrap().to_string(); + if s.starts_with("if-") + && let Some(otherwise) = split.next() + { + s += &(String::from("+") + otherwise); + } + if let Some(act) = event::parse_action(&s) { + actions.push(act); + } + } + if actions.is_empty() { + Err(eyre!("Empty action chain or unknown action `{}`", action_chain)) + } else { + Ok(actions) + } +} + +/// Parse a single keymap and return the key and action(s) +pub fn parse_keymap(key_action: &str) -> Result<(&str, Vec)> { + if key_action.is_empty() { + return Err(eyre!("Got an empty keybind, skipping")); + } + debug!("got key_action: {:?}", key_action); + let (key, action_chain) = key_action + .split_once(':') + .ok_or(eyre!("Failed to parse {} as key and action", key_action))?; + debug!("parsed key_action: {:?}: {:?}", key, action_chain); + Ok((key, parse_action_chain(action_chain)?)) +} diff --git a/skim/src/engine/factory.rs b/skim/src/engine/factory.rs index 8cb59b11..addc0275 100644 --- a/skim/src/engine/factory.rs +++ b/skim/src/engine/factory.rs @@ -12,6 +12,7 @@ static RE_AND: LazyLock = LazyLock::new(|| Regex::new(r"([^ |]+( +\| +[^ static RE_OR: LazyLock = LazyLock::new(|| Regex::new(r" +\| +").unwrap()); //------------------------------------------------------------------------------ // Exact engine factory +/// Factory for creating exact or fuzzy match engines based on configuration pub struct ExactOrFuzzyEngineFactory { exact_mode: bool, fuzzy_algorithm: FuzzyAlgorithm, @@ -19,6 +20,7 @@ pub struct ExactOrFuzzyEngineFactory { } impl ExactOrFuzzyEngineFactory { + /// Creates a new builder with default settings pub fn builder() -> Self { Self { exact_mode: false, @@ -27,21 +29,25 @@ impl ExactOrFuzzyEngineFactory { } } + /// Sets whether to use exact matching mode pub fn exact_mode(mut self, exact_mode: bool) -> Self { self.exact_mode = exact_mode; self } + /// Sets the fuzzy matching algorithm to use pub fn fuzzy_algorithm(mut self, fuzzy_algorithm: FuzzyAlgorithm) -> Self { self.fuzzy_algorithm = fuzzy_algorithm; self } + /// Sets the rank builder for scoring matches pub fn rank_builder(mut self, rank_builder: Arc) -> Self { self.rank_builder = rank_builder; self } + /// Builds the factory (currently a no-op, returns self) pub fn build(self) -> Self { self } @@ -129,11 +135,13 @@ impl MatchEngineFactory for ExactOrFuzzyEngineFactory { } //------------------------------------------------------------------------------ +/// Factory for creating AND/OR composite match engines pub struct AndOrEngineFactory { inner: Box, } impl AndOrEngineFactory { + /// Creates a new AND/OR engine factory wrapping another factory pub fn new(factory: impl MatchEngineFactory + 'static) -> Self { Self { inner: Box::new(factory), @@ -197,22 +205,26 @@ impl MatchEngineFactory for AndOrEngineFactory { } //------------------------------------------------------------------------------ +/// Factory for creating regex-based match engines pub struct RegexEngineFactory { rank_builder: Arc, } impl RegexEngineFactory { + /// Creates a new builder with default settings pub fn builder() -> Self { Self { rank_builder: Default::default(), } } + /// Sets the rank builder for scoring matches pub fn rank_builder(mut self, rank_builder: Arc) -> Self { self.rank_builder = rank_builder; self } + /// Builds the factory (currently a no-op, returns self) pub fn build(self) -> Self { self } diff --git a/skim/src/engine/fuzzy.rs b/skim/src/engine/fuzzy.rs index a8c36d5e..d7a99e6e 100644 --- a/skim/src/engine/fuzzy.rs +++ b/skim/src/engine/fuzzy.rs @@ -2,22 +2,26 @@ use std::cmp::min; use std::fmt::{Display, Error, Formatter}; use std::sync::Arc; -use fuzzy_matcher::FuzzyMatcher; -use fuzzy_matcher::clangd::ClangdMatcher; -use fuzzy_matcher::skim::SkimMatcherV2; +use crate::fuzzy_matcher::FuzzyMatcher; +use crate::fuzzy_matcher::clangd::ClangdMatcher; +use crate::fuzzy_matcher::skim::SkimMatcherV2; use crate::item::RankBuilder; use crate::{CaseMatching, MatchEngine}; use crate::{MatchRange, MatchResult, SkimItem}; //------------------------------------------------------------------------------ +/// Fuzzy matching algorithm to use #[derive(Debug, Copy, Clone, Default)] #[cfg_attr(feature = "cli", derive(clap::ValueEnum))] #[cfg_attr(feature = "cli", clap(rename_all = "snake_case"))] pub enum FuzzyAlgorithm { + /// Original skim fuzzy matching algorithm (v1) SkimV1, + /// Improved skim fuzzy matching algorithm (v2, default) #[default] SkimV2, + /// Clangd fuzzy matching algorithm Clangd, } @@ -56,9 +60,12 @@ impl FuzzyEngineBuilder { #[allow(deprecated)] pub fn build(self) -> FuzzyEngine { - use fuzzy_matcher::skim::SkimMatcher; + use crate::fuzzy_matcher::skim::SkimMatcher; let matcher: Box = match self.algorithm { - FuzzyAlgorithm::SkimV1 => Box::new(SkimMatcher::default()), + FuzzyAlgorithm::SkimV1 => { + debug!("Initialized SkimV1 algorithm"); + Box::new(SkimMatcher::default()) + } FuzzyAlgorithm::SkimV2 => { let matcher = SkimMatcherV2::default().element_limit(BYTES_1M); let matcher = match self.case { @@ -66,6 +73,7 @@ impl FuzzyEngineBuilder { CaseMatching::Ignore => matcher.ignore_case(), CaseMatching::Smart => matcher.smart_case(), }; + debug!("Initialized SkimV2 algorithm"); Box::new(matcher) } FuzzyAlgorithm::Clangd => { @@ -75,6 +83,7 @@ impl FuzzyEngineBuilder { CaseMatching::Ignore => matcher.ignore_case(), CaseMatching::Smart => matcher.smart_case(), }; + debug!("Initialized Clangd algorithm"); Box::new(matcher) } }; @@ -87,6 +96,7 @@ impl FuzzyEngineBuilder { } } +/// The fuzzy matching engine pub struct FuzzyEngine { query: String, matcher: Box, @@ -94,6 +104,7 @@ pub struct FuzzyEngine { } impl FuzzyEngine { + /// Returns a default builder for chaining pub fn builder() -> FuzzyEngineBuilder { FuzzyEngineBuilder::default() } @@ -141,11 +152,16 @@ impl MatchEngine for FuzzyEngine { let end = *matched_range.last().unwrap_or(&0); let item_len = item_text.len(); + + // Use individual character indices for highlighting instead of byte range + // This allows each matched character to be highlighted individually + let matched_range = MatchRange::Chars(matched_range); + Some(MatchResult { rank: self .rank_builder .build_rank(score as i32, begin, end, item_len, item.get_index()), - matched_range: MatchRange::Chars(matched_range), + matched_range, }) } } diff --git a/skim/src/event.rs b/skim/src/event.rs deleted file mode 100644 index 72ddb46a..00000000 --- a/skim/src/event.rs +++ /dev/null @@ -1,151 +0,0 @@ -// All the events that will be used - -use skim_tuikit::key::Key; -use std::sync::mpsc::{Receiver, Sender}; - -pub type EventReceiver = Receiver<(Key, Event)>; -pub type EventSender = Sender<(Key, Event)>; - -#[derive(Debug, Clone, Hash, PartialEq, Eq)] -pub enum Event { - EvInputKey(Key), - EvInputInvalid, - EvHeartBeat, - - // user bind actions - EvActAbort, - EvActAccept(Option), - EvActAddChar(char), - EvActAppendAndSelect, - EvActBackwardChar, - EvActBackwardDeleteChar, - EvActBackwardKillWord, - EvActBackwardWord, - EvActBeginningOfLine, - EvActCancel, - EvActClearScreen, - EvActDeleteChar, - EvActDeleteCharEOF, - EvActDeselectAll, - EvActDown(i32), - EvActEndOfLine, - EvActExecute(String), - EvActExecuteSilent(String), - EvActForwardChar, - EvActForwardWord, - EvActIfQueryEmpty(String), - EvActIfQueryNotEmpty(String), - EvActIfNonMatched(String), - EvActIgnore, - EvActKillLine, - EvActKillWord, - EvActNextHistory, - EvActHalfPageDown(i32), - EvActHalfPageUp(i32), - EvActPageDown(i32), - EvActPageUp(i32), - EvActPreviewUp(i32), - EvActPreviewDown(i32), - EvActPreviewLeft(i32), - EvActPreviewRight(i32), - EvActPreviewPageUp(i32), - EvActPreviewPageDown(i32), - EvActPreviousHistory, - EvActRedraw, - EvActReload(Option), - EvActRefreshCmd, - EvActRefreshPreview, - EvActRotateMode, - EvActScrollLeft(i32), - EvActScrollRight(i32), - EvActSelectAll, - EvActSelectRow(usize), - EvActToggle, - EvActToggleAll, - EvActToggleIn, - EvActToggleInteractive, - EvActToggleOut, - EvActTogglePreview, - EvActTogglePreviewWrap, - EvActToggleSort, - EvActUnixLineDiscard, - EvActUnixWordRubout, - EvActUp(i32), - EvActYank, - - #[doc(hidden)] - __Nonexhaustive, -} - -/// `Effect` is the effect of a text -pub enum UpdateScreen { - Redraw, - DontRedraw, -} - -pub trait EventHandler { - /// handle event, return whether - fn handle(&mut self, event: &Event) -> UpdateScreen; -} - -#[rustfmt::skip] -pub fn parse_event(action: &str, arg: Option) -> Option { - match action { - "abort" => Some(Event::EvActAbort), - "accept" => Some(Event::EvActAccept(arg)), - "append-and-select" => Some(Event::EvActAppendAndSelect), - "backward-char" => Some(Event::EvActBackwardChar), - "backward-delete-char" => Some(Event::EvActBackwardDeleteChar), - "backward-kill-word" => Some(Event::EvActBackwardKillWord), - "backward-word" => Some(Event::EvActBackwardWord), - "beginning-of-line" => Some(Event::EvActBeginningOfLine), - "cancel" => Some(Event::EvActCancel), - "clear-screen" => Some(Event::EvActClearScreen), - "delete-char" => Some(Event::EvActDeleteChar), - "delete-charEOF" => Some(Event::EvActDeleteCharEOF), - "deselect-all" => Some(Event::EvActDeselectAll), - "down" => Some(Event::EvActDown(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), - "end-of-line" => Some(Event::EvActEndOfLine), - "execute" => Some(Event::EvActExecute(arg.expect("execute event should have argument"))), - "execute-silent" => Some(Event::EvActExecuteSilent(arg.expect("execute-silent event should have argument"))), - "forward-char" => Some(Event::EvActForwardChar), - "forward-word" => Some(Event::EvActForwardWord), - "if-non-matched" => Some(Event::EvActIfNonMatched(arg.expect("no arg specified for event if-non-matched"))), - "if-query-empty" => Some(Event::EvActIfQueryEmpty(arg.expect("no arg specified for event if-query-empty"))), - "if-query-not-empty" => Some(Event::EvActIfQueryNotEmpty(arg.expect("no arg specified for event if-query-not-empty"))), - "ignore" => Some(Event::EvActIgnore), - "kill-line" => Some(Event::EvActKillLine), - "kill-word" => Some(Event::EvActKillWord), - "next-history" => Some(Event::EvActNextHistory), - "half-page-down" => Some(Event::EvActHalfPageDown(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), - "half-page-up" => Some(Event::EvActHalfPageUp(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), - "page-down" => Some(Event::EvActPageDown(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), - "page-up" => Some(Event::EvActPageUp(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), - "preview-up" => Some(Event::EvActPreviewUp(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), - "preview-down" => Some(Event::EvActPreviewDown(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), - "preview-left" => Some(Event::EvActPreviewLeft(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), - "preview-right" => Some(Event::EvActPreviewRight(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), - "preview-page-up" => Some(Event::EvActPreviewPageUp(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), - "preview-page-down" => Some(Event::EvActPreviewPageDown(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), - "previous-history" => Some(Event::EvActPreviousHistory), - "refresh-cmd" => Some(Event::EvActRefreshCmd), - "refresh-preview" => Some(Event::EvActRefreshPreview), - "reload" => Some(Event::EvActReload(arg.clone())), - "scroll-left" => Some(Event::EvActScrollLeft(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), - "scroll-right" => Some(Event::EvActScrollRight(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), - "select-all" => Some(Event::EvActSelectAll), - "toggle" => Some(Event::EvActToggle), - "toggle-all" => Some(Event::EvActToggleAll), - "toggle-in" => Some(Event::EvActToggleIn), - "toggle-interactive" => Some(Event::EvActToggleInteractive), - "toggle-out" => Some(Event::EvActToggleOut), - "toggle-preview" => Some(Event::EvActTogglePreview), - "toggle-preview-wrap" => Some(Event::EvActTogglePreviewWrap), - "toggle-sort" => Some(Event::EvActToggleSort), - "unix-line-discard" => Some(Event::EvActUnixLineDiscard), - "unix-word-rubout" => Some(Event::EvActUnixWordRubout), - "up" => Some(Event::EvActUp(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), - "yank" => Some(Event::EvActYank), - _ => None - } -} diff --git a/skim/src/field.rs b/skim/src/field.rs index 317db65a..96e4f507 100644 --- a/skim/src/field.rs +++ b/skim/src/field.rs @@ -1,3 +1,8 @@ +//! Field extraction and parsing utilities. +//! +//! This module provides utilities for parsing field ranges and extracting +//! fields from text based on delimiters. + use regex::Regex; use std::{ cmp::{max, min}, @@ -7,15 +12,21 @@ use std::{ static FIELD_RANGE: LazyLock = LazyLock::new(|| Regex::new(r"^(?P-?\d+)?(?P\.\.)?(?P-?\d+)?$").unwrap()); +/// Represents a range of fields to extract from text #[derive(PartialEq, Eq, Clone, Debug)] pub enum FieldRange { + /// A single field at the given index Single(i32), + /// All fields from the start up to and including the given index LeftInf(i32), + /// All fields from the given index to the end RightInf(i32), + /// Fields between two indices (inclusive) Both(i32, i32), } impl FieldRange { + /// Parses a field range from a string (e.g., "1", "1..", "..10", "1..10") #[allow(clippy::should_implement_trait)] pub fn from_str(range: &str) -> Option { use self::FieldRange::*; @@ -48,9 +59,10 @@ impl FieldRange { } } - // Parse FieldRange to index pair (left, right) - // e.g. 1..3 => (0, 4) - // note that field range is inclusive while the output index will exclude right end + /// Converts a field range to an index pair (left, right). + /// + /// For example, 1..3 => (0, 4). Note that field range is inclusive while + /// the output index will exclude the right end. pub fn to_index_pair(&self, length: usize) -> Option<(usize, usize)> { use self::FieldRange::*; match *self { @@ -112,8 +124,10 @@ fn get_ranges_by_delimiter(delimiter: &Regex, text: &str) -> Vec<(usize, usize)> ranges } -// e.g. delimiter = Regex::new(",").unwrap() -// Note that this is differnt with `to_index_pair`, it uses delimiters like ".*?," +/// Extracts a substring from text based on a field range and delimiter. +/// +/// For example, with delimiter = Regex::new(",").unwrap(), text "a,b,c", and field Single(2), +/// this returns "b". Note that this is different from `to_index_pair`, it uses delimiters. pub fn get_string_by_field<'a>(delimiter: &Regex, text: &'a str, field: &FieldRange) -> Option<&'a str> { let ranges = get_ranges_by_delimiter(delimiter, text); @@ -126,13 +140,15 @@ pub fn get_string_by_field<'a>(delimiter: &Regex, text: &'a str, field: &FieldRa } } +/// Extracts a substring from text by parsing a range string and using a delimiter pub fn get_string_by_range<'a>(delimiter: &Regex, text: &'a str, range: &str) -> Option<&'a str> { FieldRange::from_str(range).and_then(|field| get_string_by_field(delimiter, text, &field)) } -// -> a vector of the matching fields (byte wise). -// Given delimiter `,`, text: "a,b,c" -// &[Single(2), LeftInf(2)] => [(2, 4), (0, 4)] +/// Parses matching fields and returns a vector of byte ranges. +/// +/// Given delimiter `,`, text: "a,b,c", and fields &[Single(2), LeftInf(2)], +/// this returns [(2, 4), (0, 4)]. pub fn parse_matching_fields(delimiter: &Regex, text: &str, fields: &[FieldRange]) -> Vec<(usize, usize)> { let ranges = get_ranges_by_delimiter(delimiter, text); @@ -147,6 +163,7 @@ pub fn parse_matching_fields(delimiter: &Regex, text: &str, fields: &[FieldRange ret } +/// Extracts the specified fields from text using the delimiter pub fn parse_transform_fields(delimiter: &Regex, text: &str, fields: &[FieldRange]) -> String { let ranges = get_ranges_by_delimiter(delimiter, text); @@ -308,6 +325,31 @@ mod test { } use super::*; + + #[test] + fn test_null_delimiter() { + // Test with null byte delimiter + let re = Regex::new("\x00").unwrap(); + let text = "a\x00b\x00c"; + + // Test field extraction + assert_eq!(get_string_by_field(&re, text, &Single(1)), Some("a")); + assert_eq!(get_string_by_field(&re, text, &Single(2)), Some("b")); + assert_eq!(get_string_by_field(&re, text, &Single(3)), Some("c")); + + // Test matching fields - ranges include the delimiter after the field + // text bytes: a(0), \0(1), b(2), \0(3), c(4) + // Field 2 is "b" at byte 2, range includes delimiter at byte 3, so (2, 4) + assert_eq!(parse_matching_fields(&re, text, &[Single(2)]), vec![(2, 4)]); + + // Field 1 is "a" at byte 0, range includes delimiter at byte 1, so (0, 2) + // Field 3 is "c" at byte 4, no delimiter after it, so (4, 5) + assert_eq!( + parse_matching_fields(&re, text, &[Single(1), Single(3)]), + vec![(0, 2), (4, 5)] + ); + } + #[test] fn test_get_string_by_field() { // delimiter is "," diff --git a/skim/src/fuzzy_matcher/clangd.rs b/skim/src/fuzzy_matcher/clangd.rs new file mode 100644 index 00000000..3bde13d1 --- /dev/null +++ b/skim/src/fuzzy_matcher/clangd.rs @@ -0,0 +1,507 @@ +//! The fuzzy matching algorithm used in clangd. +//! https://github.com/llvm-mirror/clang-tools-extra/blob/master/clangd/FuzzyMatch.cpp +//! +//! # Example: +//! ```edition2018 +//! use crate::fuzzy_matcher::FuzzyMatcher; +//! use crate::fuzzy_matcher::clangd::ClangdMatcher; +//! +//! let matcher = ClangdMatcher::default(); +//! +//! assert_eq!(None, matcher.fuzzy_match("abc", "abx")); +//! assert!(matcher.fuzzy_match("axbycz", "abc").is_some()); +//! assert!(matcher.fuzzy_match("axbycz", "xyz").is_some()); +//! +//! let (score, indices) = matcher.fuzzy_indices("axbycz", "abc").unwrap(); +//! assert_eq!(indices, [0, 2, 4]); +//! +//! ``` +//! +//! Algorithm modified from +//! https://github.com/llvm-mirror/clang-tools-extra/blob/master/clangd/FuzzyMatch.cpp +//! Also check: https://github.com/lewang/flx/issues/98 +use crate::fuzzy_matcher::util::*; +use crate::fuzzy_matcher::{FuzzyMatcher, IndexType, ScoreType}; +use std::cell::RefCell; +use std::cmp::max; +use thread_local::ThreadLocal; + +#[derive(Eq, PartialEq, Debug, Copy, Clone)] +enum CaseMatching { + Respect, + Ignore, + Smart, +} + +#[derive(Debug)] +/// Fuzzy matcher using the clangd algorithm +pub struct ClangdMatcher { + case: CaseMatching, + + use_cache: bool, + + c_cache: ThreadLocal>>, // vector to store the characters of choice + p_cache: ThreadLocal>>, // vector to store the characters of pattern +} + +impl Default for ClangdMatcher { + fn default() -> Self { + Self { + case: CaseMatching::Ignore, + use_cache: true, + c_cache: ThreadLocal::new(), + p_cache: ThreadLocal::new(), + } + } +} + +impl ClangdMatcher { + /// Sets the matcher to ignore case when matching + pub fn ignore_case(mut self) -> Self { + self.case = CaseMatching::Ignore; + self + } + + /// Sets the matcher to use smart case (case insensitive unless pattern contains uppercase) + pub fn smart_case(mut self) -> Self { + self.case = CaseMatching::Smart; + self + } + + /// Sets the matcher to respect case when matching + pub fn respect_case(mut self) -> Self { + self.case = CaseMatching::Respect; + self + } + + /// Enables or disables caching for improved performance + pub fn use_cache(mut self, use_cache: bool) -> Self { + self.use_cache = use_cache; + self + } + + fn contains_upper(&self, string: &str) -> bool { + for ch in string.chars() { + if ch.is_ascii_uppercase() { + return true; + } + } + + false + } + + fn is_case_sensitive(&self, pattern: &str) -> bool { + match self.case { + CaseMatching::Respect => true, + CaseMatching::Ignore => false, + CaseMatching::Smart => self.contains_upper(pattern), + } + } +} + +impl FuzzyMatcher for ClangdMatcher { + fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(ScoreType, Vec)> { + let case_sensitive = self.is_case_sensitive(pattern); + + let mut choice_chars = self.c_cache.get_or(|| RefCell::new(Vec::new())).borrow_mut(); + let mut pattern_chars = self.p_cache.get_or(|| RefCell::new(Vec::new())).borrow_mut(); + + choice_chars.clear(); + for char in choice.chars() { + choice_chars.push(char); + } + + pattern_chars.clear(); + for char in pattern.chars() { + pattern_chars.push(char); + } + + cheap_matches(&choice_chars, &pattern_chars, case_sensitive)?; + + let num_pattern_chars = pattern_chars.len(); + let num_choice_chars = choice_chars.len(); + + let dp = build_graph(&choice_chars, &pattern_chars, false, case_sensitive); + + // search backwards for the matched indices + let mut indices_reverse = Vec::with_capacity(num_pattern_chars); + let cell = dp[num_pattern_chars][num_choice_chars]; + + let (mut last_action, score) = if cell.match_score > cell.miss_score { + (Action::Match, cell.match_score) + } else { + (Action::Miss, cell.miss_score) + }; + + let mut row = num_pattern_chars; + let mut col = num_choice_chars; + + while row > 0 || col > 0 { + if last_action == Action::Match { + indices_reverse.push((col - 1) as IndexType); + } + + let cell = &dp[row][col]; + if last_action == Action::Match { + last_action = cell.last_action_match; + row -= 1; + col -= 1; + } else { + last_action = cell.last_action_miss; + col -= 1; + } + } + + if !self.use_cache { + // drop the allocated memory + self.c_cache.get().map(|cell| cell.replace(vec![])); + self.p_cache.get().map(|cell| cell.replace(vec![])); + } + + indices_reverse.reverse(); + Some((adjust_score(score, num_choice_chars), indices_reverse)) + } + + fn fuzzy_match(&self, choice: &str, pattern: &str) -> Option { + let case_sensitive = self.is_case_sensitive(pattern); + + let mut choice_chars = self.c_cache.get_or(|| RefCell::new(Vec::new())).borrow_mut(); + let mut pattern_chars = self.p_cache.get_or(|| RefCell::new(Vec::new())).borrow_mut(); + + choice_chars.clear(); + for char in choice.chars() { + choice_chars.push(char); + } + + pattern_chars.clear(); + for char in pattern.chars() { + pattern_chars.push(char); + } + + cheap_matches(&choice_chars, &pattern_chars, case_sensitive)?; + + let num_pattern_chars = pattern_chars.len(); + let num_choice_chars = choice_chars.len(); + + let dp = build_graph(&choice_chars, &pattern_chars, true, case_sensitive); + + let cell = dp[num_pattern_chars & 1][num_choice_chars]; + let score = max(cell.match_score, cell.miss_score); + + if !self.use_cache { + // drop the allocated memory + self.c_cache.get().map(|cell| cell.replace(vec![])); + self.p_cache.get().map(|cell| cell.replace(vec![])); + } + + Some(adjust_score(score, num_choice_chars)) + } +} + +/// fuzzy match `line` with `pattern`, returning the score and indices of matches +pub fn fuzzy_indices(line: &str, pattern: &str) -> Option<(ScoreType, Vec)> { + ClangdMatcher::default().ignore_case().fuzzy_indices(line, pattern) +} + +/// fuzzy match `line` with `pattern`, returning the score(the larger the better) on match +pub fn fuzzy_match(line: &str, pattern: &str) -> Option { + ClangdMatcher::default().ignore_case().fuzzy_match(line, pattern) +} + +// checkout https://github.com/llvm-mirror/clang-tools-extra/blob/master/clangd/FuzzyMatch.cpp +// for the description +fn build_graph(line: &[char], pattern: &[char], compressed: bool, case_sensitive: bool) -> Vec> { + let num_line_chars = line.len(); + let num_pattern_chars = pattern.len(); + let max_rows = if compressed { 2 } else { num_pattern_chars + 1 }; + + let mut dp: Vec> = Vec::with_capacity(max_rows); + + for _ in 0..max_rows { + dp.push(vec![Score::default(); num_line_chars + 1]); + } + + dp[0][0].miss_score = 0; + + // first line + for (idx, &ch) in line.iter().enumerate() { + dp[0][idx + 1] = Score { + miss_score: dp[0][idx].miss_score - skip_penalty(idx, ch, Action::Miss), + last_action_miss: Action::Miss, + match_score: AWFUL_SCORE, + last_action_match: Action::Miss, + }; + } + + // build the matrix + let mut pat_prev_ch = '\0'; + for (pat_idx, &pat_ch) in pattern.iter().enumerate() { + let current_row_idx = if compressed { (pat_idx + 1) & 1 } else { pat_idx + 1 }; + let prev_row_idx = if compressed { pat_idx & 1 } else { pat_idx }; + + let mut line_prev_ch = '\0'; + for (line_idx, &line_ch) in line.iter().enumerate() { + if line_idx < pat_idx { + line_prev_ch = line_ch; + continue; + } + + // what if we skip current line character? + // we need to calculate the cases where the pre line character is matched/missed + let pre_miss = &dp[current_row_idx][line_idx]; + let mut match_miss_score = pre_miss.match_score; + let mut miss_miss_score = pre_miss.miss_score; + if pat_idx < num_pattern_chars - 1 { + match_miss_score -= skip_penalty(line_idx, line_ch, Action::Match); + miss_miss_score -= skip_penalty(line_idx, line_ch, Action::Miss); + } + + let (miss_score, last_action_miss) = if match_miss_score > miss_miss_score { + (match_miss_score, Action::Match) + } else { + (miss_miss_score, Action::Miss) + }; + + // what if we want to match current line character? + // so we need to calculate the cases where the pre pattern character is matched/missed + let pre_match = &dp[prev_row_idx][line_idx]; + let match_match_score = if allow_match(pat_ch, line_ch, case_sensitive) { + pre_match.match_score + + match_bonus( + pat_idx, + pat_ch, + pat_prev_ch, + line_idx, + line_ch, + line_prev_ch, + Action::Match, + ) + } else { + AWFUL_SCORE + }; + + let miss_match_score = if allow_match(pat_ch, line_ch, case_sensitive) { + pre_match.miss_score + + match_bonus( + pat_idx, + pat_ch, + pat_prev_ch, + line_idx, + line_ch, + line_prev_ch, + Action::Match, + ) + } else { + AWFUL_SCORE + }; + + let (match_score, last_action_match) = if match_match_score > miss_match_score { + (match_match_score, Action::Match) + } else { + (miss_match_score, Action::Miss) + }; + + dp[current_row_idx][line_idx + 1] = Score { + miss_score, + last_action_miss, + match_score, + last_action_match, + }; + + line_prev_ch = line_ch; + } + + pat_prev_ch = pat_ch; + } + + dp +} + +fn adjust_score(score: ScoreType, num_line_chars: usize) -> ScoreType { + // line width will affect 10 scores + score - (((num_line_chars + 1) as f64).ln().floor() as ScoreType) +} + +const AWFUL_SCORE: ScoreType = -(1 << 30); + +#[derive(Debug, PartialEq, Clone, Copy)] +enum Action { + Miss, + Match, +} + +#[derive(Debug, Clone, Copy)] +struct Score { + pub last_action_miss: Action, + pub last_action_match: Action, + pub miss_score: ScoreType, + pub match_score: ScoreType, +} + +impl Default for Score { + fn default() -> Self { + Self { + last_action_miss: Action::Miss, + last_action_match: Action::Miss, + miss_score: AWFUL_SCORE, + match_score: AWFUL_SCORE, + } + } +} + +fn skip_penalty(_ch_idx: usize, ch: char, last_action: Action) -> ScoreType { + let mut score = 1; + if last_action == Action::Match { + // Non-consecutive match. + score += 3; + } + + if char_type_of(ch) == CharType::NonWord { + // skip separator + score += 6; + } + + score +} + +fn allow_match(pat_ch: char, line_ch: char, case_sensitive: bool) -> bool { + char_equal(pat_ch, line_ch, case_sensitive) +} + +fn match_bonus( + pat_idx: usize, + pat_ch: char, + pat_prev_ch: char, + line_idx: usize, + line_ch: char, + line_prev_ch: char, + last_action: Action, +) -> ScoreType { + let mut score = 10; + let pat_role = char_role(pat_prev_ch, pat_ch); + let line_role = char_role(line_prev_ch, line_ch); + + // Bonus: pattern so far is a (case-insensitive) prefix of the word. + if pat_idx == line_idx { + score += 10; + } + + // Bonus: case match + if pat_ch == line_ch { + score += 8; + } + + // Bonus: match header + if line_role == CharRole::Head { + score += 9; + } + + // Bonus: a Head in the pattern aligns with one in the word. + if pat_role == CharRole::Head && line_role == CharRole::Head { + score += 10; + } + + // Penalty: matching inside a segment (and previous char wasn't matched). + if line_role == CharRole::Tail && pat_idx > 0 && last_action == Action::Miss { + score -= 30; + } + + // Penalty: a Head in the pattern matches in the middle of a word segment. + if pat_role == CharRole::Head && line_role == CharRole::Tail { + score -= 10; + } + + // Penalty: matching the first pattern character in the middle of a segment. + if pat_idx == 0 && line_role == CharRole::Tail { + score -= 40; + } + + score +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fuzzy_matcher::util::{assert_order, wrap_matches}; + + fn wrap_fuzzy_match(line: &str, pattern: &str) -> Option { + let (_score, indices) = fuzzy_indices(line, pattern)?; + Some(wrap_matches(line, &indices)) + } + + #[test] + fn test_match_or_not() { + assert_eq!(None, fuzzy_match("abcdefaghi", "中")); + assert_eq!(None, fuzzy_match("abc", "abx")); + assert!(fuzzy_match("axbycz", "abc").is_some()); + assert!(fuzzy_match("axbycz", "xyz").is_some()); + + assert_eq!("[a]x[b]y[c]z", &wrap_fuzzy_match("axbycz", "abc").unwrap()); + assert_eq!("a[x]b[y]c[z]", &wrap_fuzzy_match("axbycz", "xyz").unwrap()); + assert_eq!("[H]ello, [世]界", &wrap_fuzzy_match("Hello, 世界", "H世").unwrap()); + } + + #[test] + fn test_match_quality() { + let matcher = ClangdMatcher::default(); + // case + assert_order(&matcher, "monad", &["monad", "Monad", "mONAD"]); + + // initials + assert_order(&matcher, "ab", &["ab", "aoo_boo", "acb"]); + assert_order(&matcher, "CC", &["CamelCase", "camelCase", "camelcase"]); + assert_order(&matcher, "cC", &["camelCase", "CamelCase", "camelcase"]); + assert_order( + &matcher, + "cc", + &["camel case", "camelCase", "camelcase", "CamelCase", "camel ace"], + ); + assert_order( + &matcher, + "Da.Te", + &["Data.Text", "Data.Text.Lazy", "Data.Aeson.Encoding.text"], + ); + assert_order(&matcher, "foobar.h", &["foobar.h", "foo/bar.h"]); + // prefix + assert_order(&matcher, "is", &["isIEEE", "inSuf"]); + // shorter + assert_order(&matcher, "ma", &["map", "many", "maximum"]); + assert_order(&matcher, "print", &["printf", "sprintf"]); + // score(PRINT) = kMinScore + assert_order(&matcher, "ast", &["ast", "AST", "INT_FAST16_MAX"]); + // score(PRINT) > kMinScore + assert_order(&matcher, "Int", &["int", "INT", "PRINT"]); + } +} + +#[allow(dead_code)] +fn print_dp(line: &str, pattern: &str, dp: &[Vec]) { + let num_line_chars = line.chars().count(); + let num_pattern_chars = pattern.chars().count(); + + print!("\t"); + for (idx, ch) in line.chars().enumerate() { + print!("\t\t{}/{}", idx + 1, ch); + } + + for (row_num, row) in dp.iter().enumerate().take(num_pattern_chars + 1) { + print!("\n{}\t", row_num); + for cell in row.iter().take(num_line_chars + 1) { + print!( + "({},{})/({},{})\t", + cell.miss_score, + if cell.last_action_miss == Action::Miss { + 'X' + } else { + 'O' + }, + cell.match_score, + if cell.last_action_match == Action::Miss { + 'X' + } else { + 'O' + } + ); + } + } +} diff --git a/skim/src/fuzzy_matcher/mod.rs b/skim/src/fuzzy_matcher/mod.rs new file mode 100644 index 00000000..33121711 --- /dev/null +++ b/skim/src/fuzzy_matcher/mod.rs @@ -0,0 +1,31 @@ +//! Fuzzy matching algorithms and implementations. +//! +//! This module provides different fuzzy matching algorithms including +//! skim's own algorithm and clangd's algorithm for matching text patterns. + +/// Clangd fuzzy matching algorithm +pub mod clangd; +/// Skim fuzzy matching algorithm +pub mod skim; +mod util; + +#[cfg(not(feature = "compact_matcher"))] +type IndexType = usize; +#[cfg(not(feature = "compact_matcher"))] +type ScoreType = i64; + +#[cfg(feature = "compact_matcher")] +type IndexType = u32; +#[cfg(feature = "compact_matcher")] +type ScoreType = i32; + +/// Trait for fuzzy matching text patterns against choices +pub trait FuzzyMatcher: Send + Sync { + /// fuzzy match choice with pattern, and return the score & matched indices of characters + fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(ScoreType, Vec)>; + + /// fuzzy match choice with pattern, and return the score of matching + fn fuzzy_match(&self, choice: &str, pattern: &str) -> Option { + self.fuzzy_indices(choice, pattern).map(|(score, _)| score) + } +} diff --git a/skim/src/fuzzy_matcher/skim.rs b/skim/src/fuzzy_matcher/skim.rs new file mode 100644 index 00000000..0a48e4e7 --- /dev/null +++ b/skim/src/fuzzy_matcher/skim.rs @@ -0,0 +1,1219 @@ +//! The fuzzy matching algorithm used by skim +//! +//! # Example: +//! ```edition2018 +//! use crate::fuzzy_matcher::FuzzyMatcher; +//! use crate::fuzzy_matcher::skim::SkimMatcherV2; +//! +//! let matcher = SkimMatcherV2::default(); +//! assert_eq!(None, matcher.fuzzy_match("abc", "abx")); +//! assert!(matcher.fuzzy_match("axbycz", "abc").is_some()); +//! assert!(matcher.fuzzy_match("axbycz", "xyz").is_some()); +//! +//! let (score, indices) = matcher.fuzzy_indices("axbycz", "abc").unwrap(); +//! assert_eq!(indices, [0, 2, 4]); +//! ``` + +#![allow(deprecated)] + +use std::cell::RefCell; +use std::cmp::max; +use std::fmt::Formatter; + +use thread_local::ThreadLocal; + +use super::skim::Movement::{Match, Skip}; +use super::util::{char_equal, cheap_matches}; +use super::{FuzzyMatcher, IndexType, ScoreType}; + +const BONUS_MATCHED: ScoreType = 4; +const BONUS_CASE_MATCH: ScoreType = 4; +const BONUS_UPPER_MATCH: ScoreType = 6; +const BONUS_ADJACENCY: ScoreType = 10; +const BONUS_SEPARATOR: ScoreType = 8; +const BONUS_CAMEL: ScoreType = 8; +const PENALTY_CASE_UNMATCHED: ScoreType = -1; +const PENALTY_LEADING: ScoreType = -6; +// penalty applied for every letter before the first match +const PENALTY_MAX_LEADING: ScoreType = -18; +// maxing penalty for leading letters +const PENALTY_UNMATCHED: ScoreType = -2; + +#[deprecated(since = "0.3.5", note = "Please use SkimMatcherV2 instead")] +#[derive(Debug)] +/// Legacy fuzzy matcher (V1) - deprecated, use SkimMatcherV2 instead +#[derive(Default)] +pub struct SkimMatcher {} + +/// The V1 matcher is based on ForrestTheWoods's post +/// https://www.forrestthewoods.com/blog/reverse_engineering_sublime_texts_fuzzy_match/ +/// +/// V1 algorithm is deprecated, checkout `FuzzyMatcherV2` +impl FuzzyMatcher for SkimMatcher { + fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(ScoreType, Vec)> { + fuzzy_indices(choice, pattern) + } + + fn fuzzy_match(&self, choice: &str, pattern: &str) -> Option { + fuzzy_match(choice, pattern) + } +} + +#[deprecated(since = "0.3.5", note = "Please use SkimMatcherV2 instead")] +/// Legacy fuzzy matching function - returns match score only +pub fn fuzzy_match(choice: &str, pattern: &str) -> Option { + if pattern.is_empty() { + return Some(0); + } + + let scores = build_graph(choice, pattern)?; + + let last_row = &scores[scores.len() - 1]; + let (_, &MatchingStatus { final_score, .. }) = last_row + .iter() + .enumerate() + .max_by_key(|&(_, x)| x.final_score) + .expect("fuzzy_indices failed to iterate over last_row"); + Some(final_score) +} + +#[deprecated(since = "0.3.5", note = "Please use SkimMatcherV2 instead")] +/// Legacy fuzzy matching function - returns match score and character indices +pub fn fuzzy_indices(choice: &str, pattern: &str) -> Option<(ScoreType, Vec)> { + if pattern.is_empty() { + return Some((0, Vec::new())); + } + + let mut picked = vec![]; + let scores = build_graph(choice, pattern)?; + + let last_row = &scores[scores.len() - 1]; + let (mut next_col, &MatchingStatus { final_score, .. }) = last_row + .iter() + .enumerate() + .max_by_key(|&(_, x)| x.final_score) + .expect("fuzzy_indices failed to iterate over last_row"); + let mut pat_idx = scores.len() as i64 - 1; + while pat_idx >= 0 { + let status = scores[pat_idx as usize][next_col]; + next_col = status.back_ref as usize; + picked.push(status.idx); + pat_idx -= 1; + } + picked.reverse(); + Some((final_score, picked)) +} + +#[derive(Clone, Copy, Debug)] +struct MatchingStatus { + pub idx: IndexType, + pub score: ScoreType, + pub final_score: ScoreType, + pub adj_num: IndexType, + pub back_ref: IndexType, +} + +impl Default for MatchingStatus { + fn default() -> Self { + MatchingStatus { + idx: 0, + score: 0, + final_score: 0, + adj_num: 1, + back_ref: 0, + } + } +} + +fn build_graph(choice: &str, pattern: &str) -> Option>> { + let mut scores = vec![]; + + let mut match_start_idx = 0; // to ensure that the pushed char are able to match the pattern + let mut pat_prev_ch = '\0'; + + // initialize the match positions and inline scores + for (pat_idx, pat_ch) in pattern.chars().enumerate() { + let mut vec = vec![]; + let mut choice_prev_ch = '\0'; + for (idx, ch) in choice.chars().enumerate() { + if ch.eq_ignore_ascii_case(&pat_ch) && idx >= match_start_idx { + let score = fuzzy_score( + ch, + idx as IndexType, + choice_prev_ch, + pat_ch, + pat_idx as IndexType, + pat_prev_ch, + ); + vec.push(MatchingStatus { + idx: idx as IndexType, + score, + final_score: score, + adj_num: 1, + back_ref: 0, + }); + } + choice_prev_ch = ch; + } + + if vec.is_empty() { + // not matched + return None; + } + match_start_idx = vec[0].idx + 1; + scores.push(vec); + pat_prev_ch = pat_ch; + } + + // calculate max scores considering adjacent characters + for pat_idx in 1..scores.len() { + let (first_half, last_half) = scores.split_at_mut(pat_idx); + + let prev_row = &first_half[first_half.len() - 1]; + let cur_row = &mut last_half[0]; + + for idx in 0..cur_row.len() { + let next = cur_row[idx]; + let prev = if idx > 0 { + cur_row[idx - 1] + } else { + MatchingStatus::default() + }; + + let mut score_before_idx = prev.final_score - prev.score + next.score; + score_before_idx += PENALTY_UNMATCHED * ((next.idx - prev.idx) as ScoreType); + score_before_idx -= if prev.adj_num == 0 { BONUS_ADJACENCY } else { 0 }; + + let (back_ref, score, adj_num) = prev_row + .iter() + .enumerate() + .take_while(|&(_, &MatchingStatus { idx, .. })| idx < next.idx) + .skip_while(|&(_, &MatchingStatus { idx, .. })| idx < prev.idx) + .map(|(back_ref, cur)| { + let adj_num = next.idx - cur.idx - 1; + let mut final_score = cur.final_score + next.score; + final_score += if adj_num == 0 { + BONUS_ADJACENCY + } else { + PENALTY_UNMATCHED * adj_num as ScoreType + }; + (back_ref, final_score, adj_num) + }) + .max_by_key(|&(_, x, _)| x) + .unwrap_or((prev.back_ref, score_before_idx, prev.adj_num)); + + cur_row[idx] = if idx > 0 && score < score_before_idx { + MatchingStatus { + final_score: score_before_idx, + back_ref: prev.back_ref, + adj_num, + ..next + } + } else { + MatchingStatus { + final_score: score, + back_ref: back_ref as IndexType, + adj_num, + ..next + } + }; + } + } + + Some(scores) +} + +// judge how many scores the current index should get +fn fuzzy_score( + choice_ch: char, + choice_idx: IndexType, + choice_prev_ch: char, + pat_ch: char, + pat_idx: IndexType, + _pat_prev_ch: char, +) -> ScoreType { + let mut score = BONUS_MATCHED; + + let choice_prev_ch_type = CharType::of(choice_prev_ch); + let choice_role = CharRole::of(choice_prev_ch, choice_ch); + + if pat_ch == choice_ch { + if pat_ch.is_uppercase() { + score += BONUS_UPPER_MATCH; + } else { + score += BONUS_CASE_MATCH; + } + } else { + score += PENALTY_CASE_UNMATCHED; + } + + // apply bonus for camelCases + if choice_role == CharRole::Head || choice_role == CharRole::Break || choice_role == CharRole::Camel { + score += BONUS_CAMEL; + } + + // apply bonus for matches after a separator + if choice_prev_ch_type == CharType::HardSep || choice_prev_ch_type == CharType::SoftSep { + score += BONUS_SEPARATOR; + } + + if pat_idx == 0 { + score += max((choice_idx as ScoreType) * PENALTY_LEADING, PENALTY_MAX_LEADING); + } + + score +} + +#[derive(Copy, Clone, Debug)] +/// Configuration for skim's scoring algorithm +pub struct SkimScoreConfig { + /// Score for each matched character + pub score_match: i32, + /// Penalty for starting a gap (unmatched characters) + pub gap_start: i32, + /// Penalty for extending a gap + pub gap_extension: i32, + + /// The first character in the typed pattern usually has more significance + /// than the rest so it's important that it appears at special positions where + /// bonus points are given. e.g. "to-go" vs. "ongoing" on "og" or on "ogo". + /// The amount of the extra bonus should be limited so that the gap penalty is + /// still respected. + pub bonus_first_char_multiplier: i32, + + /// We prefer matches at the beginning of a word, but the bonus should not be + /// too great to prevent the longer acronym matches from always winning over + /// shorter fuzzy matches. The bonus point here was specifically chosen that + /// the bonus is cancelled when the gap between the acronyms grows over + /// 8 characters, which is approximately the average length of the words found + /// in web2 dictionary and my file system. + pub bonus_head: i32, + + /// Just like bonus_head, but its breakage of word is not that strong, so it should + /// be slighter less then bonus_head + pub bonus_break: i32, + + /// Edge-triggered bonus for matches in camelCase words. + /// Compared to word-boundary case, they don't accompany single-character gaps + /// (e.g. FooBar vs. foo-bar), so we deduct bonus point accordingly. + pub bonus_camel: i32, + + /// Minimum bonus point given to characters in consecutive chunks. + /// Note that bonus points for consecutive matches shouldn't have needed if we + /// used fixed match score as in the original algorithm. + pub bonus_consecutive: i32, + + /// Skim will match case-sensitively if the pattern contains ASCII upper case, + /// If case of case insensitive match, the penalty will be given to case mismatch + pub penalty_case_mismatch: i32, +} + +impl Default for SkimScoreConfig { + fn default() -> Self { + let score_match = 16; + let gap_start = -3; + let gap_extension = -1; + let bonus_first_char_multiplier = 2; + + Self { + score_match, + gap_start, + gap_extension, + bonus_first_char_multiplier, + bonus_head: score_match / 2, + bonus_break: score_match / 2 + gap_extension, + bonus_camel: score_match / 2 + 2 * gap_extension, + bonus_consecutive: -(gap_start + gap_extension), + penalty_case_mismatch: gap_extension * 2, + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq)] +enum Movement { + Match, + Skip, +} + +/// Inner state of the score matrix +// Implementation detail: tried to pad to 16B +// will store the m and p matrix together +#[derive(Clone, Debug)] +struct MatrixCell { + pub m_move: Movement, + pub m_score: i32, + pub p_move: Movement, + pub p_score: i32, // The max score of align pattern[..i] & choice[..j] + + // temporary fields (make use the rest of the padding) + pub matched: bool, + pub bonus: i32, +} + +const MATRIX_CELL_NEG_INFINITY: i32 = i16::MIN as i32; + +impl Default for MatrixCell { + fn default() -> Self { + Self { + m_move: Skip, + m_score: MATRIX_CELL_NEG_INFINITY, + p_move: Skip, + p_score: MATRIX_CELL_NEG_INFINITY, + matched: false, + bonus: 0, + } + } +} + +impl MatrixCell { + pub fn reset(&mut self) { + self.m_move = Skip; + self.m_score = MATRIX_CELL_NEG_INFINITY; + self.p_move = Skip; + self.p_score = MATRIX_CELL_NEG_INFINITY; + self.bonus = 0; + self.matched = false; + } +} + +/// Simulate a 1-D vector as 2-D matrix +struct ScoreMatrix<'a> { + matrix: &'a mut [MatrixCell], + pub rows: usize, + pub cols: usize, +} + +impl<'a> ScoreMatrix<'a> { + /// given a matrix, extend it to be (rows x cols) and fill in as init_val + pub fn new(matrix: &'a mut Vec, rows: usize, cols: usize) -> Self { + matrix.resize(rows * cols, MatrixCell::default()); + ScoreMatrix { matrix, rows, cols } + } + + #[inline] + fn get_index(&self, row: usize, col: usize) -> usize { + row * self.cols + col + } + + fn get_row(&self, row: usize) -> &[MatrixCell] { + let start = row * self.cols; + &self.matrix[start..start + self.cols] + } +} + +impl<'a> std::ops::Index<(usize, usize)> for ScoreMatrix<'a> { + type Output = MatrixCell; + + fn index(&self, index: (usize, usize)) -> &Self::Output { + &self.matrix[self.get_index(index.0, index.1)] + } +} + +impl<'a> std::ops::IndexMut<(usize, usize)> for ScoreMatrix<'a> { + fn index_mut(&mut self, index: (usize, usize)) -> &mut Self::Output { + &mut self.matrix[self.get_index(index.0, index.1)] + } +} + +impl<'a> std::fmt::Debug for ScoreMatrix<'a> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let _ = writeln!(f, "M score:"); + for row in 0..self.rows { + for col in 0..self.cols { + let cell = &self[(row, col)]; + write!( + f, + "{:4}/{} ", + if cell.m_score == MATRIX_CELL_NEG_INFINITY { + -999 + } else { + cell.m_score + }, + match cell.m_move { + Match => 'M', + Skip => 'S', + } + )?; + } + writeln!(f)?; + } + + let _ = writeln!(f, "P score:"); + for row in 0..self.rows { + for col in 0..self.cols { + let cell = &self[(row, col)]; + write!( + f, + "{:4}/{} ", + if cell.p_score == MATRIX_CELL_NEG_INFINITY { + -999 + } else { + cell.p_score + }, + match cell.p_move { + Match => 'M', + Skip => 'S', + } + )?; + } + writeln!(f)?; + } + + Ok(()) + } +} + +/// We categorize characters into types: +/// +/// - Empty(E): the start of string +/// - Upper(U): the ascii upper case +/// - lower(L): the ascii lower case & other unicode characters +/// - number(N): ascii number +/// - hard separator(S): clearly separate the content: ` ` `/` `\` `|` `(` `) `[` `]` `{` `}` +/// - soft separator(s): other ascii punctuation, e.g. `!` `"` `#` `$`, ... +#[derive(Debug, PartialEq, Copy, Clone)] +enum CharType { + Empty, + Upper, + Lower, + Number, + HardSep, + SoftSep, +} + +impl CharType { + pub fn of(ch: char) -> Self { + match ch { + '\0' => CharType::Empty, + ' ' | '/' | '\\' | '|' | '(' | ')' | '[' | ']' | '{' | '}' => CharType::HardSep, + '!'..='\'' | '*'..='.' | ':'..='@' | '^'..='`' | '~' => CharType::SoftSep, + '0'..='9' => CharType::Number, + 'A'..='Z' => CharType::Upper, + _ => CharType::Lower, + } + } +} + +/// Ref: https://github.com/llvm-mirror/clang-tools-extra/blob/master/clangd/FuzzyMatch.cpp +/// +/// +/// ```text +/// +-----------+--------------+-------+ +/// | Example | Chars | Type | Role | +/// +-----------+--------------+-------+ +/// | (f)oo | ^fo | Ell | Head | +/// | (F)oo | ^Fo | EUl | Head | +/// | Foo/(B)ar | /Ba | SUl | Head | +/// | Foo/(b)ar | /ba | Sll | Head | +/// | Foo.(B)ar | .Ba | SUl | Break | +/// | Foo(B)ar | oBa | lUl | Camel | +/// | 123(B)ar | 3Ba | nUl | Camel | +/// | F(o)oBar | Foo | Ull | Tail | +/// | H(T)TP | HTT | UUU | Tail | +/// | others | | | Tail | +/// +-----------+--------------+-------+ +#[derive(Debug, PartialEq, Copy, Clone)] +enum CharRole { + Head, + Tail, + Camel, + Break, +} + +impl CharRole { + pub fn of(prev: char, cur: char) -> Self { + Self::of_type(CharType::of(prev), CharType::of(cur)) + } + pub fn of_type(prev: CharType, cur: CharType) -> Self { + match (prev, cur) { + (CharType::Empty, _) | (CharType::HardSep, _) => CharRole::Head, + (CharType::SoftSep, _) => CharRole::Break, + (CharType::Lower, CharType::Upper) | (CharType::Number, CharType::Upper) => CharRole::Camel, + _ => CharRole::Tail, + } + } +} + +#[derive(Eq, PartialEq, Debug, Copy, Clone)] +enum CaseMatching { + Respect, + Ignore, + Smart, +} + +/// Fuzzy matching is a sub problem is sequence alignment. +/// Specifically what we'd like to implement is sequence alignment with affine gap penalty. +/// Ref: https://www.cs.cmu.edu/~ckingsf/bioinfo-lectures/gaps.pdf +/// +/// Given `pattern`(i) and `choice`(j), we'll maintain 2 score matrix: +/// +/// ```text +/// M[i][j] = match(i, j) + max(M[i-1][j-1] + consecutive, P[i-1][j-1]) +/// M[i][j] = -infinity if p[i][j] do not match +/// +/// M[i][j] means the score of best alignment of p[..=i] and c[..=j] ending with match/mismatch e.g.: +/// +/// c: [.........]b +/// p: [.........]b +/// +/// So that p[..=i-1] and c[..=j-1] could be any alignment +/// +/// P[i][j] = max(M[i][j-k]-gap(k)) for k in 1..j +/// +/// P[i][j] means the score of best alignment of p[..=i] and c[..=j] where c[j] is not matched. +/// So that we need to search through all the previous matches, and calculate the gap. +/// +/// (j-k)--. j +/// c: [....]bcdef +/// p: [....]b---- +/// i +/// ``` +/// +/// Note that the above is O(n^3) in the worst case. However the above algorithm uses a general gap +/// penalty, but we use affine gap: `gap = gap_start + k * gap_extend` where: +/// - u: the cost of starting of gap +/// - v: the cost of extending a gap by one more space. +/// +/// So that we could optimize the algorithm by: +/// +/// ```text +/// P[i][j] = max(gap_start + gap_extend + M[i][j-1], gap_extend + P[i][j-1]) +/// ``` +/// +/// Besides, since we are doing fuzzy matching, we'll prefer some pattern over others. +/// So we'll calculate in-place bonus for each character. e.g. bonus for camel cases. +/// +/// In summary: +/// +/// ```text +/// B[j] = in_place_bonus_of(j) +/// M[i][j] = match(i, j) + max(M[i-1][j-1] + consecutive, P[i-1][j-1]) +/// M[i][j] = -infinity if p[i] and c[j] do not match +/// P[i][j] = max(gap_start + gap_extend + M[i][j-1], gap_extend + P[i][j-1]) +/// ``` +#[derive(Debug)] +pub struct SkimMatcherV2 { + debug: bool, + + score_config: SkimScoreConfig, + element_limit: usize, + case: CaseMatching, + use_cache: bool, + + m_cache: ThreadLocal>>, + c_cache: ThreadLocal>>, // vector to store the characters of choice + p_cache: ThreadLocal>>, // vector to store the characters of pattern +} + +impl Default for SkimMatcherV2 { + fn default() -> Self { + Self { + debug: false, + score_config: SkimScoreConfig::default(), + element_limit: 0, + case: CaseMatching::Smart, + use_cache: true, + + m_cache: ThreadLocal::new(), + c_cache: ThreadLocal::new(), + p_cache: ThreadLocal::new(), + } + } +} + +impl SkimMatcherV2 { + /// Sets the scoring configuration for the matcher + pub fn score_config(mut self, score_config: SkimScoreConfig) -> Self { + self.score_config = score_config; + self + } + + /// Sets the maximum number of elements to process + pub fn element_limit(mut self, elements: usize) -> Self { + self.element_limit = elements; + self + } + + /// Sets the matcher to ignore case when matching + pub fn ignore_case(mut self) -> Self { + self.case = CaseMatching::Ignore; + self + } + + /// Sets the matcher to use smart case (case insensitive unless pattern contains uppercase) + pub fn smart_case(mut self) -> Self { + self.case = CaseMatching::Smart; + self + } + + /// Sets the matcher to respect case when matching + pub fn respect_case(mut self) -> Self { + self.case = CaseMatching::Respect; + self + } + + /// Enables or disables caching for improved performance + pub fn use_cache(mut self, use_cache: bool) -> Self { + self.use_cache = use_cache; + self + } + + /// Enables or disables debug mode + pub fn debug(mut self, debug: bool) -> Self { + self.debug = debug; + self + } + + /// Build the score matrix using the algorithm described above + fn build_score_matrix( + &self, + m: &mut ScoreMatrix, + choice: &[char], + pattern: &[char], + first_match_indices: &[usize], + compressed: bool, + case_sensitive: bool, + ) { + let mut in_place_bonuses = vec![0; m.cols]; + + self.build_in_place_bonus(choice, &mut in_place_bonuses); + + // need to reset M[row][first_match] & M[i][j-1] + m[(0, 0)].reset(); + for i in 1..m.rows { + m[(i, first_match_indices[i - 1])].reset(); + } + + for j in 0..m.cols { + // p[0][j]: the score of best alignment of p[] and c[..=j] where c[j] is not matched + m[(0, j)].reset(); + m[(0, j)].p_score = self.score_config.gap_extension; + } + + // update the matrix; + for (i, &p_ch) in pattern.iter().enumerate() { + let row = self.adjust_row_idx(i + 1, compressed); + let row_prev = self.adjust_row_idx(i, compressed); + let to_skip = first_match_indices[i]; + + // Pre-calculate base indices to reduce repeated index calculations + let row_base = row * m.cols; + let row_prev_base = row_prev * m.cols; + + for (j, &c_ch) in choice[to_skip..].iter().enumerate() { + let col = to_skip + j + 1; + let col_prev = to_skip + j; + + // Use pre-calculated bases to reduce index calculations + let idx_cur = row_base + col; + let idx_last = row_base + col_prev; + let idx_prev = row_prev_base + col_prev; + + // Cache in_place_bonus lookup to avoid repeated array access + let in_place_bonus = in_place_bonuses[col]; + + // update M matrix + // M[i][j] = match(i, j) + max(M[i-1][j-1], P[i-1][j-1]) + if let Some(cur_match_score) = self.calculate_match_score(c_ch, p_ch, case_sensitive) { + let prev_cell = &m.matrix[idx_prev]; + let prev_match_score = prev_cell.m_score; + let prev_skip_score = prev_cell.p_score; + + let prev_match_bonus = m.matrix[idx_last].bonus; + + let consecutive_bonus = max( + prev_match_bonus, + max(in_place_bonus, self.score_config.bonus_consecutive), + ); + m.matrix[idx_last].bonus = consecutive_bonus; + + let score_match = prev_match_score + consecutive_bonus; + let score_skip = prev_skip_score + in_place_bonus; + + let cur_cell = &mut m.matrix[idx_cur]; + if score_match >= score_skip { + cur_cell.m_score = score_match + cur_match_score as i32; + cur_cell.m_move = Movement::Match; + } else { + cur_cell.m_score = score_skip + cur_match_score as i32; + cur_cell.m_move = Movement::Skip; + } + } else { + let cur_cell = &mut m.matrix[idx_cur]; + cur_cell.m_score = MATRIX_CELL_NEG_INFINITY; + cur_cell.m_move = Movement::Skip; + cur_cell.bonus = 0; + } + + // update P matrix + // P[i][j] = max(gap_start + gap_extend + M[i][j-1], gap_extend + P[i][j-1]) + let last_cell = &m.matrix[idx_last]; + let prev_match_score = + self.score_config.gap_start + self.score_config.gap_extension + last_cell.m_score; + let prev_skip_score = self.score_config.gap_extension + last_cell.p_score; + + let cur_cell = &mut m.matrix[idx_cur]; + if prev_match_score >= prev_skip_score { + cur_cell.p_score = prev_match_score; + cur_cell.p_move = Movement::Match; + } else { + cur_cell.p_score = prev_skip_score; + cur_cell.p_move = Movement::Skip; + } + } + } + } + + /// check bonus for start of camel case, etc. + fn build_in_place_bonus(&self, choice: &[char], b: &mut [i32]) { + let mut prev_ch = '\0'; + for (j, &c_ch) in choice.iter().enumerate() { + let prev_ch_type = CharType::of(prev_ch); + let ch_type = CharType::of(c_ch); + b[j + 1] = self.in_place_bonus(prev_ch_type, ch_type); + prev_ch = c_ch; + } + + if b.len() > 1 { + b[1] *= self.score_config.bonus_first_char_multiplier; + } + } + + /// In case we don't need to backtrack the matching indices, we could use only 2 rows for the + /// matrix, this function could be used to rotate accessing these two rows. + fn adjust_row_idx(&self, row_idx: usize, compressed: bool) -> usize { + if compressed { row_idx & 1 } else { row_idx } + } + + /// Calculate the matching score of the characters + /// return None if not matched. + fn calculate_match_score(&self, c: char, p: char, case_sensitive: bool) -> Option { + if !char_equal(c, p, case_sensitive) { + return None; + } + + let score = self.score_config.score_match; + let mut bonus = 0; + + // penalty on case mismatch + if !case_sensitive && p != c { + bonus += self.score_config.penalty_case_mismatch; + } + + Some(max(0, score + bonus) as u16) + } + + #[inline] + fn in_place_bonus(&self, prev_char_type: CharType, char_type: CharType) -> i32 { + match CharRole::of_type(prev_char_type, char_type) { + CharRole::Head => self.score_config.bonus_head, + CharRole::Camel => self.score_config.bonus_camel, + CharRole::Break => self.score_config.bonus_break, + CharRole::Tail => 0, + } + } + + fn contains_upper(&self, string: &str) -> bool { + string.chars().any(|ch| ch.is_ascii_uppercase()) + } + + /// Performs fuzzy matching with full algorithm and returns score and indices + pub fn fuzzy(&self, choice: &str, pattern: &str, with_pos: bool) -> Option<(ScoreType, Vec)> { + if pattern.is_empty() { + return Some((0, Vec::new())); + } + + let case_sensitive = match self.case { + CaseMatching::Respect => true, + CaseMatching::Ignore => false, + CaseMatching::Smart => self.contains_upper(pattern), + }; + + let compressed = !with_pos; + + // initialize the score matrix + let mut m = self.m_cache.get_or(|| RefCell::new(Vec::new())).borrow_mut(); + let mut choice_chars = self.c_cache.get_or(|| RefCell::new(Vec::new())).borrow_mut(); + let mut pattern_chars = self.p_cache.get_or(|| RefCell::new(Vec::new())).borrow_mut(); + + choice_chars.clear(); + choice_chars.reserve(choice.chars().count()); + choice_chars.extend(choice.chars()); + + pattern_chars.clear(); + pattern_chars.reserve(pattern.chars().count()); + pattern_chars.extend(pattern.chars()); + + let first_match_indices = cheap_matches(&choice_chars, &pattern_chars, case_sensitive)?; + + let cols = choice_chars.len() + 1; + let num_char_pattern = pattern_chars.len(); + let rows = if compressed { 2 } else { num_char_pattern + 1 }; + + if self.element_limit > 0 && self.element_limit < rows * cols { + return self.simple_match( + &choice_chars, + &pattern_chars, + &first_match_indices, + case_sensitive, + with_pos, + ); + } + + let mut m = ScoreMatrix::new(&mut m, rows, cols); + self.build_score_matrix( + &mut m, + &choice_chars, + &pattern_chars, + &first_match_indices, + compressed, + case_sensitive, + ); + let first_col_of_last_row = first_match_indices[first_match_indices.len() - 1]; + let last_row = m.get_row(self.adjust_row_idx(num_char_pattern, compressed)); + let (pat_idx, &MatrixCell { m_score, .. }) = last_row[first_col_of_last_row..] + .iter() + .enumerate() + .max_by_key(|&(_, x)| x.m_score) + .map(|(idx, cell)| (idx + first_col_of_last_row, cell)) + .expect("fuzzy_matcher failed to iterate over last_row"); + + let mut positions = if with_pos { + Vec::with_capacity(num_char_pattern) + } else { + Vec::new() + }; + if with_pos { + let mut i = m.rows - 1; + let mut j = pat_idx; + let mut track_m = true; + let mut current_move = Match; + let first_col_first_row = first_match_indices[0]; + while i > 0 && j > first_col_first_row { + if current_move == Match { + positions.push((j - 1) as IndexType); + } + + let cell = &m[(i, j)]; + current_move = if track_m { cell.m_move } else { cell.p_move }; + if track_m { + i -= 1; + } + + j -= 1; + + track_m = match current_move { + Match => true, + Skip => false, + }; + } + positions.reverse(); + } + + if self.debug { + println!("Matrix:\n{:?}", m); + } + + if !self.use_cache { + // drop the allocated memory + self.m_cache.get().map(|cell| cell.replace(vec![])); + self.c_cache.get().map(|cell| cell.replace(vec![])); + self.p_cache.get().map(|cell| cell.replace(vec![])); + } + + Some((m_score as ScoreType, positions)) + } + + /// Performs simple pattern matching for cases where the full algorithm isn't needed + pub fn simple_match( + &self, + choice: &[char], + pattern: &[char], + first_match_indices: &[usize], + case_sensitive: bool, + with_pos: bool, + ) -> Option<(ScoreType, Vec)> { + if pattern.is_empty() { + return Some((0, Vec::new())); + } else if pattern.len() == 1 { + let match_idx = first_match_indices[0]; + let prev_ch = if match_idx > 0 { choice[match_idx - 1] } else { '\0' }; + let prev_ch_type = CharType::of(prev_ch); + let ch_type = CharType::of(choice[match_idx]); + let in_place_bonus = self.in_place_bonus(prev_ch_type, ch_type); + return Some((in_place_bonus as ScoreType, vec![match_idx as IndexType])); + } + + let mut start_idx = first_match_indices[0]; + let end_idx = first_match_indices[first_match_indices.len() - 1]; + + let mut pattern_iter = pattern.iter().rev().peekable(); + for (idx, &c) in choice[start_idx..=end_idx].iter().enumerate().rev() { + match pattern_iter.peek() { + Some(&&p) => { + if char_equal(c, p, case_sensitive) { + let _ = pattern_iter.next(); + start_idx = idx; + } + } + None => break, + } + } + + Some(self.calculate_score_with_pos(choice, pattern, start_idx, end_idx, case_sensitive, with_pos)) + } + + fn calculate_score_with_pos( + &self, + choice: &[char], + pattern: &[char], + start_idx: usize, + end_idx: usize, + case_sensitive: bool, + with_pos: bool, + ) -> (ScoreType, Vec) { + let mut pos = Vec::new(); + + let choice_iter = choice[start_idx..=end_idx].iter().enumerate(); + let mut pattern_iter = pattern.iter().enumerate().peekable(); + + // unfortunately we could not get the the character before the first character's(for performance) + // so we tread them as NonWord + let mut prev_ch = '\0'; + + let mut score: i32 = 0; + let mut in_gap = false; + let mut prev_match_bonus = 0; + + for (c_idx, &c) in choice_iter { + let op = pattern_iter.peek(); + if op.is_none() { + break; + } + + let prev_ch_type = CharType::of(prev_ch); + let ch_type = CharType::of(c); + let in_place_bonus = self.in_place_bonus(prev_ch_type, ch_type); + + let (_p_idx, &p) = *op.unwrap(); + + if let Some(match_score) = self.calculate_match_score(c, p, case_sensitive) { + if with_pos { + pos.push((c_idx + start_idx) as IndexType); + } + + score += match_score as i32; + + let consecutive_bonus = max( + prev_match_bonus, + max(in_place_bonus, self.score_config.bonus_consecutive), + ); + prev_match_bonus = consecutive_bonus; + + if !in_gap { + score += consecutive_bonus; + } + + in_gap = false; + let _ = pattern_iter.next(); + } else { + if !in_gap { + score += self.score_config.gap_start; + } + + score += self.score_config.gap_extension; + in_gap = true; + prev_match_bonus = 0; + } + + prev_ch = c; + } + + (score as ScoreType, pos) + } +} + +impl FuzzyMatcher for SkimMatcherV2 { + fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(ScoreType, Vec)> { + self.fuzzy(choice, pattern, true) + } + + fn fuzzy_match(&self, choice: &str, pattern: &str) -> Option { + self.fuzzy(choice, pattern, false).map(|(score, _)| score) + } +} + +#[cfg(test)] +mod tests { + use crate::fuzzy_matcher::util::{assert_order, wrap_matches}; + + use super::*; + + fn wrap_fuzzy_match(matcher: &dyn FuzzyMatcher, line: &str, pattern: &str) -> Option { + let (_score, indices) = matcher.fuzzy_indices(line, pattern)?; + println!("score: {:?}, indices: {:?}", _score, indices); + Some(wrap_matches(line, &indices)) + } + + #[test] + fn test_match_or_not() { + let matcher = SkimMatcherV2::default(); + assert_eq!(Some(0), matcher.fuzzy_match("", "")); + assert_eq!(Some(0), matcher.fuzzy_match("abcdefaghi", "")); + assert_eq!(None, matcher.fuzzy_match("", "a")); + assert_eq!(None, matcher.fuzzy_match("abcdefaghi", "中")); + assert_eq!(None, matcher.fuzzy_match("abc", "abx")); + assert!(matcher.fuzzy_match("axbycz", "abc").is_some()); + assert!(matcher.fuzzy_match("axbycz", "xyz").is_some()); + + assert_eq!("[a]x[b]y[c]z", &wrap_fuzzy_match(&matcher, "axbycz", "abc").unwrap()); + assert_eq!("a[x]b[y]c[z]", &wrap_fuzzy_match(&matcher, "axbycz", "xyz").unwrap()); + assert_eq!( + "[H]ello, [世]界", + &wrap_fuzzy_match(&matcher, "Hello, 世界", "H世").unwrap() + ); + } + + #[test] + fn test_match_quality() { + let matcher = SkimMatcherV2::default().ignore_case(); + + // initials + assert_order(&matcher, "ab", &["ab", "aoo_boo", "acb"]); + assert_order(&matcher, "CC", &["CamelCase", "camelCase", "camelcase"]); + assert_order(&matcher, "cC", &["camelCase", "CamelCase", "camelcase"]); + assert_order( + &matcher, + "cc", + &["camel case", "camelCase", "CamelCase", "camelcase", "camel ace"], + ); + assert_order( + &matcher, + "Da.Te", + &["Data.Text", "Data.Text.Lazy", "Data.Aeson.Encoding.text"], + ); + // prefix + assert_order(&matcher, "is", &["isIEEE", "inSuf"]); + // shorter + assert_order(&matcher, "ma", &["map", "many", "maximum"]); + assert_order(&matcher, "print", &["printf", "sprintf"]); + // score(PRINT) = kMinScore + assert_order(&matcher, "ast", &["ast", "AST", "INT_FAST16_MAX"]); + // score(PRINT) > kMinScore + assert_order(&matcher, "Int", &["int", "INT", "PRINT"]); + } + + fn simple_match( + matcher: &SkimMatcherV2, + choice: &str, + pattern: &str, + case_sensitive: bool, + with_pos: bool, + ) -> Option<(ScoreType, Vec)> { + let choice: Vec = choice.chars().collect(); + let pattern: Vec = pattern.chars().collect(); + let first_match_indices = cheap_matches(&choice, &pattern, case_sensitive)?; + matcher.simple_match(&choice, &pattern, &first_match_indices, case_sensitive, with_pos) + } + + #[test] + fn test_match_or_not_simple() { + let matcher = SkimMatcherV2::default(); + assert_eq!( + simple_match(&matcher, "axbycz", "xyz", false, true).unwrap().1, + vec![1, 3, 5] + ); + + assert_eq!(simple_match(&matcher, "", "", false, false), Some((0, vec![]))); + assert_eq!( + simple_match(&matcher, "abcdefaghi", "", false, false), + Some((0, vec![])) + ); + assert_eq!(simple_match(&matcher, "", "a", false, false), None); + assert_eq!(simple_match(&matcher, "abcdefaghi", "中", false, false), None); + assert_eq!(simple_match(&matcher, "abc", "abx", false, false), None); + assert_eq!( + simple_match(&matcher, "axbycz", "abc", false, true).unwrap().1, + vec![0, 2, 4] + ); + assert_eq!( + simple_match(&matcher, "axbycz", "xyz", false, true).unwrap().1, + vec![1, 3, 5] + ); + assert_eq!( + simple_match(&matcher, "Hello, 世界", "H世", false, true).unwrap().1, + vec![0, 7] + ); + } + + #[test] + fn test_match_or_not_v2() { + let matcher = SkimMatcherV2::default().debug(true); + + assert_eq!(matcher.fuzzy_match("", ""), Some(0)); + assert_eq!(matcher.fuzzy_match("abcdefaghi", ""), Some(0)); + assert_eq!(matcher.fuzzy_match("", "a"), None); + assert_eq!(matcher.fuzzy_match("abcdefaghi", "中"), None); + assert_eq!(matcher.fuzzy_match("abc", "abx"), None); + assert!(matcher.fuzzy_match("axbycz", "abc").is_some()); + assert!(matcher.fuzzy_match("axbycz", "xyz").is_some()); + + assert_eq!(&wrap_fuzzy_match(&matcher, "axbycz", "abc").unwrap(), "[a]x[b]y[c]z"); + assert_eq!(&wrap_fuzzy_match(&matcher, "axbycz", "xyz").unwrap(), "a[x]b[y]c[z]"); + assert_eq!( + &wrap_fuzzy_match(&matcher, "Hello, 世界", "H世").unwrap(), + "[H]ello, [世]界" + ); + } + + #[test] + fn test_case_option_v2() { + let matcher = SkimMatcherV2::default().ignore_case(); + assert!(matcher.fuzzy_match("aBc", "abc").is_some()); + assert!(matcher.fuzzy_match("aBc", "aBc").is_some()); + assert!(matcher.fuzzy_match("aBc", "aBC").is_some()); + + let matcher = SkimMatcherV2::default().respect_case(); + assert!(matcher.fuzzy_match("aBc", "abc").is_none()); + assert!(matcher.fuzzy_match("aBc", "aBc").is_some()); + assert!(matcher.fuzzy_match("aBc", "aBC").is_none()); + + let matcher = SkimMatcherV2::default().smart_case(); + assert!(matcher.fuzzy_match("aBc", "abc").is_some()); + assert!(matcher.fuzzy_match("aBc", "aBc").is_some()); + assert!(matcher.fuzzy_match("aBc", "aBC").is_none()); + } + + #[test] + fn test_matcher_quality_v2() { + let matcher = SkimMatcherV2::default(); + assert_order(&matcher, "ab", &["ab", "aoo_boo", "acb"]); + assert_order( + &matcher, + "cc", + &["camel case", "camelCase", "CamelCase", "camelcase", "camel ace"], + ); + assert_order( + &matcher, + "Da.Te", + &["Data.Text", "Data.Text.Lazy", "Data.Aeson.Encoding.Text"], + ); + assert_order(&matcher, "is", &["isIEEE", "inSuf"]); + assert_order(&matcher, "ma", &["map", "many", "maximum"]); + assert_order(&matcher, "print", &["printf", "sprintf"]); + assert_order(&matcher, "ast", &["ast", "AST", "INT_FAST16_MAX"]); + assert_order(&matcher, "int", &["int", "INT", "PRINT"]); + } + + #[test] + fn test_reuse_should_not_affect_indices() { + let matcher = SkimMatcherV2::default(); + let pattern = "139"; + for num in 0..10000 { + let choice = num.to_string(); + if let Some((_score, indices)) = matcher.fuzzy_indices(&choice, pattern) { + assert_eq!(indices.len(), 3); + } + } + } +} diff --git a/skim/src/fuzzy_matcher/util.rs b/skim/src/fuzzy_matcher/util.rs new file mode 100644 index 00000000..73c74093 --- /dev/null +++ b/skim/src/fuzzy_matcher/util.rs @@ -0,0 +1,132 @@ +use super::{FuzzyMatcher, IndexType, ScoreType}; + +pub fn cheap_matches(choice: &[char], pattern: &[char], case_sensitive: bool) -> Option> { + let mut first_match_indices = vec![]; + let mut pattern_iter = pattern.iter().peekable(); + for (idx, &c) in choice.iter().enumerate() { + match pattern_iter.peek() { + Some(&&p) => { + if char_equal(c, p, case_sensitive) { + first_match_indices.push(idx); + let _ = pattern_iter.next(); + } + } + None => break, + } + } + + if pattern_iter.peek().is_none() { + Some(first_match_indices) + } else { + None + } +} + +/// Given 2 character, check if they are equal (considering ascii case) +/// e.g. ('a', 'A', true) => false +/// e.g. ('a', 'A', false) => true +#[inline] +pub fn char_equal(a: char, b: char, case_sensitive: bool) -> bool { + if case_sensitive { + a == b + } else { + a.eq_ignore_ascii_case(&b) + } +} + +#[derive(Debug, PartialEq)] +pub enum CharType { + NonWord, + Lower, + Upper, + Number, +} + +#[inline] +pub fn char_type_of(ch: char) -> CharType { + if ch.is_lowercase() { + CharType::Lower + } else if ch.is_uppercase() { + CharType::Upper + } else if ch.is_numeric() { + CharType::Number + } else { + CharType::NonWord + } +} + +#[derive(Debug, PartialEq)] +pub enum CharRole { + Tail, + Head, +} + +// checkout https://github.com/llvm-mirror/clang-tools-extra/blob/master/clangd/FuzzyMatch.cpp +// The Role can be determined from the Type of a character and its neighbors: +// +// Example | Chars | Type | Role +// ---------+--------------+----- +// F(o)oBar | Foo | Ull | Tail +// Foo(B)ar | oBa | lUl | Head +// (f)oo | ^fo | Ell | Head +// H(T)TP | HTT | UUU | Tail +// +// Curr= Empty Lower Upper Separ +// Prev=Empty 0x00, 0xaa, 0xaa, 0xff, // At start, Lower|Upper->Head +// Prev=Lower 0x00, 0x55, 0xaa, 0xff, // In word, Upper->Head;Lower->Tail +// Prev=Upper 0x00, 0x55, 0x59, 0xff, // Ditto, but U(U)U->Tail +// Prev=Separ 0x00, 0xaa, 0xaa, 0xff, // After separator, like at start +pub fn char_role(prev: char, cur: char) -> CharRole { + use self::CharRole::*; + use self::CharType::*; + match (char_type_of(prev), char_type_of(cur)) { + (Lower, Upper) | (NonWord, Lower) | (NonWord, Upper) => Head, + _ => Tail, + } +} + +#[allow(dead_code)] +pub fn assert_order(matcher: &dyn FuzzyMatcher, pattern: &str, choices: &[&'static str]) { + let result = filter_and_sort(matcher, pattern, choices); + + if result != choices { + // debug print + println!("pattern: {}", pattern); + for &choice in choices.iter() { + if let Some((score, indices)) = matcher.fuzzy_indices(choice, pattern) { + println!("{}: {:?}", score, wrap_matches(choice, &indices)); + } else { + println!("NO MATCH for {}", choice); + } + } + } + + assert_eq!(result, choices); +} + +#[allow(dead_code)] +pub fn filter_and_sort(matcher: &dyn FuzzyMatcher, pattern: &str, lines: &[&'static str]) -> Vec<&'static str> { + let mut lines_with_score: Vec<(ScoreType, &'static str)> = lines + .iter() + .filter_map(|&s| matcher.fuzzy_match(s, pattern).map(|score| (score, s))) + .collect(); + lines_with_score.sort_by_key(|(score, _)| -score); + lines_with_score.into_iter().map(|(_, string)| string).collect() +} + +#[allow(dead_code)] +pub fn wrap_matches(line: &str, indices: &[IndexType]) -> String { + let mut ret = String::new(); + let mut peekable = indices.iter().peekable(); + for (idx, ch) in line.chars().enumerate() { + let next_id = **peekable.peek().unwrap_or(&&(line.len() as IndexType)); + if next_id == (idx as IndexType) { + ret.push_str(format!("[{}]", ch).as_str()); + peekable.next(); + } else { + ret.push(ch); + } + } + + ret +} diff --git a/skim/src/global.rs b/skim/src/global.rs deleted file mode 100644 index 3dffc521..00000000 --- a/skim/src/global.rs +++ /dev/null @@ -1,46 +0,0 @@ -use std::collections::HashMap; -use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::{LazyLock, Mutex}; - -// Consider that you invoke a command with different arguments several times -// If you select some items each time, how will skim remember it? -// => Well, we'll give each invocation a number, i.e. RUN_NUM -// What if you invoke the same command and same arguments twice? -// => We use NUM_MAP to specify the same run number. -static RUN_NUM: LazyLock = LazyLock::new(|| AtomicU32::new(0)); -static SEQ: LazyLock = LazyLock::new(|| AtomicU32::new(1)); -static NUM_MAP: LazyLock>> = LazyLock::new(|| { - let mut m = HashMap::new(); - m.insert("".to_string(), 0); - Mutex::new(m) -}); - -pub fn current_run_num() -> u32 { - RUN_NUM.load(Ordering::SeqCst) -} - -pub fn mark_new_run(query: &str) -> u32 { - let mut map = NUM_MAP.lock().expect("failed to lock NUM_MAP"); - let query = query.to_string(); - let run_num = *map.entry(query).or_insert_with(|| SEQ.fetch_add(1, Ordering::SeqCst)); - RUN_NUM.store(run_num, Ordering::SeqCst); - run_num -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test() { - assert_eq!(0, current_run_num()); - mark_new_run("a"); - assert_eq!(1, current_run_num()); - mark_new_run("b"); - assert_eq!(2, current_run_num()); - mark_new_run("a"); - assert_eq!(1, current_run_num()); - mark_new_run(""); - assert_eq!(0, current_run_num()); - } -} diff --git a/skim/src/header.rs b/skim/src/header.rs deleted file mode 100644 index d7c97b2c..00000000 --- a/skim/src/header.rs +++ /dev/null @@ -1,142 +0,0 @@ -//! header of the items -use crate::ansi::{ANSIParser, AnsiString}; -use crate::event::UpdateScreen; -use crate::event::{Event, EventHandler}; -use crate::item::ItemPool; -use crate::theme::ColorTheme; -use crate::theme::DEFAULT_THEME; -use crate::util::{LinePrinter, clear_canvas, print_item, str_lines}; -use crate::{DisplayContext, Matches, SkimOptions}; -use defer_drop::DeferDrop; -use skim_tuikit::prelude::*; -use std::cmp::max; -use std::sync::Arc; - -pub struct Header { - header: Vec>, - tabstop: usize, - reverse: bool, - theme: Arc, - - // for reserved header items - item_pool: Arc>, -} - -impl Header { - pub fn empty() -> Self { - Self { - header: vec![], - tabstop: 8, - reverse: false, - theme: Arc::new(*DEFAULT_THEME), - item_pool: Arc::new(DeferDrop::new(ItemPool::new())), - } - } - - pub fn item_pool(mut self, item_pool: Arc>) -> Self { - self.item_pool = item_pool; - self - } - - pub fn theme(mut self, theme: Arc) -> Self { - self.theme = theme; - self - } - - pub fn with_options(mut self, options: &SkimOptions) -> Self { - self.tabstop = max(1, options.tabstop); - - if options.layout.starts_with("reverse") { - self.reverse = true; - } - - match &options.header { - None => {} - Some(header) => { - let mut parser = ANSIParser::default(); - if !header.is_empty() { - self.header = str_lines(header).into_iter().map(|l| parser.parse_ansi(l)).collect(); - } - } - } - self - } - - fn lines_of_header(&self) -> usize { - self.header.len() + self.item_pool.reserved().len() - } - - fn adjust_row(&self, index: usize, screen_height: usize) -> usize { - if self.reverse { index } else { screen_height - index - 1 } - } -} - -impl Draw for Header { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - let (screen_width, screen_height) = canvas.size()?; - if screen_width < 3 { - return Err("screen width is too small".into()); - } - - if screen_height < self.lines_of_header() { - return Err("screen height is too small".into()); - } - - canvas.clear()?; - clear_canvas(canvas)?; - - for (idx, header) in self.header.iter().enumerate() { - // print fixed header(specified by --header) - let mut printer = LinePrinter::builder() - .row(self.adjust_row(idx, screen_height)) - .col(2) - .tabstop(self.tabstop) - .container_width(screen_width - 2) - .shift(0) - .text_width(screen_width - 2) - .build(); - - for (ch, _attr) in header.iter() { - printer.print_char(canvas, ch, self.theme.header(), false); - } - } - - let lines_used = self.header.len(); - - // print "reserved" header lines (--header-lines) - for (idx, item) in self.item_pool.reserved().iter().enumerate() { - let mut printer = LinePrinter::builder() - .row(self.adjust_row(idx + lines_used, screen_height)) - .col(2) - .tabstop(self.tabstop) - .container_width(screen_width - 2) - .shift(0) - .text_width(screen_width - 2) - .build(); - - let context = DisplayContext { - text: &item.text(), - score: 0, - matches: Matches::None, - container_width: screen_width - 2, - highlight_attr: self.theme.header(), - }; - - print_item(canvas, &mut printer, item.display(context), self.theme.header()); - } - - Ok(()) - } -} - -impl Widget for Header { - fn size_hint(&self) -> (Option, Option) { - (None, Some(self.lines_of_header())) - } -} - -impl EventHandler for Header { - fn handle(&mut self, _event: &Event) -> UpdateScreen { - UpdateScreen::DontRedraw - } -} diff --git a/skim/src/helper/item.rs b/skim/src/helper/item.rs index f4a5bbc9..49cf19ac 100644 --- a/skim/src/helper/item.rs +++ b/skim/src/helper/item.rs @@ -1,8 +1,8 @@ -use crate::ansi::ANSIParser; use crate::field::{FieldRange, parse_matching_fields, parse_transform_fields}; -use crate::{AnsiString, DisplayContext, Matches, SkimItem}; +use crate::{DisplayContext, SkimItem}; +use ansi_to_tui::IntoText; +use ratatui::text::{Line, Span}; use regex::Regex; -use skim_tuikit::prelude::Attr; use std::borrow::Cow; //------------------------------------------------------------------------------ @@ -23,8 +23,17 @@ pub struct DefaultSkimItem { /// `None` => that it is safe to output `text` directly orig_text: Option, - /// The text that will be shown on screen and matched. - text: AnsiString<'static>, + /// The text that will be shown on screen. + text: String, + + /// The text stripped of all ansi sequences, used for matching + /// Will be Some when ANSI is enabled, None otherwise + stripped_text: Option, + + /// A mapping of positions from stripped text to original text. + /// Each element is (byte_position, char_position) in the original raw text. + /// Will be empty if ansi is disabled. + ansi_info: Option>, // Option> to reduce memory use in normal cases where no matching ranges are specified. #[allow(clippy::box_collection)] @@ -54,60 +63,125 @@ impl DefaultSkimItem { // | | // +- F -> orig | orig - let mut ansi_parser: ANSIParser = Default::default(); - - let (orig_text, text) = if using_transform_fields && ansi_enabled { - // ansi and transform - let transformed = ansi_parser.parse_ansi(&parse_transform_fields(delimiter, &orig_text, trans_fields)); - (Some(orig_text), transformed) - } else if using_transform_fields { - // transformed, not ansi - let transformed = parse_transform_fields(delimiter, &orig_text, trans_fields).into(); - (Some(orig_text), transformed) - } else if ansi_enabled { - // not transformed, ansi - (None, ansi_parser.parse_ansi(&orig_text)) - } else { - // normal case - (None, orig_text.into()) + let (mut orig_text, mut text) = match (using_transform_fields, ansi_enabled) { + (true, true) => { + let transformed = parse_transform_fields(delimiter, &orig_text, trans_fields); + (Some(orig_text), transformed) + } + (true, false) => { + let transformed = parse_transform_fields(delimiter, &escape_ansi(&orig_text), trans_fields); + (Some(orig_text), transformed) + } + (false, true) => (None, orig_text), + (false, false) => (None, escape_ansi(&orig_text)), }; + // Keep track of whether we have null bytes for special handling + let has_null_bytes = text.contains('\0'); + + // Preserve original text with null bytes for output if needed + if has_null_bytes && orig_text.is_none() { + orig_text = Some(text.clone()); + } + + // Strip null bytes from text used for display and matching + // Null bytes are control characters that cause rendering issues (zero-width) + // They are preserved in orig_text for output + if has_null_bytes { + text = text.replace('\0', ""); + } + + let (stripped_text, ansi_info) = if ansi_enabled { + let (stripped, info) = strip_ansi(&text); + (Some(stripped), Some(info)) + } else { + (None, None) + }; + + // Calculate matching ranges on text WITHOUT null bytes (after stripping) + // This ensures the byte positions match the actual text used for matching let matching_ranges = if !matching_fields.is_empty() { - Some(Box::new(parse_matching_fields( - delimiter, - text.stripped(), - matching_fields, - ))) + // Use stripped text for matching ranges when ANSI is enabled + let text_for_matching = if ansi_enabled { + stripped_text.as_ref().unwrap() + } else { + &text + }; + + // Parse the original text with null bytes to determine field boundaries + // Then extract those fields, strip null bytes, and recalculate positions + let orig_text_for_fields = if has_null_bytes { + orig_text.as_ref().unwrap() + } else { + text_for_matching + }; + + if has_null_bytes { + // Extract each field from the original text (with null bytes) + // then strip null bytes and build new ranges in the cleaned text + let mut adjusted_ranges = Vec::new(); + + for field in matching_fields { + // Get the field text from original (with null bytes) + if let Some(field_text) = crate::field::get_string_by_field(delimiter, orig_text_for_fields, field) + { + // Strip null bytes from this field + let cleaned_field = field_text.replace('\0', ""); + + // Find this cleaned field in the cleaned full text + if let Some(pos) = text_for_matching.find(&cleaned_field) { + adjusted_ranges.push((pos, pos + cleaned_field.len())); + } + } + } + Some(Box::new(adjusted_ranges)) + } else { + Some(Box::new(parse_matching_fields( + delimiter, + text_for_matching, + matching_fields, + ))) + } } else { None }; DefaultSkimItem { - orig_text, text, + orig_text, + stripped_text, + ansi_info, matching_ranges, index, } } } +impl DefaultSkimItem { + /// Get the display text (with ANSI codes if present) for rendering purposes + #[inline] + #[allow(dead_code)] + pub fn get_display_text(&self) -> &str { + &self.text + } +} + impl SkimItem for DefaultSkimItem { #[inline] fn text(&self) -> Cow<'_, str> { - Cow::Borrowed(self.text.stripped()) + // Return stripped text for matching when ANSI is enabled + if let Some(ref stripped) = self.stripped_text { + Cow::Borrowed(stripped) + } else { + Cow::Borrowed(&self.text) + } } fn output(&self) -> Cow<'_, str> { - if self.orig_text.is_some() { - if self.text.has_attrs() { - let mut ansi_parser: ANSIParser = Default::default(); - let text = ansi_parser.parse_ansi(self.orig_text.as_ref().unwrap()); - text.into_inner() - } else { - Cow::Borrowed(self.orig_text.as_ref().unwrap()) - } + if let Some(ref orig) = self.orig_text { + Cow::Borrowed(orig) } else { - Cow::Borrowed(self.text.stripped()) + Cow::Borrowed(&self.text) } } @@ -115,23 +189,203 @@ impl SkimItem for DefaultSkimItem { self.matching_ranges.as_ref().map(|vec| vec as &[(usize, usize)]) } - fn display<'a>(&'a self, context: DisplayContext<'a>) -> AnsiString<'a> { - let new_fragments: Vec<(Attr, (u32, u32))> = match context.matches { - Matches::CharIndices(indices) => indices - .iter() - .map(|&idx| (context.highlight_attr, (idx as u32, idx as u32 + 1))) - .collect(), - Matches::CharRange(start, end) => vec![(context.highlight_attr, (start as u32, end as u32))], - Matches::ByteRange(start, end) => { - let ch_start = context.text[..start].chars().count(); - let ch_end = ch_start + context.text[start..end].chars().count(); - vec![(context.highlight_attr, (ch_start as u32, ch_end as u32))] + fn display<'a>(&'a self, context: DisplayContext) -> Line<'a> { + // If we have ANSI info, we need to handle ANSI codes properly and map matches + if self.ansi_info.is_some() { + // Parse the ANSI text using ansi-to-tui to get proper styled spans + let text_bytes = self.text.as_bytes().to_vec(); + let parsed_text = match text_bytes.into_text() { + Ok(text) => text, + Err(_) => { + // Fallback to plain text if parsing fails + return context.to_line(Cow::Borrowed(&self.text)); + } + }; + + // Extract all spans from the parsed text (should be a single line) + let all_spans: Vec = parsed_text.lines.into_iter().flat_map(|line| line.spans).collect(); + + // Now apply highlighting based on matched positions + // We need to map match positions from stripped text to original text + match context.matches { + crate::Matches::CharIndices(ref indices) => { + // Indices are already in stripped text coordinates (same as parsed ANSI text) + // No need to remap since both matching and ANSI parsing strip the codes + let highlight_positions: std::collections::HashSet = indices.iter().copied().collect(); + + // Apply highlighting to characters at those positions + let mut new_spans = Vec::new(); + let mut char_idx = 0; + + for span in all_spans { + let mut current_content = String::new(); + let mut highlighted_content = String::new(); + let base_style = span.style; + + for ch in span.content.chars() { + if highlight_positions.contains(&char_idx) { + // Flush normal content if any + if !current_content.is_empty() { + new_spans.push(Span::styled(current_content.clone(), base_style)); + current_content.clear(); + } + highlighted_content.push(ch); + } else { + // Flush highlighted content if any + if !highlighted_content.is_empty() { + // Combine styles: use highlight bg, preserve ANSI fg and modifiers + let mut combined_style = base_style; + if let Some(bg) = context.style.bg { + combined_style = combined_style.bg(bg); + } + if let Some(fg) = context.style.fg + && base_style.fg.is_none() + { + combined_style = combined_style.fg(fg); + } + combined_style = combined_style.add_modifier(context.style.add_modifier); + new_spans.push(Span::styled(highlighted_content.clone(), combined_style)); + highlighted_content.clear(); + } + current_content.push(ch); + } + char_idx += 1; + } + + // Flush remaining content + if !current_content.is_empty() { + new_spans.push(Span::styled(current_content, base_style)); + } + if !highlighted_content.is_empty() { + // Combine styles: use highlight bg, preserve ANSI fg and modifiers + let mut combined_style = base_style; + if let Some(bg) = context.style.bg { + combined_style = combined_style.bg(bg); + } + if let Some(fg) = context.style.fg + && base_style.fg.is_none() + { + combined_style = combined_style.fg(fg); + } + combined_style = combined_style.add_modifier(context.style.add_modifier); + new_spans.push(Span::styled(highlighted_content, combined_style)); + } + } + + Line::from(new_spans) + } + crate::Matches::CharRange(start, end) => { + // Positions are already in stripped text coordinates (same as parsed ANSI text) + // No need to remap since both matching and ANSI parsing strip the codes + + // Apply highlighting to the range + let mut new_spans = Vec::new(); + let mut char_idx = 0; + + for span in all_spans { + let mut before = String::new(); + let mut highlighted = String::new(); + let mut after = String::new(); + let base_style = span.style; + + for ch in span.content.chars() { + if char_idx < start { + before.push(ch); + } else if char_idx < end { + highlighted.push(ch); + } else { + after.push(ch); + } + char_idx += 1; + } + + if !before.is_empty() { + new_spans.push(Span::styled(before, base_style)); + } + if !highlighted.is_empty() { + // Combine styles: use highlight bg, preserve ANSI fg and modifiers + let mut combined_style = base_style; + if let Some(bg) = context.style.bg { + combined_style = combined_style.bg(bg); + } + if let Some(fg) = context.style.fg + && base_style.fg.is_none() + { + combined_style = combined_style.fg(fg); + } + combined_style = combined_style.add_modifier(context.style.add_modifier); + new_spans.push(Span::styled(highlighted, combined_style)); + } + if !after.is_empty() { + new_spans.push(Span::styled(after, base_style)); + } + } + + Line::from(new_spans) + } + crate::Matches::ByteRange(start, end) => { + // Convert byte positions to char positions in stripped text + let stripped = self.stripped_text.as_ref().unwrap(); + let char_start = stripped.get(0..start).map(|s| s.chars().count()).unwrap_or(0); + let char_end = stripped + .get(0..end) + .map(|s| s.chars().count()) + .unwrap_or(stripped.chars().count()); + + // Apply highlighting to the range + let mut new_spans = Vec::new(); + let mut char_idx = 0; + + for span in all_spans { + let mut before = String::new(); + let mut highlighted = String::new(); + let mut after = String::new(); + let base_style = span.style; + + for ch in span.content.chars() { + if char_idx < char_start { + before.push(ch); + } else if char_idx < char_end { + highlighted.push(ch); + } else { + after.push(ch); + } + char_idx += 1; + } + + if !before.is_empty() { + new_spans.push(Span::styled(before, base_style)); + } + if !highlighted.is_empty() { + // Combine styles: use highlight bg, preserve ANSI fg and modifiers + let mut combined_style = base_style; + if let Some(bg) = context.style.bg { + combined_style = combined_style.bg(bg); + } + if let Some(fg) = context.style.fg + && base_style.fg.is_none() + { + combined_style = combined_style.fg(fg); + } + combined_style = combined_style.add_modifier(context.style.add_modifier); + new_spans.push(Span::styled(highlighted, combined_style)); + } + if !after.is_empty() { + new_spans.push(Span::styled(after, base_style)); + } + } + + Line::from(new_spans) + } + crate::Matches::None => { + // No highlighting needed, just return the parsed ANSI text + Line::from(all_spans) + } } - Matches::None => vec![], - }; - let mut ret = self.text.clone(); - ret.override_attrs(new_fragments); - ret + } else { + // No ANSI mapping needed, use text as-is + context.to_line(Cow::Borrowed(&self.text)) + } } fn get_index(&self) -> usize { @@ -142,3 +396,493 @@ impl SkimItem for DefaultSkimItem { self.index = index; } } + +/// Strip ANSI escape sequences from a string +/// +/// This function removes all ANSI escape codes (CSI sequences, OSC sequences, etc.) +/// from the input string, leaving only the visible text. +/// +/// Returns the stripped string as well as a mapping of positions. Each element in the +/// mapping vector is a tuple `(byte_position, char_position)` where: +/// - `byte_position`: The byte offset in the original raw string +/// - `char_position`: The character index in the original raw string +/// +/// For the character at position `i` in the stripped string: +/// - `mapping[i].0` gives its byte position in the original string +/// - `mapping[i].1` gives its character index in the original string +/// +/// Examples of ANSI codes that are stripped: +/// - `\x1b[31m` (set foreground color to red) +/// - `\x1b[01;32m` (bold green) +/// - `\x1b[0m` (reset) +/// - `\x1b]0;title\x07` (OSC sequences) +pub fn strip_ansi(text: &str) -> (String, Vec<(usize, usize)>) { + let mut result = String::with_capacity(text.len()); + let mut index_mapping = Vec::new(); + let mut chars = text.char_indices().peekable(); + let mut char_idx = 0; + + while let Some((byte_pos, ch)) = chars.next() { + if ch == '\x1b' { + // ESC sequence detected + if let Some(&(_, next_ch)) = chars.peek() { + match next_ch { + '[' => { + // CSI sequence: ESC [ ... (ending with a letter) + chars.next(); // consume '[' + char_idx += 1; + while let Some(&(_, c)) = chars.peek() { + chars.next(); + char_idx += 1; + if c.is_ascii_alphabetic() { + break; + } + } + } + ']' => { + // OSC sequence: ESC ] ... (ending with BEL or ESC \) + chars.next(); // consume ']' + char_idx += 1; + while let Some((_, c)) = chars.next() { + char_idx += 1; + if c == '\x07' { + // BEL + break; + } + if c == '\x1b' + && let Some(&(_, '\\')) = chars.peek() + { + chars.next(); // consume '\' + char_idx += 1; + break; + } + } + } + '(' | ')' | '#' | '%' => { + // Other escape sequences + chars.next(); // consume the next char + char_idx += 1; + chars.next(); // and one more + char_idx += 1; + } + _ => { + // Unknown escape sequence, consume next char + chars.next(); + char_idx += 1; + } + } + } + } else { + result.push(ch); + index_mapping.push((byte_pos, char_idx)); + } + char_idx += 1; + } + + (result, index_mapping) +} + +/// Replace the ANSI ESC code by a ? +/// +/// Unsafe: bytes are parsed back from the original string or b'?' +/// No risk associated +fn escape_ansi(raw: &str) -> String { + unsafe { String::from_utf8_unchecked(raw.bytes().map(|b| if b == 27 { b'?' } else { b }).collect()) } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_strip_ansi() { + // Test basic ANSI color codes + // "\x1b[31mred\x1b[0m" has chars at positions: 0=ESC, 1=[, 2=3, 3=1, 4=m, 5=r, 6=e, 7=d, 8=ESC, 9=[, 10=0, 11=m + let (text, mapping) = strip_ansi("\x1b[31mred\x1b[0m"); + assert_eq!(text, "red"); + assert_eq!(mapping, vec![(5, 5), (6, 6), (7, 7)]); + + let (text, mapping) = strip_ansi("\x1b[01;32mgreen\x1b[0m"); + assert_eq!(text, "green"); + assert_eq!(mapping, vec![(8, 8), (9, 9), (10, 10), (11, 11), (12, 12)]); + + let (text, mapping) = strip_ansi("\x1b[01;34mblue\x1b[0m"); + assert_eq!(text, "blue"); + assert_eq!(mapping, vec![(8, 8), (9, 9), (10, 10), (11, 11)]); + + // Test text without ANSI codes + let (text, mapping) = strip_ansi("plain text"); + assert_eq!(text, "plain text"); + assert_eq!( + mapping, + vec![ + (0, 0), + (1, 1), + (2, 2), + (3, 3), + (4, 4), + (5, 5), + (6, 6), + (7, 7), + (8, 8), + (9, 9) + ] + ); + + // Test multiple ANSI sequences + let (text, mapping) = strip_ansi("\x1b[31mred\x1b[0m and \x1b[32mgreen\x1b[0m"); + assert_eq!(text, "red and green"); + assert_eq!( + mapping, + vec![ + (5, 5), + (6, 6), + (7, 7), + (12, 12), + (13, 13), + (14, 14), + (15, 15), + (16, 16), + (22, 22), + (23, 23), + (24, 24), + (25, 25), + (26, 26) + ] + ); + + // Test ANSI codes in the middle of text + let (text, mapping) = strip_ansi("be\x1b[01;34mf\x1b[0more"); + assert_eq!(text, "before"); + assert_eq!(mapping, vec![(0, 0), (1, 1), (10, 10), (15, 15), (16, 16), (17, 17)]); + + // Test real ls --color output + let (text, mapping) = strip_ansi("\x1b[01;32mbench.sh\x1b[0m"); + assert_eq!(text, "bench.sh"); + assert_eq!( + mapping, + vec![ + (8, 8), + (9, 9), + (10, 10), + (11, 11), + (12, 12), + (13, 13), + (14, 14), + (15, 15) + ] + ); + + let (text, mapping) = strip_ansi("\x1b[01;34mbin\x1b[0m"); + assert_eq!(text, "bin"); + assert_eq!(mapping, vec![(8, 8), (9, 9), (10, 10)]); + + // Test with multi-byte UTF-8 characters to verify byte vs char position difference + // "😀" is 4 bytes but 1 char - when followed by ANSI codes, byte and char positions diverge + let (text, mapping) = strip_ansi("😀\x1b[32mtext\x1b[0m"); + assert_eq!(text, "😀text"); + // Original: "😀\x1b[32mtext\x1b[0m" + // byte positions: 😀=0-3, \x1b=4, [=5, 3=6, 2=7, m=8, t=9, e=10, x=11, t=12, \x1b=13, [=14, 0=15, m=16 + // char positions: 😀=0, \x1b=1, [=2, 3=3, 2=4, m=5, t=6, e=7, x=8, t=9, \x1b=10, [=11, 0=12, m=13 + // After stripping: "😀text" + // stripped[0]='😀' -> (byte=0, char=0) + // stripped[1]='t' -> (byte=9, char=6) <- Here byte and char positions differ! + assert_eq!(mapping, vec![(0, 0), (9, 6), (10, 7), (11, 8), (12, 9)]); + } + + #[test] + fn test_ansi_matching_and_display() { + use crate::{DisplayContext, Matches, SkimItem}; + use ratatui::style::{Color, Style}; + use regex::Regex; + + // Create an item with ANSI codes + let input = "\x1b[32mgreen\x1b[0m text"; + let delimiter = Regex::new(r"\s+").unwrap(); + let item = DefaultSkimItem::new( + input.to_string(), + true, // ansi_enabled + &[], + &[], + &delimiter, + 0, + ); + + // text() should return stripped text for matching + assert_eq!(item.text(), "green text"); + + // Verify we have ANSI info + assert!(item.ansi_info.is_some()); + + // Create a match context as if we matched "text" (positions 6-10 in stripped string) + let context = DisplayContext { + score: 100, + matches: Matches::CharRange(6, 10), + container_width: 80, + style: Style::default().fg(Color::Yellow), + }; + + // display() should map the match positions back to the original ANSI text + let line = item.display(context); + + // The line should have the original ANSI codes intact + // We can't easily verify the exact ANSI codes in the output, but we can check + // that it's not empty and has multiple spans (original text + highlighted match) + assert!(!line.spans.is_empty()); + } + + #[test] + fn test_ansi_char_indices_mapping() { + use crate::{DisplayContext, Matches, SkimItem}; + use ratatui::style::{Color, Style}; + use regex::Regex; + + // Create an item with ANSI codes: "😀\x1b[32mtext\x1b[0m" + let input = "😀\x1b[32mtext\x1b[0m"; + let delimiter = Regex::new(r"\s+").unwrap(); + let item = DefaultSkimItem::new( + input.to_string(), + true, // ansi_enabled + &[], + &[], + &delimiter, + 0, + ); + + // text() should return "😀text" + assert_eq!(item.text(), "😀text"); + + // Match indices 1,2 in stripped text (the 't' and 'e') + let context = DisplayContext { + score: 100, + matches: Matches::CharIndices(vec![1, 2]), + container_width: 80, + style: Style::default().fg(Color::Yellow), + }; + + // display() should map these to positions 6,7 in original text + let line = item.display(context); + assert!(!line.spans.is_empty()); + } + + #[test] + fn test_text_returns_stripped() { + use crate::SkimItem; + use regex::Regex; + + let delimiter = Regex::new(r"\s+").unwrap(); + + // Test with ANSI enabled + let item_ansi = DefaultSkimItem::new( + "\x1b[31mred\x1b[0m".to_string(), + true, // ansi_enabled + &[], + &[], + &delimiter, + 0, + ); + assert_eq!( + item_ansi.text(), + "red", + "text() should return stripped text when ANSI is enabled" + ); + + // Test with ANSI disabled + let item_no_ansi = DefaultSkimItem::new( + "\x1b[31mred\x1b[0m".to_string(), + false, // ansi_enabled + &[], + &[], + &delimiter, + 0, + ); + assert_eq!( + item_no_ansi.text(), + "?[31mred?[0m", + "text() should return text with ? when ANSI is disabled" + ); + } + + #[test] + fn test_highlighting_applied() { + use crate::{DisplayContext, Matches, SkimItem}; + use ratatui::style::{Color, Style}; + use regex::Regex; + + let delimiter = Regex::new(r"\s+").unwrap(); + + // Create item with ANSI codes: "\x1b[32mgreen\x1b[0m" + let item = DefaultSkimItem::new( + "\x1b[32mgreen\x1b[0m".to_string(), + true, // ansi_enabled + &[], + &[], + &delimiter, + 0, + ); + + // Create display context with yellow background highlight for character 0 (the 'g') + let context = DisplayContext { + score: 100, + matches: Matches::CharIndices(vec![0]), + container_width: 80, + style: Style::default().bg(Color::Yellow), + }; + + let line = item.display(context); + + // The line should have spans with highlighting + // At least one span should have the yellow background + let has_highlight = line.spans.iter().any(|span| span.style.bg == Some(Color::Yellow)); + assert!(has_highlight, "Highlighted character should have yellow background"); + + // The green foreground from ANSI should be preserved in at least one span + let has_green_fg = line.spans.iter().any(|span| span.style.fg == Some(Color::Green)); + assert!(has_green_fg, "ANSI green foreground should be preserved"); + } + + #[test] + fn test_char_range_highlighting() { + use crate::{DisplayContext, Matches, SkimItem}; + use ratatui::style::{Color, Style}; + use regex::Regex; + + let delimiter = Regex::new(r"\s+").unwrap(); + + // Create item with ANSI codes: "\x1b[32mgreen\x1b[0m" + let item = DefaultSkimItem::new( + "\x1b[32mgreen\x1b[0m".to_string(), + true, // ansi_enabled + &[], + &[], + &delimiter, + 0, + ); + + // Create display context with yellow background highlight for characters 1-3 ('re') + let context = DisplayContext { + score: 100, + matches: Matches::CharRange(1, 3), + container_width: 80, + style: Style::default().bg(Color::Yellow), + }; + + let line = item.display(context); + + // Should have multiple spans: before, highlighted, after + assert!(line.spans.len() >= 2, "Should have multiple spans for highlighting"); + + // At least one span should have the yellow background (the highlighted portion) + let has_highlight = line.spans.iter().any(|span| span.style.bg == Some(Color::Yellow)); + assert!(has_highlight, "Highlighted characters should have yellow background"); + + // The green foreground from ANSI should be preserved + let has_green_fg = line.spans.iter().any(|span| span.style.fg == Some(Color::Green)); + assert!(has_green_fg, "ANSI green foreground should be preserved"); + } + + #[test] + fn test_byte_range_highlighting() { + use crate::{DisplayContext, Matches, SkimItem}; + use ratatui::style::{Color, Style}; + use regex::Regex; + + let delimiter = Regex::new(r"\s+").unwrap(); + + // Create item with ANSI codes: "\x1b[32mgreen\x1b[0m" + let item = DefaultSkimItem::new( + "\x1b[32mgreen\x1b[0m".to_string(), + true, // ansi_enabled + &[], + &[], + &delimiter, + 0, + ); + + // Create display context with yellow background highlight for bytes 1-3 ('re' in stripped text) + let context = DisplayContext { + score: 100, + matches: Matches::ByteRange(1, 3), + container_width: 80, + style: Style::default().bg(Color::Yellow), + }; + + let line = item.display(context); + + // Should have multiple spans for highlighting + assert!(line.spans.len() >= 1, "Should have spans"); + + // At least one span should have the yellow background (the highlighted portion) + let has_highlight = line.spans.iter().any(|span| span.style.bg == Some(Color::Yellow)); + assert!(has_highlight, "Highlighted bytes should have yellow background"); + + // The green foreground from ANSI should be preserved + let has_green_fg = line.spans.iter().any(|span| span.style.fg == Some(Color::Green)); + assert!(has_green_fg, "ANSI green foreground should be preserved"); + } + + #[test] + fn test_matching_with_ansi_basic() { + use crate::SkimItem; + use regex::Regex; + + let delimiter = Regex::new(r"\s+").unwrap(); + + // Create item with ANSI codes: "\x1b[32mgreen_text\x1b[0m" + let item = DefaultSkimItem::new( + "\x1b[32mgreen_text\x1b[0m".to_string(), + true, // ansi_enabled + &[], + &[], // no matching fields restriction + &delimiter, + 0, + ); + + // text() should return stripped text "green_text" + assert_eq!(item.text(), "green_text"); + + // With no matching_fields, get_matching_ranges should return None (match whole text) + assert!(item.get_matching_ranges().is_none()); + + // Verify the stripped_text and ansi_info are populated correctly + assert!(item.stripped_text.is_some()); + assert!(item.ansi_info.is_some()); + assert_eq!(item.stripped_text.as_ref().unwrap(), "green_text"); + } + + #[test] + fn test_null_delimiter_with_matching_fields() { + use crate::SkimItem; + use crate::field::FieldRange; + use regex::Regex; + + // Test with null byte delimiter and matching_fields + let delimiter = Regex::new("\x00").unwrap(); + let text = "a\x00b\x00c"; + + // Create item with matching field 2 + let item = DefaultSkimItem::new( + text.to_string(), + false, // no ansi + &[], // no transform fields + &[FieldRange::Single(2)], // match field 2 + &delimiter, + 0, + ); + + // text() should return text with null bytes stripped for display + assert_eq!(item.text(), "abc"); + + // get_matching_ranges should return the range for field 2 in the stripped text + let ranges = item.get_matching_ranges().expect("Should have matching ranges"); + assert_eq!(ranges.len(), 1, "Should have one matching range"); + + // Field 2 is "b" which is at position 1 in the stripped text "abc" + assert_eq!(ranges[0], (1, 2), "Field 2 should be at position 1-2 in stripped text"); + + // Verify the substring matches what we expect + let stripped_text = item.text(); + let field_text = &stripped_text[ranges[0].0..ranges[0].1]; + assert_eq!(field_text, "b", "Field text should be 'b'"); + } +} diff --git a/skim/src/helper/item_reader.rs b/skim/src/helper/item_reader.rs index 52c38220..f43f2d40 100644 --- a/skim/src/helper/item_reader.rs +++ b/skim/src/helper/item_reader.rs @@ -1,4 +1,5 @@ -/// helper for turn a BufRead into a skim stream +//! Helper utilities for converting input sources into skim item streams. + use std::env; use std::error::Error; use std::io::{BufRead, BufReader}; @@ -7,16 +8,14 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::thread; -use crossbeam::channel::{Receiver, Sender, bounded}; use regex::Regex; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; use crate::field::FieldRange; use crate::helper::item::DefaultSkimItem; use crate::reader::CommandCollector; -use crate::{SkimItem, SkimItemReceiver, SkimItemSender}; +use crate::{SkimItem, SkimItemReceiver, SkimItemSender, SkimOptions}; -const CMD_CHANNEL_SIZE: usize = 1024; -const ITEM_CHANNEL_SIZE: usize = 10240; const DELIMITER_STR: &str = r"[\t\n ]+"; const READ_BUFFER_SIZE: usize = 1024; @@ -25,6 +24,7 @@ pub enum CollectorInput { Command(String), } +/// Options for configuring how items are read and parsed #[derive(Debug)] pub struct SkimItemReaderOption { buf_size: usize, @@ -51,28 +51,52 @@ impl Default for SkimItemReaderOption { } impl SkimItemReaderOption { + /// Creates reader options from skim options + pub fn from_options(options: &SkimOptions) -> Self { + Self { + buf_size: READ_BUFFER_SIZE, + line_ending: if options.read0 { b'\0' } else { b'\n' }, + use_ansi_color: options.ansi, + transform_fields: options + .with_nth + .iter() + .filter_map(|f| if !f.is_empty() { FieldRange::from_str(f) } else { None }) + .collect(), + matching_fields: options + .nth + .iter() + .filter_map(|f| if !f.is_empty() { FieldRange::from_str(f) } else { None }) + .collect(), + delimiter: options.delimiter.clone(), + show_error: options.show_cmd_error, + } + } + + /// Sets the buffer size for reading pub fn buf_size(mut self, buf_size: usize) -> Self { self.buf_size = buf_size; self } + /// Sets the line ending character (default: '\n') pub fn line_ending(mut self, line_ending: u8) -> Self { self.line_ending = line_ending; self } + /// Enables or disables ANSI color code parsing pub fn ansi(mut self, enable: bool) -> Self { self.use_ansi_color = enable; self } - pub fn delimiter(mut self, delimiter: &str) -> Self { - if !delimiter.is_empty() { - self.delimiter = Regex::new(delimiter).unwrap_or_else(|_| Regex::new(DELIMITER_STR).unwrap()); - } + /// Sets the field delimiter regex + pub fn delimiter(mut self, delimiter: Regex) -> Self { + self.delimiter = delimiter; self } + /// Sets the fields to display (transform) from the input pub fn with_nth<'a, T>(mut self, with_nth: T) -> Self where T: Iterator, @@ -81,11 +105,13 @@ impl SkimItemReaderOption { self } + /// Sets the transform fields directly pub fn transform_fields(mut self, transform_fields: Vec) -> Self { self.transform_fields = transform_fields; self } + /// Sets the fields to use for matching pub fn nth<'a, T>(mut self, nth: T) -> Self where T: Iterator, @@ -94,11 +120,13 @@ impl SkimItemReaderOption { self } + /// Sets the matching fields directly pub fn matching_fields(mut self, matching_fields: Vec) -> Self { self.matching_fields = matching_fields; self } + /// Enables reading null-terminated lines instead of newline-terminated pub fn read0(mut self, enable: bool) -> Self { if enable { self.line_ending = b'\0'; @@ -108,20 +136,24 @@ impl SkimItemReaderOption { self } + /// Sets whether to show command errors pub fn show_error(mut self, show_error: bool) -> Self { self.show_error = show_error; self } + /// Builds the options (currently a no-op, returns self) pub fn build(self) -> Self { self } + /// Returns true if no field transformations or ANSI parsing is needed pub fn is_simple(&self) -> bool { !self.use_ansi_color && self.matching_fields.is_empty() && self.transform_fields.is_empty() } } +/// Reader for converting various input sources into streams of skim items pub struct SkimItemReader { option: Arc, } @@ -135,12 +167,14 @@ impl Default for SkimItemReader { } impl SkimItemReader { + /// Creates a new item reader with the given options pub fn new(option: SkimItemReaderOption) -> Self { Self { option: Arc::new(option), } } + /// Sets the reader options pub fn option(mut self, option: SkimItemReaderOption) -> Self { self.option = Arc::new(option); self @@ -148,6 +182,7 @@ impl SkimItemReader { } impl SkimItemReader { + /// Converts a BufRead source into a stream of skim items pub fn of_bufread(&self, source: impl BufRead + Send + 'static) -> SkimItemReceiver { if self.option.is_simple() { self.raw_bufread(source) @@ -159,7 +194,7 @@ impl SkimItemReader { /// helper: convert bufread into SkimItemReceiver fn raw_bufread(&self, mut source: impl BufRead + Send + 'static) -> SkimItemReceiver { - let (tx_item, rx_item): (SkimItemSender, SkimItemReceiver) = bounded(self.option.buf_size); + let (tx_item, rx_item): (SkimItemSender, SkimItemReceiver) = unbounded_channel(); let line_ending = self.option.line_ending; let use_ansi = self.option.use_ansi_color; let delimiter = self.option.delimiter.clone(); @@ -210,27 +245,27 @@ impl SkimItemReader { &self, components_to_stop: Arc, input: CollectorInput, - ) -> (Receiver>, Sender) { + ) -> (UnboundedReceiver>, UnboundedSender) { + let send_error = self.option.show_error; let (command, mut source) = match input { CollectorInput::Pipe(pipe) => (None, pipe), - CollectorInput::Command(cmd) => get_command_output(&cmd).expect("command not found"), + CollectorInput::Command(cmd) => get_command_output(&cmd, send_error).expect("command not found"), }; - let (tx_interrupt, rx_interrupt) = bounded(CMD_CHANNEL_SIZE); - let (tx_item, rx_item): (SkimItemSender, SkimItemReceiver) = bounded(ITEM_CHANNEL_SIZE); + let (tx_interrupt, mut rx_interrupt) = unbounded_channel(); + let (tx_item, rx_item): (SkimItemSender, SkimItemReceiver) = unbounded_channel::>(); let started = Arc::new(AtomicBool::new(false)); let started_clone = started.clone(); let components_to_stop_clone = components_to_stop.clone(); let tx_item_clone = tx_item.clone(); - let send_error = self.option.show_error; // listening to close signal and kill command if needed thread::spawn(move || { debug!("collector: command killer start"); components_to_stop_clone.fetch_add(1, Ordering::SeqCst); started_clone.store(true, Ordering::SeqCst); // notify parent that it is started - let _ = rx_interrupt.recv(); // block waiting + let _ = rx_interrupt.blocking_recv(); if let Some(mut child) = command { // clean up resources let _ = child.kill(); @@ -242,6 +277,7 @@ impl SkimItemReader { .map(|os| os.map(|s| !s.success()).unwrap_or(true)) .unwrap_or(false); if has_error { + trace!("collector: sending error"); let output = child.wait_with_output().expect("could not retrieve error message"); for line in String::from_utf8_lossy(&output.stderr).lines() { let _ = tx_item_clone.send(Arc::new(line.to_string())); @@ -279,7 +315,7 @@ impl SkimItemReader { if buffer.ends_with(b"\r\n") { buffer.pop(); buffer.pop(); - } else if buffer.ends_with(b"\n") || buffer.ends_with(b"\0") { + } else if buffer.ends_with(&[option.line_ending]) { buffer.pop(); } @@ -305,7 +341,9 @@ impl SkimItemReader { } line_idx += 1; } - Err(_err) => {} // String not UTF8 or other error, skip. + Err(err) => { + trace!("Got {err:?} when reading from command collector, skipping"); + } // String not UTF8 or other error, skip. } } @@ -323,26 +361,24 @@ impl SkimItemReader { } impl CommandCollector for SkimItemReader { - fn invoke(&mut self, cmd: &str, components_to_stop: Arc) -> (SkimItemReceiver, Sender) { + fn invoke(&mut self, cmd: &str, components_to_stop: Arc) -> (SkimItemReceiver, UnboundedSender) { self.read_and_collect_from_command(components_to_stop, CollectorInput::Command(cmd.to_string())) } } type CommandOutput = (Option, Box); -fn get_command_output(cmd: &str) -> Result> { +fn get_command_output(cmd: &str, send_error: bool) -> Result> { let shell = env::var("SHELL").unwrap_or_else(|_| "sh".to_string()); - let mut command: Child = Command::new(shell) - .arg("-c") - .arg(cmd) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; + let (reader, writer) = std::io::pipe()?; + let mut sh = Command::new(shell); + let command = sh.arg("-c").arg(cmd).stdout(writer.try_clone()?); + if send_error { + trace!("redirecting stderr to the output"); + command.stderr(writer); + } else { + command.stderr(Stdio::null()); + } - let stdout = command - .stdout - .take() - .ok_or_else(|| "command output: unwrap failed".to_owned())?; - - Ok((Some(command), Box::new(BufReader::new(stdout)))) + Ok((command.spawn().ok(), Box::new(BufReader::new(reader)))) } diff --git a/skim/src/helper/selector.rs b/skim/src/helper/selector.rs index 579da496..77c633d2 100644 --- a/skim/src/helper/selector.rs +++ b/skim/src/helper/selector.rs @@ -4,6 +4,7 @@ use regex::Regex; use crate::{Selector, SkimItem}; +/// Default implementation of the selector trait for pre-selecting items #[derive(Debug, Default)] pub struct DefaultSkimSelector { first_n: usize, @@ -12,12 +13,14 @@ pub struct DefaultSkimSelector { } impl DefaultSkimSelector { + /// Selects the first N items pub fn first_n(mut self, first_n: usize) -> Self { trace!("select first_n: {first_n}"); self.first_n = first_n; self } + /// Selects items whose text matches any of the preset strings pub fn preset(mut self, preset: impl IntoIterator) -> Self { if self.preset.is_none() { self.preset = Some(HashSet::new()) @@ -29,6 +32,7 @@ impl DefaultSkimSelector { self } + /// Selects items whose text matches the given regex pattern pub fn regex(mut self, regex: &str) -> Self { trace!("select regex: {regex}"); if !regex.is_empty() { diff --git a/skim/src/input.rs b/skim/src/input.rs deleted file mode 100644 index f8045fff..00000000 --- a/skim/src/input.rs +++ /dev/null @@ -1,253 +0,0 @@ -//! Input will listens to user input, modify the query string, send special -//! keystrokes(such as Enter, Ctrl-p, Ctrl-n, etc) to the controller. -use crate::event::{Event, parse_event}; -use regex::Regex; -use skim_tuikit::event::Event as TermEvent; -use skim_tuikit::key::{Key, from_keyname}; -use std::collections::HashMap; -use std::sync::LazyLock; - -pub type ActionChain = Vec; - -pub struct Input { - keymap: HashMap, -} - -impl Input { - pub fn new() -> Self { - Input { - keymap: get_default_key_map(), - } - } - - pub fn translate_event(&self, event: TermEvent) -> (Key, ActionChain) { - match event { - // search event from keymap - TermEvent::Key(key) => ( - key, - self.keymap.get(&key).cloned().unwrap_or_else(|| { - if let Key::Char(ch) = key { - vec![Event::EvActAddChar(ch)] - } else { - vec![Event::EvInputKey(key)] - } - }), - ), - TermEvent::Resize { .. } => (Key::Null, vec![Event::EvActRedraw]), - _ => (Key::Null, vec![Event::EvInputInvalid]), - } - } - - pub fn bind(&mut self, key: &str, action_chain: ActionChain) { - let key = from_keyname(key); - if key.is_none() || action_chain.is_empty() { - return; - } - - let key = key.unwrap(); - - // remove the key for existing keymap; - let _ = self.keymap.remove(&key); - self.keymap.entry(key).or_insert(action_chain); - } - - pub fn parse_keymaps<'a, T>(&mut self, maps: T) - where - T: Iterator, - { - for map in maps { - self.parse_keymap(map); - } - } - - // key_action is comma separated: 'ctrl-j:accept,ctrl-k:kill-line' - pub fn parse_keymap(&mut self, key_action: &str) { - debug!("got key_action: {key_action:?}"); - for (key, action_chain) in parse_key_action(key_action).into_iter() { - debug!("parsed key_action: {key:?}: {action_chain:?}"); - let action_chain = action_chain - .into_iter() - .filter_map(|(action, arg)| parse_event(action, arg)) - .collect(); - self.bind(key, action_chain); - } - } - - pub fn parse_expect_keys<'a, T>(&mut self, keys: T) - where - T: Iterator, - { - for key in keys { - self.bind(key, vec![Event::EvActAccept(Some(key.to_string()))]); - } - } -} - -type KeyActions<'a> = (&'a str, Vec<(&'a str, Option)>); - -/// parse key action string to `(key, action, argument)` tuple -/// key_action is comma separated: 'ctrl-j:accept,ctrl-k:kill-line' -pub fn parse_key_action(key_action: &str) -> Vec> { - // match `key:action` or `key:action:arg` or `key:action(arg)` etc. - static RE: LazyLock = LazyLock::new(|| { - Regex::new( - r#"(?si)([^:]+?):((?:\+?[a-z-]+?(?:"[^"]*?"|'[^']*?'|\([^\)]*?\)|\[[^\]]*?\]|:[^:]*?)?\s*)+)(?:,|$)"#, - ) - .unwrap() - }); - // grab key, action and arg out. - static RE_BIND: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?si)([a-z-]+)("[^"]+?"|'[^']+?'|\([^\)]+?\)|\[[^\]]+?\]|:[^:]+?)?(?:\+|$)"#).unwrap() - }); - - RE.captures_iter(key_action) - .map(|caps| { - debug!("RE: caps: {caps:?}"); - let key = caps.get(1).unwrap().as_str(); - let actions = RE_BIND - .captures_iter(caps.get(2).unwrap().as_str()) - .map(|caps| { - debug!("RE_BIND: caps: {caps:?}"); - ( - caps.get(1).unwrap().as_str(), - caps.get(2).map(|s| { - // (arg) => arg, :end_arg => arg - let action = s.as_str(); - if let Some(stripped) = action.strip_prefix(':') { - stripped.to_owned() - } else { - action[1..action.len() - 1].to_string() - } - }), - ) - }) - .collect(); - (key, actions) - }) - .collect() -} - -/// e.g. execute(...) => Some(Event::EvActExecute, Box::new(Option("..."))) -pub fn parse_action_arg(action_arg: &str) -> Option { - // construct a fake key_action: `fake_key:action(arg)` - let fake_key_action = format!("fake_key:{action_arg}"); - // get keys: [(key, [(action, arg), (action, arg)]), ...] - let keys = parse_key_action(&fake_key_action); - // only get the first key(since it is faked), and get the first action - if keys.is_empty() || keys[0].1.is_empty() { - None - } else { - // first action pair of key(keys[0].1) and first action (keys[0].1[0]) - let (action, new_arg) = keys[0].1[0].clone(); - parse_event(action, new_arg) - } -} - -#[rustfmt::skip] -fn get_default_key_map() -> HashMap { - let mut ret = HashMap::new(); - ret.insert(Key::ESC, vec![Event::EvActAbort]); - ret.insert(Key::Ctrl('c'), vec![Event::EvActAbort]); - ret.insert(Key::Ctrl('g'), vec![Event::EvActAbort]); - ret.insert(Key::Enter, vec![Event::EvActAccept(None)]); - ret.insert(Key::Left, vec![Event::EvActBackwardChar]); - ret.insert(Key::Ctrl('b'), vec![Event::EvActBackwardChar]); - ret.insert(Key::Ctrl('h'), vec![Event::EvActBackwardDeleteChar]); - ret.insert(Key::Backspace, vec![Event::EvActBackwardDeleteChar]); - ret.insert(Key::AltBackspace, vec![Event::EvActBackwardKillWord]); - ret.insert(Key::Alt('b'), vec![Event::EvActBackwardWord]); - ret.insert(Key::ShiftLeft, vec![Event::EvActBackwardWord]); - ret.insert(Key::CtrlLeft, vec![Event::EvActBackwardWord]); - ret.insert(Key::Ctrl('a'), vec![Event::EvActBeginningOfLine]); - ret.insert(Key::Home, vec![Event::EvActBeginningOfLine]); - ret.insert(Key::Ctrl('l'), vec![Event::EvActClearScreen]); - ret.insert(Key::Delete, vec![Event::EvActDeleteChar]); - ret.insert(Key::Ctrl('d'), vec![Event::EvActDeleteCharEOF]); - ret.insert(Key::Ctrl('j'), vec![Event::EvActDown(1)]); - ret.insert(Key::Ctrl('n'), vec![Event::EvActDown(1)]); - ret.insert(Key::Down, vec![Event::EvActDown(1)]); - ret.insert(Key::Ctrl('e'), vec![Event::EvActEndOfLine]); - ret.insert(Key::End, vec![Event::EvActEndOfLine]); - ret.insert(Key::Ctrl('f'), vec![Event::EvActForwardChar]); - ret.insert(Key::Right, vec![Event::EvActForwardChar]); - ret.insert(Key::Alt('f'), vec![Event::EvActForwardWord]); - ret.insert(Key::CtrlRight, vec![Event::EvActForwardWord]); - ret.insert(Key::ShiftRight, vec![Event::EvActForwardWord]); - ret.insert(Key::Alt('d'), vec![Event::EvActKillWord]); - ret.insert(Key::ShiftUp, vec![Event::EvActPreviewUp(1)]); - ret.insert(Key::ShiftDown, vec![Event::EvActPreviewDown(1)]); - ret.insert(Key::PageDown, vec![Event::EvActPageDown(1)]); - ret.insert(Key::PageUp, vec![Event::EvActPageUp(1)]); - ret.insert(Key::Ctrl('r'), vec![Event::EvActRotateMode]); - ret.insert(Key::Alt('h'), vec![Event::EvActScrollLeft(1)]); - ret.insert(Key::Alt('l'), vec![Event::EvActScrollRight(1)]); - ret.insert(Key::Tab, vec![Event::EvActToggle, Event::EvActDown(1)]); - ret.insert(Key::Ctrl('q'), vec![Event::EvActToggleInteractive]); - ret.insert(Key::BackTab, vec![Event::EvActToggle, Event::EvActUp(1)]); - ret.insert(Key::Ctrl('u'), vec![Event::EvActUnixLineDiscard]); - ret.insert(Key::Ctrl('w'), vec![Event::EvActUnixWordRubout]); - ret.insert(Key::Ctrl('p'), vec![Event::EvActUp(1)]); - ret.insert(Key::Ctrl('k'), vec![Event::EvActUp(1)]); - ret.insert(Key::Up, vec![Event::EvActUp(1)]); - ret.insert(Key::Ctrl('y'), vec![Event::EvActYank]); - ret.insert(Key::Null, vec![Event::EvActAbort]); - ret -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn execute_should_be_parsed_correctly() { - // example from https://github.com/lotabout/skim/issues/73 - let cmd = " - (grep -o '[a-f0-9]\\{7\\}' | head -1 | - xargs -I % sh -c 'git show --color=always % | less -R') << 'FZF-EOF' - {} - FZF-EOF"; - - let key_action_str = format!("ctrl-s:toggle-sort,ctrl-m:execute:{cmd},ctrl-t:toggle"); - - let key_action = parse_key_action(&key_action_str); - assert_eq!(("ctrl-s", vec![("toggle-sort", None)]), key_action[0]); - assert_eq!(("ctrl-m", vec![("execute", Some(cmd.to_string()))]), key_action[1]); - assert_eq!(("ctrl-t", vec![("toggle", None)]), key_action[2]); - - let key_action_str = "f1:execute(less -f {}),ctrl-y:execute-silent(echo {} | pbcopy)"; - let key_action = parse_key_action(key_action_str); - assert_eq!(("f1", vec![("execute", Some("less -f {}".to_string()))]), key_action[0]); - assert_eq!( - ("ctrl-y", vec![("execute-silent", Some("echo {} | pbcopy".to_string()))]), - key_action[1] - ); - - // #196 - let key_action_str = "enter:execute($EDITOR +{2} {1})"; - let key_action = parse_key_action(key_action_str); - assert_eq!( - ("enter", vec![("execute", Some("$EDITOR +{2} {1}".to_string()))]), - key_action[0] - ); - } - - #[test] - fn action_chain_should_be_parsed() { - let key_action = parse_key_action("ctrl-t:toggle+up"); - assert_eq!(("ctrl-t", vec![("toggle", None), ("up", None)]), key_action[0]); - - let key_action_str = "f1:execute(less -f {}),ctrl-y:execute-silent(echo {} | pbcopy)+abort"; - let key_action = parse_key_action(key_action_str); - assert_eq!(("f1", vec![("execute", Some("less -f {}".to_string()))]), key_action[0]); - assert_eq!( - ( - "ctrl-y", - vec![ - ("execute-silent", Some("echo {} | pbcopy".to_string())), - ("abort", None) - ] - ), - key_action[1] - ); - } -} diff --git a/skim/src/item.rs b/skim/src/item.rs index a69fe104..d5a08588 100644 --- a/skim/src/item.rs +++ b/skim/src/item.rs @@ -1,7 +1,10 @@ -//! An item is line of text that read from `find` command or stdin together with -//! the internal states, such as selected or not +//! Item representation and management. +//! +//! This module provides the core item types used by skim, including ranked items, +//! item pools for efficient storage, and ranking criteria for sorting matches. use std::cmp::min; use std::default::Default; +use std::hash::Hash; use std::ops::Deref; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -16,6 +19,7 @@ use crate::{MatchRange, Rank, SkimItem}; //------------------------------------------------------------------------------ +/// Builder for creating rank values based on configurable criteria #[derive(Debug)] pub struct RankBuilder { criterion: Vec, @@ -30,6 +34,7 @@ impl Default for RankBuilder { } impl RankBuilder { + /// Creates a new rank builder with the given criteria pub fn new(mut criterion: Vec) -> Self { if !criterion.contains(&RankCriteria::Score) && !criterion.contains(&RankCriteria::NegScore) { criterion.insert(0, RankCriteria::Score); @@ -70,12 +75,40 @@ impl RankBuilder { } //------------------------------------------------------------------------------ +/// An item that has been matched against a query #[derive(Clone)] pub struct MatchedItem { + /// The underlying skim item pub item: Arc, + /// The rank/score of this match pub rank: Rank, - pub matched_range: Option, // range of chars that matched the pattern - pub item_idx: u32, + /// Range of characters that matched the pattern + pub matched_range: Option, +} + +impl std::fmt::Debug for MatchedItem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MatchedItem") + .field("item", &self.item.text()) + .field("rank", &self.rank) + .field("matched_range", &self.matched_range) + .finish() + } +} + +impl Hash for MatchedItem { + fn hash(&self, state: &mut H) { + state.write_usize(self.get_index()); + self.text().hash(state); + } +} + +impl Deref for MatchedItem { + type Target = Arc; + + fn deref(&self) -> &Self::Target { + &self.item + } } impl MatchedItem {} @@ -84,7 +117,7 @@ use std::cmp::Ordering as CmpOrd; impl PartialEq for MatchedItem { fn eq(&self, other: &Self) -> bool { - self.rank.eq(&other.rank) + self.text().eq(&other.text()) && self.get_index().eq(&other.get_index()) } } @@ -92,7 +125,7 @@ impl std::cmp::Eq for MatchedItem {} impl PartialOrd for MatchedItem { fn partial_cmp(&self, other: &Self) -> Option { - Some(self.rank.cmp(&other.rank)) + Some(self.cmp(other)) } } @@ -103,17 +136,23 @@ impl Ord for MatchedItem { } //------------------------------------------------------------------------------ -const ITEM_POOL_CAPACITY: usize = 1024; +const ITEM_POOL_CAPACITY: usize = 16384; +/// Thread-safe pool for storing and managing items efficiently pub struct ItemPool { + /// Total number of items in the pool length: AtomicUsize, + /// The main pool of items pool: SpinLock>>, - /// number of items that was `take`n + /// Number of items that were taken taken: AtomicUsize, - /// reverse first N lines as header + /// Reserved first N lines as header reserved_items: SpinLock>>, + /// Number of lines to reserve as header lines_to_reserve: usize, + /// Reverse the order of items (--tac flag) + tac: bool, } impl Default for ItemPool { @@ -124,42 +163,50 @@ impl Default for ItemPool { taken: AtomicUsize::new(0), reserved_items: SpinLock::new(Vec::new()), lines_to_reserve: 0, + tac: false, } } } impl ItemPool { + /// Creates a new empty item pool pub fn new() -> Self { + Self::default() + } + + /// Creates a new item pool from skim options + pub fn from_options(options: &crate::SkimOptions) -> Self { Self { length: AtomicUsize::new(0), pool: SpinLock::new(Vec::with_capacity(ITEM_POOL_CAPACITY)), taken: AtomicUsize::new(0), reserved_items: SpinLock::new(Vec::new()), - lines_to_reserve: 0, + lines_to_reserve: options.header_lines, + tac: options.tac, } } - pub fn lines_to_reserve(mut self, lines_to_reserve: usize) -> Self { - self.lines_to_reserve = lines_to_reserve; - self - } - + /// Returns the total number of items in the pool pub fn len(&self) -> usize { self.length.load(Ordering::SeqCst) } + /// Returns true if the pool contains no items pub fn is_empty(&self) -> bool { self.len() == 0 } + /// Returns the number of items that have not been taken yet pub fn num_not_taken(&self) -> usize { self.length.load(Ordering::SeqCst) - self.taken.load(Ordering::SeqCst) } + /// Returns the number of items that have been taken pub fn num_taken(&self) -> usize { self.taken.load(Ordering::SeqCst) } + /// Clears all items from the pool and resets counters pub fn clear(&self) { let mut items = self.pool.lock(); items.clear(); @@ -169,9 +216,11 @@ impl ItemPool { self.length.store(0, Ordering::SeqCst); } + /// Resets the taken counter without clearing items pub fn reset(&self) { // lock to ensure consistency let _items = self.pool.lock(); + self.taken.store(0, Ordering::SeqCst); } @@ -185,28 +234,51 @@ impl ItemPool { let to_reserve = self.lines_to_reserve - header_items.len(); if to_reserve > 0 { let to_reserve = min(to_reserve, items.len()); - header_items.extend_from_slice(&items[..to_reserve]); - pool.extend_from_slice(&items[to_reserve..]); + // Split items: first part goes to header, rest to main pool + let remaining = items.split_off(to_reserve); + + // Header items are always in input order, regardless of tac + header_items.extend(items); + + if self.tac { + // For --tac, prepend non-header items (newest items go to front) + for item in remaining.into_iter() { + pool.insert(0, item); + } + } else { + pool.extend(remaining); + } + } else if self.tac { + // For --tac, prepend items (newest items go to front) + for item in items.into_iter() { + pool.insert(0, item); + } } else { - pool.append(&mut items); + pool.extend(items); } self.length.store(pool.len(), Ordering::SeqCst); - trace!("item pool, done append {len} items"); + trace!("item pool, done append {len} items, total: {}", pool.len()); pool.len() } - pub fn take(&self) -> ItemPoolGuard<'_, Arc> { + /// Takes items from the pool, copying new items since last take and releasing lock immediately + pub fn take(&self) -> Vec> { let guard = self.pool.lock(); let taken = self.taken.swap(guard.len(), Ordering::SeqCst); - ItemPoolGuard { guard, start: taken } + // Copy the new items out so we can release the lock immediately + let items = guard[taken..].to_vec(); + drop(guard); // Explicitly release lock + items } - pub fn reserved(&self) -> ItemPoolGuard<'_, Arc> { + /// Returns a copy of the reserved header items + pub fn reserved(&self) -> Vec> { let guard = self.reserved_items.lock(); - ItemPoolGuard { guard, start: 0 } + guard.clone() } } +/// Guard for accessing a slice of items from the pool pub struct ItemPoolGuard<'a, T: Sized + 'a> { guard: SpinLockGuard<'a, Vec>, start: usize, @@ -221,17 +293,28 @@ impl Deref for ItemPoolGuard<'_, T> { } //------------------------------------------------------------------------------ +/// Criteria for ranking and sorting matched items #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum RankCriteria { + /// Sort by match score (lower is better) Score, + /// Sort by match score (higher is better) NegScore, + /// Sort by beginning position of match Begin, + /// Sort by beginning position of match (reversed) NegBegin, + /// Sort by ending position of match End, + /// Sort by ending position of match (reversed) NegEnd, + /// Sort by item length Length, + /// Sort by item length (reversed) NegLength, + /// Sort by item index Index, + /// Sort by item index (reversed) NegIndex, } diff --git a/skim/src/lib.rs b/skim/src/lib.rs index 1d7a3ca4..c7a762d3 100644 --- a/skim/src/lib.rs +++ b/skim/src/lib.rs @@ -1,53 +1,86 @@ +//! Skim is a fuzzy finder library for Rust. +//! +//! It provides a fast and customizable way to filter and select items interactively, +//! similar to fzf. Skim can be used as a library or as a command-line tool. +//! +//! # Examples +//! +//! ```no_run +//! use skim::prelude::*; +//! use std::io::Cursor; +//! +//! let options = SkimOptionsBuilder::default() +//! .height(Some("50%")) +//! .multi(true) +//! .build() +//! .unwrap(); +//! +//! let input = "awk\nbash\ncsh\ndash\nfish\nksh\nzsh"; +//! let item_reader = SkimItemReader::default(); +//! let items = item_reader.of_bufread(Cursor::new(input)); +//! +//! let output = Skim::run_with(&options, Some(items)).unwrap(); +//! ``` + +#![warn(missing_docs)] + #[macro_use] extern crate log; use std::any::Any; use std::borrow::Cow; +use std::env; use std::fmt::Display; use std::sync::Arc; -use std::sync::mpsc::channel; -use std::thread; +use std::sync::atomic::AtomicBool; +use std::time::{Duration, Instant}; -use crossbeam::channel::{Receiver, Sender}; -use skim_tuikit::prelude::{Event as TermEvent, *}; +use color_eyre::eyre::Result; +use color_eyre::eyre::{self, OptionExt}; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::prelude::CrosstermBackend; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use reader::Reader; +use tokio::select; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; +use tui::App; +use tui::Event; +use tui::Size; -pub use crate::ansi::AnsiString; pub use crate::engine::fuzzy::FuzzyAlgorithm; -use crate::event::{EventReceiver, EventSender}; pub use crate::item::RankCriteria; -use crate::model::Model; pub use crate::options::SkimOptions; pub use crate::output::SkimOutput; -use crate::reader::Reader; -pub use skim_common::spinlock; -pub use skim_tuikit as tuikit; +pub use crate::skim_item::SkimItem; +use crate::tui::event::Action; -mod ansi; +pub mod binds; mod engine; -mod event; pub mod field; -mod global; -mod header; +pub mod fuzzy_matcher; mod helper; -mod input; pub mod item; mod matcher; -mod model; pub mod options; -mod orderedvec; mod output; pub mod prelude; -mod previewer; -mod query; pub mod reader; -mod selection; +mod skim_item; +pub mod spinlock; mod theme; pub mod tmux; +pub mod tui; mod util; //------------------------------------------------------------------------------ +/// Trait for downcasting to concrete types from trait objects pub trait AsAny { + /// Returns a reference to the value as `Any` fn as_any(&self) -> &dyn Any; + /// Returns a mutable reference to the value as `Any` fn as_any_mut(&mut self) -> &mut dyn Any; } @@ -61,131 +94,78 @@ impl AsAny for T { } } -/// A `SkimItem` defines what's been processed(fetched, matched, previewed and returned) by skim -/// -/// # Downcast Example -/// Skim will return the item back, but in `Arc` form. We might want a reference -/// to the concrete type instead of trait object. Skim provide a somehow "complicated" way to -/// `downcast` it back to the reference of the original concrete type. -/// -/// ```rust -/// use skim::prelude::*; -/// -/// struct MyItem {} -/// impl SkimItem for MyItem { -/// fn text(&self) -> Cow { -/// unimplemented!() -/// } -/// } -/// -/// impl MyItem { -/// pub fn mutable(&mut self) -> i32 { -/// 1 -/// } -/// -/// pub fn immutable(&self) -> i32 { -/// 0 -/// } -/// } -/// -/// let mut ret: Arc = Arc::new(MyItem{}); -/// let mutable: &mut MyItem = Arc::get_mut(&mut ret) -/// .expect("item is referenced by others") -/// .as_any_mut() // cast to Any -/// .downcast_mut::() // downcast to (mut) concrete type -/// .expect("something wrong with downcast"); -/// assert_eq!(mutable.mutable(), 1); -/// -/// let immutable: &MyItem = (*ret).as_any() // cast to Any -/// .downcast_ref::() // downcast to concrete type -/// .expect("something wrong with downcast"); -/// assert_eq!(immutable.immutable(), 0) -/// ``` -pub trait SkimItem: AsAny + Send + Sync + 'static { - /// The string to be used for matching (without color) - fn text(&self) -> Cow<'_, str>; - - /// The content to be displayed on the item list, could contain ANSI properties - fn display<'a>(&'a self, context: DisplayContext<'a>) -> AnsiString<'a> { - AnsiString::from(context) - } - - /// Custom preview content, default to `ItemPreview::Global` which will use global preview - /// setting(i.e. the command set by `preview` option) - fn preview(&self, _context: PreviewContext) -> ItemPreview { - ItemPreview::Global - } - - /// Get output text(after accept), default to `text()` - /// Note that this function is intended to be used by the caller of skim and will not be used by - /// skim. And since skim will return the item back in `SkimOutput`, if string is not what you - /// want, you could still use `downcast` to retain the pointer to the original struct. - fn output(&self) -> Cow<'_, str> { - self.text() - } - - /// we could limit the matching ranges of the `get_text` of the item. - /// providing (`start_byte`, `end_byte`) of the range - fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> { - None - } - - /// Get index, for matching purposes - /// - /// Implemented as no-op for retro-compatibility purposes - fn get_index(&self) -> usize { - 0 - } - /// Set index, for matching purposes - /// - /// Implemented as no-op for retro-compatibility purposes - fn set_index(&mut self, _index: usize) {} -} - -//------------------------------------------------------------------------------ -// Implement SkimItem for raw strings - -impl + Send + Sync + 'static> SkimItem for T { - fn text(&self) -> Cow<'_, str> { - Cow::Borrowed(self.as_ref()) - } -} - //------------------------------------------------------------------------------ // Display Context -pub enum Matches<'a> { +#[derive(Default, Debug)] +/// Represents how a query matches an item +pub enum Matches { + /// No matches + #[default] None, - CharIndices(&'a [usize]), + /// Matches at specific character indices + CharIndices(Vec), + /// Matches in a character range (start, end) CharRange(usize, usize), + /// Matches in a byte range (start, end) ByteRange(usize, usize), } -pub struct DisplayContext<'a> { - pub text: &'a str, +#[derive(Default)] +/// Context information for displaying an item +pub struct DisplayContext { + /// The match score for this item pub score: i32, - pub matches: Matches<'a>, + /// Where the query matched in the item + pub matches: Matches, + /// The width of the container to display in pub container_width: usize, - pub highlight_attr: Attr, + /// The style to apply to matched portions + pub style: Style, } -impl<'a> From> for AnsiString<'a> { - fn from(context: DisplayContext<'a>) -> Self { - match context.matches { - Matches::CharIndices(indices) => AnsiString::from((context.text, indices, context.highlight_attr)), +impl DisplayContext { + /// Converts the context and text into a styled `Line` with highlighted matches + pub fn to_line(self, cow: Cow) -> Line { + let text: String = cow.into_owned(); + + match &self.matches { + Matches::CharIndices(indices) => { + let mut res = Line::default(); + let mut chars = text.chars(); + let mut prev_index = 0; + for &index in indices { + let span_content = chars.by_ref().take(index - prev_index); + res.push_span(Span::raw(span_content.collect::())); + let highlighted_char = chars.next().unwrap_or_default().to_string(); + + res.push_span(Span::styled(highlighted_char, self.style)); + prev_index = index + 1; + } + res.push_span(Span::raw(chars.collect::())); + res + } + // AnsiString::from((context.text, indices, context.highlight_attr)), #[allow(clippy::cast_possible_truncation)] Matches::CharRange(start, end) => { - AnsiString::new_str(context.text, vec![(context.highlight_attr, (start as u32, end as u32))]) + let mut chars = text.chars(); + let mut res = Line::raw(chars.by_ref().take(*start).collect::()); + let highlighted_text = chars.by_ref().take(*end - *start).collect::(); + + res.push_span(Span::styled(highlighted_text, self.style)); + res.push_span(Span::raw(chars.collect::())); + res } Matches::ByteRange(start, end) => { - let ch_start = context.text[..start].chars().count(); - let ch_end = ch_start + context.text[start..end].chars().count(); - #[allow(clippy::cast_possible_truncation)] - AnsiString::new_str( - context.text, - vec![(context.highlight_attr, (ch_start as u32, ch_end as u32))], - ) + let mut bytes = text.bytes(); + let mut res = Line::raw(String::from_utf8(bytes.by_ref().take(*start).collect()).unwrap()); + let highlighted_bytes = bytes.by_ref().take(*end - *start).collect(); + let highlighted_text = String::from_utf8(highlighted_bytes).unwrap(); + + res.push_span(Span::styled(highlighted_text, self.style)); + res.push_span(Span::raw(String::from_utf8(bytes.collect()).unwrap())); + res } - Matches::None => AnsiString::new_str(context.text, vec![]), + Matches::None => Line::raw(text), } } } @@ -193,12 +173,19 @@ impl<'a> From> for AnsiString<'a> { //------------------------------------------------------------------------------ // Preview Context +/// Context information for generating item previews pub struct PreviewContext<'a> { + /// The current search query pub query: &'a str, + /// The current command query (for interactive mode) pub cmd_query: &'a str, + /// Width of the preview window pub width: usize, + /// Height of the preview window pub height: usize, + /// Index of the current item pub current_index: usize, + /// Text of the current selection pub current_selection: &'a str, /// selected item indices (may or may not include current item) pub selected_indices: &'a [usize], @@ -209,13 +196,19 @@ pub struct PreviewContext<'a> { //------------------------------------------------------------------------------ // Preview #[derive(Default, Copy, Clone, Debug)] +/// Position and scroll information for preview display pub struct PreviewPosition { + /// Horizontal scroll position pub h_scroll: Size, + /// Horizontal offset pub h_offset: Size, + /// Vertical scroll position pub v_scroll: Size, + /// Vertical offset pub v_offset: Size, } +/// Defines how an item should be previewed pub enum ItemPreview { /// execute the command and print the command's output Command(String), @@ -223,8 +216,11 @@ pub enum ItemPreview { Text(String), /// Display the colored text(lines) AnsiText(String), + /// Execute a command and display output with position CommandWithPos(String, PreviewPosition), + /// Display text with position TextWithPos(String, PreviewPosition), + /// Display ANSI-colored text with position AnsiWithPos(String, PreviewPosition), /// Use global command settings to preview the item Global, @@ -236,31 +232,42 @@ pub enum ItemPreview { #[derive(Eq, PartialEq, Debug, Copy, Clone, Default)] #[cfg_attr(feature = "cli", derive(clap::ValueEnum))] #[cfg_attr(feature = "cli", clap(rename_all = "snake_case"))] +/// Case sensitivity mode for matching pub enum CaseMatching { + /// Case-sensitive matching Respect, + /// Case-insensitive matching Ignore, + /// Smart case: case-insensitive unless query contains uppercase #[default] Smart, } #[derive(PartialEq, Eq, Clone, Debug)] #[allow(dead_code)] +/// Represents the range of a match in an item pub enum MatchRange { + /// Range of bytes (start, end) ByteRange(usize, usize), - // range of bytes - Chars(Vec), // individual character indices matched + /// Individual character indices that matched + Chars(Vec), } +/// Rank tuple used for sorting match results pub type Rank = [i32; 5]; #[derive(Clone)] +/// Result of matching a query against an item pub struct MatchResult { + /// The rank/score of this match pub rank: Rank, + /// The range where the match occurred pub matched_range: MatchRange, } impl MatchResult { #[must_use] + /// Converts the match range to character indices pub fn range_char_indices(&self, text: &str) -> Vec { match &self.matched_range { &MatchRange::ByteRange(start, end) => { @@ -273,12 +280,17 @@ impl MatchResult { } } +/// A matching engine that can match queries against items pub trait MatchEngine: Sync + Send + Display { + /// Matches an item against the query, returning a result if matched fn match_item(&self, item: Arc) -> Option; } +/// Factory for creating match engines pub trait MatchEngineFactory { + /// Creates a match engine with explicit case sensitivity fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box; + /// Creates a match engine with default case sensitivity fn create_engine(&self, query: &str) -> Box { self.create_engine_with_case(query, CaseMatching::default()) } @@ -289,13 +301,17 @@ pub trait MatchEngineFactory { /// A selector that determines whether an item should be "pre-selected" in multi-selection mode pub trait Selector { + /// Returns true if the item at the given index should be pre-selected fn should_select(&self, index: usize, item: &dyn SkimItem) -> bool; } //------------------------------------------------------------------------------ -pub type SkimItemSender = Sender>; -pub type SkimItemReceiver = Receiver>; +/// Sender for streaming items to skim +pub type SkimItemSender = UnboundedSender>; +/// Receiver for streaming items to skim +pub type SkimItemReceiver = UnboundedReceiver>; +/// Main entry point for running skim pub struct Skim {} impl Skim { @@ -313,117 +329,143 @@ impl Skim { /// # Panics /// /// Panics if the tui fails to initilize - #[must_use] - pub fn run_with(options: &SkimOptions, source: Option) -> Option { - let min_height = Skim::parse_height_string(&options.min_height); - let height = Skim::parse_height_string(&options.height); + pub fn run_with(options: SkimOptions, source: Option) -> Result { + let height = Size::try_from(options.height.as_str())?; + let backend = CrosstermBackend::new(std::io::stderr()); + let mut tui = tui::Tui::new_with_height(backend, height)?; - let (tx, rx): (EventSender, EventReceiver) = channel(); - let term = Arc::new( - Term::with_options( - TermOptions::default() - .min_height(min_height) - .height(height) - .clear_on_exit(!options.no_clear) - .disable_alternate_screen(options.no_clear_start) - .clear_on_start(!options.no_clear_start) - .hold(options.select_1 || options.exit_0 || options.sync), - ) - .unwrap(), - ); - if !options.no_mouse { - let _ = term.enable_mouse_support(); - } + // application state + // Initialize theme from options + let theme = Arc::new(crate::theme::ColorTheme::init_from_options(&options)); + let mut reader = Reader::from_options(&options).source(source); + const SKIM_DEFAULT_COMMAND: &str = "find ."; + let default_command = String::from(match env::var("SKIM_DEFAULT_COMMAND").as_deref() { + Err(_) | Ok("") => SKIM_DEFAULT_COMMAND, + Ok(v) => v, + }); + let cmd = options.cmd.clone().unwrap_or(default_command); - //------------------------------------------------------------------------------ - // input - let mut input = input::Input::new(); - input.parse_keymaps(options.bind.iter().map(String::as_str)); - input.parse_expect_keys(options.expect.iter().map(String::as_str)); + let mut app = App::from_options(options, theme.clone(), cmd.clone()); - let tx_clone = tx.clone(); - let term_clone = term.clone(); - let input_thread = thread::spawn(move || { - loop { - if let Ok(key) = term_clone.poll_event() { - if key == TermEvent::User(()) { - break; - } + let rt = tokio::runtime::Runtime::new()?; + let mut final_event: Event = Event::Quit; + let mut final_key: KeyEvent = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()); + rt.block_on(async { + tui.enter()?; - let (key, action_chain) = input.translate_event(key); - for event in action_chain { - let _ = tx_clone.send((key, event)); + //------------------------------------------------------------------------------ + // reader + // In interactive mode, expand all placeholders ({}, {q}, etc) with initial query (empty or from --query) + let initial_cmd = if app.options.interactive && app.options.cmd.is_some() { + let expanded = app.expand_cmd(&cmd); + log::debug!( + "Interactive mode: initial_cmd = {:?} (from template {:?})", + expanded, + cmd + ); + expanded + } else { + cmd.clone() + }; + log::debug!("Starting reader with initial_cmd: {:?}", initial_cmd); + let (item_tx, mut item_rx) = unbounded_channel(); + let mut reader_control = reader.run(item_tx.clone(), &initial_cmd); + + //------------------------------------------------------------------------------ + // model + previewer + + let mut matcher_interval = tokio::time::interval(Duration::from_millis(100)); + let reader_done = Arc::new(AtomicBool::new(false)); + let reader_done_clone = reader_done.clone(); + + let item_pool = app.item_pool.clone(); + tokio::spawn(async move { + const BATCH: usize = 4096; // Smaller batches for more responsive updates + loop { + let mut buf = Vec::with_capacity(BATCH); + if item_rx.recv_many(&mut buf, BATCH).await > 0 { + item_pool.append(buf); + trace!("Got new items, len {}", item_pool.len()); + } else { + reader_done_clone.store(true, std::sync::atomic::Ordering::Relaxed); } } + }); + + // Start matcher initially + app.restart_matcher(true); + + loop { + select! { + event = tui.next() => { + let evt = event.ok_or_eyre("Could not acquire next event")?; + + // Handle reload event + if let Event::Reload(new_cmd) = &evt { + debug!("reloading with cmd {new_cmd}"); + // Kill the current reader + reader_control.kill(); + // Clear items + app.item_pool.clear(); + // Clear displayed items unless no_clear_if_empty is set + // (in which case the item_list will handle keeping stale items) + if !app.options.no_clear_if_empty { + app.item_list.clear(); + } + app.restart_matcher(true); + // Start a new reader with the new command (no source, using cmd) + reader_control = reader.run(item_tx.clone(), new_cmd); + app.status.reading = true; + reader_done.store(false, std::sync::atomic::Ordering::Relaxed); + } + if let Event::Key(k) = &evt { + final_key = k.to_owned(); + } else { + final_event = evt.to_owned(); + } + + // Check reader status and update + if !reader_control.is_done() { + app.reader_timer = Instant::now(); + } else if ! reader_done.load(std::sync::atomic::Ordering::Relaxed) { + reader_done.store(true, std::sync::atomic::Ordering::Relaxed); + app.restart_matcher(true); + app.status.reading = false; + } + app.handle_event(&mut tui, &evt)?; + } + _ = matcher_interval.tick() => { + app.restart_matcher(false); + } + } + + if app.should_quit { + break; + } } - }); + reader_control.kill(); + eyre::Ok(()) + })?; - //------------------------------------------------------------------------------ - // reader + // Extract final_key and is_abort from final_event + let is_abort = !matches!(&final_event, Event::Action(Action::Accept(_))); - let reader = Reader::with_options(options).source(source); - - //------------------------------------------------------------------------------ - // model + previewer - let mut model = Model::new(rx, tx, reader, term.clone(), options); - let ret = model.start(); - let _ = term.send_event(TermEvent::User(())); // interrupt the input thread - let _ = input_thread.join(); - ret - } - - /// Converts a &str to a TermHeight, based on whether or not it ends with a percent sign - /// - /// Will clamp percentages into [0, 100] and fixed into [0, MAX_USIZE] - /// 10 -> TermHeight::Fixed(10) - /// 10% -> TermHeight::Percent(10) - fn parse_height_string(string: &str) -> TermHeight { - if string.ends_with('%') { - let inner = string[0..string.len() - 1].parse().unwrap_or(100); - TermHeight::Percent(inner.clamp(0, 100)) - } else { - let inner = string.parse().unwrap_or(0); - TermHeight::Fixed(inner) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn parse_height_string_fixed() { - let TermHeight::Fixed(h) = Skim::parse_height_string("10") else { - panic!("Expected fixed, found percent"); - }; - assert_eq!(h, 10) - } - #[test] - fn parse_height_string_percent() { - let TermHeight::Percent(h) = Skim::parse_height_string("10%") else { - panic!("Expected percent, found fixed"); - }; - assert_eq!(h, 10) - } - #[test] - fn parse_height_string_percent_neg() { - let TermHeight::Percent(h) = Skim::parse_height_string("-20%") else { - panic!("Expected fixed, found percent"); - }; - assert_eq!(h, 100) - } - #[test] - fn parse_height_string_percent_too_large() { - let TermHeight::Percent(h) = Skim::parse_height_string("120%") else { - panic!("Expected percent, found fixed"); - }; - assert_eq!(h, 100) - } - #[test] - fn parse_height_string_fixed_neg() { - let TermHeight::Fixed(h) = Skim::parse_height_string("-20") else { - panic!("Expected fixed, found percent"); - }; - assert_eq!(h, 0) + Ok(SkimOutput { + cmd: if app.options.interactive { + // In interactive mode, cmd is what the user typed + app.input.to_string() + } else if app.options.cmd_query.is_some() { + // If cmd_query was provided, use that for output + app.options.cmd_query.clone().unwrap() + } else { + // Otherwise use the execution command + cmd + }, + final_event, + final_key, + query: app.input.to_string(), + is_abort, + selected_items: app.results(), + }) } } diff --git a/skim/src/matcher.rs b/skim/src/matcher.rs index 49ba0e79..b37fc5c3 100644 --- a/skim/src/matcher.rs +++ b/skim/src/matcher.rs @@ -1,23 +1,23 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::thread; -use std::thread::JoinHandle; use rayon::prelude::*; -use crate::item::{ItemPool, MatchedItem}; +use crate::item::{ItemPool, MatchedItem, RankBuilder}; +use crate::prelude::{AndOrEngineFactory, ExactOrFuzzyEngineFactory, RegexEngineFactory}; use crate::spinlock::SpinLock; -use crate::{CaseMatching, MatchEngineFactory}; +use crate::{CaseMatching, MatchEngineFactory, SkimOptions}; use defer_drop::DeferDrop; use std::rc::Rc; //============================================================================== +#[derive(Default)] pub struct MatcherControl { stopped: Arc, processed: Arc, matched: Arc, items: Arc>>, - thread_matcher: JoinHandle<()>, } impl MatcherControl { @@ -29,18 +29,23 @@ impl MatcherControl { self.matched.load(Ordering::Relaxed) } - pub fn kill(self) { + pub fn kill(&mut self) { self.stopped.store(true, Ordering::Relaxed); - let _ = self.thread_matcher.join(); } pub fn stopped(&self) -> bool { self.stopped.load(Ordering::Relaxed) } - pub fn into_items(self) -> Arc>> { - while !self.stopped.load(Ordering::Relaxed) {} - self.items + pub fn items(&self) -> Arc>> { + while !self.stopped() {} + self.items.clone() + } +} + +impl Drop for MatcherControl { + fn drop(&mut self) { + self.stopped.store(true, Ordering::Relaxed); } } @@ -67,6 +72,23 @@ impl Matcher { self } + pub fn from_options(options: &SkimOptions) -> Self { + let engine_factory: Rc = if options.regex { + Rc::new(RegexEngineFactory::builder()) + } else { + let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone())); + log::debug!("Creating matcher for algo {:?}", options.algorithm); + let fuzzy_engine_factory = ExactOrFuzzyEngineFactory::builder() + .fuzzy_algorithm(options.algorithm) + .exact_mode(options.exact) + .rank_builder(rank_builder) + .build(); + Rc::new(AndOrEngineFactory::new(fuzzy_engine_factory)) + }; + + Matcher::builder(engine_factory).case(options.case).build() + } + pub fn run(&self, query: &str, item_pool: Arc>, callback: C) -> MatcherControl where C: Fn(Arc>>) + Send + 'static, @@ -82,7 +104,7 @@ impl Matcher { let matched_items = Arc::new(SpinLock::new(Vec::new())); let matched_items_clone = matched_items.clone(); - let thread_matcher = thread::spawn(move || { + thread::spawn(move || { let _num_taken = item_pool.num_taken(); let items = item_pool.take(); @@ -95,17 +117,16 @@ impl Matcher { .into_par_iter() .enumerate() .filter_map(|(_, item)| { - let item_idx = item.get_index(); processed.fetch_add(1, Ordering::Relaxed); if stopped.load(Ordering::Relaxed) { Some(Err("matcher killed")) } else if let Some(match_result) = matcher_engine.match_item(item.clone()) { matched.fetch_add(1, Ordering::Relaxed); + // item is Arc but we get &Arc from iterator, so one clone is needed Some(Ok(MatchedItem { item: item.clone(), rank: match_result.rank, matched_range: Some(match_result.matched_range), - item_idx: item_idx as u32, })) } else { None @@ -128,7 +149,6 @@ impl Matcher { matched: matched_clone, processed: processed_clone, items: matched_items_clone, - thread_matcher, } } } diff --git a/skim/src/model/mod.rs b/skim/src/model/mod.rs deleted file mode 100644 index 806fddc3..00000000 --- a/skim/src/model/mod.rs +++ /dev/null @@ -1,923 +0,0 @@ -pub(crate) mod options; -mod status; - -use options::InfoDisplay; -use status::{ClearStrategy, Direction, Status}; - -use std::cmp::max; -use std::env; - -use std::process::Command; -use std::rc::Rc; -use std::sync::{Arc, LazyLock}; -use std::time::Instant; - -use chrono::Duration as TimerDuration; -use defer_drop::DeferDrop; -use regex::Regex; -use skim_tuikit::prelude::{Event as TermEvent, *}; -use timer::{Guard as TimerGuard, Timer}; - -use crate::engine::factory::{AndOrEngineFactory, ExactOrFuzzyEngineFactory, RegexEngineFactory}; -use crate::event::{Event, EventHandler, EventReceiver, EventSender}; -use crate::global::current_run_num; -use crate::header::Header; -use crate::helper::item::DefaultSkimItem; -use crate::input::parse_action_arg; -use crate::item::{ItemPool, MatchedItem, RankBuilder}; -use crate::matcher::{Matcher, MatcherControl}; -use crate::options::SkimOptions; -use crate::output::SkimOutput; -use crate::previewer::{PreviewSource, Previewer}; -use crate::query::Query; -use crate::reader::{Reader, ReaderControl}; -use crate::selection::Selection; -use crate::spinlock::SpinLock; -use crate::theme::ColorTheme; -use crate::util::{InjectContext, depends_on_items, inject_command, margin_string_to_size, parse_margin}; -use crate::{FuzzyAlgorithm, MatchEngineFactory, MatchRange, SkimItem}; - -const REFRESH_DURATION: i64 = 100; - -static RE_PREVIEW_OFFSET: LazyLock = - LazyLock::new(|| Regex::new(r"^\+([0-9]+|\{-?[0-9]+\})(-[0-9]+|-/[1-9][0-9]*)?$").unwrap()); - -struct ModelEnv { - pub cmd: String, - pub query: String, - pub cmd_query: String, - pub clear_selection: ClearStrategy, - pub in_query_mode: bool, -} - -pub struct Model { - reader: Reader, - query: Query, - selection: Selection, - num_options: usize, - select_1: bool, - exit_0: bool, - sync: bool, - - use_regex: bool, - regex_matcher: Matcher, - matcher: Matcher, - - term: Arc, - - item_pool: Arc>, - - rx: EventReceiver, - tx: EventSender, - - fuzzy_algorithm: FuzzyAlgorithm, - reader_timer: Instant, - matcher_timer: Instant, - reader_control: Option, - matcher_control: Option, - - header: Header, - - preview_hidden: bool, - previewer: Option, - preview_direction: Direction, - preview_size: Size, - - margin_top: Size, - margin_right: Size, - margin_bottom: Size, - margin_left: Size, - - layout: String, - delimiter: Regex, - info: InfoDisplay, - no_clear_if_empty: bool, - theme: Arc, - - // Minimum query length to show results - min_query_length: Option, - - // timer thread for scheduled events - timer: Timer, - hb_timer_guard: Option, - - // for AppendAndSelect action - rank_builder: Arc, -} - -impl Model { - pub fn new(rx: EventReceiver, tx: EventSender, reader: Reader, term: Arc, options: &SkimOptions) -> Self { - let default_command = match env::var("SKIM_DEFAULT_COMMAND").as_ref().map(String::as_ref) { - Ok("") | Err(_) => "find .".to_owned(), - Ok(val) => val.to_owned(), - }; - - let theme = Arc::new(ColorTheme::init_from_options(options)); - let query = Query::from_options(options) - .replace_base_cmd_if_not_set(&default_command) - .theme(theme.clone()) - .build(); - - let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone())); - - let selection = Selection::with_options(options).theme(theme.clone()); - let regex_engine: Rc = - Rc::new(RegexEngineFactory::builder().rank_builder(rank_builder.clone()).build()); - let regex_matcher = Matcher::builder(regex_engine).build(); - - let fuzzy_engine_factory: Rc = Rc::new(AndOrEngineFactory::new( - ExactOrFuzzyEngineFactory::builder() - .exact_mode(options.exact) - .rank_builder(rank_builder.clone()) - .build(), - )); - let matcher = Matcher::builder(fuzzy_engine_factory).case(options.case).build(); - - let item_pool = Arc::new(DeferDrop::new(ItemPool::new().lines_to_reserve(options.header_lines))); - let header = Header::empty() - .with_options(options) - .item_pool(item_pool.clone()) - .theme(theme.clone()); - - let margins = parse_margin(&options.margin); - let (margin_top, margin_right, margin_bottom, margin_left) = margins; - - let mut ret = Model { - reader, - query, - selection, - num_options: 0, - select_1: false, - exit_0: false, - sync: false, - use_regex: options.regex, - regex_matcher, - matcher, - term, - item_pool, - - rx, - tx, - reader_timer: Instant::now(), - matcher_timer: Instant::now(), - reader_control: None, - matcher_control: None, - fuzzy_algorithm: FuzzyAlgorithm::default(), - - header, - preview_hidden: true, - previewer: None, - preview_direction: Direction::Right, - preview_size: Size::Default, - - margin_top, - margin_right, - margin_bottom, - margin_left, - - layout: "default".to_string(), - delimiter: Regex::new(r"[\t\n ]+").unwrap(), - info: InfoDisplay::Default, - no_clear_if_empty: false, - theme, - min_query_length: options.min_query_length, - timer: Timer::new(), - hb_timer_guard: None, - - rank_builder, - }; - ret.parse_options(options); - ret - } - - fn parse_options(&mut self, options: &SkimOptions) { - let Ok(delimiter) = Regex::new(&options.delimiter) else { - panic!("Could not parse delimiter {} as a valid regex", options.delimiter); - }; - self.delimiter = delimiter; - - self.layout = options.layout.clone(); - - self.info = if options.inline_info { - InfoDisplay::Inline - } else if options.no_info { - InfoDisplay::Hidden - } else { - options.info.clone() - }; - - self.use_regex = options.regex; - - self.fuzzy_algorithm = options.algorithm; - - // preview related - let (preview_direction, preview_size, preview_wrap, preview_shown) = - Self::parse_preview(options.preview_window.clone()); - self.preview_direction = preview_direction; - self.preview_size = preview_size; - self.preview_hidden = !preview_shown; - - let preview_source = if let Some(cmd) = &options.preview { - PreviewSource::Command(cmd.to_string()) - } else if let Some(ref callback) = options.preview_fn { - PreviewSource::Callback(callback.clone()) - } else { - PreviewSource::Empty - }; - - let tx = Arc::new(SpinLock::new(self.tx.clone())); - if !matches!(preview_source, PreviewSource::Empty) { - self.previewer = Some( - Previewer::new(preview_source, move || { - let _ = tx.lock().send((Key::Null, Event::EvHeartBeat)); - }) - .wrap(preview_wrap) - .delimiter(self.delimiter.clone()) - .preview_offset(Self::parse_preview_offset(options.preview_window.clone())), - ); - } - - self.select_1 = options.select_1; - self.exit_0 = options.exit_0; - - self.sync = options.sync; - self.no_clear_if_empty = options.no_clear_if_empty; - } - - // -> (direction, size, wrap, shown) - fn parse_preview(preview_option: String) -> (Direction, Size, bool, bool) { - let options = preview_option.split(':').collect::>(); - - let mut direction = Direction::Right; - let mut shown = true; - let mut wrap = false; - let mut size = Size::Percent(50); - - for option in options { - // mistake - if option.is_empty() { - continue; - } - - let first_char = option.chars().next().unwrap_or('A'); - - // raw string - if first_char.is_ascii_digit() { - size = margin_string_to_size(option); - } else { - match option.to_uppercase().as_str() { - "UP" => direction = Direction::Up, - "DOWN" => direction = Direction::Down, - "LEFT" => direction = Direction::Left, - "RIGHT" => direction = Direction::Right, - "HIDDEN" => shown = false, - "WRAP" => wrap = true, - _ => {} - } - } - } - - (direction, size, wrap, shown) - } - - // -> string - fn parse_preview_offset(preview_window: String) -> String { - for token in preview_window.split(':').rev() { - if RE_PREVIEW_OFFSET.is_match(token) { - return token.to_string(); - } - } - - String::new() - } - - fn act_heart_beat(&mut self, env: &mut ModelEnv) { - // Check if query meets minimum length requirement - if let Some(min_length) = self.min_query_length { - // In normal mode, check query length - // In interactive mode, check cmd_query length - let query_to_check = if env.in_query_mode { &env.query } else { &env.cmd_query }; - - if query_to_check.chars().count() < min_length { - // Clear selection if query is too short - self.selection.clear(); - // Exit early to prevent showing any results - return; - } - } - - // save the processed items - let matcher_stopped = self - .matcher_control - .as_ref() - .map(|ctrl| ctrl.stopped()) - .unwrap_or(false); - - if matcher_stopped { - let reader_stopped = self.reader_control.as_ref().map(ReaderControl::is_done).unwrap_or(true); - let ctrl = self.matcher_control.take().unwrap(); - let lock = ctrl.into_items(); - let mut items = lock.lock(); - let matched = std::mem::take(&mut *items); - - match env.clear_selection { - ClearStrategy::DontClear => {} - ClearStrategy::Clear => { - self.selection.clear(); - env.clear_selection = ClearStrategy::DontClear; - } - ClearStrategy::ClearIfNotNull => { - if (!self.no_clear_if_empty && reader_stopped) || !matched.is_empty() { - self.selection.clear(); - env.clear_selection = ClearStrategy::DontClear; - } - } - }; - self.num_options += matched.len(); - self.selection.append_sorted_items(matched); - } - - let items_consumed = self.item_pool.num_not_taken() == 0; - let reader_stopped = self.reader_control.as_ref().map(|c| c.is_done()).unwrap_or(true); - let processed = reader_stopped && items_consumed; - - // run matcher if matcher had been stopped and reader had new items. - if !processed && self.matcher_control.is_none() { - self.restart_matcher(); - } - - // send next heart beat if matcher is still running or there are items not been processed. - if self.matcher_control.is_some() || !processed { - let tx = self.tx.clone(); - let hb_timer_guard = - self.timer - .schedule_with_delay(TimerDuration::milliseconds(REFRESH_DURATION), move || { - let _ = tx.send((Key::Null, Event::EvHeartBeat)); - }); - self.hb_timer_guard.replace(hb_timer_guard); - } - } - - fn act_rotate_mode(&mut self, env: &mut ModelEnv) { - self.use_regex = !self.use_regex; - - // restart matcher - if let Some(ctrl) = self.matcher_control.take() { - ctrl.kill(); - } - - env.clear_selection = ClearStrategy::Clear; - self.item_pool.reset(); - self.num_options = 0; - self.restart_matcher(); - } - - fn handle_select1_or_exit0(&mut self) { - if !self.select_1 && !self.exit_0 && !self.sync { - return; - } - - let items_consumed = self.item_pool.num_not_taken() == 0; - let reader_stopped = self.reader_control.as_ref().map(|c| c.is_done()).unwrap_or(true); - let matcher_stopped = self.matcher_control.as_ref().map(|ctrl| ctrl.stopped()).unwrap_or(true); - - let processed = reader_stopped && items_consumed && matcher_stopped; - let num_matched = self.selection.get_num_options(); - if processed { - if num_matched == 1 && self.select_1 { - debug!("select-1 triggered, accept"); - let _ = self.tx.send((Key::Null, Event::EvActAccept(None))); - } else if num_matched == 0 && self.exit_0 { - debug!("exit-0 triggered, accept"); - let _ = self.tx.send((Key::Null, Event::EvActAbort)); - } else { - // no longer need need to handle select-1, exit-1, sync, etc. - self.select_1 = false; - self.exit_0 = false; - self.sync = false; - let _ = self.term.restart(); - } - } - } - - fn on_cmd_query_change(&mut self, env: &mut ModelEnv) { - // stop matcher - if let Some(ctrl) = self.reader_control.take() { - ctrl.kill(); - } - if let Some(ctrl) = self.matcher_control.take() { - ctrl.kill(); - } - - env.clear_selection = ClearStrategy::ClearIfNotNull; - self.item_pool.clear(); - self.num_options = 0; - - // restart reader - self.reader_control.replace(self.reader.run(&env.cmd)); - - // Check if query meets minimum length requirement before restarting matcher - // In interactive mode, the command query is used as the search query - if let Some(min_length) = self.min_query_length - && env.cmd_query.chars().count() < min_length - { - // Clear selection if query is too short - self.selection.clear(); - // Don't restart matcher if query is too short - return; - } - - self.restart_matcher(); - self.reader_timer = Instant::now(); - } - - fn on_query_change(&mut self, env: &mut ModelEnv) { - // restart matcher - if let Some(ctrl) = self.matcher_control.take() { - ctrl.kill(); - } - - // Always clear selection when query changes - env.clear_selection = ClearStrategy::Clear; - self.item_pool.reset(); - self.num_options = 0; - - // Check if query meets minimum length requirement - if let Some(min_length) = self.min_query_length - && env.query.chars().count() < min_length - { - // Clear selection if query is too short - self.selection.clear(); - // Don't restart matcher if query is too short - return; - } - - self.restart_matcher(); - } - - fn act_execute(&mut self, cmd: &str) { - let item = self.selection.get_current_item(); - if depends_on_items(cmd) && item.is_none() { - debug!("act_execute: command refers to items and there is no item for now"); - debug!("command to execute: [{cmd}]"); - return; - } - - let _ = self.term.pause(); - self.act_execute_silent(cmd); - let _ = self.term.restart(); - } - - fn act_execute_silent(&mut self, cmd: &str) { - let current_index = self.selection.get_current_item_idx(); - let current_item = self.selection.get_current_item(); - if depends_on_items(cmd) && current_item.is_none() { - debug!("act_execute_silent: command refers to items and there is no item for now"); - debug!("command to execute: [{cmd}]"); - return; - } - - let current_selection = current_item.as_ref().map(|item| item.output()).unwrap_or_default(); - let query = self.query.get_fz_query(); - let cmd_query = self.query.get_cmd_query(); - - let (indices, selections) = self.selection.get_selected_indices_and_items(); - let tmp: Vec = selections.into_iter().map(|item| item.text().to_string()).collect(); - let selected_texts: Vec<&str> = tmp.iter().map(|cow| cow.as_ref()).collect(); - - let context = InjectContext { - current_index, - delimiter: &self.delimiter, - current_selection: ¤t_selection, - selections: &selected_texts, - indices: &indices, - query: &query, - cmd_query: &cmd_query, - }; - - let cmd = inject_command(cmd, context).to_string(); - let shell = env::var("SHELL").unwrap_or_else(|_| "sh".to_string()); - let _ = Command::new(shell).arg("-c").arg(cmd).status(); - } - - fn act_reload(&mut self, cmd_opt: Option) { - let cmd = match cmd_opt { - Some(s) => s, - None => self.query.get_cmd(), - }; - debug!("command to execute: [{cmd}]"); - let mut env = ModelEnv { - cmd: cmd.to_string(), - cmd_query: self.query.get_cmd_query(), - query: self.query.get_fz_query(), - clear_selection: ClearStrategy::ClearIfNotNull, - in_query_mode: self.query.in_query_mode(), - }; - - self.selection.clear(); - self.on_cmd_query_change(&mut env); - } - - #[allow(clippy::trivial_regex)] - fn act_append_and_select(&mut self, env: &mut ModelEnv) { - let query = self.query.get_fz_query(); - if query.is_empty() { - return; - } - - let item_len = query.len(); - let item_idx = self.item_pool.len(); - let query_item = DefaultSkimItem::new(query, true, &[], &[], &self.delimiter, item_idx); - let item: Arc = Arc::new(query_item); - let new_len = self.item_pool.append(vec![item.clone()]); - trace!( - "appended and selected item with internal id {} and matched as id {}", - item_idx, - max(new_len, 1) - 1 - ); - let matched_item = MatchedItem { - item, - rank: self.rank_builder.build_rank(0, 0, 0, item_len, item_idx), - matched_range: Some(MatchRange::ByteRange(0, 0)), - item_idx: (max(new_len, 1) - 1) as u32, - }; - - self.selection.act_select_matched(current_run_num(), matched_item); - - self.act_heart_beat(env); - } - - pub fn start(&mut self) -> Option { - let mut env = ModelEnv { - cmd: self.query.get_cmd(), - query: self.query.get_fz_query(), - cmd_query: self.query.get_cmd_query(), - in_query_mode: self.query.in_query_mode(), - clear_selection: ClearStrategy::DontClear, - }; - - self.reader_control = Some(self.reader.run(&env.cmd)); - - // In the event loop, there might need - let mut next_event = Some((Key::Null, Event::EvHeartBeat)); - loop { - let (key, ev) = next_event.take().or_else(|| self.rx.recv().ok())?; - - debug!("handle event: {ev:?}"); - - match ev { - Event::EvHeartBeat => { - // consume following HeartBeat event - next_event = self.consume_additional_event(&Event::EvHeartBeat); - self.act_heart_beat(&mut env); - self.handle_select1_or_exit0(); - } - - Event::EvActIfNonMatched(ref arg_str) => { - let matched = - self.num_options + self.matcher_control.as_ref().map(|c| c.get_num_matched()).unwrap_or(0); - if matched == 0 { - next_event = parse_action_arg(arg_str).map(|ev| (key, ev)); - continue; - } - } - - Event::EvActIfQueryEmpty(ref arg_str) => { - if env.query.is_empty() { - next_event = parse_action_arg(arg_str).map(|ev| (key, ev)); - continue; - } - } - - Event::EvActIfQueryNotEmpty(ref arg_str) => { - if !env.query.is_empty() { - next_event = parse_action_arg(arg_str).map(|ev| (key, ev)); - continue; - } - } - - Event::EvActTogglePreview => { - self.preview_hidden = !self.preview_hidden; - } - - Event::EvActRotateMode => { - self.act_rotate_mode(&mut env); - } - - Event::EvActAccept(accept_key) => { - if let Some(ctrl) = self.reader_control.take() { - ctrl.kill(); - } - if let Some(ctrl) = self.matcher_control.take() { - ctrl.kill(); - } - - return Some(SkimOutput { - is_abort: false, - final_event: Event::EvActAccept(accept_key), - final_key: key, - query: self.query.get_fz_query(), - cmd: self.query.get_cmd_query(), - selected_items: self.selection.get_selected_indices_and_items().1, - }); - } - - Event::EvActAbort => { - if let Some(ctrl) = self.reader_control.take() { - ctrl.kill(); - } - if let Some(ctrl) = self.matcher_control.take() { - ctrl.kill(); - } - - return Some(SkimOutput { - is_abort: true, - final_event: ev.clone(), - final_key: key, - query: self.query.get_fz_query(), - cmd: self.query.get_cmd_query(), - selected_items: self.selection.get_selected_indices_and_items().1, - }); - } - - Event::EvActDeleteCharEOF => { - if env.in_query_mode && env.query.is_empty() || !env.in_query_mode && env.cmd_query.is_empty() { - next_event = Some((key, Event::EvActAbort)); - continue; - } - } - - Event::EvActExecute(ref cmd) => { - self.act_execute(cmd); - } - - Event::EvActExecuteSilent(ref cmd) => { - self.act_execute_silent(cmd); - } - - Event::EvActReload(ref cmd) => { - self.act_reload(cmd.clone()); - } - - Event::EvActAppendAndSelect => { - self.act_append_and_select(&mut env); - } - - Event::EvInputKey(key) => { - // dispatch key(normally the mouse keys) to sub-widgets - self.do_with_widget(|root| { - let (width, height) = self.term.term_size().unwrap(); - let rect = Rectangle { - top: 0, - left: 0, - width, - height, - }; - let messages = root.on_event(TermEvent::Key(key), rect); - for message in messages { - let _ = self.tx.send((key, message)); - } - }) - } - - Event::EvActRefreshCmd => { - self.on_cmd_query_change(&mut env); - } - - Event::EvActRefreshPreview => { - self.draw_preview(&env, true); - } - - _ => {} - } - - // dispatch events to sub-components - - self.header.handle(&ev); - - self.query.handle(&ev); - env.cmd_query = self.query.get_cmd_query(); - - let new_query = self.query.get_fz_query(); - let new_cmd = self.query.get_cmd(); - - // re-run reader & matcher if needed; - if new_cmd != env.cmd { - env.cmd = new_cmd; - self.on_cmd_query_change(&mut env); - } else if new_query != env.query { - env.query = new_query; - self.on_query_change(&mut env); - } - - self.selection.handle(&ev); - - if let Some(previewer) = self.previewer.as_mut() { - previewer.handle(&ev); - } - - self.draw_preview(&env, false); - - let _ = self.do_with_widget(|root| self.term.draw(&root)); - let _ = self.term.present(); - } - } - - fn draw_preview(&mut self, env: &ModelEnv, force: bool) { - if self.preview_hidden { - return; - } - - // re-draw - let item_index = self.selection.get_current_item_idx(); - let item = self.selection.get_current_item(); - if let Some(previewer) = self.previewer.as_mut() { - let selections = &self.selection; - let get_selected_items = || selections.get_selected_indices_and_items(); - previewer.on_item_change( - item_index, - item, - env.query.to_string(), - env.cmd_query.to_string(), - selections.get_num_of_selected_exclude_current(), - get_selected_items, - force, - ); - } - } - - fn consume_additional_event(&self, target_event: &Event) -> Option<(Key, Event)> { - // consume additional HeartBeat event - let mut rx_try_iter = self.rx.try_iter().peekable(); - while let Some((_key, ev)) = rx_try_iter.peek() { - if *ev == *target_event { - let _ = rx_try_iter.next(); - } else { - break; - } - } - // once the event is peeked, it is removed from the pipe, thus need to be saved. - rx_try_iter.next() - } - - fn restart_matcher(&mut self) { - self.matcher_timer = Instant::now(); - let query = self.query.get_fz_query(); - let cmd_query = self.query.get_cmd_query(); - let in_query_mode = self.query.in_query_mode(); - - // Check if query meets minimum length requirement before doing anything - if let Some(min_length) = self.min_query_length { - // Check the appropriate query based on mode - let query_to_check = if in_query_mode { &query } else { &cmd_query }; - - if query_to_check.chars().count() < min_length { - // Don't run matcher if query is too short - // Also kill any existing matcher - if let Some(ctrl) = self.matcher_control.take() { - ctrl.kill(); - } - return; - } - } - - // kill existing matcher if exits - if let Some(ctrl) = self.matcher_control.take() { - ctrl.kill(); - } - - // if there are new items, move them to item pool - let processed = self.reader_control.as_ref().map(|c| c.is_done()).unwrap_or(true); - if !processed { - // take out new items and put them into items - let new_items = self.reader_control.as_ref().map(|c| c.take()).unwrap(); - let _ = self.item_pool.append(new_items); - }; - - // send heart beat (so that heartbeat/refresh is triggered) - let _ = self.tx.send((Key::Null, Event::EvHeartBeat)); - - let matcher = if self.use_regex { - &self.regex_matcher - } else { - &self.matcher - }; - - let tx = self.tx.clone(); - let new_matcher_control = matcher.run(&query, self.item_pool.clone(), move |_| { - // notify refresh immediately - let _ = tx.send((Key::Null, Event::EvHeartBeat)); - }); - - self.matcher_control.replace(new_matcher_control); - } - - /// construct the widget tree - fn do_with_widget(&'_ self, action: F) -> R - where - F: Fn(Box + '_>) -> R, - { - let total = self.item_pool.len(); - let matched = self.num_options + self.matcher_control.as_ref().map(|c| c.get_num_matched()).unwrap_or(0); - let matcher_running = self.item_pool.num_not_taken() != 0 || matched != self.num_options; - let processed = self - .matcher_control - .as_ref() - .map(|c| c.get_num_processed()) - .unwrap_or(total); - - let status = Status { - total, - matched, - processed, - matcher_running, - multi_selection: self.selection.is_multi_selection(), - selected: self.selection.get_num_selected(), - current_item_idx: self.selection.get_current_item_idx(), - hscroll_offset: self.selection.get_hscroll_offset(), - reading: !self.reader_control.as_ref().map(|c| c.is_done()).unwrap_or(true), - time_since_read: self.reader_timer.elapsed(), - time_since_match: self.matcher_timer.elapsed(), - matcher_mode: if self.use_regex { - "RE".to_string() - } else { - "".to_string() - }, - theme: self.theme.clone(), - info: self.info.clone(), - }; - let status_inline = status.clone(); - - let win_selection = Win::new(&self.selection); - let win_query = Win::new(&self.query) - .basis(if self.info == InfoDisplay::Default { 1 } else { 0 }) - .grow(0) - .shrink(0); - let win_status = Win::new(status) - .basis(if self.info == InfoDisplay::Default { 1 } else { 0 }) - .grow(0) - .shrink(0); - let win_header = Win::new(&self.header).grow(0).shrink(0); - let win_query_status = HSplit::default() - .basis(if self.info == InfoDisplay::Default { 0 } else { 1 }) - .grow(0) - .shrink(0) - .split(Win::new(&self.query).grow(0).shrink(0)) - .split(Win::new(status_inline).grow(1).shrink(0)); - - let layout = &self.layout as &str; - let win_main = match layout { - "reverse" => VSplit::default() - .split(win_query_status) - .split(win_query) - .split(win_status) - .split(win_header) - .split(win_selection), - "reverse-list" => VSplit::default() - .split(win_selection) - .split(win_header) - .split(win_status) - .split(win_query) - .split(win_query_status), - _ => VSplit::default() - .split(win_selection) - .split(win_header) - .split(win_status) - .split(win_query) - .split(win_query_status), - }; - - let screen: Box> = if !self.preview_hidden && self.previewer.is_some() { - let previewer = self.previewer.as_ref().unwrap(); - let win = Win::new(previewer) - .basis(self.preview_size) - .grow(0) - .shrink(0) - .border_attr(self.theme.border()); - - let win_preview = match self.preview_direction { - Direction::Up => win.border_bottom(true), - Direction::Right => win.border_left(true), - Direction::Down => win.border_top(true), - Direction::Left => win.border_right(true), - }; - - match self.preview_direction { - Direction::Up => Box::new(VSplit::default().split(win_preview).split(win_main)), - Direction::Right => Box::new(HSplit::default().split(win_main).split(win_preview)), - Direction::Down => Box::new(VSplit::default().split(win_main).split(win_preview)), - Direction::Left => Box::new(HSplit::default().split(win_preview).split(win_main)), - } - } else { - Box::new(win_main) - }; - - let root = Win::new(screen) - .margin_top(self.margin_top) - .margin_right(self.margin_right) - .margin_bottom(self.margin_bottom) - .margin_left(self.margin_left); - - action(Box::new(root)) - } -} diff --git a/skim/src/model/options.rs b/skim/src/model/options.rs deleted file mode 100644 index 85e89316..00000000 --- a/skim/src/model/options.rs +++ /dev/null @@ -1,29 +0,0 @@ -#[cfg(feature = "cli")] -use clap::ValueEnum; -#[cfg(feature = "cli")] -use clap::builder::PossibleValue; - -#[derive(Debug, Clone, Default, Eq, PartialEq)] -pub enum InfoDisplay { - #[default] - Default, - Inline, - Hidden, -} - -#[cfg(feature = "cli")] -impl ValueEnum for InfoDisplay { - fn value_variants<'a>() -> &'a [Self] { - use InfoDisplay::*; - &[Default, Inline, Hidden] - } - - fn to_possible_value(&self) -> Option { - use InfoDisplay::*; - match self { - Default => Some(PossibleValue::new("default")), - Inline => Some(PossibleValue::new("inline")), - Hidden => Some(PossibleValue::new("hidden")), - } - } -} diff --git a/skim/src/model/status.rs b/skim/src/model/status.rs deleted file mode 100644 index 8f7dbde0..00000000 --- a/skim/src/model/status.rs +++ /dev/null @@ -1,143 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use skim_tuikit::attr::{Attr, Effect}; -use skim_tuikit::canvas::Canvas; -use skim_tuikit::draw::{Draw, DrawResult}; -use skim_tuikit::widget::Widget; - -use crate::event::Event; -use crate::theme::ColorTheme; -use crate::util::clear_canvas; - -use super::InfoDisplay; - -const SPINNER_DURATION: u32 = 200; -// const SPINNERS: [char; 8] = ['-', '\\', '|', '/', '-', '\\', '|', '/']; -const SPINNERS_INLINE: [char; 2] = ['-', '<']; -const SPINNERS_UNICODE: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - -#[derive(Clone)] -pub(crate) struct Status { - pub(crate) total: usize, - pub(crate) matched: usize, - pub(crate) processed: usize, - pub(crate) matcher_running: bool, - pub(crate) multi_selection: bool, - pub(crate) selected: usize, - pub(crate) current_item_idx: usize, - pub(crate) hscroll_offset: i64, - pub(crate) reading: bool, - pub(crate) time_since_read: Duration, - pub(crate) time_since_match: Duration, - pub(crate) matcher_mode: String, - pub(crate) theme: Arc, - pub(crate) info: InfoDisplay, -} - -#[allow(unused_assignments)] -impl Draw for Status { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - // example: - // /--num_matched/num_read /-- current_item_index - // [| 869580/869580 0.] - // `-spinner `-- still matching - - // example(inline): - // /--num_matched/num_read /-- current_item_index - // [> - 549334/549334 0.] - // `-spinner `-- still matching - - canvas.clear()?; - let (screen_width, _) = canvas.size()?; - clear_canvas(canvas)?; - if self.info == InfoDisplay::Hidden { - return Ok(()); - } - - let info_attr = self.theme.info(); - let info_attr_bold = Attr { - effect: Effect::BOLD, - ..self.theme.info() - }; - - let a_while_since_read = self.time_since_read > Duration::from_millis(50); - let a_while_since_match = self.time_since_match > Duration::from_millis(50); - - let mut col = 0; - let spinner_set: &[char] = match self.info { - InfoDisplay::Default => &SPINNERS_UNICODE, - InfoDisplay::Inline => &SPINNERS_INLINE, - InfoDisplay::Hidden => panic!("This should never happen"), - }; - - if self.info == InfoDisplay::Inline { - col += canvas.put_char_with_attr(0, col, ' ', info_attr)?; - } - - // draw the spinner - if self.reading && a_while_since_read { - let mills = (self.time_since_read.as_secs() * 1000) as u32 + self.time_since_read.subsec_millis(); - let index = (mills / SPINNER_DURATION) % (spinner_set.len() as u32); - let ch = spinner_set[index as usize]; - col += canvas.put_char_with_attr(0, col, ch, self.theme.spinner())?; - } else { - match self.info { - InfoDisplay::Inline => col += canvas.put_char_with_attr(0, col, '<', self.theme.prompt())?, - InfoDisplay::Default => col += canvas.put_char_with_attr(0, col, ' ', self.theme.prompt())?, - InfoDisplay::Hidden => panic!("This should never happen"), - } - } - - // display matched/total number - col += canvas.print_with_attr(0, col, format!(" {}/{}", self.matched, self.total).as_ref(), info_attr)?; - - // display the matcher mode - if !self.matcher_mode.is_empty() { - col += canvas.print_with_attr(0, col, format!("/{}", &self.matcher_mode).as_ref(), info_attr)?; - } - - // display the percentage of the number of processed items - if self.matcher_running && a_while_since_match { - col += canvas.print_with_attr( - 0, - col, - format!(" ({}%) ", self.processed * 100 / self.total).as_ref(), - info_attr, - )?; - } - - // selected number - if self.multi_selection && self.selected > 0 { - col += canvas.print_with_attr(0, col, format!(" [{}]", self.selected).as_ref(), info_attr_bold)?; - } - - // item cursor - let line_num_str = format!( - " {}/{}{}", - self.current_item_idx, - self.hscroll_offset, - if self.matcher_running { '.' } else { ' ' } - ); - canvas.print_with_attr(0, screen_width - line_num_str.len(), &line_num_str, info_attr_bold)?; - - Ok(()) - } -} - -impl Widget for Status {} - -#[derive(PartialEq, Eq, Clone, Debug, Copy)] -pub(crate) enum Direction { - Up, - Down, - Left, - Right, -} - -#[derive(PartialEq, Eq, Clone, Debug, Copy)] -pub(crate) enum ClearStrategy { - DontClear, - Clear, - ClearIfNotNull, -} diff --git a/skim/src/options.rs b/skim/src/options.rs index 14d92d50..34cf988f 100644 --- a/skim/src/options.rs +++ b/skim/src/options.rs @@ -1,18 +1,33 @@ +//! Configuration options for skim. +//! +//! This module provides the `SkimOptions` struct and builder for configuring +//! all aspects of skim's behavior, including search, display, layout, and interaction settings. + use std::cell::RefCell; use std::rc::Rc; -#[cfg(feature = "cli")] -use clap::Parser; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use derive_builder::Builder; +use regex::Regex; +use crate::binds::KeyMap; use crate::item::RankCriteria; -use crate::model::options::InfoDisplay; use crate::prelude::SkimItemReader; -use crate::previewer::PreviewCallback; use crate::reader::CommandCollector; +use crate::tui::PreviewCallback; +use crate::tui::event::Action; +use crate::tui::options::{PreviewLayout, TuiLayout}; +use crate::tui::statusline::InfoDisplay; use crate::util::read_file_lines; use crate::{CaseMatching, FuzzyAlgorithm, Selector}; +#[cfg(feature = "cli")] +/// Custom value parser for delimiter that handles escape sequences +fn parse_delimiter_value(s: &str) -> Result { + let unescaped = crate::util::unescape_delimiter(s); + Regex::new(&unescaped).map_err(|e| format!("Invalid regex delimiter: {}", e)) +} + /// sk - fuzzy finder in Rust /// /// sk is a general purpose command-line fuzzy finder. @@ -20,7 +35,11 @@ use crate::{CaseMatching, FuzzyAlgorithm, Selector}; /// /// # ENVIRONMENT VARIABLES /// -/// ## NO_COLOR +/// NO_COLOR +/// +/// If set and not empty, sk will not use any colors in the output. +/// +/// SKIM_DEFAULT_COMMAND /// /// If set and not empty, sk will not use any colors in the output. /// @@ -83,13 +102,16 @@ use crate::{CaseMatching, FuzzyAlgorithm, Selector}; #[derive(Builder)] #[builder(build_fn(name = "final_build"))] #[builder(default)] -#[cfg_attr(feature = "cli", derive(Parser))] -#[cfg_attr(feature = "cli", command(name = "sk", args_override_self = true, version))] +#[cfg_attr(feature = "cli", derive(clap::Parser))] +#[cfg_attr( + feature = "cli", + command(name = "sk", args_override_self = true, verbatim_doc_comment, version, about) +)] pub struct SkimOptions { // --- Search --- /// Show results in reverse order /// - /// *Often used in combination with `--no-sort`* + /// Often used in combination with --no-sort #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))] pub tac: bool, @@ -101,9 +123,8 @@ pub struct SkimOptions { /// Do not sort the results /// - /// *Often used in combination with `--tac`* - /// - /// **Example**: `history | sk --tac --no-sort` + /// Often used in combination with --tac + /// Example: `history | sk --tac --no-sort` #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))] pub no_sort: bool, @@ -111,19 +132,8 @@ pub struct SkimOptions { /// /// * **score**: Score of the fuzzy match algorithm /// - /// * **index**: Prefers line that appeared earlier in the input stream - /// - /// * **begin**: Prefers line with matched substring closer to the beginning - /// - /// * **end**: Prefers line with matched substring closer to the end - /// - /// * **length**: Prefers line with shorter length - /// - /// Notes: - /// - /// * Each criterion could be negated, e.g. (-index) - /// - /// * Each criterion should appear only once in the list + /// - Each criterion could be negated, e.g. (-index) + /// - Each criterion should appear only once in the list #[cfg_attr( feature = "cli", arg( @@ -132,7 +142,9 @@ pub struct SkimOptions { default_value = "score,begin,end", value_enum, value_delimiter = ',', - help_heading = "Search" + help_heading = "Search", + allow_hyphen_values = true, + verbatim_doc_comment ) )] pub tiebreak: Vec, @@ -142,26 +154,26 @@ pub struct SkimOptions { /// A field index expression can be a non-zero integer or a range expression (`[BEGIN]..[END]`). /// `--nth` and `--with-nth` take a comma-separated list of field index expressions. /// - /// **Examples**: - /// - /// * `1`: The 1st field - /// - /// * `2`: The 2nd field - /// - /// * `-1`: The last field - /// - /// * `-2`: The 2nd to last field - /// - /// * `3..5`: From the 3rd field to the 5th field - /// - /// * `2..`: From the 2nd field to the last field - /// - /// * `..-3`: From the 1st field to the 3rd to the last field - /// - /// * `..`: All the fields + /// **Examples:** + /// 1 The 1st field + /// 2 The 2nd field + /// -1 The last field + /// -2 The 2nd to last field + /// 3..5 From the 3rd field to the 5th field + /// 2.. From the 2nd field to the last field + /// ..-3 From the 1st field to the 3rd to the last field + /// .. All the fields #[cfg_attr( feature = "cli", - arg(short, long, default_value = "", help_heading = "Search", value_delimiter = ',') + arg( + short, + long, + default_value = "", + help_heading = "Search", + verbatim_doc_comment, + value_delimiter = ',', + allow_hyphen_values = true, + ) )] pub nth: Vec, @@ -176,12 +188,12 @@ pub struct SkimOptions { /// Delimiter between fields /// - /// In regex format, default to AWK-style + /// In regex format, default to AWK-style. Escape sequences like \x00, \t, \n are supported. #[cfg_attr( feature = "cli", - arg(short, long, default_value = r"[\t\n ]+", help_heading = "Search") + arg(short, long, default_value = r"[\t\n ]+", value_parser = parse_delimiter_value, help_heading = "Search") )] - pub delimiter: String, + pub delimiter: Regex, /// Run in exact mode #[cfg_attr(feature = "cli", arg(short, long, help_heading = "Search"))] @@ -193,14 +205,18 @@ pub struct SkimOptions { /// Fuzzy matching algorithm /// - /// * **skim_v2**: Latest skim algorithm, should be better in almost any case - /// - /// * **skim_v1**: Legacy skim algorithm - /// - /// * **clangd**: Used in clangd for keyword completion + /// skim_v2 Latest skim algorithm, should be better in almost any case + /// skim_v1 Legacy skim algorithm + /// clangd Used in clangd for keyword completion #[cfg_attr( feature = "cli", - arg(long = "algo", default_value = "skim_v2", value_enum, help_heading = "Search") + arg( + long = "algo", + default_value = "skim_v2", + value_enum, + help_heading = "Search", + verbatim_doc_comment + ) )] pub algorithm: FuzzyAlgorithm, @@ -305,12 +321,16 @@ pub struct SkimOptions { /// /// * accept(...): enter *the argument will be printed when the binding is triggered* /// + /// * append-and-select(c): append c to the query + /// /// * append-and-select: /// /// * backward-char: ctrl-b left /// /// * backward-delete-char: ctrl-h bspace /// + /// * backward-delete-char/eof: + /// /// * backward-kill-word: alt-bs /// /// * backward-word: alt-b shift-left @@ -321,7 +341,7 @@ pub struct SkimOptions { /// /// * delete-char: del /// - /// * delete-charEOF: ctrl-d + /// * delete-char/eof: ctrl-d /// /// * deselect-all: /// @@ -373,10 +393,18 @@ pub struct SkimOptions { /// /// * previous-history: ctrl-p with `--history` or `--cmd-history` /// + /// * redraw: + /// + /// * refresh-cmd: + /// + /// * refresh-preview: + /// /// * reload(...): /// /// * select-all: /// + /// * select-row: + /// /// * toggle: /// /// * toggle-all: @@ -385,6 +413,8 @@ pub struct SkimOptions { /// /// * toggle-in: (--layout=reverse ? toggle+up: toggle+down) /// + /// * toggle-interactive: + /// /// * toggle-out: (--layout=reverse ? toggle+down: toggle+up) /// /// * toggle-preview: @@ -395,6 +425,8 @@ pub struct SkimOptions { /// /// * toggle+up: btab shift-tab /// + /// * top: + /// /// * unix-line-discard: ctrl-u /// /// * unix-word-rubout: ctrl-w @@ -448,7 +480,10 @@ pub struct SkimOptions { /// /// If the query is empty, skim will execute abort action, otherwise execute delete-char action. It /// is equal to ‘delete-char/eof‘. - #[cfg_attr(feature = "cli", arg(short, long, help_heading = "Interface", value_delimiter = ','))] + #[cfg_attr( + feature = "cli", + arg(short, long, help_heading = "Interface", verbatim_doc_comment, default_value = "", num_args=0..) + )] pub bind: Vec, /// Enable multiple selection @@ -484,50 +519,7 @@ pub struct SkimOptions { /// Set color theme /// - /// Use `--color` to customize the color scheme of skim. The format is: - /// - /// **Format**: [BASE_SCHEME][,COLOR:ANSI_VALUE] - /// - /// ### Base Color Schemes - /// - /// - **dark**: Default 256-color dark theme (default) - /// - **light**: 256-color light theme - /// - **16**: Basic 16-color theme - /// - **bw**: Minimal black & white theme (no colors, just styles) - /// - **none**: Minimal black & white theme (no colors, no styles). Default when NO_COLOR is set - /// - **molokai**: Molokai-inspired 256-color theme - /// - /// ### Color Customization - /// - /// Colors can be specified in two ways: - /// - ANSI color code (0-255): `--color=fg:232,bg:255` - /// - RGB hex values: `--color=fg:#FF0000` (red text) - /// - /// ### Customizable UI Elements - /// - /// - **fg**: Normal text foreground color - /// - **bg**: Normal text background color - /// - **matched** (or **hl**): Matched text in search results - /// - **matched_bg**: Background of matched text - /// - **current** (or **fg+**): Current line foreground color - /// - **current_bg** (or **bg+**): Current line background color - /// - **current_match** (or **hl+**): Matched text in current line - /// - **current_match_bg**: Background of matched text in current line - /// - **spinner**: Progress indicator color - /// - **info**: Information line color - /// - **prompt**: Prompt color - /// - **cursor** (or **pointer**): Cursor color - /// - **selected** (or **marker**): Selected item marker color - /// - **header**: Header text color - /// - **border**: Border color for preview/layout - /// - /// ### Examples - /// - /// - `--color=light`: Use light color scheme - /// - `--color=dark,fg:232,bg:255`: Use dark scheme with custom colors - /// - `--color=current_bg:24`: Default scheme with custom current line background - /// - `--color=dark,matched:#00FF00`: Green matched text on dark theme - /// - `--color=fg:#FFFFFF,bg:#000000`: Custom white-on-black color scheme + /// Format: [BASE][,COLOR:ANSI] #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))] pub color: Option, @@ -546,8 +538,8 @@ pub struct SkimOptions { /// Line will start with the start of the matched pattern. Effective only when the query /// string is empty. Was designed to skip showing starts of paths of rg/grep results. /// - /// **Example**: `sk -i -c "rg {} --color=always" --skip-to-pattern '[^/]*:' --ansi` - #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))] + /// e.g. sk -i -c "rg {} --color=always" --skip-to-pattern '[^/]*:' --ansi + #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))] pub skip_to_pattern: Option, /// Do not clear previous line if the command returns an empty result @@ -558,7 +550,7 @@ pub struct SkimOptions { /// This is not the default behavior because similar use cases for grep and rg have already been op‐ /// timized where empty query results actually mean "empty" and previous results should be /// cleared. - #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))] + #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))] pub no_clear_if_empty: bool, /// Do not clear items on start @@ -580,20 +572,11 @@ pub struct SkimOptions { // --- Layout --- /// Set layout /// - /// *default: Display from the bottom of the screen - /// - /// *reverse: Display from the top of the screen - /// - /// *reverse-list: Display from the top of the screen, prompt at the bottom - #[cfg_attr(feature = "cli", arg( - long, - default_value = "default", - value_parser = clap::builder::PossibleValuesParser::new( - ["default", "reverse", "reverse-list"] - ), - help_heading = "Layout", - ))] - pub layout: String, + #[cfg_attr( + feature = "cli", + arg(long, help_heading = "Layout", verbatim_doc_comment, default_value = "default") + )] + pub layout: TuiLayout, /// Shorthand for reverse layout #[cfg_attr(feature = "cli", arg(long, help_heading = "Layout"))] @@ -605,16 +588,18 @@ pub struct SkimOptions { #[cfg_attr(feature = "cli", arg(long, default_value = "100%", help_heading = "Layout"))] pub height: String, - /// Disable height feature + /// Disable height (force full screen) #[cfg_attr(feature = "cli", arg(long, help_heading = "Layout"))] pub no_height: bool, /// Minimum height of skim's window /// /// Useful when the height is set as a percentage - /// - /// Ignored when `--height` is not specified - #[cfg_attr(feature = "cli", arg(long, default_value = "10", help_heading = "Layout"))] + /// Ignored when --height is not specified + #[cfg_attr( + feature = "cli", + arg(long, default_value = "10", help_heading = "Layout", verbatim_doc_comment) + )] pub min_height: String, /// Screen margin @@ -622,17 +607,15 @@ pub struct SkimOptions { /// For each side, can be either a row count or a percentage of the terminal size /// /// Format can be one of: - /// - /// * TRBL - /// - /// * TB,RL - /// - /// * T,RL,B - /// - /// * T,R,B,L - /// - /// **Example**: 1,10% - #[cfg_attr(feature = "cli", arg(long, default_value = "0", help_heading = "Layout"))] + /// - TRBL + /// - TB,RL + /// - T,RL,B + /// - T,R,B,L + /// Example: 1,10% + #[cfg_attr( + feature = "cli", + arg(long, default_value = "0", help_heading = "Layout", verbatim_doc_comment) + )] pub margin: String, /// Set prompt @@ -645,6 +628,20 @@ pub struct SkimOptions { // --- Display --- /// Parse ANSI color codes in input strings + /// + /// When using skim as a library, this has no effect and ansi parsing should + /// be enabled by manually injecting a cmd_collector like so: + /// ```rust + /// use skim::prelude::*; + /// + /// let _options = SkimOptionsBuilder::default() + /// .cmd(ls --color) + /// .cmd_collector(Rc::new(RefCell::new(SkimItemReader::new( + /// SkimItemReaderOption::default().ansi(true), + /// ))) as Rc>) + /// .build() + /// .unwrap() + /// ``` #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))] pub ansi: bool, @@ -654,9 +651,9 @@ pub struct SkimOptions { /// Set matching result count display position /// - /// * hidden: do not display info - /// * inline: display info in the same row as the input - /// * default: display info in a dedicated row above the input + /// hidden: do not display info + /// inline: display info in the same row as the input + /// default: display info in a dedicated row above the input #[cfg_attr( feature = "cli", arg(long, help_heading = "Display", value_enum, default_value = "default") @@ -674,8 +671,8 @@ pub struct SkimOptions { /// Set header, displayed next to the info /// /// The given string will be printed as the sticky header. The lines are displayed in the - /// given order from top to bottom regardless of `--layout` option, and are not affected by - /// `--with-nth`. ANSI color codes are processed even when `--ansi` is not set. + /// given order from top to bottom regardless of --layout option, and are not affected by + /// --with-nth. ANSI color codes are processed even when --ansi is not set. #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))] pub header: Option, @@ -686,6 +683,11 @@ pub struct SkimOptions { #[cfg_attr(feature = "cli", arg(long, default_value = "0", help_heading = "Display"))] pub header_lines: usize, + /// Draw borders around the UI components + /// + #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))] + pub border: bool, + // --- History --- /// History file /// @@ -746,7 +748,7 @@ pub struct SkimOptions { /// /// Preview window will be updated even when there is no match for the current query if any of the placeholder ex‐ /// pressions evaluates to a non-empty string. - #[cfg_attr(feature = "cli", arg(long, help_heading = "Preview"))] + #[cfg_attr(feature = "cli", arg(long, help_heading = "Preview", verbatim_doc_comment))] pub preview: Option, /// Preview window layout @@ -775,14 +777,21 @@ pub struct SkimOptions { /// git grep --line-number '' | /// sk --delimiter: --preview 'nl {1}' --preview-window +{2}-5 /// - /// # Preview with bat, matching line in the middle of the window (-/2) - /// git grep --line-number '' | - /// sk --delimiter: \ - /// --preview 'bat --style=numbers --color=always --highlight-line {2} {1}' \ - /// --preview-window +{2}-/2 - /// ``` - #[cfg_attr(feature = "cli", arg(long, default_value = "right:50%", help_heading = "Preview"))] - pub preview_window: String, + /// # Preview with bat, matching line in the middle of the window (-/2) + /// git grep --line-number '' | + /// sk --delimiter : \ + /// --preview 'bat --style=numbers --color=always --highlight-line {2} {1}' \ + /// --preview-window +{2}-/2 + #[cfg_attr( + feature = "cli", + arg( + long, + default_value = "right:50%", + help_heading = "Preview", + allow_hyphen_values = true + ) + )] + pub preview_window: PreviewLayout, // --- Scripting --- /// Initial query @@ -793,18 +802,6 @@ pub struct SkimOptions { #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))] pub cmd_query: Option, - /// [Deprecated: Use `--bind=:accept()` instead] Comma separated list of keys used to complete skim - /// - /// Comma-separated list of keys that can be used to complete sk in addition to the default enter key. When this - /// option is set, sk will print the name of the key pressed as the first line of its output (or as the second - /// line if --print-query is also used). No line will be printed if sk is completed with the default enter key. If - /// --expect option is specified multiple times, sk will expect the union of the keys. --no-expect will clear the - /// list. - /// - /// **Example**: `sk --expect=ctrl-v,ctrl-t,alt-s --expect=f1,f2,~,@` - #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", value_delimiter = ','))] - pub expect: Vec, - /// Read input delimited by ASCII NUL(\\0) characters #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))] pub read0: bool, @@ -838,7 +835,7 @@ pub struct SkimOptions { /// Synchronous search for multi-staged filtering. If specified, /// skim will launch ncurses finder only after the input stream is complete. /// - /// **Example**: `sk --multi | sk --sync` + /// e.g. sk --multi | sk --sync #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))] pub sync: bool, @@ -855,7 +852,7 @@ pub struct SkimOptions { /// Pre-select the items separated by newline character /// - /// **Example**: `item1\nitem2` + /// Example: 'item1\nitem2' #[cfg_attr(feature = "cli", arg(long, default_value = "", help_heading = "Scripting"))] pub pre_select_items: String, @@ -884,23 +881,25 @@ pub struct SkimOptions { )] pub shell: Option, + /// Generate man page and output it to stdout + #[cfg(feature = "cli")] + #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))] + pub man: bool, + /// Run in a tmux popup /// /// Format: `sk --tmux [,SIZE[%]][,SIZE[%]]` /// /// Depending on the direction, the order and behavior of the sizes varies: /// - /// * center: (width, height) or (size, size) if only one is provided - /// - /// * top | bottom: (height, width) or height = size, width = 100% if only one is provided - /// - /// * left | right: (width, height) or height = 100%, width = size if only one is provided - /// - /// Note: env vars are only passed to the tmux command if they are either `PATH` or prefixed with - /// `RUST` or `SKIM` - #[cfg_attr(feature = "cli", arg(long, help_heading = "Display", default_missing_value = "center,50%", num_args=0..))] + /// Default: center,50% + #[cfg_attr(feature = "cli", arg(long, verbatim_doc_comment, help_heading = "Display", default_missing_value = "center,50%", num_args=0..))] pub tmux: Option, + /// Pipe log output to a file + #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))] + pub log_file: Option, + /// Reserved for later use #[cfg_attr( feature = "cli", @@ -939,10 +938,6 @@ pub struct SkimOptions { )] pub jump_labels: String, - /// Reserved for later use - #[cfg_attr(feature = "cli", arg(long, hide = true, help_heading = "Reserved for later use", default_missing_value="Hi", num_args=0..=1))] - pub border: Option, - /// Reserved for later use #[cfg_attr(feature = "cli", arg(long, hide = true, help_heading = "Reserved for later use"))] pub no_bold: bool, @@ -959,12 +954,20 @@ pub struct SkimOptions { #[cfg_attr(feature = "cli", arg(long, hide = true, help_heading = "Reserved for later use"))] pub phony: bool, + /// Deprecated, kept for compatibility purposes. See accept() bind instead. + #[cfg_attr(feature = "cli", arg(long, help_heading = "Deprecated", default_value = ""))] + pub expect: String, + + /// Command collector for reading items from commands #[cfg_attr(feature = "cli", clap(skip = Rc::new(RefCell::new(SkimItemReader::default())) as Rc>))] pub cmd_collector: Rc>, + /// Query history entries loaded from history file #[cfg_attr(feature = "cli", clap(skip))] pub query_history: Vec, + /// Command history entries loaded from cmd history file #[cfg_attr(feature = "cli", clap(skip))] pub cmd_history: Vec, + /// Selector for pre-selecting items #[cfg_attr(feature = "cli", clap(skip))] pub selector: Option>, /// Preview Callback @@ -975,6 +978,10 @@ pub struct SkimOptions { /// and return a Vec with the lines to display in UTF-8 #[cfg_attr(feature = "cli", clap(skip))] pub preview_fn: Option, + + /// The internal (parsed) keymap + #[cfg_attr(feature = "cli", clap(skip))] + pub keymap: KeyMap, } impl Default for SkimOptions { @@ -986,7 +993,7 @@ impl Default for SkimOptions { tiebreak: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End], nth: Default::default(), with_nth: Default::default(), - delimiter: String::from(r"[\t\n ]+"), + delimiter: Regex::new(r"[\t\n ]+").unwrap(), exact: Default::default(), regex: Default::default(), algorithm: Default::default(), @@ -1006,7 +1013,7 @@ impl Default for SkimOptions { no_clear_start: Default::default(), no_clear: Default::default(), show_cmd_error: Default::default(), - layout: String::from("default"), + layout: TuiLayout::default(), reverse: Default::default(), height: String::from("100%"), no_height: Default::default(), @@ -1026,10 +1033,9 @@ impl Default for SkimOptions { cmd_history_file: Default::default(), cmd_history_size: 1000, preview: Default::default(), - preview_window: String::from("right:50%"), + preview_window: PreviewLayout::default(), query: Default::default(), cmd_query: Default::default(), - expect: Default::default(), read0: Default::default(), print0: Default::default(), print_query: Default::default(), @@ -1043,9 +1049,8 @@ impl Default for SkimOptions { pre_select_items: Default::default(), pre_select_file: Default::default(), filter: Default::default(), - #[cfg(feature = "cli")] - shell: Default::default(), tmux: Default::default(), + log_file: Default::default(), extended: Default::default(), literal: Default::default(), cycle: Default::default(), @@ -1057,46 +1062,64 @@ impl Default for SkimOptions { pointer: Default::default(), marker: Default::default(), phony: Default::default(), + expect: Default::default(), cmd_collector: Rc::new(RefCell::new(SkimItemReader::default())) as Rc>, query_history: Default::default(), cmd_history: Default::default(), selector: Default::default(), preview_fn: Default::default(), + keymap: Default::default(), + #[cfg(feature = "cli")] + shell: Default::default(), + #[cfg(feature = "cli")] + man: false, } } } impl SkimOptionsBuilder { + /// Builds the SkimOptions from the builder pub fn build(&mut self) -> Result { - if let Some(true) = self.no_height { - self.height = Some("100%".to_string()); - } - - if let Some(true) = self.reverse { - self.layout = Some("reverse".to_string()); - } - - self.final_build() + self.final_build().map(|opts| opts.build()) } } impl SkimOptions { + /// Finalizes the options by applying defaults and initializing components pub fn build(mut self) -> Self { if self.no_height { self.height = String::from("100%"); } + self.keymap = self.bind.iter().fold(KeyMap::default(), |mut res, part| { + res.add_keymaps(part.split(',')); + res + }); + if self.reverse { - self.layout = String::from("reverse"); + self.layout = TuiLayout::Reverse } - let history_binds = String::from("ctrl-p:previous-history,ctrl-n:next-history"); if self.history_file.is_some() || self.cmd_history_file.is_some() { self.init_histories(); - self.bind.push(history_binds); + self.keymap.insert( + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), + vec![Action::PreviousHistory], + ); + self.keymap.insert( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + vec![Action::NextHistory], + ); + } + if self.inline_info { + self.info = InfoDisplay::Inline; + } + if self.no_info { + self.info = InfoDisplay::Hidden; } self } + /// Initializes history from configured history files pub fn init_histories(&mut self) { if let Some(histfile) = &self.history_file { self.query_history.extend(read_file_lines(histfile).unwrap_or_default()); diff --git a/skim/src/orderedvec.rs b/skim/src/orderedvec.rs deleted file mode 100644 index f2b1a2f4..00000000 --- a/skim/src/orderedvec.rs +++ /dev/null @@ -1,278 +0,0 @@ -// ordered container -// Normally, user will only care about the first several options. So we only keep several of them -// in order. Other items are kept unordered and are sorted on demand. - -use defer_drop::DeferDrop; -use rayon::prelude::ParallelSliceMut; -use std::cell::{Ref, RefCell}; -use std::cmp::Ordering; - -const ORDERED_SIZE: usize = 300; -const MAX_MOVEMENT: usize = 100; - -pub struct OrderedVec { - // sorted vectors for merge, reverse ordered, last one is the smallest one - sub_vectors: RefCell>>>, - // globally sorted items, the first one is the smallest one. - sorted: RefCell>>, - tac: bool, - nosort: bool, -} - -impl OrderedVec { - pub fn new() -> Self { - OrderedVec { - sub_vectors: RefCell::new(DeferDrop::new(Vec::new())), - sorted: RefCell::new(DeferDrop::new(Vec::with_capacity(ORDERED_SIZE))), - tac: false, - nosort: false, - } - } - - pub fn tac(&mut self, tac: bool) -> &mut Self { - self.tac = tac; - self - } - - pub fn nosort(&mut self, nosort: bool) -> &mut Self { - self.nosort = nosort; - self - } - - pub fn append(&mut self, mut items: Vec) { - trace!("orderedvec append: new vec size: {}", items.len()); - if self.nosort { - self.sorted.borrow_mut().append(&mut items); - return; - } - - self.sort_vector(&mut items, false); - let mut sorted = self.sorted.borrow_mut(); - - let mut items_smaller = Vec::new(); - if !sorted.is_empty() { - // move the ones <= sorted to sorted - while items_smaller.len() < MAX_MOVEMENT - && !items.is_empty() - && self.compare_item(items.last().unwrap(), sorted.last().unwrap()) == Ordering::Less - { - items_smaller.push(items.pop().unwrap()); - } - } - - if !items.is_empty() { - self.sub_vectors.borrow_mut().push(items); - } - - let too_many_moved = items_smaller.len() >= ORDERED_SIZE; - trace!("append_ordered: num_moved: {}", items_smaller.len()); - - sorted.append(&mut items_smaller); - if too_many_moved { - // means the current sorted vector contains item that's large - // so we'll move the sorted vector to partially sorted candidates. - self.sort_vector(&mut sorted, false); - let old_vec = self.sorted.replace(DeferDrop::new(Vec::new())); - self.sub_vectors.borrow_mut().push(DeferDrop::into_inner(old_vec)); - } else { - self.sort_vector(&mut sorted, true); - } - - trace!( - "orderedvec done append: sub_vector size: {}", - self.sub_vectors.borrow().len() - ); - } - - fn sort_vector(&self, vec: &mut [T], asc: bool) { - let asc = asc ^ self.tac; - vec.par_sort(); - if !asc { - vec.reverse(); - } - } - - #[inline] - fn compare_item(&self, a: &T, b: &T) -> Ordering { - if !self.tac { a.cmp(b) } else { b.cmp(a) } - } - - fn merge_till(&self, index: usize) { - let mut sorted = self.sorted.borrow_mut(); - let mut vectors = self.sub_vectors.borrow_mut(); - - if index >= sorted.len() { - trace!("merge_till: index: {}, num_sorted: {}", index, sorted.len()); - } - - while index >= sorted.len() { - let o_min_index = vectors - .iter() - .map(|v| v.last()) - .enumerate() - .filter(|(_idx, item)| item.is_some()) - .min_by(|(_, a), (_, b)| self.compare_item(a.unwrap(), b.unwrap())) - .map(|(idx, _)| idx); - if o_min_index.is_none() { - break; - } - - let min_index = o_min_index.unwrap(); - let min_item = vectors[min_index].pop(); - if min_item.is_none() { - break; - } - - if vectors[min_index].is_empty() { - vectors.remove(min_index); - } - - sorted.push(min_item.unwrap()); - } - } - - pub fn get(&self, index: usize) -> Option> { - self.merge_till(index); - if self.len() <= index { - None - } else { - let index = if self.tac && self.nosort { - self.len() - index - 1 - } else { - index - }; - Some(Ref::map(self.sorted.borrow(), |list| &list[index])) - } - } - - pub fn len(&self) -> usize { - let sorted_len = self.sorted.borrow().len(); - let unsorted_len: usize = self.sub_vectors.borrow().iter().map(|v| v.len()).sum(); - sorted_len + unsorted_len - } - - pub fn clear(&mut self) { - self.sub_vectors.replace(DeferDrop::new(Vec::new())); - self.sorted.replace(DeferDrop::new(Vec::new())); - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - pub fn iter(&self) -> impl Iterator> { - self.merge_till(self.len()); - OrderedVecIter { - ordered_vec: self, - index: 0, - } - } -} - -struct OrderedVecIter<'a, T: Send + Ord + 'static> { - ordered_vec: &'a OrderedVec, - index: usize, -} - -impl<'a, T: Send + Ord + 'static> Iterator for OrderedVecIter<'a, T> { - type Item = Ref<'a, T>; - - fn next(&mut self) -> Option { - let ret = self.ordered_vec.get(self.index); - self.index += 1; - ret - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test() { - let a = vec![1, 3, 5, 7]; - let b = vec![4, 8, 9]; - let c = vec![2, 6, 10]; - let mut ordered_vec = OrderedVec::new(); - ordered_vec.append(a); - assert_eq!(*ordered_vec.get(0).unwrap(), 1); - - ordered_vec.append(b); - assert_eq!(*ordered_vec.get(1).unwrap(), 3); - assert_eq!(*ordered_vec.get(2).unwrap(), 4); - assert_eq!(*ordered_vec.get(3).unwrap(), 5); - - ordered_vec.append(c); - - for (idx, item) in ordered_vec.iter().enumerate() { - assert_eq!(idx + 1, *item) - } - } - - #[test] - fn test_tac() { - let a = vec![1, 3, 5, 7]; - let b = vec![4, 8, 9]; - let c = vec![2, 6, 10]; - let mut ordered_vec = OrderedVec::new(); - ordered_vec.tac(true); - - ordered_vec.append(a); - assert_eq!(*ordered_vec.get(0).unwrap(), 7); - - ordered_vec.append(b); - assert_eq!(*ordered_vec.get(1).unwrap(), 8); - assert_eq!(*ordered_vec.get(2).unwrap(), 7); - assert_eq!(*ordered_vec.get(3).unwrap(), 5); - - ordered_vec.append(c); - for (idx, item) in ordered_vec.iter().enumerate() { - assert_eq!(10 - idx, *item) - } - } - - #[test] - fn test_nosort() { - let a = vec![1, 3, 5, 7]; - let b = vec![4, 8, 9]; - let c = vec![2, 6, 10]; - let d = [1, 3, 5, 7, 4, 8, 9, 2, 6, 10]; - let mut ordered_vec = OrderedVec::new(); - ordered_vec.nosort(true); - ordered_vec.append(a); - ordered_vec.append(b); - ordered_vec.append(c); - for (a, b) in ordered_vec.iter().zip(d.iter()) { - assert_eq!(*a, *b); - } - } - - #[test] - fn test_nosort_and_tac() { - let a = vec![1, 3, 5, 7]; - let b = vec![4, 8, 9]; - let c = vec![2, 6, 10]; - let d = [10, 6, 2, 9, 8, 4, 7, 5, 3, 1]; - let mut ordered_vec = OrderedVec::new(); - ordered_vec.nosort(true).tac(true); - ordered_vec.append(a); - ordered_vec.append(b); - ordered_vec.append(c); - for (a, b) in ordered_vec.iter().zip(d.iter()) { - assert_eq!(*a, *b); - } - } - - #[test] - fn test_equals() { - let a = vec![1, 2, 3, 4]; - let b = vec![5, 6, 7, 8]; - let target = [1, 2, 3, 4, 5, 6, 7, 8]; - let mut ordered_vec = OrderedVec::new(); - ordered_vec.append(a); - ordered_vec.append(b); - for (a, b) in ordered_vec.iter().zip(target.iter()) { - assert_eq!(*a, *b); - } - } -} diff --git a/skim/src/output.rs b/skim/src/output.rs index 6aaf19fb..8c45c823 100644 --- a/skim/src/output.rs +++ b/skim/src/output.rs @@ -1,8 +1,8 @@ use crate::SkimItem; -use crate::event::Event; -use skim_tuikit::key::Key; +use crate::tui::Event; use std::sync::Arc; +/// Output from running skim, containing the final selection and state pub struct SkimOutput { /// The final event that makes skim accept/quit. /// Was designed to determine if skim quit or accept. @@ -14,7 +14,7 @@ pub struct SkimOutput { /// The final key that makes skim accept/quit. /// Note that it might be Key::Null if it is triggered by skim. - pub final_key: Key, + pub final_key: crossterm::event::KeyEvent, /// The query pub query: String, diff --git a/skim/src/prelude.rs b/skim/src/prelude.rs index fd5e6feb..0a5f65d4 100644 --- a/skim/src/prelude.rs +++ b/skim/src/prelude.rs @@ -1,16 +1,23 @@ -pub use crate::ansi::AnsiString; -pub use crate::engine::{factory::*, fuzzy::FuzzyAlgorithm}; -pub use crate::event::Event; +//! Convenience re-exports of commonly used types. +//! +//! This module provides a convenient way to import all the commonly used +//! skim types and traits with a single `use skim::prelude::*;` statement. + +pub use crate::engine::{ + factory::*, + fuzzy::{FuzzyAlgorithm, FuzzyEngine}, +}; +pub use crate::fuzzy_matcher::skim::SkimMatcherV2; pub use crate::helper::item_reader::{SkimItemReader, SkimItemReaderOption}; pub use crate::helper::selector::DefaultSkimSelector; pub use crate::options::{SkimOptions, SkimOptionsBuilder}; pub use crate::output::SkimOutput; -pub use crate::previewer::PreviewCallback; +pub use crate::reader::CommandCollector; +pub use crate::tui::{Event, PreviewCallback}; pub use crate::*; -pub use crossbeam::channel::{Receiver, Sender, bounded, unbounded}; -pub use skim_tuikit::event::Key; pub use std::borrow::Cow; pub use std::cell::RefCell; pub use std::rc::Rc; pub use std::sync::Arc; pub use std::sync::atomic::{AtomicUsize, Ordering}; +pub use tokio::sync::mpsc::{UnboundedReceiver as Receiver, UnboundedSender as Sender, unbounded_channel as unbounded}; diff --git a/skim/src/previewer.rs b/skim/src/previewer.rs deleted file mode 100644 index 887c4f5b..00000000 --- a/skim/src/previewer.rs +++ /dev/null @@ -1,673 +0,0 @@ -use std::borrow::Cow; -use std::cmp::{max, min}; -use std::env; -use std::process::{Command, Stdio}; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::mpsc::{Receiver, Sender, channel}; -use std::thread; -use std::thread::JoinHandle; - -use derive_builder::Builder; -use nix::libc; -use regex::Regex; -use skim_tuikit::prelude::{Event as TermEvent, *}; - -use crate::ansi::{ANSIParser, AnsiString}; -use crate::event::{Event, EventHandler, UpdateScreen}; -use crate::spinlock::SpinLock; -use crate::util::{InjectContext, atoi, clear_canvas, depends_on_items, inject_command}; -use crate::{ItemPreview, PreviewContext, PreviewPosition, SkimItem}; - -const TAB_STOP: usize = 8; -const DELIMITER_STR: &str = r"[\t\n ]+"; - -/// A wrapper around a thread-safe (Arc) to a function -pub type PreviewCallbackFn = dyn Fn(Vec>) -> Vec> + Send + Sync + 'static; -#[derive(Clone)] -pub struct PreviewCallback { - inner: Arc, -} - -impl From for PreviewCallback -where - F: Fn(Vec>) -> Vec> + Send + Sync + 'static, -{ - fn from(func: F) -> Self { - Self { inner: Arc::new(func) } - } -} - -impl std::ops::Deref for PreviewCallback { - type Target = dyn Fn(Vec>) -> Vec> + Send + Sync + 'static; - - fn deref(&self) -> &Self::Target { - &*self.inner - } -} - -pub struct Previewer { - tx_preview: Sender, - content_lines: Arc>>>, - - width: Arc, - height: Arc, - hscroll_offset: Arc, - vscroll_offset: Arc, - wrap: bool, - - prev_item: Option>, - prev_query: Option, - prev_cmd_query: Option, - prev_num_selected: usize, - - preview_source: PreviewSource, - preview_offset: String, // e.g. +SCROLL-OFFSET - delimiter: Regex, - thread_previewer: Option>, -} - -/// Source for the Preview window. -#[non_exhaustive] -pub enum PreviewSource { - Empty, - Command(String), - Callback(PreviewCallback), -} - -impl Previewer { - pub fn new(source: PreviewSource, callback: C) -> Self - where - C: Fn() + Send + Sync + 'static, - { - let content_lines = Arc::new(SpinLock::new(Vec::new())); - let (tx_preview, rx_preview) = channel(); - let width = Arc::new(AtomicUsize::new(80)); - let height = Arc::new(AtomicUsize::new(60)); - let hscroll_offset = Arc::new(AtomicUsize::new(1)); - let vscroll_offset = Arc::new(AtomicUsize::new(1)); - - let content_clone = content_lines.clone(); - let width_clone = width.clone(); - let height_clone = height.clone(); - let hscroll_offset_clone = hscroll_offset.clone(); - let vscroll_offset_clone = vscroll_offset.clone(); - let thread_previewer = thread::spawn(move || { - run(rx_preview, move |lines, pos| { - let width = width_clone.load(Ordering::SeqCst); - let height = height_clone.load(Ordering::SeqCst); - - let hscroll = pos.h_scroll.calc_fixed_size(lines.len(), 0); - let hoffset = pos.h_offset.calc_fixed_size(width, 0); - let vscroll = pos.v_scroll.calc_fixed_size(usize::MAX, 0); - let voffset = pos.v_offset.calc_fixed_size(height, 0); - - hscroll_offset_clone.store(max(1, max(hscroll, hoffset) - hoffset), Ordering::SeqCst); - vscroll_offset_clone.store(max(1, max(vscroll, voffset) - voffset), Ordering::SeqCst); - *content_clone.lock() = lines; - - callback(); - }) - }); - - Self { - tx_preview, - content_lines, - width, - height, - hscroll_offset, - vscroll_offset, - wrap: false, - prev_item: None, - prev_query: None, - prev_cmd_query: None, - prev_num_selected: 0, - preview_source: source, - preview_offset: "".to_string(), - delimiter: Regex::new(DELIMITER_STR).unwrap(), - thread_previewer: Some(thread_previewer), - } - } - - pub fn wrap(mut self, wrap: bool) -> Self { - self.wrap = wrap; - self - } - - pub fn delimiter(mut self, delimiter: Regex) -> Self { - self.delimiter = delimiter; - self - } - - // e.g. +SCROLL-OFFSET - pub fn preview_offset(mut self, offset: String) -> Self { - self.preview_offset = offset; - self - } - - #[allow(clippy::too_many_arguments)] - pub fn on_item_change( - &mut self, - new_item_index: usize, - new_item: impl Into>>, - new_query: impl Into>, - new_cmd_query: impl Into>, - num_selected: usize, - get_selected_items: impl Fn() -> (Vec, Vec>), // lazy get - force: bool, - ) { - let new_item = new_item.into(); - let new_query = new_query.into(); - let new_cmd_query = new_cmd_query.into(); - - let item_changed = match (self.prev_item.as_ref(), new_item.as_ref()) { - (None, None) => false, - (None, Some(_)) => true, - (Some(_), None) => true, - #[allow(ambiguous_wide_pointer_comparisons)] - (Some(prev), Some(new)) => !Arc::ptr_eq(prev, new), - }; - - let query_changed = match (self.prev_query.as_ref(), new_query.as_ref()) { - (None, None) => false, - (None, Some(_)) => true, - (Some(_), None) => true, - (Some(prev), Some(cur)) => prev != cur, - }; - - let cmd_query_changed = match (self.prev_cmd_query.as_ref(), new_cmd_query.as_ref()) { - (None, None) => false, - (None, Some(_)) => true, - (Some(_), None) => true, - (Some(prev), Some(cur)) => prev != cur, - }; - - let selected_items_changed = self.prev_num_selected != num_selected; - - if !force && !item_changed && !query_changed && !cmd_query_changed && !selected_items_changed { - return; - } - - self.prev_item = new_item.clone(); - self.prev_query = new_query; - self.prev_cmd_query = new_cmd_query; - self.prev_num_selected = num_selected; - - // prepare preview context - - let current_selection = self - .prev_item - .as_ref() - .map(|item| item.output()) - .unwrap_or_else(|| "".into()); - let query = self.prev_query.as_deref().unwrap_or(""); - let cmd_query = self.prev_cmd_query.as_deref().unwrap_or(""); - - let (indices, selections) = get_selected_items(); - let tmp: Vec> = selections.iter().map(|item| item.text()).collect(); - let selected_texts: Vec<&str> = tmp.iter().map(|cow| cow.as_ref()).collect(); - - let columns = self.width.load(Ordering::Relaxed); - let lines = self.height.load(Ordering::Relaxed); - - let inject_context = InjectContext { - current_index: new_item_index, - delimiter: &self.delimiter, - current_selection: ¤t_selection, - selections: &selected_texts, - indices: &indices, - query, - cmd_query, - }; - - let preview_context = PreviewContext { - query, - cmd_query, - width: columns, - height: lines, - current_index: new_item_index, - current_selection: ¤t_selection, - selected_indices: &indices, - selections: &selected_texts, - }; - - let preview_event = match new_item { - Some(item) => match (item.preview(preview_context), PreviewPosition::default()) { - (ItemPreview::Text(text), pos) => PreviewEvent::PreviewPlainText(text, pos), - (ItemPreview::AnsiText(text), pos) => PreviewEvent::PreviewAnsiText(text, pos), - (ItemPreview::TextWithPos(text, pos), _) => PreviewEvent::PreviewPlainText(text, pos), - (ItemPreview::AnsiWithPos(text, pos), _) => PreviewEvent::PreviewAnsiText(text, pos), - (ItemPreview::Command(cmd), pos) | (ItemPreview::CommandWithPos(cmd, pos), _) => { - if depends_on_items(&cmd) && self.prev_item.is_none() { - debug!("the command for preview refers to items and currently there is no item"); - debug!("command to execute: [{cmd}]"); - PreviewEvent::PreviewPlainText("no item matched".to_string(), Default::default()) - } else { - let cmd = inject_command(&cmd, inject_context).to_string(); - let preview_command = PreviewCommand { cmd, columns, lines }; - PreviewEvent::PreviewCommand(preview_command, pos) - } - } - (ItemPreview::Global, _) => match &self.preview_source { - PreviewSource::Command(cmd) => { - if depends_on_items(cmd) && self.prev_item.is_none() { - debug!("the command for preview refers to items and currently there is no item"); - debug!("command to execute: [{cmd}]"); - PreviewEvent::PreviewPlainText("no item matched".to_string(), Default::default()) - } else { - let cmd = inject_command(cmd, inject_context).to_string(); - let pos = self.eval_scroll_offset(inject_context); - let preview_command = PreviewCommand { cmd, columns, lines }; - PreviewEvent::PreviewCommand(preview_command, pos) - } - } - PreviewSource::Callback(cb) => { - let pos = self.eval_scroll_offset(inject_context); - let cb = cb.clone(); - PreviewEvent::PreviewCallback(Box::new(move || cb(selections)), pos) - } - PreviewSource::Empty => PreviewEvent::Noop, - }, - }, - None => PreviewEvent::Noop, - }; - - let _ = self.tx_preview.send(preview_event); - } - - fn act_scroll_down(&mut self, diff: i32) { - let vscroll_offset = self.vscroll_offset.load(Ordering::SeqCst); - let new_offset = if diff > 0 { - vscroll_offset + diff as usize - } else { - vscroll_offset - min((-diff) as usize, vscroll_offset) - }; - - let new_offset = min(new_offset, max(self.content_lines.lock().len(), 1) - 1); - self.vscroll_offset.store(max(new_offset, 1), Ordering::SeqCst); - } - - fn act_scroll_right(&mut self, diff: i32) { - let hscroll_offset = self.hscroll_offset.load(Ordering::SeqCst); - let new_offset = if diff > 0 { - hscroll_offset + diff as usize - } else { - hscroll_offset - min((-diff) as usize, hscroll_offset) - }; - self.hscroll_offset.store(max(1, new_offset), Ordering::SeqCst); - } - - fn act_toggle_wrap(&mut self) { - self.wrap = !self.wrap; - } - - fn eval_scroll_offset(&self, context: InjectContext) -> PreviewPosition { - // currently, only h_scroll and h_offset is supported - // The syntax follows fzf's - - // +SCROLL[-OFFSET] determines the initial scroll offset of the preview window. - // SCROLL can be either a numeric integer or a single-field index expression - // that refers to a numeric integer. The optional -OFFSET part is for adjusting - // the base offset so that you can see the text above it. It should be given as a - // numeric integer (-INTEGER), or as a denominator form (-/INTEGER) for - // specifying a fraction of the preview window height - - if self.preview_offset.is_empty() { - return Default::default(); - } - - let offset_expr = inject_command(&self.preview_offset, context); - if offset_expr.is_empty() { - return Default::default(); - } - - let nums: Vec<&str> = offset_expr.split('-').collect(); - let v_scroll = if nums.is_empty() { - Size::Default - } else { - Size::Fixed(atoi::(nums[0]).unwrap_or(0)) - }; - - let v_offset = if nums.len() >= 2 { - let expr = nums[1]; - if expr.starts_with('/') { - let num = atoi::(expr).unwrap_or(0); - Size::Percent(if num == 0 { 0 } else { 100 / num }) - } else { - let num = atoi::(expr).unwrap_or(0); - Size::Fixed(num) - } - } else { - Size::Default - }; - - PreviewPosition { - h_scroll: Default::default(), - h_offset: Default::default(), - v_scroll, - v_offset, - } - } -} - -impl Drop for Previewer { - fn drop(&mut self) { - let _ = self.tx_preview.send(PreviewEvent::Abort); - self.thread_previewer.take().map(|handle| handle.join()); - } -} - -impl EventHandler for Previewer { - fn handle(&mut self, event: &Event) -> UpdateScreen { - use crate::event::Event::*; - let height = self.height.load(Ordering::Relaxed); - match event { - EvActTogglePreviewWrap => self.act_toggle_wrap(), - EvActPreviewUp(diff) => self.act_scroll_down(-*diff), - EvActPreviewDown(diff) => self.act_scroll_down(*diff), - EvActPreviewLeft(diff) => self.act_scroll_right(-*diff), - EvActPreviewRight(diff) => self.act_scroll_right(*diff), - EvActPreviewPageUp(diff) => self.act_scroll_down(-(height as i32 * *diff)), - EvActPreviewPageDown(diff) => self.act_scroll_down(height as i32 * *diff), - _ => return UpdateScreen::DontRedraw, - } - UpdateScreen::Redraw - } -} - -impl Draw for Previewer { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - canvas.clear()?; - let (screen_width, screen_height) = canvas.size()?; - clear_canvas(canvas)?; - - if screen_width == 0 || screen_height == 0 { - return Ok(()); - } - - self.width.store(screen_width, Ordering::Relaxed); - self.height.store(screen_height, Ordering::Relaxed); - - let content = self.content_lines.lock(); - - let vscroll_offset = self.vscroll_offset.load(Ordering::SeqCst); - let hscroll_offset = self.hscroll_offset.load(Ordering::SeqCst); - - let mut printer = PrinterBuilder::default() - .width(screen_width) - .height(screen_height) - .skip_rows(max(1, vscroll_offset) - 1) - .skip_cols(max(1, hscroll_offset) - 1) - .wrap(self.wrap) - .build() - .unwrap(); - printer.print_lines(canvas, &content); - - // print the vscroll info - let status = format!("{}/{}", vscroll_offset, content.len()); - let col = max(status.len() + 1, screen_width - status.len() - 1); - canvas.print_with_attr( - 0, - col, - &status, - Attr { - effect: Effect::REVERSE, - ..Attr::default() - }, - )?; - - Ok(()) - } -} - -impl Widget for Previewer { - fn on_event(&self, event: TermEvent, _rect: Rectangle) -> Vec { - let mut ret = vec![]; - match event { - TermEvent::Key(Key::WheelUp(.., count)) => ret.push(Event::EvActPreviewUp(count as i32)), - TermEvent::Key(Key::WheelDown(.., count)) => ret.push(Event::EvActPreviewDown(count as i32)), - _ => {} - } - ret - } -} - -#[derive(Debug, Ord, PartialOrd, PartialEq, Eq)] -pub struct PreviewCommand { - pub cmd: String, - pub lines: usize, - pub columns: usize, -} - -// #[derive(Debug)] -enum PreviewEvent { - PreviewCallback( - Box Vec> + Send + Sync + 'static>, - PreviewPosition, - ), - PreviewCommand(PreviewCommand, PreviewPosition), - PreviewPlainText(String, PreviewPosition), - PreviewAnsiText(String, PreviewPosition), - Noop, - Abort, -} - -struct PreviewThread { - pid: u32, - thread: thread::JoinHandle<()>, - stopped: Arc, -} - -impl PreviewThread { - fn kill(self) { - if !self.stopped.load(Ordering::Relaxed) { - unsafe { libc::kill(self.pid as i32, libc::SIGKILL) }; - } - self.thread.join().expect("Failed to join Preview process"); - } -} - -fn run(rx_preview: Receiver, on_return: C) -where - C: Fn(Vec>, PreviewPosition) + Send + Sync + 'static, -{ - let callback = Arc::new(on_return); - let mut preview_thread: Option = None; - while let Ok(_event) = rx_preview.recv() { - if preview_thread.is_some() { - preview_thread.unwrap().kill(); - preview_thread = None; - } - - let mut event = match _event { - PreviewEvent::Abort => return, - _ => _event, - }; - - // Try to empty the channel. Happens when spamming up/down or typing fast. - while let Ok(_event) = rx_preview.try_recv() { - event = match _event { - PreviewEvent::Abort => return, - _ => _event, - } - } - - match event { - PreviewEvent::PreviewCommand(preview_cmd, pos) => { - let cmd = &preview_cmd.cmd; - if cmd.is_empty() { - continue; - } - - let shell = env::var("SHELL").unwrap_or_else(|_| "sh".to_string()); - let spawned = Command::new(shell) - .env("LINES", preview_cmd.lines.to_string()) - .env("COLUMNS", preview_cmd.columns.to_string()) - .arg("-c") - .arg(cmd) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn(); - - match spawned { - Err(err) => { - let astdout = AnsiString::parse(format!("Failed to spawn: {cmd} / {err}").as_str()); - callback(vec![astdout], pos); - preview_thread = None; - } - Ok(spawned) => { - let pid = spawned.id(); - let stopped = Arc::new(AtomicBool::new(false)); - let stopped_clone = stopped.clone(); - let callback_clone = callback.clone(); - let thread = thread::spawn(move || { - wait(spawned, move |lines| { - stopped_clone.store(true, Ordering::SeqCst); - callback_clone(lines, pos); - }) - }); - preview_thread = Some(PreviewThread { pid, thread, stopped }); - } - } - } - PreviewEvent::PreviewCallback(cb, pos) => callback(cb(), pos), - PreviewEvent::PreviewPlainText(text, pos) => { - callback(text.lines().map(|line| line.to_string().into()).collect(), pos); - } - PreviewEvent::PreviewAnsiText(text, pos) => { - let mut parser = ANSIParser::default(); - let color_lines = text.lines().map(|line| parser.parse_ansi(line)).collect(); - callback(color_lines, pos); - } - PreviewEvent::Noop => {} - PreviewEvent::Abort => return, - }; - } -} - -fn wait(spawned: std::process::Child, callback: C) -where - C: Fn(Vec>), -{ - let output = spawned.wait_with_output(); - - if output.is_err() { - return; - } - - let output = output.unwrap(); - - if output.status.code().is_none() { - // On Unix it means the process is terminated by a signal - // directly return to avoid flickering - return; - } - - // Capture stderr in case users want to debug ... - let out_str = String::from_utf8_lossy(if output.status.success() { - &output.stdout - } else { - &output.stderr - }); - - let lines = out_str.lines().map(AnsiString::parse).collect(); - callback(lines); -} - -#[derive(Builder, Default, Debug)] -#[builder(default)] -struct Printer { - #[builder(setter(skip))] - row: usize, - #[builder(setter(skip))] - col: usize, - skip_rows: usize, - skip_cols: usize, - wrap: bool, - width: usize, - height: usize, -} - -impl Printer { - pub fn print_lines(&mut self, canvas: &mut dyn Canvas, content: &[AnsiString]) { - for (line_no, line) in content.iter().enumerate() { - if line_no < self.skip_rows { - self.move_to_next_line(); - continue; - } else if self.row >= self.skip_rows + self.height { - break; - } - - for (ch, attr) in line.iter() { - let _ = self.print_char_with_attr(canvas, ch, attr); - - // skip if the content already exceeded the canvas - if !self.wrap && self.col >= self.width + self.skip_cols { - break; - } - - if self.row >= self.skip_rows + self.height { - break; - } - } - - self.move_to_next_line(); - } - } - - fn move_to_next_line(&mut self) { - self.row += 1; - self.col = 0; - } - - fn print_char_with_attr(&mut self, canvas: &mut dyn Canvas, ch: char, attr: Attr) -> Result<()> { - match ch { - '\n' | '\r' | '\0' => {} - '\t' => { - // handle tabstop - let rest = TAB_STOP - self.col % TAB_STOP; - let rest = min(rest, max(self.col, self.width) - self.col); - for _ in 0..rest { - self.print_char_raw(canvas, ' ', attr)?; - } - } - - ch => { - self.print_char_raw(canvas, ch, attr)?; - } - } - Ok(()) - } - - fn print_char_raw(&mut self, canvas: &mut dyn Canvas, ch: char, attr: Attr) -> Result<()> { - if self.row < self.skip_rows || self.row >= self.height + self.skip_rows { - return Ok(()); - } - - if self.wrap { - // if wrap is enabled, hscroll is discarded - self.col += self.adjust_scroll_print(canvas, ch, attr)?; - - if self.col >= self.width { - // re-print the wide character - self.move_to_next_line(); - } - - if self.col > self.width { - self.col += self.adjust_scroll_print(canvas, ch, attr)?; - } - } else { - self.col += self.adjust_scroll_print(canvas, ch, attr)?; - } - - Ok(()) - } - - fn adjust_scroll_print(&self, canvas: &mut dyn Canvas, ch: char, attr: Attr) -> Result { - if self.row < self.skip_rows || self.col < self.skip_cols { - canvas.put_char_with_attr(usize::MAX, usize::MAX, ch, attr) - } else { - canvas.put_char_with_attr(self.row - self.skip_rows, self.col - self.skip_cols, ch, attr) - } - } -} diff --git a/skim/src/query.rs b/skim/src/query.rs deleted file mode 100644 index 3696eb1f..00000000 --- a/skim/src/query.rs +++ /dev/null @@ -1,616 +0,0 @@ -use std::mem; -use std::sync::Arc; - -use skim_tuikit::prelude::*; -use unicode_width::UnicodeWidthStr; - -use crate::event::{Event, EventHandler, UpdateScreen}; -use crate::options::SkimOptions; -use crate::theme::{ColorTheme, DEFAULT_THEME}; -use crate::util::{clear_canvas, read_file_lines}; - -#[derive(Clone, Copy, PartialEq)] -enum QueryMode { - Cmd, - Query, -} - -pub struct Query { - cmd_before: Vec, - cmd_after: Vec, - fz_query_before: Vec, - fz_query_after: Vec, - yank: Vec, - - mode: QueryMode, - base_cmd: String, - replstr: String, - query_prompt: String, - cmd_prompt: String, - - cmd_history_before: Vec, - cmd_history_after: Vec, - fz_query_history_before: Vec, - fz_query_history_after: Vec, - - pasted: Option, - - theme: Arc, -} - -#[allow(dead_code)] -impl Query { - pub fn builder() -> Self { - Query { - cmd_before: Vec::new(), - cmd_after: Vec::new(), - fz_query_before: Vec::new(), - fz_query_after: Vec::new(), - yank: Vec::new(), - mode: QueryMode::Query, - base_cmd: String::new(), - replstr: "{}".to_string(), - query_prompt: "> ".to_string(), - cmd_prompt: "c> ".to_string(), - - cmd_history_before: Vec::new(), - cmd_history_after: Vec::new(), - fz_query_history_before: Vec::new(), - fz_query_history_after: Vec::new(), - - pasted: None, - - theme: Arc::new(*DEFAULT_THEME), - } - } - - pub fn from_options(options: &SkimOptions) -> Self { - let mut query = Self::builder(); - query.parse_options(options); - query - } - - pub fn replace_base_cmd_if_not_set(mut self, base_cmd: &str) -> Self { - if self.base_cmd.is_empty() { - self.base_cmd = base_cmd.to_owned(); - } - self - } - - pub fn fz_query(mut self, query: &str) -> Self { - self.fz_query_before = query.chars().collect(); - self - } - - pub fn theme(mut self, theme: Arc) -> Self { - self.theme = theme; - self - } - - pub fn cmd_history(mut self, mut history: Vec) -> Self { - self.cmd_history_before.append(&mut history); - self - } - - pub fn fz_query_history(mut self, mut history: Vec) -> Self { - self.fz_query_history_before.append(&mut history); - self - } - - pub fn build(self) -> Self { - self - } - - fn parse_options(&mut self, options: &SkimOptions) { - // some options accept multiple values, thus take the last one - - if let Some(base_cmd) = &options.cmd { - self.base_cmd = base_cmd.to_string(); - } - - if let Some(query) = &options.query { - self.fz_query_before = query.chars().collect(); - } - - if let Some(cmd_query) = &options.cmd_query { - self.cmd_before = cmd_query.chars().collect(); - } - - if options.interactive { - self.mode = QueryMode::Cmd; - } - - self.query_prompt = options.prompt.clone(); - - self.cmd_prompt = options.cmd_prompt.clone(); - - self.replstr = options.replstr.clone(); - - if let Some(file) = &options.history_file { - self.fz_query_history_before = - read_file_lines(file).unwrap_or_else(|_| panic!("Failed to open history file {file}")); - } - - if let Some(file) = &options.cmd_history_file { - self.cmd_history_before = - read_file_lines(file).unwrap_or_else(|_| panic!("Failed to open command history file {file}")); - } - } - - pub fn in_query_mode(&self) -> bool { - match self.mode { - QueryMode::Cmd => false, - QueryMode::Query => true, - } - } - - pub fn get_fz_query(&self) -> String { - self.fz_query_before - .iter() - .cloned() - .chain(self.fz_query_after.iter().cloned().rev()) - .collect() - } - - pub fn get_cmd(&self) -> String { - let arg: String = self - .cmd_before - .iter() - .cloned() - .chain(self.cmd_after.iter().cloned().rev()) - .collect(); - self.base_cmd.replace(&self.replstr, &arg) - } - - pub fn get_cmd_query(&self) -> String { - self.cmd_before - .iter() - .cloned() - .chain(self.cmd_after.iter().cloned().rev()) - .collect() - } - - fn get_query(&mut self) -> String { - match self.mode { - QueryMode::Query => self.get_fz_query(), - QueryMode::Cmd => self.get_cmd_query(), - } - } - - fn get_before(&self) -> String { - match self.mode { - QueryMode::Cmd => self.cmd_before.iter().cloned().collect(), - QueryMode::Query => self.fz_query_before.iter().cloned().collect(), - } - } - - fn get_after(&self) -> String { - match self.mode { - QueryMode::Cmd => self.cmd_after.iter().cloned().rev().collect(), - QueryMode::Query => self.fz_query_after.iter().cloned().rev().collect(), - } - } - - fn get_prompt(&self) -> &str { - match self.mode { - QueryMode::Cmd => &self.cmd_prompt, - QueryMode::Query => &self.query_prompt, - } - } - - fn get_query_ref(&mut self) -> (&mut Vec, &mut Vec) { - match self.mode { - QueryMode::Query => (&mut self.fz_query_before, &mut self.fz_query_after), - QueryMode::Cmd => (&mut self.cmd_before, &mut self.cmd_after), - } - } - - fn get_history_ref(&mut self) -> (&mut Vec, &mut Vec) { - match self.mode { - QueryMode::Query => (&mut self.fz_query_history_before, &mut self.fz_query_history_after), - QueryMode::Cmd => (&mut self.cmd_history_before, &mut self.cmd_history_after), - } - } - - fn save_yank(&mut self, mut yank: Vec, reverse: bool) { - if yank.is_empty() { - return; - } - - self.yank.clear(); - - if reverse { - self.yank.append(&mut yank.into_iter().rev().collect()); - } else { - self.yank.append(&mut yank); - } - } - - //------------------------------------------------------------------------------ - // Actions - // - pub fn act_query_toggle_interactive(&mut self) { - self.mode = match self.mode { - QueryMode::Query => QueryMode::Cmd, - QueryMode::Cmd => QueryMode::Query, - } - } - - pub fn act_add_char(&mut self, ch: char) { - let (before, _) = self.get_query_ref(); - before.push(ch); - } - - pub fn act_backward_delete_char(&mut self) { - let (before, _) = self.get_query_ref(); - let _ = before.pop(); - } - - // delete char foraward - pub fn act_delete_char(&mut self) { - let (_, after) = self.get_query_ref(); - let _ = after.pop(); - } - - pub fn act_backward_char(&mut self) { - let (before, after) = self.get_query_ref(); - if let Some(ch) = before.pop() { - after.push(ch); - } - } - - pub fn act_forward_char(&mut self) { - let (before, after) = self.get_query_ref(); - if let Some(ch) = after.pop() { - before.push(ch); - } - } - - pub fn act_unix_word_rubout(&mut self) { - let mut yank = Vec::new(); - - { - let (before, _) = self.get_query_ref(); - // kill things other than whitespace - while !before.is_empty() && before[before.len() - 1].is_whitespace() { - yank.push(before.pop().unwrap()); - } - - // kill word until whitespace - while !before.is_empty() && !before[before.len() - 1].is_whitespace() { - yank.push(before.pop().unwrap()); - } - } - - self.save_yank(yank, true); - } - - pub fn act_backward_kill_word(&mut self) { - let mut yank = Vec::new(); - - { - let (before, _) = self.get_query_ref(); - // kill things other than alphanumeric - while !before.is_empty() && !before[before.len() - 1].is_alphanumeric() { - yank.push(before.pop().unwrap()); - } - - // kill word until whitespace (not alphanumeric) - while !before.is_empty() && before[before.len() - 1].is_alphanumeric() { - yank.push(before.pop().unwrap()); - } - } - - self.save_yank(yank, true); - } - - pub fn act_kill_word(&mut self) { - let mut yank = Vec::new(); - - { - let (_, after) = self.get_query_ref(); - - // kill non alphanumeric - while !after.is_empty() && !after[after.len() - 1].is_alphanumeric() { - yank.push(after.pop().unwrap()); - } - // kill alphanumeric - while !after.is_empty() && after[after.len() - 1].is_alphanumeric() { - yank.push(after.pop().unwrap()); - } - } - self.save_yank(yank, false); - } - - pub fn act_backward_word(&mut self) { - let (before, after) = self.get_query_ref(); - // skip whitespace - while !before.is_empty() && !before[before.len() - 1].is_alphanumeric() { - if let Some(ch) = before.pop() { - after.push(ch); - } - } - - // backword char until whitespace - while !before.is_empty() && before[before.len() - 1].is_alphanumeric() { - if let Some(ch) = before.pop() { - after.push(ch); - } - } - } - - pub fn act_forward_word(&mut self) { - let (before, after) = self.get_query_ref(); - // backword char until whitespace - // skip whitespace - while !after.is_empty() && after[after.len() - 1].is_whitespace() { - if let Some(ch) = after.pop() { - before.push(ch); - } - } - - while !after.is_empty() && !after[after.len() - 1].is_whitespace() { - if let Some(ch) = after.pop() { - before.push(ch); - } - } - } - - pub fn act_beginning_of_line(&mut self) { - let (before, after) = self.get_query_ref(); - while !before.is_empty() { - if let Some(ch) = before.pop() { - after.push(ch); - } - } - } - - pub fn act_end_of_line(&mut self) { - let (before, after) = self.get_query_ref(); - while !after.is_empty() { - if let Some(ch) = after.pop() { - before.push(ch); - } - } - } - - pub fn act_kill_line(&mut self) { - let (_, after) = self.get_query_ref(); - let after = std::mem::take(after); - self.save_yank(after, false); - } - - pub fn act_line_discard(&mut self) { - let (before, _) = self.get_query_ref(); - let before = std::mem::take(before); - self.save_yank(before, false); - } - - pub fn act_yank(&mut self) { - let yank = std::mem::take(&mut self.yank); - for &c in &yank { - self.act_add_char(c); - } - let _ = mem::replace(&mut self.yank, yank); - } - - pub fn previous_history(&mut self) { - let current_query = self.get_query(); - let (history_before, history_after) = self.get_history_ref(); - if let Some(history) = history_before.pop() { - history_after.push(current_query); - - // store history into current query - let (query_before, _) = self.get_query_ref(); - query_before.clear(); - let mut new_query_chars = history.chars().collect(); - query_before.append(&mut new_query_chars); - } - } - - pub fn next_history(&mut self) { - let current_query = self.get_query(); - let (history_before, history_after) = self.get_history_ref(); - if let Some(history) = history_after.pop() { - history_before.push(current_query); - - // store history into current query - let (query_before, _) = self.get_query_ref(); - query_before.clear(); - let mut new_query_chars = history.chars().collect(); - query_before.append(&mut new_query_chars); - } - } - - fn query_changed( - &self, - mode: QueryMode, - query_before_len: usize, - query_after_len: usize, - cmd_before_len: usize, - cmd_after_len: usize, - ) -> bool { - self.mode != mode - || self.fz_query_before.len() != query_before_len - || self.fz_query_after.len() != query_after_len - || self.cmd_before.len() != cmd_before_len - || self.cmd_after.len() != cmd_after_len - } -} - -impl EventHandler for Query { - fn handle(&mut self, event: &Event) -> UpdateScreen { - use crate::event::Event::*; - - let mode = self.mode; - let query_before_len = self.fz_query_before.len(); - let query_after_len = self.fz_query_after.len(); - let cmd_before_len = self.cmd_before.len(); - let cmd_after_len = self.cmd_after.len(); - - match event { - EvActAddChar(ch) => match self.pasted.as_mut() { - Some(pasted) => pasted.push(*ch), - None => self.act_add_char(*ch), - }, - - EvActDeleteChar | EvActDeleteCharEOF => { - self.act_delete_char(); - } - - EvActBackwardChar => { - self.act_backward_char(); - } - - EvActBackwardDeleteChar => { - self.act_backward_delete_char(); - } - - EvActBackwardKillWord => { - self.act_backward_kill_word(); - } - - EvActBackwardWord => { - self.act_backward_word(); - } - - EvActBeginningOfLine => { - self.act_beginning_of_line(); - } - - EvActEndOfLine => { - self.act_end_of_line(); - } - - EvActForwardChar => { - self.act_forward_char(); - } - - EvActForwardWord => { - self.act_forward_word(); - } - - EvActKillLine => { - self.act_kill_line(); - } - - EvActKillWord => { - self.act_kill_word(); - } - - EvActPreviousHistory => self.previous_history(), - - EvActNextHistory => { - self.next_history(); - } - - EvActUnixLineDiscard => { - self.act_line_discard(); - } - - EvActUnixWordRubout => { - self.act_unix_word_rubout(); - } - - EvActYank => { - self.act_yank(); - } - - EvActToggleInteractive => { - self.act_query_toggle_interactive(); - } - - EvInputKey(Key::BracketedPasteStart) => { - self.pasted.replace(String::new()); - } - - EvInputKey(Key::BracketedPasteEnd) => { - let pasted = self.pasted.take().unwrap_or_default(); - for ch in pasted.chars() { - self.act_add_char(ch); - } - } - - _ => {} - } - - if self.query_changed(mode, query_before_len, query_after_len, cmd_before_len, cmd_after_len) { - UpdateScreen::Redraw - } else { - UpdateScreen::DontRedraw - } - } -} - -impl Draw for Query { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - canvas.clear()?; - let before = self.get_before(); - let after = self.get_after(); - let prompt = self.get_prompt(); - clear_canvas(canvas)?; - - let prompt_width = canvas.print_with_attr(0, 0, prompt, self.theme.prompt())?; - let before_width = canvas.print_with_attr(0, prompt_width, &before, self.theme.query())?; - let col = prompt_width + before_width; - canvas.print_with_attr(0, col, &after, self.theme.query())?; - canvas.set_cursor(0, col)?; - canvas.show_cursor(true)?; - Ok(()) - } -} - -impl Widget for Query { - fn size_hint(&self) -> (Option, Option) { - let before = self.get_before(); - let after = self.get_after(); - let prompt = self.get_prompt(); - (Some(prompt.width() + before.width() + after.width() + 1), None) - } -} - -#[cfg(test)] -mod test { - use super::Query; - - #[test] - fn test_new_query() { - let query1 = Query::builder().fz_query("").build(); - assert_eq!(query1.get_fz_query(), ""); - - let query2 = Query::builder().fz_query("abc").build(); - assert_eq!(query2.get_fz_query(), "abc"); - } - - #[test] - fn test_add_char() { - let mut query1 = Query::builder().fz_query("").build(); - query1.act_add_char('a'); - assert_eq!(query1.get_fz_query(), "a"); - query1.act_add_char('b'); - assert_eq!(query1.get_fz_query(), "ab"); - query1.act_add_char('中'); - assert_eq!(query1.get_fz_query(), "ab中"); - } - - #[test] - fn test_backward_delete_char() { - let mut query = Query::builder().fz_query("AB中c").build(); - assert_eq!(query.get_fz_query(), "AB中c"); - - query.act_backward_delete_char(); - assert_eq!(query.get_fz_query(), "AB中"); - - query.act_backward_delete_char(); - assert_eq!(query.get_fz_query(), "AB"); - - query.act_backward_delete_char(); - assert_eq!(query.get_fz_query(), "A"); - - query.act_backward_delete_char(); - assert_eq!(query.get_fz_query(), ""); - - query.act_backward_delete_char(); - assert_eq!(query.get_fz_query(), ""); - } -} diff --git a/skim/src/reader.rs b/skim/src/reader.rs index af834ccf..5a176e93 100644 --- a/skim/src/reader.rs +++ b/skim/src/reader.rs @@ -1,19 +1,18 @@ //! Reader is used for reading items from datasource (e.g. stdin or command output) //! //! After reading in a line, reader will save an item into the pool(items) -use crate::global::mark_new_run; +use tokio::select; +use tokio::sync::mpsc::{UnboundedSender, unbounded_channel}; + use crate::options::SkimOptions; use crate::spinlock::SpinLock; use crate::{SkimItem, SkimItemReceiver}; -use crossbeam::channel::{Sender, bounded, select}; use std::cell::RefCell; use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::thread; - -const CHANNEL_SIZE: usize = 1024; +/// Trait for collecting items from command output pub trait CommandCollector { /// execute the `cmd` and produce a /// - skim item producer @@ -22,17 +21,19 @@ pub trait CommandCollector { /// Internally, the command collector may start several threads(components), the collector /// should add `1` on every thread creation and sub `1` on thread termination. reader would use /// this information to determine whether the collector had stopped or not. - fn invoke(&mut self, cmd: &str, components_to_stop: Arc) -> (SkimItemReceiver, Sender); + fn invoke(&mut self, cmd: &str, components_to_stop: Arc) -> (SkimItemReceiver, UnboundedSender); } +/// Handle for controlling a running reader pub struct ReaderControl { - tx_interrupt: Sender, - tx_interrupt_cmd: Option>, + tx_interrupt: UnboundedSender, + tx_interrupt_cmd: Option>, components_to_stop: Arc, items: Arc>>>, } impl ReaderControl { + /// Kills the reader and waits for all components to stop pub fn kill(self) { debug!( "kill reader, components before: {}", @@ -44,6 +45,7 @@ impl ReaderControl { while self.components_to_stop.load(Ordering::SeqCst) != 0 {} } + /// Takes all items collected so far pub fn take(&self) -> Vec> { let mut items = self.items.lock(); let mut ret = Vec::with_capacity(items.len()); @@ -51,36 +53,38 @@ impl ReaderControl { ret } + /// Returns true if the reader has finished and no items remain pub fn is_done(&self) -> bool { let items = self.items.lock(); self.components_to_stop.load(Ordering::SeqCst) == 0 && items.is_empty() } } +/// Reader for streaming items from commands or other sources pub struct Reader { cmd_collector: Rc>, rx_item: Option, } impl Reader { - pub fn with_options(options: &SkimOptions) -> Self { + /// Creates a new reader from skim options + pub fn from_options(options: &SkimOptions) -> Self { Self { cmd_collector: options.cmd_collector.clone(), rx_item: None, } } + /// Sets the item source (if None, will use command collector) pub fn source(mut self, rx_item: Option) -> Self { self.rx_item = rx_item; self } - pub fn run(&mut self, cmd: &str) -> ReaderControl { - mark_new_run(cmd); - + /// Starts the reader and returns a control handle + pub fn run(&mut self, app_tx: UnboundedSender>, cmd: &str) -> ReaderControl { let components_to_stop: Arc = Arc::new(AtomicUsize::new(0)); let items = Arc::new(SpinLock::new(Vec::new())); - let items_clone = items.clone(); let (rx_item, tx_interrupt_cmd) = self.rx_item.take().map(|rx| (rx, None)).unwrap_or_else(|| { let components_to_stop_clone = components_to_stop.clone(); @@ -89,7 +93,7 @@ impl Reader { }); let components_to_stop_clone = components_to_stop.clone(); - let tx_interrupt = collect_item(components_to_stop_clone, rx_item, items_clone); + let tx_interrupt = collect_item(components_to_stop_clone, rx_item, app_tx); ReaderControl { tx_interrupt, @@ -102,28 +106,29 @@ impl Reader { fn collect_item( components_to_stop: Arc, - rx_item: SkimItemReceiver, - items: Arc>>>, -) -> Sender { - let (tx_interrupt, rx_interrupt) = bounded(CHANNEL_SIZE); + mut rx_item: SkimItemReceiver, + app_tx: UnboundedSender>, +) -> UnboundedSender { + let (tx_interrupt, mut rx_interrupt) = unbounded_channel(); let started = Arc::new(AtomicBool::new(false)); let started_clone = started.clone(); - thread::spawn(move || { + tokio::spawn(async move { debug!("reader: collect_item start"); components_to_stop.fetch_add(1, Ordering::SeqCst); started_clone.store(true, Ordering::SeqCst); // notify parent that it is started loop { select! { - recv(rx_item) -> new_item => match new_item { - Ok(item) => { - let mut vec = items.lock(); - vec.push(item); - } - Err(_) => break, - }, - recv(rx_interrupt) -> _msg => break, + new_item = rx_item.recv() => { + match new_item { + Some(item) => { + let _ = app_tx.send(item); + } + None => break, + } + } + _ = rx_interrupt.recv() => break, } } diff --git a/skim/src/selection.rs b/skim/src/selection.rs deleted file mode 100644 index ececa1bf..00000000 --- a/skim/src/selection.rs +++ /dev/null @@ -1,617 +0,0 @@ -//! Handle the selections of items -use std::cmp::max; -use std::cmp::min; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use skim_tuikit::prelude::{Event as TermEvent, *}; - -use crate::event::{Event, EventHandler, UpdateScreen}; -use crate::global::current_run_num; -use crate::item::MatchedItem; -use crate::orderedvec::OrderedVec; -use crate::prelude::DefaultSkimSelector; -use crate::theme::{ColorTheme, DEFAULT_THEME}; -use crate::util::clear_canvas; -use crate::util::read_file_lines; -use crate::util::{LinePrinter, print_item, reshape_string}; -use crate::{DisplayContext, MatchRange, Matches, Selector, SkimItem, SkimOptions}; -use indexmap::IndexMap; -use regex::Regex; -use std::rc::Rc; -use unicode_width::UnicodeWidthStr; - -type ItemIndex = (u32, u32); - -pub struct Selection { - // all items - items: OrderedVec, - selected: IndexMap>, - - // - // |>------ items[items.len()-1] - // | - // +======+ screen end - // | - // |>------ line_cursor, position from screen start - // | - // +======+ item_cursor, screen start - // | - // |>------ item[0] - // - // the index of matched item currently highlighted. - item_cursor: usize, - // line No. - line_cursor: usize, - hscroll_offset: i64, - keep_right: bool, - skip_to_pattern: Option, - height: AtomicUsize, - tabstop: usize, - - // Options - multi_selection: bool, - reverse: bool, - no_hscroll: bool, - theme: Arc, - - // Pre-selection will be performed the first time an item was seen by Selection. - // To avoid remember all items, we'll track the latest run_num and index. - latest_select_run_num: u32, - pre_selected_watermark: usize, - selector: Option>, -} - -impl Selection { - pub fn new() -> Self { - Selection { - items: OrderedVec::new(), - selected: IndexMap::new(), - item_cursor: 0, - line_cursor: 0, - hscroll_offset: 0, - keep_right: false, - skip_to_pattern: None, - height: AtomicUsize::new(0), - tabstop: 8, - multi_selection: false, - reverse: false, - no_hscroll: false, - theme: Arc::new(*DEFAULT_THEME), - latest_select_run_num: 0, - pre_selected_watermark: 0, - selector: None, - } - } - - pub fn with_options(options: &SkimOptions) -> Self { - let mut selection = Self::new(); - selection.parse_options(options); - selection - } - - fn parse_options(&mut self, options: &SkimOptions) { - if options.multi { - self.multi_selection = true; - } - - if options.layout.starts_with("reverse") { - self.reverse = true; - } - - if options.no_hscroll { - self.no_hscroll = true; - } - - self.tabstop = max(1, options.tabstop); - - if options.tac { - self.items.tac(true); - } - - if options.no_sort { - self.items.nosort(true); - } - - if let Some(skip_to_pattern) = options.skip_to_pattern.clone() { - self.skip_to_pattern = Regex::new(&skip_to_pattern).ok(); - } - - self.keep_right = options.keep_right; - let pre_select_items: Vec = options - .pre_select_items - .clone() - .split('\n') - .map(|item| item.to_string()) - .collect(); - - if options.pre_select_n > 0 - || !options.pre_select_pat.is_empty() - || !pre_select_items.is_empty() - || options.pre_select_file.is_some() - || options.selector.is_some() - { - match options.selector.clone() { - None => { - let mut preset_file_items: Vec = vec![]; - if let Some(pre_select_file) = options.pre_select_file.clone() { - preset_file_items = read_file_lines(&pre_select_file).unwrap(); - } - - let selector = DefaultSkimSelector::default() - .first_n(options.pre_select_n) - .regex(&options.pre_select_pat) - .preset(pre_select_items) - .preset(preset_file_items); - self.selector = Some(Rc::new(selector)); - } - Some(s) => { - self.selector = Some(s); - } - } - } - } - - pub fn theme(mut self, theme: Arc) -> Self { - self.theme = theme; - self - } - - pub fn append_sorted_items(&mut self, items: Vec) { - debug!("append_sorted_items: num: {}", items.len()); - let current_run_num = current_run_num(); - if !items.is_empty() && current_run_num > self.latest_select_run_num { - self.latest_select_run_num = current_run_num; - self.pre_selected_watermark = 0; - } - - if self.items.len() >= self.pre_selected_watermark { - self.pre_select(&items); - } - - self.items.append(items); - self.pre_selected_watermark = max(self.pre_selected_watermark, self.items.len()); - - let height = self.height.load(Ordering::Relaxed); - if self.items.len() <= self.line_cursor { - // if not enough items, move cursor down - self.line_cursor = max(min(self.items.len(), height), 1) - 1; - } - - if self.items.len() <= self.line_cursor + self.item_cursor { - // if not enough items, scroll the cursor a page down - self.item_cursor = max(self.items.len(), height) - height; - } - } - - pub fn clear(&mut self) { - self.items.clear(); - } - - fn pre_select(&mut self, items: &[MatchedItem]) { - debug!("perform pre selection for {} items", items.len()); - if self.selector.is_none() || !self.multi_selection { - return; - } - - let current_run_num = current_run_num(); - for item in items { - if self - .selector - .as_ref() - .map(|s| s.should_select(item.item_idx as usize, item.item.as_ref())) - .unwrap_or(false) - { - self.act_select_raw_item(current_run_num, item.item_idx, item.item.clone()); - } - } - debug!("done perform pre selection for {} items", items.len()); - } - - // > 0 means move up, < 0 means move down - pub fn act_move_line_cursor(&mut self, diff: i32) { - let diff = if self.reverse { -diff } else { diff }; - - let mut line_cursor = self.line_cursor as i32; - let mut item_cursor = self.item_cursor as i32; - let item_len = self.items.len() as i32; - - let height = self.height.load(Ordering::Relaxed) as i32; - - line_cursor += diff; - if line_cursor >= height { - item_cursor += line_cursor - height + 1; - item_cursor = max(0, min(item_cursor, item_len - height)); - line_cursor = min(height - 1, item_len - item_cursor - 1); - } else if line_cursor < 0 { - item_cursor += line_cursor; - item_cursor = max(item_cursor, 0); - line_cursor = 0; - } else { - line_cursor = min(line_cursor, item_len - 1 - item_cursor); - } - - line_cursor = max(0, line_cursor); - - self.item_cursor = item_cursor as usize; - self.line_cursor = line_cursor as usize; - } - - pub fn act_select_screen_row(&mut self, rows_to_top: usize) { - let height = self.height.load(Ordering::Relaxed); - let diff = if self.reverse { - self.line_cursor as i32 - rows_to_top as i32 - } else { - height as i32 - rows_to_top as i32 - 1 - self.line_cursor as i32 - }; - self.act_move_line_cursor(diff); - } - - #[allow(clippy::map_entry)] - pub fn act_toggle(&mut self) { - if !self.multi_selection || self.items.is_empty() { - return; - } - - let cursor = self.item_cursor + self.line_cursor; - let current_item = self - .items - .get(cursor) - .unwrap_or_else(|| panic!("model:act_toggle: failed to get item {cursor}")); - trace!( - "Toggling item {} with idx {}", - current_item.item.text(), - current_item.item_idx - ); - let index = (current_run_num(), current_item.item_idx); - if !self.selected.contains_key(&index) { - self.selected.insert(index, current_item.item.clone()); - } else { - self.selected.shift_remove(&index); - } - } - - #[allow(clippy::map_entry)] - pub fn act_toggle_all(&mut self) { - if !self.multi_selection || self.items.is_empty() { - return; - } - - let run_num = current_run_num(); - for current_item in self.items.iter() { - let index = (run_num, current_item.item_idx); - if !self.selected.contains_key(&index) { - self.selected.insert(index, current_item.item.clone()); - } else { - self.selected.shift_remove(&index); - } - } - } - - pub fn act_select_matched(&mut self, run_num: u32, matched: MatchedItem) { - self.act_select_raw_item(run_num, matched.item_idx, matched.item.clone()); - } - - pub fn act_select_raw_item(&mut self, run_num: u32, item_index: u32, item: Arc) { - if !self.multi_selection { - return; - } - self.selected.insert((run_num, item_index), item); - } - - pub fn act_select_all(&mut self) { - if !self.multi_selection || self.items.is_empty() { - return; - } - - let run_num = current_run_num(); - for current_item in self.items.iter() { - let item = current_item.item.clone(); - self.selected.insert((run_num, current_item.item_idx), item); - } - } - - pub fn act_deselect_all(&mut self) { - self.selected.clear(); - } - - pub fn act_scroll(&mut self, offset: i32) { - self.hscroll_offset += offset as i64; - } - - pub fn get_selected_indices_and_items(&self) -> (Vec, Vec>) { - // select the current one - let select_cursor = !self.multi_selection || self.selected.is_empty(); - let mut selected: Vec> = self.selected.values().cloned().collect(); - let mut item_indices: Vec = self.selected.keys().map(|(_run, idx)| *idx as usize).collect(); - - if select_cursor && !self.items.is_empty() { - let cursor = self.item_cursor + self.line_cursor; - let current_item = self - .items - .get(cursor) - .unwrap_or_else(|| panic!("model:act_output: failed to get item {cursor}")); - let item = current_item.item.clone(); - item_indices.push(cursor); - selected.push(item); - } - - (item_indices, selected) - } - - pub fn get_num_of_selected_exclude_current(&self) -> usize { - self.selected.len() - } - - pub fn get_current_item_idx(&self) -> usize { - self.item_cursor + self.line_cursor - } - - pub fn get_num_selected(&self) -> usize { - self.selected.len() - } - - pub fn is_multi_selection(&self) -> bool { - self.multi_selection - } - - pub fn get_current_item(&self) -> Option> { - let item_idx = self.get_current_item_idx(); - self.items.get(item_idx).map(|item| item.item.clone()) - } - - pub fn get_hscroll_offset(&self) -> i64 { - self.hscroll_offset - } - - pub fn get_num_options(&self) -> usize { - self.items.len() - } - - fn calc_skip_width(&self, text: &str) -> usize { - let skip = if self.skip_to_pattern.is_none() { - 0 - } else { - let regex = self.skip_to_pattern.as_ref().unwrap(); - if let Some(mat) = regex.find(text) { - text[..mat.start()].width_cjk() - } else { - 0 - } - }; - max(2, skip) - 2 - } -} - -impl EventHandler for Selection { - fn handle(&mut self, event: &Event) -> UpdateScreen { - use crate::event::Event::*; - match event { - EvActUp(diff) => { - self.act_move_line_cursor(*diff); - } - EvActDown(diff) => { - self.act_move_line_cursor(-*diff); - } - EvActToggle => { - self.act_toggle(); - } - EvActToggleAll => { - self.act_toggle_all(); - } - EvActSelectAll => { - self.act_select_all(); - } - EvActDeselectAll => { - self.act_deselect_all(); - } - EvActHalfPageDown(diff) => { - let height = 1 - (self.height.load(Ordering::Relaxed) as i32); - self.act_move_line_cursor(height * *diff / 2); - } - EvActHalfPageUp(diff) => { - let height = (self.height.load(Ordering::Relaxed) as i32) - 1; - self.act_move_line_cursor(height * *diff / 2); - } - EvActPageDown(diff) => { - let height = 1 - (self.height.load(Ordering::Relaxed) as i32); - self.act_move_line_cursor(height * *diff); - } - EvActPageUp(diff) => { - let height = (self.height.load(Ordering::Relaxed) as i32) - 1; - self.act_move_line_cursor(height * *diff); - } - EvActSelectRow(row) => { - self.act_select_screen_row(*row); - } - EvActScrollLeft(diff) => { - self.act_scroll(-*diff); - } - EvActScrollRight(diff) => { - self.act_scroll(*diff); - } - _ => return UpdateScreen::DontRedraw, - } - UpdateScreen::Redraw - } -} - -impl Selection { - fn draw_item( - &self, - canvas: &mut dyn Canvas, - row: usize, - matched_item: &MatchedItem, - is_current: bool, - ) -> DrawResult<()> { - let (screen_width, screen_height) = canvas.size()?; - - // update item heights - self.height.store(screen_height, Ordering::Relaxed); - - if screen_width < 3 { - return Err("screen width is too small".into()); - } - - let default_attr = if is_current { - self.theme.current() - } else { - self.theme.normal() - }; - - let matched_attr = if is_current { - self.theme.current_match() - } else { - self.theme.matched() - }; - - // print selection cursor - let index = (current_run_num(), matched_item.item_idx); - if self.selected.contains_key(&index) { - let _ = canvas.print_with_attr(row, 1, ">", default_attr.extend(self.theme.selected())); - } else { - let _ = canvas.print_with_attr(row, 1, " ", default_attr); - } - - let item = &matched_item.item; - let item_text = item.text(); - let container_width = screen_width - 2; - - let matches = match matched_item.matched_range { - Some(MatchRange::Chars(ref matched_indices)) => Matches::CharIndices(matched_indices), - Some(MatchRange::ByteRange(start, end)) => Matches::ByteRange(start, end), - _ => Matches::None, - }; - - let context = DisplayContext { - text: &item_text, - score: 0, - matches, - container_width, - highlight_attr: matched_attr, - }; - - let display_content = item.display(context); - - let mut printer = if display_content.stripped() == item_text { - // need to display the match content - let (match_start_char, match_end_char) = match matched_item.matched_range { - Some(MatchRange::Chars(ref matched_indices)) => { - if !matched_indices.is_empty() { - (matched_indices[0], matched_indices[matched_indices.len() - 1] + 1) - } else { - (0, 0) - } - } - Some(MatchRange::ByteRange(match_start, match_end)) => { - let match_start_char = item_text[..match_start].chars().count(); - let diff = item_text[match_start..match_end].chars().count(); - (match_start_char, match_start_char + diff) - } - None => (0, 0), - }; - - let (shift, full_width) = reshape_string( - &item_text, - container_width, - match_start_char, - match_end_char, - self.tabstop, - ); - - let shift = if self.no_hscroll { - 0 - } else if match_start_char == 0 && match_end_char == 0 { - // no match - if self.keep_right { - max(full_width, container_width) - container_width - } else { - self.calc_skip_width(&item_text) - } - } else { - shift - }; - - LinePrinter::builder() - .row(row) - .col(2) - .tabstop(self.tabstop) - .container_width(container_width) - .shift(shift) - .text_width(full_width) - .hscroll_offset(self.hscroll_offset) - .build() - } else { - LinePrinter::builder() - .row(row) - .col(2) - .tabstop(self.tabstop) - .container_width(container_width) - .text_width(display_content.stripped().width_cjk()) - .hscroll_offset(self.hscroll_offset) - .build() - }; - - // print out the original content - print_item(canvas, &mut printer, display_content, default_attr); - - Ok(()) - } -} - -impl Draw for Selection { - fn draw(&self, canvas: &mut dyn Canvas) -> DrawResult<()> { - let (_screen_width, screen_height) = canvas.size()?; - canvas.clear()?; - - let item_idx_lower = self.item_cursor; - let max_upper = self.item_cursor + screen_height; - let item_idx_upper = min(max_upper, self.items.len()); - - clear_canvas(canvas)?; - - for item_idx in item_idx_lower..item_idx_upper { - let line_cursor = item_idx - item_idx_lower; - let line_no = if self.reverse { - // top down - line_cursor - } else { - // bottom up - screen_height - 1 - line_cursor - }; - - // print the cursor label - let label = if line_cursor == self.line_cursor { ">" } else { " " }; - let _next_col = canvas.print_with_attr(line_no, 0, label, self.theme.cursor()).unwrap(); - - let item = self - .items - .get(item_idx) - .unwrap_or_else(|| panic!("model:draw_items: failed to get item at {item_idx}")); - - let _ = self.draw_item(canvas, line_no, &item, line_cursor == self.line_cursor); - } - - Ok(()) - } -} - -impl Widget for Selection { - fn on_event(&self, event: TermEvent, _rect: Rectangle) -> Vec { - let mut ret = vec![]; - match event { - TermEvent::Key(Key::WheelUp(.., count)) => ret.push(Event::EvActUp(count as i32)), - TermEvent::Key(Key::WheelDown(.., count)) => ret.push(Event::EvActDown(count as i32)), - TermEvent::Key(Key::SingleClick(MouseButton::Left, row, _)) => { - ret.push(Event::EvActSelectRow(row as usize)) - } - TermEvent::Key(Key::DoubleClick(MouseButton::Left, ..)) => ret.push(Event::EvActAccept(None)), - TermEvent::Key(Key::SingleClick(MouseButton::Right, row, _)) => { - ret.push(Event::EvActSelectRow(row as usize)); - ret.push(Event::EvActToggle); - } - _ => {} - } - ret - } -} diff --git a/skim/src/skim_item.rs b/skim/src/skim_item.rs new file mode 100644 index 00000000..d8ca54e4 --- /dev/null +++ b/skim/src/skim_item.rs @@ -0,0 +1,95 @@ +use ratatui::text::Line; +use std::borrow::Cow; + +use crate::{AsAny, DisplayContext, ItemPreview, PreviewContext}; + +/// A `SkimItem` defines what's been processed(fetched, matched, previewed and returned) by skim +/// +/// # Downcast Example +/// Skim will return the item back, but in `Arc` form. We might want a reference +/// to the concrete type instead of trait object. Skim provide a somehow "complicated" way to +/// `downcast` it back to the reference of the original concrete type. +/// +/// ```rust +/// use skim::prelude::*; +/// +/// struct MyItem {} +/// impl SkimItem for MyItem { +/// fn text(&self) -> Cow { +/// unimplemented!() +/// } +/// } +/// +/// impl MyItem { +/// pub fn mutable(&mut self) -> i32 { +/// 1 +/// } +/// +/// pub fn immutable(&self) -> i32 { +/// 0 +/// } +/// } +/// +/// let mut ret: Arc = Arc::new(MyItem{}); +/// let mutable: &mut MyItem = Arc::get_mut(&mut ret) +/// .expect("item is referenced by others") +/// .as_any_mut() // cast to Any +/// .downcast_mut::() // downcast to (mut) concrete type +/// .expect("something wrong with downcast"); +/// assert_eq!(mutable.mutable(), 1); +/// +/// let immutable: &MyItem = (*ret).as_any() // cast to Any +/// .downcast_ref::() // downcast to concrete type +/// .expect("something wrong with downcast"); +/// assert_eq!(immutable.immutable(), 0) +/// ``` +pub trait SkimItem: AsAny + Send + Sync + 'static { + /// The string to be used for matching (without color) + fn text(&self) -> Cow<'_, str>; + + /// The content to be displayed on the item list, could contain ANSI properties + fn display<'a>(&'a self, context: DisplayContext) -> Line<'a> { + context.to_line(self.text()) + } + + /// Custom preview content, default to `ItemPreview::Global` which will use global preview + /// setting(i.e. the command set by `preview` option) + fn preview(&self, _context: PreviewContext) -> ItemPreview { + ItemPreview::Global + } + + /// Get output text(after accept), default to `text()` + /// + /// Note that this function is intended to be used by the caller of skim and will not be used by + /// skim. And since skim will return the item back in `SkimOutput`, if string is not what you + /// want, you could still use `downcast` to retain the pointer to the original struct. + fn output(&self) -> Cow<'_, str> { + self.text() + } + + /// Limit the matching ranges of the `get_text` of the item. + /// providing (`start_byte`, `end_byte`) of the range + fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> { + None + } + + /// Get index, for matching purposes + /// + /// Implemented as no-op for retro-compatibility purposes + fn get_index(&self) -> usize { + 0 + } + /// Set index, for matching purposes + /// + /// Implemented as no-op for retro-compatibility purposes + fn set_index(&mut self, _index: usize) {} +} + +//------------------------------------------------------------------------------ +// Implement SkimItem for raw strings + +impl + Send + Sync + 'static> SkimItem for T { + fn text(&self) -> Cow<'_, str> { + Cow::Borrowed(self.as_ref()) + } +} diff --git a/skim-common/src/spinlock.rs b/skim/src/spinlock.rs similarity index 90% rename from skim-common/src/spinlock.rs rename to skim/src/spinlock.rs index 3778a1b0..a00c4737 100644 --- a/skim-common/src/spinlock.rs +++ b/skim/src/spinlock.rs @@ -10,6 +10,8 @@ use std::ops::DerefMut; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; +/// A spin lock that uses busy-waiting instead of OS blocking +#[derive(Default)] pub struct SpinLock { locked: AtomicBool, data: UnsafeCell, @@ -18,6 +20,7 @@ pub struct SpinLock { unsafe impl Send for SpinLock {} unsafe impl Sync for SpinLock {} +/// RAII guard for a spin lock, automatically releases the lock when dropped pub struct SpinLockGuard<'a, T: ?Sized + 'a> { // funny underscores due to how Deref/DerefMut currently work (they // disregard field privacy). @@ -25,6 +28,7 @@ pub struct SpinLockGuard<'a, T: ?Sized + 'a> { } impl<'a, T: ?Sized + 'a> SpinLockGuard<'a, T> { + /// Creates a new guard for the given lock pub fn new(pool: &'a SpinLock) -> SpinLockGuard<'a, T> { Self { __lock: pool } } @@ -33,6 +37,7 @@ impl<'a, T: ?Sized + 'a> SpinLockGuard<'a, T> { unsafe impl Sync for SpinLockGuard<'_, T> {} impl SpinLock { + /// Creates a new unlocked spin lock containing the given value pub fn new(t: T) -> SpinLock { Self { locked: AtomicBool::new(false), @@ -42,6 +47,7 @@ impl SpinLock { } impl SpinLock { + /// Acquires the lock, blocking the current thread until it succeeds pub fn lock(&self) -> SpinLockGuard<'_, T> { while self .locked diff --git a/skim/src/theme.rs b/skim/src/theme.rs index 453ce019..0935432d 100644 --- a/skim/src/theme.rs +++ b/skim/src/theme.rs @@ -1,8 +1,9 @@ //! Handle the color theme -use std::{env, sync::LazyLock}; +use std::sync::LazyLock; + +use ratatui::style::{Color, Modifier, Style}; use crate::options::SkimOptions; -use skim_tuikit::prelude::*; pub static DEFAULT_THEME: LazyLock = LazyLock::new(ColorTheme::dark256); @@ -18,23 +19,23 @@ pub static DEFAULT_THEME: LazyLock = LazyLock::new(ColorTheme::dark2 /// +----------------+ /// #[rustfmt::skip] -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, Default)] pub struct ColorTheme { fg: Color, bg: Color, - normal_effect: Effect, + normal_effect: Modifier, matched: Color, matched_bg: Color, - matched_effect: Effect, + matched_effect: Modifier, current: Color, current_bg: Color, - current_effect: Effect, + current_effect: Modifier, current_match: Color, current_match_bg: Color, - current_match_effect: Effect, + current_match_effect: Modifier, query_fg: Color, query_bg: Color, - query_effect: Effect, + query_effect: Modifier, spinner: Color, info: Color, prompt: Color, @@ -52,7 +53,8 @@ impl ColorTheme { if let Some(color) = options.color.clone() { ColorTheme::from_options(&color) } else { - match env::var_os("NO_COLOR") { + // Check for NO_COLOR environment variable + match std::env::var_os("NO_COLOR") { Some(no_color) if !no_color.is_empty() => ColorTheme::none(), _ => ColorTheme::dark256(), } @@ -61,112 +63,112 @@ impl ColorTheme { fn none() -> Self { ColorTheme { - fg: Color::Default, - bg: Color::Default, - normal_effect: Effect::empty(), - matched: Color::Default, - matched_bg: Color::Default, - matched_effect: Effect::empty(), - current: Color::Default, - current_bg: Color::Default, - current_effect: Effect::empty(), - current_match: Color::Default, - current_match_bg: Color::Default, - current_match_effect: Effect::empty(), - query_fg: Color::Default, - query_bg: Color::Default, - query_effect: Effect::empty(), - spinner: Color::Default, - info: Color::Default, - prompt: Color::Default, - cursor: Color::Default, - selected: Color::Default, - header: Color::Default, - border: Color::Default, + fg: Color::Reset, + bg: Color::Reset, + normal_effect: Modifier::empty(), + matched: Color::Reset, + matched_bg: Color::Reset, + matched_effect: Modifier::empty(), + current: Color::Reset, + current_bg: Color::Reset, + current_effect: Modifier::empty(), + current_match: Color::Reset, + current_match_bg: Color::Reset, + current_match_effect: Modifier::empty(), + query_fg: Color::Reset, + query_bg: Color::Reset, + query_effect: Modifier::empty(), + spinner: Color::Reset, + info: Color::Reset, + prompt: Color::Reset, + cursor: Color::Reset, + selected: Color::Reset, + header: Color::Reset, + border: Color::Reset, } } fn bw() -> Self { ColorTheme { - matched_effect: Effect::UNDERLINE, - current_effect: Effect::REVERSE, - current_match_effect: Effect::UNDERLINE | Effect::REVERSE, + matched_effect: Modifier::UNDERLINED, + current_effect: Modifier::REVERSED, + current_match_effect: Modifier::UNDERLINED | Modifier::REVERSED, ..ColorTheme::none() } } fn default16() -> Self { ColorTheme { - matched: Color::GREEN, - matched_bg: Color::BLACK, - current: Color::YELLOW, - current_bg: Color::BLACK, - current_match: Color::GREEN, - current_match_bg: Color::BLACK, - spinner: Color::GREEN, - info: Color::WHITE, - prompt: Color::BLUE, - cursor: Color::RED, - selected: Color::MAGENTA, - header: Color::CYAN, - border: Color::LIGHT_BLACK, + matched: Color::Green, + matched_bg: Color::Black, + current: Color::Yellow, + current_bg: Color::Black, + current_match: Color::Green, + current_match_bg: Color::Black, + spinner: Color::Green, + info: Color::White, + prompt: Color::Blue, + cursor: Color::Red, + selected: Color::Magenta, + header: Color::Cyan, + border: Color::Black, ..ColorTheme::none() } } fn dark256() -> Self { ColorTheme { - matched: Color::AnsiValue(108), - matched_bg: Color::AnsiValue(0), - current: Color::AnsiValue(254), - current_bg: Color::AnsiValue(236), - current_match: Color::AnsiValue(151), - current_match_bg: Color::AnsiValue(236), - spinner: Color::AnsiValue(148), - info: Color::AnsiValue(144), - prompt: Color::AnsiValue(110), - cursor: Color::AnsiValue(161), - selected: Color::AnsiValue(168), - header: Color::AnsiValue(109), - border: Color::AnsiValue(59), + matched: Color::Indexed(108), + matched_bg: Color::Indexed(0), + current: Color::Indexed(254), + current_bg: Color::Indexed(236), + current_match: Color::Indexed(151), + current_match_bg: Color::Indexed(236), + spinner: Color::Indexed(148), + info: Color::Indexed(144), + prompt: Color::Indexed(110), + cursor: Color::Indexed(161), + selected: Color::Indexed(168), + header: Color::Indexed(109), + border: Color::Indexed(59), ..ColorTheme::none() } } fn molokai256() -> Self { ColorTheme { - matched: Color::AnsiValue(234), - matched_bg: Color::AnsiValue(186), - current: Color::AnsiValue(254), - current_bg: Color::AnsiValue(236), - current_match: Color::AnsiValue(234), - current_match_bg: Color::AnsiValue(186), - spinner: Color::AnsiValue(148), - info: Color::AnsiValue(144), - prompt: Color::AnsiValue(110), - cursor: Color::AnsiValue(161), - selected: Color::AnsiValue(168), - header: Color::AnsiValue(109), - border: Color::AnsiValue(59), + matched: Color::Indexed(234), + matched_bg: Color::Indexed(186), + current: Color::Indexed(254), + current_bg: Color::Indexed(236), + current_match: Color::Indexed(234), + current_match_bg: Color::Indexed(186), + spinner: Color::Indexed(148), + info: Color::Indexed(144), + prompt: Color::Indexed(110), + cursor: Color::Indexed(161), + selected: Color::Indexed(168), + header: Color::Indexed(109), + border: Color::Indexed(59), ..ColorTheme::none() } } fn light256() -> Self { ColorTheme { - matched: Color::AnsiValue(0), - matched_bg: Color::AnsiValue(220), - current: Color::AnsiValue(237), - current_bg: Color::AnsiValue(251), - current_match: Color::AnsiValue(66), - current_match_bg: Color::AnsiValue(251), - spinner: Color::AnsiValue(65), - info: Color::AnsiValue(101), - prompt: Color::AnsiValue(25), - cursor: Color::AnsiValue(161), - selected: Color::AnsiValue(168), - header: Color::AnsiValue(31), - border: Color::AnsiValue(145), + matched: Color::Indexed(0), + matched_bg: Color::Indexed(220), + current: Color::Indexed(237), + current_bg: Color::Indexed(251), + current_match: Color::Indexed(66), + current_match_bg: Color::Indexed(251), + spinner: Color::Indexed(65), + info: Color::Indexed(101), + prompt: Color::Indexed(25), + cursor: Color::Indexed(161), + selected: Color::Indexed(168), + header: Color::Indexed(31), + border: Color::Indexed(145), ..ColorTheme::none() } } @@ -182,7 +184,7 @@ impl ColorTheme { "light" => ColorTheme::light256(), "16" => ColorTheme::default16(), "bw" => ColorTheme::bw(), - "none" | "empty" => ColorTheme::none(), + "none" | "empty" => ColorTheme::none(), "dark" | "default" | _ => ColorTheme::dark256(), }; continue; @@ -196,8 +198,8 @@ impl ColorTheme { Color::Rgb(r, g, b) } else { color[1].parse::() - .map(Color::AnsiValue) - .unwrap_or(Color::Default) + .map(Color::Indexed) + .unwrap_or(Color::Reset) }; match color[0] { @@ -224,99 +226,87 @@ impl ColorTheme { theme } - pub fn normal(&self) -> Attr { - Attr { - fg: self.fg, - bg: self.bg, - effect: self.normal_effect, - } + pub fn normal(&self) -> Style { + Style::new() + .fg(self.fg) + .bg(self.bg) + .add_modifier(self.normal_effect) } - pub fn matched(&self) -> Attr { - Attr { - fg: self.matched, - bg: self.matched_bg, - effect: self.matched_effect, - } + pub fn matched(&self) -> Style { + Style::new() + .fg(self.matched) + .bg(self.matched_bg) + .add_modifier(self.matched_effect) } - pub fn current(&self) -> Attr { - Attr { - fg: self.current, - bg: self.current_bg, - effect: self.current_effect, - } + pub fn current(&self) -> Style { + Style::new() + .fg(self.current) + .bg(self.current_bg) + .add_modifier(self.current_effect) } - pub fn current_match(&self) -> Attr { - Attr { - fg: self.current_match, - bg: self.current_match_bg, - effect: self.current_match_effect, - } + pub fn current_match(&self) -> Style { + Style::new() + .fg(self.current_match) + .bg(self.current_match_bg) + .add_modifier(self.current_match_effect) } - pub fn query(&self) -> Attr { - Attr { - fg: self.query_fg, - bg: self.query_bg, - effect: self.query_effect, - } + pub fn query(&self) -> Style { + Style::new() + .fg(self.query_fg) + .bg(self.query_bg) + .add_modifier(self.query_effect) } - pub fn spinner(&self) -> Attr { - Attr { - fg: self.spinner, - bg: self.bg, - effect: Effect::BOLD, - } + pub fn spinner(&self) -> Style { + Style::new() + .fg(self.spinner) + .bg(self.bg) + .add_modifier(Modifier::BOLD) } - pub fn info(&self) -> Attr { - Attr { - fg: self.info, - bg: self.bg, - effect: Effect::empty(), - } + pub fn info(&self) -> Style { + Style::new() + .fg(self.info) + .bg(self.bg) + .add_modifier(Modifier::empty()) } - pub fn prompt(&self) -> Attr { - Attr { - fg: self.prompt, - bg: self.bg, - effect: Effect::empty(), - } + pub fn prompt(&self) -> Style { + Style::new() + .fg(self.prompt) + .bg(self.bg) + .add_modifier(Modifier::empty()) } - pub fn cursor(&self) -> Attr { - Attr { - fg: self.cursor, - bg: self.current_bg, - effect: Effect::empty(), - } + pub fn cursor(&self) -> Style { + Style::new() + .fg(self.cursor) + .bg(self.current_bg) + .add_modifier(Modifier::empty()) } - pub fn selected(&self) -> Attr { - Attr { - fg: self.selected, - bg: self.current_bg, - effect: Effect::empty(), - } + pub fn selected(&self) -> Style { + Style::new() + .fg(self.selected) + .bg(self.current_bg) + .add_modifier(Modifier::empty()) } - pub fn header(&self) -> Attr { - Attr { - fg: self.header, - bg: self.bg, - effect: Effect::empty(), - } + pub fn header(&self) -> Style { + Style::new() + .fg(self.header) + .bg(self.bg) + .add_modifier(Modifier::empty()) } - pub fn border(&self) -> Attr { - Attr { - fg: self.border, - bg: self.bg, - effect: Effect::empty(), - } + pub fn border(&self) -> Style { + Style::new() + .fg(self.border) + .bg(self.bg) + .add_modifier(Modifier::empty()) } } diff --git a/skim/src/tmux.rs b/skim/src/tmux.rs index 55babc88..d74e3eda 100644 --- a/skim/src/tmux.rs +++ b/skim/src/tmux.rs @@ -1,19 +1,32 @@ +//! Tmux integration utilities. +//! +//! This module provides functionality for running skim within tmux panes, +//! allowing skim to be used as a tmux popup or split pane. + use std::{ borrow::Cow, env, - fs::File, - io::{BufRead as _, BufReader, IsTerminal as _, Write as _}, + io::{BufRead as _, BufReader, BufWriter, IsTerminal as _, Write as _}, process::{Command, Stdio}, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, + }, thread, + time::Duration, }; -use nix::{sys::stat, unistd::mkfifo}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use nix::sys::stat::Mode; +use nix::unistd::mkfifo; use rand::{Rng, distr::Alphanumeric}; -use skim_tuikit::key::Key; use which::which; -use crate::{SkimItem, SkimOptions, SkimOutput, event::Event}; +use crate::{ + SkimItem, SkimOptions, SkimOutput, + tui::{Event, event::Action}, +}; #[derive(Debug, PartialEq, Eq)] enum TmuxWindowDir { @@ -111,17 +124,33 @@ pub fn run_with(opts: &SkimOptions) -> Option { let mut stdin_reader = BufReader::new(std::io::stdin()); let line_ending = if opts.read0 { b'\0' } else { b'\n' }; - let t_tmp_stdin = temp_dir.join("stdin"); + let stop_reading = Arc::new(AtomicBool::new(false)); let stdin_handle = if has_piped_input { - debug!("Reading stdin and piping to file"); + debug!("Reading stdin and piping to fifo"); - mkfifo(&tmp_stdin, stat::Mode::S_IRWXU) - .unwrap_or_else(|e| panic!("Failed to create stdin pipe {}: {}", tmp_stdin.clone().display(), e)); + // Create a named pipe (FIFO) + // This allows the nested skim to continuously read as data arrives + let stdin_path_str = tmp_stdin + .to_str() + .unwrap_or_else(|| panic!("Failed to convert stdin path to string")); + mkfifo(stdin_path_str, Mode::S_IRUSR | Mode::S_IWUSR) + .unwrap_or_else(|e| panic!("Failed to create fifo {}: {}", tmp_stdin.display(), e)); + + let tmp_stdin_clone = tmp_stdin.clone(); + let stop_flag = Arc::clone(&stop_reading); Some(thread::spawn(move || { - let mut stdin_writer = File::create(&t_tmp_stdin) - .unwrap_or_else(|e| panic!("Failed to open stdin pipe {}: {}", t_tmp_stdin.clone().display(), e)); - + debug!("Opening fifo for writing (may block until reader starts)"); + let stdin_f = std::fs::File::create(tmp_stdin_clone.clone()) + .unwrap_or_else(|e| panic!("Failed to open fifo {}: {}", tmp_stdin_clone.display(), e)); + debug!("Fifo opened for writing"); + let mut stdin_writer = BufWriter::new(stdin_f); loop { + // Check if we should stop reading + if stop_flag.load(Ordering::Relaxed) { + debug!("Stop signal received, exiting stdin reader thread"); + break; + } + let mut buf = vec![]; match stdin_reader.read_until(line_ending, &mut buf) { Ok(0) => break, @@ -129,9 +158,11 @@ pub fn run_with(opts: &SkimOptions) -> Option { debug!("Read {n} bytes from stdin"); stdin_writer.write_all(&buf).unwrap(); } - Err(e) => panic!("Failed to read from stdin: {e}"), + Err(e) => panic!("Failed to read from stdin: {}", e), } } + // Ensure all buffered data is written to the file + let _ = stdin_writer.flush(); })) } else { None @@ -198,8 +229,23 @@ pub fn run_with(opts: &SkimOptions) -> Option { .status() .unwrap_or_else(|e| panic!("Tmux invocation failed with {e}")); - if let Some(h) = stdin_handle { - h.join().unwrap_or(()); + // Signal the stdin thread to stop and wait for it to exit + if let Some(handle) = stdin_handle { + stop_reading.store(true, Ordering::Relaxed); + debug!("Signaled stdin thread to stop"); + + // Use a channel-based timeout since JoinHandle doesn't have join_timeout + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let _ = tx.send(handle.join()); + }); + + // Give the thread a short time to finish gracefully + // If it's blocked on read, this will timeout and the thread will be dropped + match rx.recv_timeout(Duration::from_millis(100)) { + Ok(_) => debug!("Stdin thread exited cleanly"), + Err(_) => debug!("Stdin thread did not exit within timeout, dropping handle"), + } } let output_ending = if opts.print0 { "\0" } else { "\n" }; @@ -228,15 +274,18 @@ pub fn run_with(opts: &SkimOptions) -> Option { let is_abort = !status.success(); let final_event = match is_abort { - true => Event::EvActAbort, - false => Event::EvActAccept(None), // if --expect or --bind accept(key) are used, - // the key is technically returned in the selected_items + true => Event::Action(Action::Abort), + false => Event::Action(Action::Accept(None)), // if --bind accept(key) is used, + // the key is technically returned in the selected_items }; let skim_output = SkimOutput { final_event, is_abort, - final_key: Key::Null, + final_key: KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + // Note: In tmux mode, the actual final key is not available since skim runs in a separate + // tmux popup process. Only the output text is captured. Use --expect with --bind to capture + // specific accept keys in the output if needed. query: query_str.to_string(), cmd: command_str.to_string(), selected_items: output_lines, diff --git a/skim/src/tui/app.rs b/skim/src/tui/app.rs new file mode 100644 index 00000000..e807fd79 --- /dev/null +++ b/skim/src/tui/app.rs @@ -0,0 +1,1227 @@ +use std::borrow::Cow; +use std::process::{Command, Stdio}; +use std::rc::Rc; +use std::sync::Arc; + +use crate::item::{ItemPool, MatchedItem}; +use crate::matcher::{Matcher, MatcherControl}; +use crate::prelude::ExactOrFuzzyEngineFactory; +use crate::tui::options::TuiLayout; +use crate::tui::statusline::InfoDisplay; +use crate::tui::widget::SkimWidget; +use crate::util::{self, printf}; +use crate::{ItemPreview, PreviewContext, SkimItem, SkimOptions}; + +use super::Event; +use super::event::Action; +use super::header::Header; +use super::item_list::ItemList; +use super::statusline::StatusLine; +use color_eyre::eyre::{Result, bail}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; +use defer_drop::DeferDrop; +use input::Input; +use preview::Preview; +use ratatui::buffer::Buffer; +use ratatui::crossterm::event::KeyCode::Char; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::widgets::Widget; + +use super::{input, preview}; + +/// Application state for skim's TUI +pub struct App<'a> { + /// Pool of items to be filtered + pub item_pool: Arc>, + /// Whether the application should quit + pub should_quit: bool, + + /// Current cursor position (x, y) + pub cursor_pos: (u16, u16), + /// Control handle for the matcher thread + pub matcher_control: MatcherControl, + /// The matcher for filtering items + pub matcher: Matcher, + /// Register for yank/paste operations + pub yank_register: Cow<'a, str>, + /// Last time the matcher was restarted + pub last_matcher_restart: std::time::Instant, + /// Whether a matcher restart is pending + pub pending_matcher_restart: bool, + + /// Input field widget + pub input: Input, + /// Preview pane widget + pub preview: Preview<'a>, + /// Header widget + pub header: Header, + /// Status line widget + pub status: StatusLine, + /// Item list widget + pub item_list: ItemList, + /// Color theme + pub theme: Arc, + + /// Timer for tracking reader activity + pub reader_timer: std::time::Instant, + /// Timer for tracking matcher activity + pub matcher_timer: std::time::Instant, + + /// Last time spinner visibility changed + pub spinner_last_change: std::time::Instant, + + /// Track when items were just updated to avoid unnecessary status updates + pub items_just_updated: bool, + + /// Query history navigation + pub query_history: Vec, + /// Current position in query history + pub history_index: Option, + /// Saved input when navigating history + pub saved_input: String, + + /// Command history navigation (for interactive mode) + pub cmd_history: Vec, + /// Current position in command history + pub cmd_history_index: Option, + /// Saved command input when navigating history + pub saved_cmd_input: String, + + /// Skim configuration options + pub options: SkimOptions, + /// Whether to show a border around the input field + pub input_border: bool, + /// The command being executed + pub cmd: String, + /// Preview area rectangle for mouse event handling + pub preview_area: Option, +} + +impl Widget for &mut App<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let status_area; + let input_area; + let input_len = (self.input.chars().count() + 2 + self.options.prompt.chars().count()) as u16; + let remaining_height = 1 + + (self.options.header.as_ref().and(Some(1)).unwrap_or(0)) + + if self.options.info == InfoDisplay::Default { + 1 + } else { + 0 + }; + + // Determine if preview should be split from the root area (for left/right) or from list area (for up/down) + let preview_visible = self.options.preview.is_some() && !self.options.preview_window.hidden; + + // Split preview from root area if it's on left/right + let (work_area, preview_area_opt) = if preview_visible { + let size = match self.options.preview_window.size { + super::Size::Fixed(n) => Constraint::Length(n), + super::Size::Percent(n) => Constraint::Percentage(n), + }; + match self.options.preview_window.direction { + super::Direction::Left => { + let areas: [_; 2] = Layout::new(Direction::Horizontal, [size, Constraint::Fill(1)]).areas(area); + (areas[1], Some(areas[0])) + } + super::Direction::Right => { + let areas: [_; 2] = Layout::new(Direction::Horizontal, [Constraint::Fill(1), size]).areas(area); + (areas[0], Some(areas[1])) + } + super::Direction::Up => { + let areas: [_; 2] = Layout::new(Direction::Vertical, [size, Constraint::Fill(1)]).areas(area); + (areas[1], Some(areas[0])) + } + super::Direction::Down => { + let areas: [_; 2] = Layout::new(Direction::Vertical, [Constraint::Fill(1), size]).areas(area); + (areas[0], Some(areas[1])) + } + } + } else { + (area, None) + }; + + let [mut list_area, mut remaining_area] = match self.options.layout { + TuiLayout::Default | TuiLayout::ReverseList => { + Layout::vertical([Constraint::Fill(1), Constraint::Length(remaining_height)]).areas(work_area) + } + TuiLayout::Reverse => { + let mut layout = + Layout::vertical([Constraint::Length(remaining_height), Constraint::Fill(1)]).areas(work_area); + layout.reverse(); + layout + } + }; + if self.options.header.is_some() { + let header_area; + [header_area, remaining_area] = match self.options.layout { + TuiLayout::Default | TuiLayout::ReverseList => { + Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(remaining_area) + } + TuiLayout::Reverse => { + let mut a = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(remaining_area); + a.reverse(); + a + } + }; + self.header.render(header_area, buf); + } + if self.options.info == InfoDisplay::Hidden { + input_area = remaining_area; + } else { + match self.options.info { + InfoDisplay::Default => { + let areas: [_; 2] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(remaining_area); + if self.options.layout == TuiLayout::Reverse { + input_area = areas[0]; + status_area = areas[1]; + } else { + status_area = areas[0]; + input_area = areas[1]; + } + } + InfoDisplay::Inline => { + [input_area, status_area] = + Layout::horizontal([Constraint::Length(input_len), Constraint::Fill(1)]).areas(remaining_area); + } + InfoDisplay::Hidden => { + unreachable!() + } + }; + self.status.render(status_area, buf); + } + self.input.border = self.input_border; + self.input.render(input_area, buf); + + // Render preview if enabled + if let Some(preview_area) = preview_area_opt { + // Preview was already split at the root level (left/right) + self.preview_area = Some(preview_area); + self.preview.render(preview_area, buf); + } else if self.options.preview.is_some() && !self.options.preview_window.hidden { + // Preview needs to be split from list area (up/down) + let direction = Direction::Vertical; + let size = match self.options.preview_window.size { + super::Size::Fixed(n) => Constraint::Length(n), + super::Size::Percent(n) => Constraint::Percentage(n), + }; + let preview_area = match self.options.preview_window.direction { + super::Direction::Down => { + let areas: [_; 2] = Layout::new(direction, [size, Constraint::Fill(1)]).areas(list_area); + list_area = areas[1]; + areas[0] + } + super::Direction::Up => { + let areas: [_; 2] = Layout::new(direction, [Constraint::Fill(1), size]).areas(list_area); + list_area = areas[0]; + areas[1] + } + _ => unreachable!(), + }; + self.preview_area = Some(preview_area); + self.preview.render(preview_area, buf); + } else { + self.preview_area = None; + } + self.items_just_updated = self.item_list.render(list_area, buf).items_updated; + + self.cursor_pos = (input_area.x + self.input.cursor_pos(), input_area.y); + } +} + +impl Default for App<'_> { + fn default() -> Self { + let theme = Arc::new(crate::theme::ColorTheme::default()); + let opts = SkimOptions::default(); + Self { + input: Input::from_options(&opts, theme.clone()), + preview: Preview::from_options(&opts, theme.clone()), + header: Header::from_options(&opts, theme.clone()), + status: StatusLine::from_options(&opts, theme.clone()), + item_list: ItemList::from_options(&opts, theme.clone()), + item_pool: Arc::default(), + theme, + should_quit: false, + cursor_pos: (0, 0), + matcher: Matcher::builder(Rc::new(ExactOrFuzzyEngineFactory::builder().build())) + .case(crate::CaseMatching::default()) + .build(), + yank_register: Cow::default(), + matcher_control: MatcherControl::default(), + reader_timer: std::time::Instant::now(), + matcher_timer: std::time::Instant::now(), + last_matcher_restart: std::time::Instant::now(), + pending_matcher_restart: false, + // spinner initial state + spinner_last_change: std::time::Instant::now(), + items_just_updated: false, + query_history: Vec::new(), + history_index: None, + saved_input: String::new(), + cmd_history: Vec::new(), + cmd_history_index: None, + saved_cmd_input: String::new(), + options: SkimOptions::default(), + input_border: false, + cmd: String::new(), + preview_area: None, + } + } +} + +impl<'a> App<'a> { + /// Creates a new App from skim options + pub fn from_options(options: SkimOptions, theme: Arc, cmd: String) -> Self { + let mut input = Input::from_options(&options, theme.clone()); + + // In interactive mode, use cmd_prompt instead of regular prompt + if options.interactive { + input.prompt = options.cmd_prompt.clone(); + // In interactive mode, use cmd_query if provided + if let Some(ref cmd_query) = options.cmd_query { + input.value = cmd_query.clone(); + input.move_to_end(); + } + } + + Self { + input, + preview: Preview::from_options(&options, theme.clone()), + header: Header::from_options(&options, theme.clone()), + status: StatusLine::from_options(&options, theme.clone()), + item_pool: Arc::new(DeferDrop::new(ItemPool::from_options(&options))), + item_list: ItemList::from_options(&options, theme.clone()), + theme, + should_quit: false, + cursor_pos: (0, 0), + matcher: Matcher::from_options(&options), + yank_register: Cow::default(), + matcher_control: MatcherControl::default(), + reader_timer: std::time::Instant::now(), + matcher_timer: std::time::Instant::now(), + last_matcher_restart: std::time::Instant::now(), + pending_matcher_restart: false, + // spinner initial state + spinner_last_change: std::time::Instant::now(), + items_just_updated: false, + query_history: options.query_history.clone(), + history_index: None, + saved_input: String::new(), + cmd_history: options.cmd_history.clone(), + cmd_history_index: None, + saved_cmd_input: String::new(), + options, + input_border: false, + cmd, + preview_area: None, + } + } +} + +impl<'a> App<'a> { + /// Calculate preview offset from offset expression (e.g., "+123", "+{2}", "+{2}-2") + fn calculate_preview_offset(&self, offset_expr: &str) -> u16 { + // Remove the leading '+' + let expr = offset_expr.trim_start_matches('+'); + + // Substitute field placeholders using printf + let substituted = printf( + expr.to_string(), + &self.options.delimiter, + &self.options.replstr, + self.item_list.selection.iter().map(|m| m.item.clone()), + self.item_list.selected(), + &self.input.value, + &self.input.value, + ); + + // Evaluate the expression (handle simple arithmetic like "321-2") + if let Some((left, right)) = substituted.split_once('-') { + let left_val = left.trim_matches(|x: char| !x.is_numeric()).parse::().unwrap_or(0); + let right_val = right + .trim_matches(|x: char| !x.is_numeric()) + .parse::() + .unwrap_or(0); + left_val.saturating_sub(right_val) + } else if let Some((left, right)) = substituted.split_once('+') { + let left_val = left.trim_matches(|x: char| !x.is_numeric()).parse::().unwrap_or(0); + let right_val = right + .trim_matches(|x: char| !x.is_numeric()) + .parse::() + .unwrap_or(0); + left_val.saturating_add(right_val) + } else { + substituted + .trim_matches(|x: char| !x.is_numeric()) + .parse::() + .unwrap_or(0) + } + } + + /// Call after items are added or filtered (e.g., Event::NewItem, matcher completes) + fn on_items_updated(&mut self) { + self.status.total = self.item_pool.len(); + self.status.matched = self.item_list.count(); + self.status.processed = self.matcher_control.get_num_processed(); + // keep multi-selection flag synced with options + self.status.multi_selection = self.options.multi; + // reading/time updates should be performed by whoever owns reader timers + } + + /// Call after selection changes (e.g., selection actions, Event::Key) + fn on_selection_changed(&mut self) { + self.status.selected = self.item_list.selection.len(); + self.status.current_item_idx = self.item_list.current; + // ensure multi-selection display reflects current options + self.status.multi_selection = self.options.multi; + } + + /// Call when matcher state changes (start/stop) + fn on_matcher_state_changed(&mut self) { + self.status.matcher_running = !self.matcher_control.stopped(); + // set matcher mode string based on current options (e.g., regex mode) + self.status.matcher_mode = if self.options.regex { + "RE".to_string() + } else { + String::new() + }; + } + + /// Call when query changes (e.g., AddChar, BackwardDeleteChar, etc.) + fn on_query_changed(&mut self) -> Result> { + // In interactive mode with --cmd, execute the command with {} substitution + if self.options.interactive && self.options.cmd.is_some() { + let expanded_cmd = self.expand_cmd(&self.cmd); + return Ok(vec![Event::Reload(expanded_cmd)]); + } + self.restart_matcher_debounced(); + Ok(vec![ + Event::Key(KeyEvent::new(KeyCode::F(255), KeyModifiers::NONE)), // Send F255 which is the change bind + Event::RunPreview, + ]) + } + + fn run_preview(&mut self, tui: &mut super::Tui) -> Result<()> { + if let Some(preview_opt) = &self.options.preview + && let Some(item) = self.item_list.selected() + { + let selection: Vec<_> = self.item_list.selection.iter().map(|i| i.text().into_owned()).collect(); + let selection_str: Vec<_> = selection.iter().map(|s| s.as_str()).collect(); + let ctx = PreviewContext { + query: &self.input.value, + cmd_query: if self.options.interactive { + &self.input.value + } else { + self.options.cmd_query.as_deref().unwrap_or(&self.input.value) + }, + width: self.preview.cols as usize, + height: self.preview.rows as usize, + current_index: self.item_list.selected().map(|i| i.get_index()).unwrap_or_default(), + current_selection: &self + .item_list + .selected() + .map(|i| i.text().into_owned()) + .unwrap_or_default(), + selected_indices: &self + .item_list + .selection + .iter() + .map(|v| v.get_index()) + .collect::>(), + selections: &selection_str, + }; + let preview = item.preview(ctx); + match preview { + ItemPreview::Command(cmd) => self.preview.run( + tui, + &printf( + cmd, + &self.options.delimiter, + &self.options.replstr, + self.item_list.selection.iter().map(|m| m.item.clone()), + self.item_list.selected(), + &self.input, + &self.input, + ), + ), + ItemPreview::Text(t) | ItemPreview::AnsiText(t) => self.preview.content(t.bytes().collect())?, + ItemPreview::CommandWithPos(cmd, preview_position) => { + // Execute command and apply position after content is ready + self.preview.run( + tui, + &printf( + cmd.to_string(), + &self.options.delimiter, + &self.options.replstr, + self.item_list.selection.iter().map(|m| m.item.clone()), + self.item_list.selected(), + &self.input, + &self.input, + ), + ); + // Apply position offsets + let v_scroll = match preview_position.v_scroll { + crate::tui::Size::Fixed(n) => n, + crate::tui::Size::Percent(p) => (self.preview.rows as u32 * p as u32 / 100) as u16, + }; + let v_offset = match preview_position.v_offset { + crate::tui::Size::Fixed(n) => n, + crate::tui::Size::Percent(p) => (self.preview.rows as u32 * p as u32 / 100) as u16, + }; + self.preview.scroll_y = v_scroll.saturating_add(v_offset); + + let h_scroll = match preview_position.h_scroll { + crate::tui::Size::Fixed(n) => n, + crate::tui::Size::Percent(p) => (self.preview.cols as u32 * p as u32 / 100) as u16, + }; + let h_offset = match preview_position.h_offset { + crate::tui::Size::Fixed(n) => n, + crate::tui::Size::Percent(p) => (self.preview.cols as u32 * p as u32 / 100) as u16, + }; + self.preview.scroll_x = h_scroll.saturating_add(h_offset); + } + ItemPreview::TextWithPos(t, preview_position) => self + .preview + .content_with_position(t.bytes().collect(), preview_position)?, + ItemPreview::AnsiWithPos(t, preview_position) => self + .preview + .content_with_position(t.bytes().collect(), preview_position)?, + ItemPreview::Global => self.preview.run( + tui, + &printf( + preview_opt.to_string(), + &self.options.delimiter, + &self.options.replstr, + self.item_list.selection.iter().map(|m| m.item.clone()), + self.item_list.selected(), + &self.input, + &self.input, + ), + ), + } + } + Ok(()) + } + + /// Handles a TUI event and updates application state + pub fn handle_event(&mut self, tui: &mut super::Tui, event: &Event) -> Result<()> { + let prev_item = self.item_list.selected(); + match event { + Event::Render => { + // Always render to avoid freezing, but the render function itself can optimize + tui.get_frame(); + tui.draw(|f| { + f.render_widget(&mut *self, f.area()); + f.set_cursor_position(self.cursor_pos); + })?; + } + Event::Heartbeat => { + // Heartbeat is used for periodic UI updates + self.on_matcher_state_changed(); + + // Update status & spinner + self.status.time_since_read = self.reader_timer.elapsed(); + self.status.time_since_match = self.matcher_timer.elapsed(); + self.status.reading = self.item_pool.num_not_taken() != 0; + self.status.matcher_running = !self.matcher_control.stopped(); + + const MATCHER_DEBOUNCE_MS: u128 = 300; + const READER_DEBOUNCE_MS: u128 = 50; + const HIDE_GRACE_MS: u128 = 600; + + let matcher_running = !self.matcher_control.stopped(); + let time_since_match = self.matcher_timer.elapsed(); + let time_since_read = self.reader_timer.elapsed(); + + let matcher_ready = matcher_running && time_since_match.as_millis() > MATCHER_DEBOUNCE_MS; + let reader_ready = + self.item_pool.num_not_taken() != 0 && time_since_read.as_millis() > READER_DEBOUNCE_MS; + + let desired = matcher_ready || reader_ready; + + if desired || self.spinner_last_change.elapsed().as_millis() >= HIDE_GRACE_MS { + self.toggle_spinner(); + } + self.on_items_updated(); + } + Event::RunPreview => { + self.run_preview(tui)?; + } + Event::Clear => { + tui.clear()?; + } + Event::Quit => { + tui.exit()?; + self.should_quit = true; + } + Event::Close => { + tui.exit()?; + self.should_quit = true; + } + Event::PreviewReady(s) => { + self.preview.content(s.to_owned())?; + // Apply preview offset if configured + if let Some(offset_expr) = &self.options.preview_window.offset { + let offset = self.calculate_preview_offset(offset_expr); + self.preview.set_offset(offset); + } + } + Event::Error(msg) => { + tui.exit()?; + bail!(msg.to_owned()); + } + Event::Action(act) => { + for evt in self.handle_action(act)? { + tui.event_tx.send(evt)?; + } + self.on_selection_changed(); + self.on_matcher_state_changed(); + } + Event::Key(key) => { + for evt in self.handle_key(key) { + tui.event_tx.send(evt)?; + } + } + Event::Redraw => { + tui.clear()?; + } + Event::Mouse(mouse_event) => { + self.handle_mouse(mouse_event, tui)?; + } + _ => (), + }; + + // Check if item changed + let new_item = self.item_list.selected(); + if let Some(new) = new_item { + if let Some(prev) = prev_item { + if prev.text() != new.text() || prev.get_index() != new.get_index() { + self.on_item_changed(tui)?; + } + } else { + self.on_item_changed(tui)?; + } + } + Ok(()) + } + /// Handles new items received from the reader + pub fn handle_items(&mut self, items: Vec>) { + self.item_pool.append(items); + // Don't restart matcher immediately - use debounced restart instead + self.pending_matcher_restart = true; + trace!("Got new items, len {}", self.item_pool.len()); + // mark reader activity and reset reader timer + self.reader_timer = std::time::Instant::now(); + self.status.reading = true; + self.items_just_updated = true; + // Update status to reflect new pool state + self.on_items_updated(); + } + /// Called when the selected item changes + pub fn on_item_changed(&mut self, tui: &mut crate::tui::Tui) -> Result<()> { + tui.event_tx.send(Event::RunPreview)?; + + Ok(()) + } + fn handle_key(&mut self, key: &KeyEvent) -> Vec { + debug!("key event: {:?}", key); + + if let Some(act) = &self.options.keymap.get(key) { + debug!("{act:?}"); + return act.iter().map(|a| Event::Action(a.clone())).collect(); + } + match key.modifiers { + KeyModifiers::CONTROL => { + if let Char('c') = key.code { + return vec![Event::Quit]; + } + } + KeyModifiers::NONE => { + if let Char(c) = key.code { + return vec![Event::Action(Action::AddChar(c))]; + } + } + KeyModifiers::SHIFT => { + if let Char(c) = key.code { + return vec![Event::Action(Action::AddChar(c.to_uppercase().next().unwrap()))]; + } + } + _ => (), + }; + vec![] + } + + fn handle_action(&mut self, act: &Action) -> Result> { + use Action::*; + match act { + Abort => { + self.should_quit = true; + } + Accept(_) => { + self.should_quit = true; + } + AddChar(c) => { + self.input.insert(*c); + return self.on_query_changed(); + } + AppendAndSelect => { + let value = self.input.value.clone(); + let item: Arc = Arc::new(value); + self.item_pool.append(vec![item.clone()]); + self.item_list.append(&mut vec![MatchedItem { + item, + rank: [0, 0, 0, 0, 0], + matched_range: None, + }]); + self.item_list.select_row(self.item_list.items.len() - 1); + self.restart_matcher_debounced(); + return Ok(vec![Event::RunPreview]); + } + BackwardChar => { + self.input.move_cursor(-1); + } + BackwardDeleteChar => { + self.input.delete(-1); + return self.on_query_changed(); + } + BackwardDeleteCharEof => { + if self.input.is_empty() { + self.should_quit = true; + return Ok(vec![]); + } else { + self.input.delete(-1); + return self.on_query_changed(); + } + } + BackwardKillWord => { + let deleted = Cow::Owned(self.input.delete_backward_word()); + self.yank(deleted); + return self.on_query_changed(); + } + BackwardWord => { + self.input.move_cursor_backward_word(); + } + BeginningOfLine => { + self.input.move_cursor_to(0); + } + Cancel => { + self.matcher_control.kill(); + + if let Some(th) = &self.preview.thread_handle { + th.abort(); + } + } + ClearScreen => { + return Ok(vec![Event::Clear]); + } + DeleteChar => { + self.input.delete(0); + return self.on_query_changed(); + } + DeleteCharEof => { + if self.input.is_empty() { + self.should_quit = true; + return Ok(vec![]); + } else { + self.input.delete(0); + return self.on_query_changed(); + } + } + DeselectAll => { + self.item_list.selection = Default::default(); + return Ok(vec![Event::RunPreview]); + } + Down(n) => { + use ratatui::widgets::ListDirection::*; + match self.item_list.direction { + TopToBottom => self.item_list.scroll_by(*n as i32), + BottomToTop => self.item_list.scroll_by(-(*n as i32)), + } + return Ok(vec![Event::RunPreview]); + } + EndOfLine => { + self.input.move_to_end(); + } + Execute(cmd) => { + let mut command = Command::new("sh"); + let expanded_cmd = self.expand_cmd(cmd); + debug!("execute: {}", expanded_cmd); + command.args(["-c", &expanded_cmd]); + let in_raw_mode = crossterm::terminal::is_raw_mode_enabled()?; + if in_raw_mode { + crossterm::terminal::disable_raw_mode()?; + } + crossterm::execute!( + std::io::stderr(), + crossterm::terminal::LeaveAlternateScreen, + crossterm::event::DisableMouseCapture + )?; + let _ = command.spawn().and_then(|mut c| c.wait()); + if in_raw_mode { + crossterm::terminal::enable_raw_mode()?; + } + crossterm::execute!( + std::io::stderr(), + crossterm::terminal::EnterAlternateScreen, + crossterm::event::EnableMouseCapture + )?; + return Ok(vec![Event::Redraw]); + } + ExecuteSilent(cmd) => { + let mut command = Command::new("sh"); + let expanded_cmd = self.expand_cmd(cmd); + command.args(["-c", &expanded_cmd]); + command.stdout(Stdio::null()); + command.stderr(Stdio::null()); + let _ = command.spawn(); + } + First | Top => { + // Jump to first item (considering reserved items) + self.item_list.jump_to_first(); + return Ok(vec![Event::RunPreview]); + } + ForwardChar => { + self.input.move_cursor(1); + } + ForwardWord => { + self.input.move_cursor_forward_word(); + } + IfQueryEmpty(then, otherwise) => { + let inner = crate::binds::parse_action_chain(then)?; + if self.input.is_empty() { + return Ok(inner.iter().map(|e| Event::Action(e.to_owned())).collect()); + } else if let Some(o) = otherwise { + return Ok(crate::binds::parse_action_chain(o)? + .iter() + .map(|e| Event::Action(e.to_owned())) + .collect()); + } + } + IfQueryNotEmpty(then, otherwise) => { + let inner = crate::binds::parse_action_chain(then)?; + if !self.input.is_empty() { + return Ok(inner.iter().map(|e| Event::Action(e.to_owned())).collect()); + } else if let Some(o) = otherwise { + return Ok(crate::binds::parse_action_chain(o)? + .iter() + .map(|e| Event::Action(e.to_owned())) + .collect()); + } + } + IfNonMatched(then, otherwise) => { + let inner = crate::binds::parse_action_chain(then)?; + if self.item_list.items.is_empty() { + return Ok(inner.iter().map(|e| Event::Action(e.to_owned())).collect()); + } else if let Some(o) = otherwise { + return Ok(crate::binds::parse_action_chain(o)? + .iter() + .map(|e| Event::Action(e.to_owned())) + .collect()); + } + } + Ignore => (), + KillLine => { + let cursor = self.input.cursor_pos as usize; + let deleted = Cow::Owned(self.input.split_off(cursor)); + self.yank(deleted); + return Ok(vec![Event::RunPreview]); + } + KillWord => { + let deleted = Cow::Owned(self.input.delete_forward_word()); + self.yank(deleted); + return self.on_query_changed(); + } + Last => { + // Jump to last item + self.item_list.jump_to_last(); + return Ok(vec![Event::RunPreview]); + } + NextHistory => { + // Use cmd_history in interactive mode, query_history otherwise + let (history, history_index, saved_input) = if self.options.interactive { + ( + &self.cmd_history, + &mut self.cmd_history_index, + &mut self.saved_cmd_input, + ) + } else { + (&self.query_history, &mut self.history_index, &mut self.saved_input) + }; + + if history.is_empty() { + return Ok(vec![]); + } + + match *history_index { + None => { + // Already at most recent (current input), do nothing + } + Some(idx) => { + if idx + 1 >= history.len() { + // Move to most recent (restore saved input) + self.input.value = saved_input.clone(); + self.input.move_to_end(); + *history_index = None; + } else { + // Move forward in history (toward more recent) + let new_idx = idx + 1; + self.input.value = history[new_idx].clone(); + self.input.move_to_end(); + *history_index = Some(new_idx); + } + } + } + + return self.on_query_changed(); + } + HalfPageDown(n) => { + let offset = self.item_list.height as i32 / 2; + self.item_list.scroll_by(offset * n); + return Ok(vec![Event::RunPreview]); + } + HalfPageUp(n) => { + let offset = self.item_list.height as i32 / 2; + self.item_list.scroll_by(offset * n); + return Ok(vec![Event::RunPreview]); + } + PageDown(n) => { + let offset = self.item_list.height as i32; + self.item_list.scroll_by(offset * n); + return Ok(vec![Event::RunPreview]); + } + PageUp(n) => { + let offset = self.item_list.height as i32; + self.item_list.scroll_by(offset * n); + return Ok(vec![Event::RunPreview]); + } + PreviewUp(n) => { + self.preview.scroll_up(*n as u16); + } + PreviewDown(n) => { + self.preview.scroll_down(*n as u16); + } + PreviewLeft(n) => { + self.preview.scroll_left(*n as u16); + } + PreviewRight(n) => { + self.preview.scroll_right(*n as u16); + } + PreviewPageUp(_n) => { + self.preview.page_up(); + } + PreviewPageDown(_n) => { + self.preview.page_down(); + } + PreviousHistory => { + // Use cmd_history in interactive mode, query_history otherwise + let (history, history_index, saved_input) = if self.options.interactive { + ( + &self.cmd_history, + &mut self.cmd_history_index, + &mut self.saved_cmd_input, + ) + } else { + (&self.query_history, &mut self.history_index, &mut self.saved_input) + }; + + if history.is_empty() { + return Ok(vec![]); + } + + match *history_index { + None => { + // Save current input and go to most recent history entry + *saved_input = self.input.value.clone(); + let new_idx = history.len() - 1; + self.input.value = history[new_idx].clone(); + self.input.move_to_end(); + *history_index = Some(new_idx); + } + Some(idx) => { + if idx > 0 { + // Move backward in history (toward older entries) + let new_idx = idx - 1; + self.input.value = history[new_idx].clone(); + self.input.move_to_end(); + *history_index = Some(new_idx); + } + // else: already at oldest, do nothing + } + } + + return self.on_query_changed(); + } + Redraw => return Ok(vec![Event::Clear]), + Reload(Some(s)) => { + self.item_list.clear_selection(); + return Ok(vec![Event::Reload(self.expand_cmd(s))]); + } + Reload(None) => { + self.item_list.clear_selection(); + return Ok(vec![Event::Reload(self.cmd.clone())]); + } + RefreshCmd => { + // Refresh the command (reload in interactive mode) + if self.options.interactive { + let expanded_cmd = self.expand_cmd(&self.cmd); + return Ok(vec![Event::Reload(expanded_cmd)]); + } + } + RefreshPreview => { + return Ok(vec![Event::RunPreview]); + } + RestartMatcher => { + self.restart_matcher(true); + } + RotateMode => { + // Cycle through modes: fuzzy -> exact -> regex -> fuzzy + if self.options.regex { + // regex -> fuzzy + self.options.regex = false; + self.options.exact = false; + } else if self.options.exact { + // exact -> regex + self.options.exact = false; + self.options.regex = true; + } else { + // fuzzy -> exact + self.options.exact = true; + } + self.matcher = Matcher::from_options(&self.options); + self.restart_matcher(true); + } + ScrollLeft(n) => { + self.item_list.manual_hscroll = self.item_list.manual_hscroll.saturating_sub(*n); + } + ScrollRight(n) => { + self.item_list.manual_hscroll = self.item_list.manual_hscroll.saturating_add(*n); + } + SelectAll => self.item_list.select_all(), + SelectRow(row) => self.item_list.select_row(*row), + Select => self.item_list.select(), + Toggle => self.item_list.toggle(), + ToggleAll => self.item_list.toggle_all(), + ToggleIn => { + self.item_list.toggle(); + use ratatui::widgets::ListDirection::*; + match self.item_list.direction { + TopToBottom => self.item_list.select_next(), + BottomToTop => self.item_list.select_previous(), + } + return Ok(vec![Event::RunPreview]); + } + ToggleInteractive => { + self.options.interactive = !self.options.interactive; + } + ToggleOut => { + self.item_list.toggle(); + use ratatui::widgets::ListDirection::*; + match self.item_list.direction { + TopToBottom => self.item_list.select_previous(), + BottomToTop => self.item_list.select_next(), + } + return Ok(vec![Event::RunPreview]); + } + TogglePreview => { + self.options.preview_window.hidden = !self.options.preview_window.hidden; + } + TogglePreviewWrap => { + self.preview.wrap = !self.preview.wrap; + } + ToggleSort => { + self.options.no_sort = !self.options.no_sort; + self.restart_matcher(true); + } + UnixLineDiscard => { + self.input.delete_to_beginning(); + return self.on_query_changed(); + } + UnixWordRubout => { + self.input.delete_backward_to_whitespace(); + return self.on_query_changed(); + } + Up(n) => { + use ratatui::widgets::ListDirection::*; + match self.item_list.direction { + TopToBottom => self.item_list.scroll_by(-(*n as i32)), + BottomToTop => self.item_list.scroll_by(*n as i32), + } + return Ok(vec![Event::RunPreview]); + } + Yank => { + // Insert from yank register at cursor position + self.input.insert_str(&self.yank_register); + return self.on_query_changed(); + } + } + Ok(Vec::default()) + } + + /// Returns the selected items as results + pub fn results(&self) -> Vec> { + if self.options.multi && !self.item_list.selection.is_empty() { + self.item_list + .selection + .iter() + .map(|item| { + debug!("res index: {}", item.get_index()); + item.item.clone() + }) + .collect() + } else if let Some(sel) = self.item_list.selected() { + vec![sel] + } else { + vec![] + } + } + + pub(crate) fn restart_matcher(&mut self, force: bool) { + // Check if query meets minimum length requirement + if let Some(min_length) = self.options.min_query_length { + let query_to_check = &self.input.value; + + if query_to_check.chars().count() < min_length { + // Query is too short, clear items and don't run matcher + self.matcher_control.kill(); + self.item_list.items.clear(); + self.item_list.current = 0; + self.item_list.offset = 0; + self.status.matcher_running = false; + return; + } + } + + let matcher_stopped = self.matcher_control.stopped(); + if force || self.pending_matcher_restart || (matcher_stopped && self.item_pool.num_not_taken() > 0) { + // Reset debounce timer on any restart to prevent interference + self.last_matcher_restart = std::time::Instant::now(); + self.pending_matcher_restart = false; + self.matcher_control.kill(); + let tx = self.item_list.tx.clone(); + self.item_pool.reset(); + // record matcher start time for statusline spinner/progress + self.matcher_timer = std::time::Instant::now(); + self.status.matcher_running = true; + // In interactive mode, use empty query so all items are shown + // The input contains the command to execute, not a filter query + let query = if self.options.interactive { + &input::Input::default() + } else { + &self.input + }; + let item_pool = self.item_pool.clone(); + self.matcher_control = self.matcher.run(query, item_pool.clone(), move |matches| { + let m = matches.lock(); + debug!("Got {} results from matcher, sending to item list...", m.len()); + + // Prepend reserved header items to the matched results + let reserved_items = item_pool.reserved(); + let mut all_items = Vec::with_capacity(reserved_items.len() + m.len()); + + // Add reserved items as MatchedItems with min rank (always at top, unmatched) + for item in reserved_items { + all_items.push(MatchedItem { + item, + rank: [i32::MIN, 0, 0, 0, 0], + matched_range: None, + }); + } + + // Add matched items + all_items.extend_from_slice(&m); + + let _ = tx.send(all_items); + }); + } + } + + fn yank(&mut self, contents: Cow<'a, str>) { + self.yank_register = contents; + } + + /// Expand placeholders in a command string with current app state. + /// Replaces {}, {q}, {cq}, {n}, {+}, {+n}, and field patterns. + /// + /// Note: in command mode, the replstr is replaced by the current query + pub fn expand_cmd(&self, cmd: &str) -> String { + let cmd_to_expand = if self.options.interactive { + cmd.replace(&self.options.replstr, "{cq}") + } else { + cmd.to_string() + }; + util::printf( + cmd_to_expand, + &self.options.delimiter, + &self.options.replstr, + self.item_list.items.iter().map(|x| x.item.clone()), + self.item_list.selected(), + &self.input.value, + &self.input.value, + ) + } + + /// Restart matcher with debouncing to avoid excessive restarts during rapid typing + fn restart_matcher_debounced(&mut self) { + const DEBOUNCE_MS: u64 = 50; + let now = std::time::Instant::now(); + + // If enough time has passed since last restart, restart immediately + if now.duration_since(self.last_matcher_restart).as_millis() > DEBOUNCE_MS as u128 { + self.restart_matcher(true); + } else { + self.pending_matcher_restart = true; + } + } + + /// Handle mouse events + fn handle_mouse(&mut self, mouse_event: &MouseEvent, tui: &mut super::Tui) -> Result<()> { + let mouse_pos = ratatui::layout::Position { + x: mouse_event.column, + y: mouse_event.row, + }; + + match mouse_event.kind { + MouseEventKind::ScrollUp => { + // Check if mouse is over preview area + if let Some(preview_area) = self.preview_area + && preview_area.contains(mouse_pos) + { + // Scroll preview up + for evt in self.handle_action(&Action::PreviewUp(3))? { + tui.event_tx.send(evt)?; + } + return Ok(()); + } + // Otherwise scroll item list up + for evt in self.handle_action(&Action::Up(1))? { + tui.event_tx.send(evt)?; + } + } + MouseEventKind::ScrollDown => { + // Check if mouse is over preview area + if let Some(preview_area) = self.preview_area + && preview_area.contains(mouse_pos) + { + // Scroll preview down + for evt in self.handle_action(&Action::PreviewDown(3))? { + tui.event_tx.send(evt)?; + } + return Ok(()); + } + // Otherwise scroll item list down + for evt in self.handle_action(&Action::Down(1))? { + tui.event_tx.send(evt)?; + } + } + _ => { + // Ignore other mouse events for now + } + } + Ok(()) + } + fn toggle_spinner(&mut self) { + self.status.show_spinner = !self.status.show_spinner; + self.spinner_last_change = std::time::Instant::now(); + } +} diff --git a/skim/src/tui/backend.rs b/skim/src/tui/backend.rs new file mode 100644 index 00000000..87c55e6b --- /dev/null +++ b/skim/src/tui/backend.rs @@ -0,0 +1,192 @@ +use std::ops::{Deref, DerefMut}; +use std::sync::Once; + +use color_eyre::eyre::{Context, Result}; +use crossterm::cursor; +use crossterm::event::{DisableMouseCapture, EnableMouseCapture, KeyEventKind}; +use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; +use futures::{FutureExt as _, StreamExt as _}; +use ratatui::prelude::Backend; +use ratatui::{TerminalOptions, Viewport}; +use tokio::sync::mpsc::unbounded_channel; +use tokio::{ + sync::mpsc::{UnboundedReceiver, UnboundedSender}, + task::JoinHandle, +}; +use tokio_util::sync::CancellationToken; + +use super::{Event, Size}; + +const TICK_RATE: f64 = 12.; +const FRAME_RATE: f64 = 12.; +static PANIC_HOOK_SET: Once = Once::new(); + +/// Terminal user interface handler for skim +pub struct Tui> { + /// The ratatui terminal instance + pub terminal: ratatui::Terminal, + /// Background task handle for event polling + pub task: Option>, + /// Receiver for TUI events + pub event_rx: UnboundedReceiver, + /// Sender for TUI events + pub event_tx: UnboundedSender, + /// Frame rate for rendering (frames per second) + pub frame_rate: f64, + /// Tick rate for updates (ticks per second) + pub tick_rate: f64, + /// Token for cancelling background tasks + pub cancellation_token: CancellationToken, + /// Whether running in fullscreen mode + pub is_fullscreen: bool, +} + +impl Tui { + /// Creates a new TUI with the specified backend and height + pub fn new_with_height(backend: B, height: Size) -> Result { + let event_channel = unbounded_channel(); + let (is_fullscreen, viewport) = match height { + Size::Percent(100) => (true, Viewport::Fullscreen), + Size::Fixed(lines) => (false, Viewport::Inline(lines)), + Size::Percent(p) => { + let term_height = backend.size().context("Failed to get terminal size")?.height; + (false, Viewport::Inline(term_height * p / 100)) + } + }; + set_panic_hook(); + Ok(Self { + terminal: ratatui::Terminal::with_options(backend, TerminalOptions { viewport })?, + task: None, + event_rx: event_channel.1, + event_tx: event_channel.0, + frame_rate: FRAME_RATE, + tick_rate: TICK_RATE, + cancellation_token: CancellationToken::default(), + is_fullscreen, + }) + } + /// Enters the TUI by enabling raw mode and starting event handling + pub fn enter(&mut self) -> Result<()> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(std::io::stderr(), EnableMouseCapture)?; + if self.is_fullscreen { + crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?; + } + self.start(); + Ok(()) + } + + /// Exits the TUI by stopping event handling and disabling raw mode + pub fn exit(&mut self) -> Result<()> { + self.stop(); + if crossterm::terminal::is_raw_mode_enabled()? { + self.flush()?; + crossterm::execute!( + std::io::stderr(), + DisableMouseCapture, + LeaveAlternateScreen, + cursor::Show + )?; + crossterm::terminal::disable_raw_mode()?; + } + // When using the inline layout, we want to remove all previous output + // -> reset cursor at the top of the drawing area + if !self.is_fullscreen { + let area = self.get_frame().area(); + let orig = ratatui::layout::Position { x: area.x, y: area.y }; + self.set_cursor_position(orig)?; + }; + Ok(()) + } + /// Stops the TUI event loop + /// Equivalent to self.cancel() + pub fn stop(&self) { + self.cancel(); + } + /// Cancels all background tasks + pub fn cancel(&self) { + self.cancellation_token.cancel(); + } + /// Starts the event loop for handling keyboard and timer events + pub fn start(&mut self) { + let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate); + let render_delay = std::time::Duration::from_secs_f64(1.0 / self.frame_rate); + let event_tx_clone = self.event_tx.clone(); + let cancellation_token_clone = self.cancellation_token.clone(); + if self.task.is_some() { + self.cancel(); + } + self.task = Some(tokio::spawn(async move { + let mut reader = crossterm::event::EventStream::new(); + let mut tick_interval = tokio::time::interval(tick_delay); + let mut render_interval = tokio::time::interval(render_delay); + loop { + let tick_delay = tick_interval.tick(); + let render_delay = render_interval.tick(); + let crossterm_event = reader.next().fuse(); + tokio::select! { + _ = cancellation_token_clone.cancelled() => { + break; + } + maybe_event = crossterm_event => { + match maybe_event { + Some(Ok(crossterm::event::Event::Key(key))) => { + if key.kind == KeyEventKind::Press { + _ = event_tx_clone.send(Event::Key(key)); + } + } + Some(Ok(crossterm::event::Event::Mouse(mouse))) => { + _ = event_tx_clone.send(Event::Mouse(mouse)); + } + Some(Err(e)) => { + _ = event_tx_clone.send(Event::Error(e.to_string())); + } + None | Some(Ok(_)) => {}, + } + }, + _ = tick_delay => { + _ = event_tx_clone.send(Event::Heartbeat); + }, + _ = render_delay => { + _ = event_tx_clone.send(Event::Render); + }, + } + } + })); + } + + /// Gets the next event from the event queue + pub async fn next(&mut self) -> Option { + self.event_rx.recv().await + } +} + +impl Deref for Tui { + type Target = ratatui::Terminal; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for Tui { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for Tui { + fn drop(&mut self) { + let _ = self.exit(); + } +} + +fn set_panic_hook() { + PANIC_HOOK_SET.call_once(|| { + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + ratatui::restore(); // ignore any errors as we are already failing + hook(panic_info); + })); + }); +} diff --git a/skim/src/tui/event.rs b/skim/src/tui/event.rs new file mode 100644 index 00000000..f3422d0f --- /dev/null +++ b/skim/src/tui/event.rs @@ -0,0 +1,280 @@ +use crossterm::event::{KeyEvent, MouseEvent}; + +/// Events that can occur during skim's execution +#[derive(Clone)] +pub enum Event { + /// Quit the application + Quit, + /// An error occurred + Error(String), + /// Close the application + Close, + /// Timer tick event + Tick, + /// Render the UI + Render, + /// A key was pressed + Key(KeyEvent), + /// A mouse event occurred + Mouse(MouseEvent), + /// Preview content is ready to display + PreviewReady(Vec), + /// Invalid input received + InvalidInput, + /// An action was triggered + Action(Action), + /// Clear all items + ClearItems, + /// Clear the screen + Clear, + /// Heartbeat event + Heartbeat, + /// Run the preview command + RunPreview, + /// Redraw the screen + Redraw, + /// Reload with a new command + Reload(String), +} + +/// Actions that can be performed in skim +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub enum Action { + /// Abort and exit with error + Abort, + /// Accept selection and exit with optional key + Accept(Option), + /// Add a character to the query + AddChar(char), + /// Append to selection and select + AppendAndSelect, + /// Move cursor backward one character + BackwardChar, + /// Delete character before cursor + BackwardDeleteChar, + /// Delete character before cursor or exit if the query is empty + BackwardDeleteCharEof, + /// Delete word before cursor + BackwardKillWord, + /// Move cursor backward one word + BackwardWord, + /// Move cursor to beginning of line + BeginningOfLine, + /// Cancel current operation + Cancel, + /// Clear the screen + ClearScreen, + /// Delete character under cursor + DeleteChar, + /// Delete character or exit if empty + DeleteCharEof, + /// Deselect all items + DeselectAll, + /// Move selection down by N items + Down(u16), + /// Move cursor to end of line + EndOfLine, + /// Execute a command + Execute(String), + /// Execute a command silently + ExecuteSilent(String), + /// Jump to first item in list + First, + /// Move cursor forward one character + ForwardChar, + /// Move cursor forward one word + ForwardWord, + /// Execute action if query is empty + IfQueryEmpty(String, Option), + /// Execute action if query is not empty + IfQueryNotEmpty(String, Option), + /// Execute action if no items match + IfNonMatched(String, Option), + /// Ignore the action + Ignore, + /// Delete from cursor to end of line + KillLine, + /// Delete word after cursor + KillWord, + /// Jump to last item in list + Last, + /// Move to next history entry + NextHistory, + /// Scroll down by half a page + HalfPageDown(i32), + /// Scroll up by half a page + HalfPageUp(i32), + /// Scroll down by a page + PageDown(i32), + /// Scroll up by a page + PageUp(i32), + /// Scroll preview up + PreviewUp(i32), + /// Scroll preview down + PreviewDown(i32), + /// Scroll preview left + PreviewLeft(i32), + /// Scroll preview right + PreviewRight(i32), + /// Scroll preview up by a page + PreviewPageUp(i32), + /// Scroll preview down by a page + PreviewPageDown(i32), + /// Move to previous history entry + PreviousHistory, + /// Redraw the screen + Redraw, + /// Refresh the command + RefreshCmd, + /// Refresh the preview + RefreshPreview, + /// Restart the matcher + RestartMatcher, + /// Reload with optional new command + Reload(Option), + /// Rotate through matching modes + RotateMode, + /// Scroll item list left + ScrollLeft(i32), + /// Scroll item list right + ScrollRight(i32), + /// Select all items + SelectAll, + /// Select a specific row + SelectRow(usize), + /// Select current item + Select, + /// Toggle selection of current item + Toggle, + /// Toggle selection of all items + ToggleAll, + /// Toggle and move in + ToggleIn, + /// Toggle interactive mode + ToggleInteractive, + /// Toggle and move out + ToggleOut, + /// Toggle preview visibility + TogglePreview, + /// Toggle preview line wrapping + TogglePreviewWrap, + /// Toggle sorting + ToggleSort, + /// Jump to first item in list (alias for First) + Top, + /// Discard line (unix-style) + UnixLineDiscard, + /// Delete word backward (unix-style) + UnixWordRubout, + /// Move selection up by N items + Up(u16), + /// Yank (paste) + Yank, +} + +/// Parses an action string into an Action enum +#[rustfmt::skip] +pub fn parse_action(raw_action: &str) -> Option { + let parts = raw_action.split_once([':', '(', ')']); + let action; + let mut arg = None; + match parts { + None => { action = raw_action } + Some((act, "")) => { action = act } + Some((act, a)) => { action = act; arg = Some(a.trim_end_matches(")").to_string()) } + } + debug!("parse_action: action={action}, arg={arg:?}"); + + // Parse `if` chains + if action.starts_with("if-") { + let then_arg; + let mut otherwise_arg = None; + + let if_arg = arg.unwrap_or_else(|| panic!("no arg specified for event {action}")); + if if_arg.contains("+") { + let split = if_arg.split_once("+"); + match split { + Some((a, "")) => { then_arg = a.to_string(); } + Some((a, b)) => { + then_arg = a.to_string(); + otherwise_arg = Some(b.to_string()); + } + None => unreachable!() + } + } else { + then_arg = if_arg.to_string(); + } + match action { + "if-non-matched" => Some(Action::IfNonMatched(then_arg, otherwise_arg)), + "if-query-empty" => Some(Action::IfQueryEmpty(then_arg, otherwise_arg)), + "if-query-not-empty" => Some(Action::IfQueryNotEmpty(then_arg, otherwise_arg)), + _ => None + } + } else { + use Action::*; + match action { + "abort" => Some(Abort), + "accept" => Some(Accept(arg)), + "add-char" => Some(AddChar(arg.unwrap_or_default().chars().next().expect("add-char should have an argument"))), + "append-and-select" => Some(AppendAndSelect), + "backward-char" => Some(BackwardChar), + "backward-delete-char" => Some(BackwardDeleteChar), + "backward-delete-char/eof" => Some(BackwardDeleteCharEof), + "backward-kill-word" => Some(BackwardKillWord), + "backward-word" => Some(BackwardWord), + "beginning-of-line" => Some(BeginningOfLine), + "cancel" => Some(Cancel), + "clear-screen" => Some(ClearScreen), + "delete-char" => Some(DeleteChar), + "delete-char/eof" => Some(DeleteCharEof), + "deselect-all" => Some(DeselectAll), + "down" => Some(Down(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), + "end-of-line" => Some(EndOfLine), + "execute" => Some(Execute(arg.expect("execute event should have argument"))), + "execute-silent" => Some(ExecuteSilent(arg.expect("execute-silent event should have argument"))), + "first" => Some(First), + "forward-char" => Some(ForwardChar), + "forward-word" => Some(ForwardWord), + "ignore" => Some(Ignore), + "kill-line" => Some(KillLine), + "kill-word" => Some(KillWord), + "last" => Some(Last), + "next-history" => Some(NextHistory), + "half-page-down" => Some(HalfPageDown(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), + "half-page-up" => Some(HalfPageUp(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), + "page-down" => Some(PageDown(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), + "page-up" => Some(PageUp(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), + "preview-up" => Some(PreviewUp(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), + "preview-down" => Some(PreviewDown(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), + "preview-left" => Some(PreviewLeft(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), + "preview-right" => Some(PreviewRight(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), + "preview-page-up" => Some(PreviewPageUp(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), + "preview-page-down" => Some(PreviewPageDown(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), + "previous-history" => Some(PreviousHistory), + "redraw" => Some(Redraw), + "refresh-cmd" => Some(RefreshCmd), + "refresh-preview" => Some(RefreshPreview), + "reload" => Some(Reload(arg.clone())), + "scroll-left" => Some(ScrollLeft(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), + "scroll-right" => Some(ScrollRight(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), + "select" => Some(Select), + "select-all" => Some(SelectAll), + "select-row" => Some(SelectRow(arg.and_then(|s| s.parse().ok()).unwrap_or_default())), + "toggle" => Some(Toggle), + "toggle-all" => Some(ToggleAll), + "toggle-in" => Some(ToggleIn), + "toggle-interactive" => Some(ToggleInteractive), + "toggle-out" => Some(ToggleOut), + "toggle-preview" => Some(TogglePreview), + "toggle-preview-wrap" => Some(TogglePreviewWrap), + "toggle-sort" => Some(ToggleSort), + "top" => Some(Top), + "unix-line-discard" => Some(UnixLineDiscard), + "unix-word-rubout" => Some(UnixWordRubout), + "up" => Some(Up(arg.and_then(|s|s.parse().ok()).unwrap_or(1))), + "yank" => Some(Yank), + _ => None + } + + } +} diff --git a/skim/src/tui/header.rs b/skim/src/tui/header.rs new file mode 100644 index 00000000..d55865fb --- /dev/null +++ b/skim/src/tui/header.rs @@ -0,0 +1,87 @@ +//! Header display widget for skim's TUI. +//! +//! This module provides the header widget that displays static text above the item list. +use crate::SkimOptions; +use crate::theme::ColorTheme; +use crate::theme::DEFAULT_THEME; +use crate::tui::widget::{SkimRender, SkimWidget}; + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use std::cmp::max; +use std::sync::Arc; +use unicode_width::UnicodeWidthChar; + +/// Header widget for displaying static text above the item list +#[derive(Clone)] +pub struct Header { + header: String, + theme: Arc, +} + +impl Default for Header { + fn default() -> Self { + Self { + header: Default::default(), + theme: Arc::new(*DEFAULT_THEME), + } + } +} + +impl Header { + /// Sets the color theme for the header + pub fn theme(mut self, theme: Arc) -> Self { + self.theme = theme; + self + } +} + +/// Expands tab characters to spaces based on tabstop width and current position +fn apply_tabstop(text: &str, tabstop: usize) -> String { + let mut result = String::new(); + let mut current_width = 0; + + for ch in text.chars() { + if ch == '\t' { + let tab_width = tabstop - (current_width % tabstop); + result.push_str(&" ".repeat(tab_width)); + current_width += tab_width; + } else { + result.push(ch); + current_width += ch.width_cjk().unwrap_or(0); + } + } + + result +} + +impl SkimWidget for Header { + fn from_options(options: &SkimOptions, theme: Arc) -> Self { + let tabstop = max(1, options.tabstop); + let header = options.header.clone().unwrap_or_default(); + + // Expand tabs once during initialization + let expanded_header = apply_tabstop(&header, tabstop); + + Self { + header: expanded_header, + theme, + } + } + + fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender { + if area.width < 3 { + panic!("screen width is too small to fit the header"); + } + + if area.height < 1 { + panic!("screen height is too small to fit the header"); + } + + Paragraph::new(self.header.as_str()).render(area, buf); + + SkimRender::default() + } +} diff --git a/skim/src/tui/input.rs b/skim/src/tui/input.rs new file mode 100644 index 00000000..ecc5bd77 --- /dev/null +++ b/skim/src/tui/input.rs @@ -0,0 +1,315 @@ +use std::ops::{Deref, DerefMut}; + +use ratatui::{prelude::*, widgets::Widget}; + +use crate::tui::widget::{SkimRender, SkimWidget}; +use crate::{SkimOptions, theme::ColorTheme}; +use std::sync::Arc; + +pub struct Input { + pub prompt: String, + pub value: String, + pub cursor_pos: u16, + pub theme: Arc, + pub border: bool, +} + +impl Default for Input { + fn default() -> Self { + Self { + prompt: String::from(">"), + value: String::default(), + cursor_pos: 0, + theme: Arc::new(ColorTheme::default()), + border: false, + } + } +} + +impl Input { + pub fn insert(&mut self, c: char) { + self.value.insert(self.cursor_pos.into(), c); + // unwrap: len_utf8 < 4 + self.move_cursor(c.len_utf8().try_into().unwrap()); + } + pub fn insert_str(&mut self, s: &str) { + self.value.insert_str(self.cursor_pos as usize, s); + self.move_cursor( + s.chars() + .count() + .try_into() + .expect("Failed to fit inserted str len into an i32"), + ); + } + fn nchars(&self) -> usize { + self.value.chars().count() + } + pub fn delete(&mut self, offset: i32) -> Option { + if self.value.is_empty() { + return None; + } + let new_pos = self.cursor_pos as i32 + offset; + if new_pos < 0 || new_pos as usize >= self.value.len() { + return None; + } + let pos = new_pos as usize; + let ch = self.value.remove(pos); + // Only move cursor if deleting backwards + if offset < 0 { + self.move_cursor(-1); + } + Some(ch) + } + pub fn move_cursor(&mut self, offset: i32) { + if offset == 0 { + return; + } + if offset < 0 { + self.move_cursor_to( + self.value + .floor_char_boundary((self.cursor_pos as i32 + offset) as usize) as u16, + ); + } else { + self.move_cursor_to( + self.value + .ceil_char_boundary((self.cursor_pos as i32 + offset) as usize) as u16, + ); + } + } + pub fn move_cursor_to(&mut self, pos: u16) { + if self.value.is_char_boundary(pos as usize) { + self.cursor_pos = u16::clamp(pos, 0, self.value.len() as u16); + } else { + warn!("Invalid cursor pos"); + } + } + pub fn move_to_end(&mut self) { + self.move_cursor_to( + self.value + .len() + .try_into() + .expect("Failed to fit input len into an u16"), + ) + } + + /// Check if a character is a word character (alphanumeric only) + fn is_word_char(ch: char) -> bool { + ch.is_alphanumeric() + } + + /// Find the position of the end of the next word (alphanumeric boundaries for deletion) + fn find_next_word_end(&self, start_pos: usize) -> usize { + let mut pos = start_pos; + + // Skip any non-word characters + while pos < self.nchars() { + let ch = self.value.chars().nth(pos).unwrap(); + if Self::is_word_char(ch) { + break; + } + pos += 1; + } + + // Skip to the end of the word + while pos < self.nchars() { + let ch = self.value.chars().nth(pos).unwrap(); + if !Self::is_word_char(ch) { + break; + } + pos += 1; + } + + pos + } + + /// Find the end of compound word (whitespace boundaries for cursor movement) + fn find_compound_word_end(&self, start_pos: usize) -> usize { + let mut pos = start_pos; + + // Skip any whitespace + while pos < self.nchars() { + let ch = self.value.chars().nth(pos).unwrap(); + if !ch.is_whitespace() { + break; + } + pos += 1; + } + + // Skip to the end of the non-whitespace sequence (includes punctuation) + while pos < self.nchars() { + let ch = self.value.chars().nth(pos).unwrap(); + if ch.is_whitespace() { + break; + } + pos += 1; + } + + pos + } + + /// Find the position of the start of the previous word (alphanumeric word boundaries) + fn find_prev_word_start(&self, start_pos: usize) -> usize { + if start_pos == 0 { + return 0; + } + + let mut pos = start_pos; + + // Move back at least one position + pos = pos.saturating_sub(1); + + // Skip any non-word characters + while pos > 0 && !Self::is_word_char(self.value.chars().nth(pos).unwrap()) { + pos -= 1; + } + + // Skip to the beginning of the word + while pos > 0 && Self::is_word_char(self.value.chars().nth(pos - 1).unwrap()) { + pos -= 1; + } + + pos + } + + /// Find the position to delete backward to (stops at non-word characters) + fn find_delete_backward_pos(&self, start_pos: usize) -> usize { + if start_pos == 0 { + return 0; + } + + let mut pos = start_pos; + + // Skip any non-word characters (whitespace, punctuation, etc.) + while pos > 0 { + let ch = self.value.chars().nth(pos - 1).unwrap(); + if Self::is_word_char(ch) { + break; + } + pos -= 1; + } + + // Skip back through word characters + while pos > 0 { + let ch = self.value.chars().nth(pos - 1).unwrap(); + if !Self::is_word_char(ch) { + break; + } + pos -= 1; + } + + pos + } + + pub fn delete_backward_word(&mut self) -> String { + if self.cursor_pos == 0 { + return String::new(); + } + // Delete back by alphanumeric word boundaries (for Alt+Backspace) + let start_pos = self.find_delete_backward_pos(self.cursor_pos as usize); + let deleted = self.value[start_pos..self.cursor_pos as usize].to_string(); + self.value = format!( + "{}{}", + &self.value[..start_pos], + &self.value[self.cursor_pos as usize..] + ); + self.cursor_pos = start_pos as u16; + deleted + } + + pub fn delete_backward_to_whitespace(&mut self) -> String { + if self.cursor_pos == 0 { + return String::new(); + } + // Unix word rubout: delete back to whitespace (for Ctrl+W) + let mut pos = self.cursor_pos as usize; + + // Skip any trailing whitespace + while pos > 0 && self.value.chars().nth(pos - 1).unwrap().is_whitespace() { + pos -= 1; + } + + // Delete back to next whitespace or start + while pos > 0 && !self.value.chars().nth(pos - 1).unwrap().is_whitespace() { + pos -= 1; + } + + let deleted = self.value[pos..self.cursor_pos as usize].to_string(); + self.value = format!("{}{}", &self.value[..pos], &self.value[self.cursor_pos as usize..]); + self.cursor_pos = pos as u16; + deleted + } + + pub fn delete_forward_word(&mut self) -> String { + if self.cursor_pos as usize >= self.value.len() { + return String::new(); + } + let end_pos = self.find_next_word_end(self.cursor_pos as usize); + let deleted = self.value[self.cursor_pos as usize..end_pos].to_string(); + self.value = format!("{}{}", &self.value[..self.cursor_pos as usize], &self.value[end_pos..]); + deleted + } + pub fn move_cursor_forward_word(&mut self) { + let new_pos = self.find_compound_word_end(self.cursor_pos as usize); + self.cursor_pos = new_pos as u16; + } + + pub fn move_cursor_backward_word(&mut self) { + let new_pos = self.find_prev_word_start(self.cursor_pos as usize); + self.cursor_pos = new_pos as u16; + } + pub fn delete_to_beginning(&mut self) -> String { + let deleted = self.value[..self.cursor_pos as usize].to_string(); + self.value = self.value[self.cursor_pos as usize..].to_string(); + self.cursor_pos = 0; + deleted + } + pub fn cursor_pos(&self) -> u16 { + (self.value[..(self.cursor_pos as usize)].chars().count() + self.prompt.chars().count()) + .try_into() + .expect("Failed to fit cursor char into an u16") + } +} + +impl SkimWidget for Input { + fn from_options(options: &SkimOptions, theme: Arc) -> Self { + Self { + prompt: options.prompt.clone(), + value: options.query.clone().unwrap_or_default(), + theme, + border: options.border, + cursor_pos: options.query.clone().map(|q| q.len() as u16).unwrap_or_default(), + } + } + + fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender { + use ratatui::text::{Line, Span}; + use ratatui::widgets::Paragraph; + + let prompt_span = Span::styled(&self.prompt, self.theme.prompt()); + let value_span = Span::styled(&self.value, self.theme.query()); + let line = Line::from(vec![prompt_span, value_span]); + use ratatui::widgets::{Block, Borders}; + let block = if self.border { + Block::default().borders(Borders::ALL).border_style(self.theme.border()) + } else { + Block::default() + }; + Paragraph::new(line).block(block).render(area, buf); + + SkimRender::default() + } +} + +impl Deref for Input { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +impl DerefMut for Input { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.value + } +} diff --git a/skim/src/tui/item_list.rs b/skim/src/tui/item_list.rs new file mode 100644 index 00000000..5d06fbaa --- /dev/null +++ b/skim/src/tui/item_list.rs @@ -0,0 +1,705 @@ +use std::{rc::Rc, sync::Arc}; + +use indexmap::IndexSet; +use ratatui::widgets::{Clear, List, ListDirection, ListState, StatefulWidget, Widget}; +use ratatui::{ + style::Modifier, + text::{Line, Span}, +}; +use regex::Regex; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use crate::{ + DisplayContext, MatchRange, Selector, SkimItem, SkimOptions, + item::MatchedItem, + spinlock::SpinLock, + theme::ColorTheme, + tui::options::TuiLayout, + tui::widget::{SkimRender, SkimWidget}, +}; + +/// Processed items ready for rendering +struct ProcessedItems { + items: Vec, +} + +/// Widget for displaying and managing the list of filtered items +pub struct ItemList { + pub(crate) items: Vec, + pub(crate) selection: IndexSet, + pub(crate) tx: UnboundedSender>, + processed_items: Arc>>, + pub(crate) direction: ListDirection, + pub(crate) offset: usize, + pub(crate) current: usize, + pub(crate) height: u16, + pub(crate) theme: std::sync::Arc, + pub(crate) multi_select: bool, + reserved: usize, + no_hscroll: bool, + keep_right: bool, + skip_to_pattern: Option, + tabstop: usize, + selector: Option>, + pre_select_target: usize, // How many items we want to pre-select + no_clear_if_empty: bool, + interactive: bool, // Whether we're in interactive mode + showing_stale_items: bool, // True when displaying old items due to no_clear_if_empty + pub(crate) manual_hscroll: i32, // Manual horizontal scroll offset for ScrollLeft/ScrollRight +} + +impl Default for ItemList { + fn default() -> Self { + let (tx, _rx) = unbounded_channel(); + let processed_items = Arc::new(SpinLock::new(None)); + + Self { + tx, + processed_items, + direction: ListDirection::BottomToTop, + items: Default::default(), + selection: Default::default(), + offset: Default::default(), + current: Default::default(), + height: Default::default(), + theme: Arc::new(ColorTheme::default()), + multi_select: false, + reserved: 0, + no_hscroll: false, + keep_right: false, + skip_to_pattern: None, + tabstop: 8, + selector: None, + pre_select_target: 0, + no_clear_if_empty: false, + interactive: false, + showing_stale_items: false, + manual_hscroll: 0, + } + } +} + +impl ItemList { + /// Background task that processes incoming items from matcher + /// Performs expensive operations (sorting) in background to keep render path fast + fn process_items_task( + mut rx: UnboundedReceiver>, + processed_items: Arc>>, + ) { + while let Some(mut items) = rx.blocking_recv() { + debug!("Background task: Got {} items to process", items.len()); + + // Sort items immediately - use stable sort to preserve order for equal ranks + items.sort_by_key(|item| item.rank); + + // Write processed items to shared state for render thread + // Move items instead of cloning for efficiency + let processed = ProcessedItems { items }; + + *processed_items.lock() = Some(processed); + } + debug!("Background task: rx channel closed, exiting"); + } + + fn cursor(&self) -> usize { + self.current + } + + /// Returns the count of items for status display. + /// + /// This may differ from items.len() when no_clear_if_empty is active and showing stale items + pub fn count(&self) -> usize { + if self.showing_stale_items { 0 } else { self.items.len() } + } + + /// Returns the currently selected item, if any + pub fn selected(&self) -> Option> { + self.items.get(self.cursor()).map(|x| x.item.clone()) + } + + /// Appends new matched items to the list + pub fn append(&mut self, items: &mut Vec) { + self.items.append(items); + self.showing_stale_items = false; + } + + /// Calculate the width to skip when using skip_to_pattern + /// Returns the actual skip width (not accounting for ".." - that's handled in apply_hscroll) + fn calc_skip_width(&self, text: &str) -> usize { + if let Some(ref regex) = self.skip_to_pattern + && let Some(mat) = regex.find(text) + { + return text[..mat.start()].width_cjk(); + } + 0 + } + + /// Calculate horizontal scroll offset for displaying a line with matches + /// Returns (shift, full_width, has_left_overflow, has_right_overflow) + fn calc_hscroll( + &self, + text: &str, + container_width: usize, + match_start_char: usize, + match_end_char: usize, + ) -> (usize, usize, bool, bool) { + // Calculate display width considering tab expansion + let full_width = text.chars().fold(0, |acc, ch| { + if ch == '\t' { + acc + self.tabstop - (acc % self.tabstop) + } else { + acc + ch.to_string().width_cjk() + } + }); + + // Reserve 2 chars for ".." indicators + let available_width = if container_width >= 2 { + container_width + } else { + return (0, full_width, false, false); + }; + + let base_shift = if self.no_hscroll { + // No horizontal scroll: always start from beginning + 0 + } else if match_start_char == 0 && match_end_char == 0 { + // No match to center on (empty query or no matches) + let skip_width = self.calc_skip_width(text); + if skip_width > 0 { + // skip_to_pattern is set and found a match + skip_width + } else if self.keep_right { + // Show the right end + full_width.saturating_sub(available_width) + } else { + // Start from beginning + 0 + } + } else { + // Calculate shift to show the match + // Calculate display widths for match positions + let mut match_start_width = 0; + let mut match_end_width = 0; + let mut current_width = 0; + let mut found_start = false; + let mut found_end = false; + + for (idx, ch) in text.chars().enumerate() { + if idx == match_start_char { + match_start_width = current_width; + found_start = true; + } + if idx == match_end_char { + match_end_width = current_width; + found_end = true; + break; + } + + if ch == '\t' { + current_width += self.tabstop - (current_width % self.tabstop); + } else { + current_width += ch.to_string().width_cjk(); + } + } + + // If we didn't find the end, use the current width + if found_start && !found_end { + match_end_width = current_width; + } + + let match_width = match_end_width.saturating_sub(match_start_width); + + // Try to center the match, but ensure we show as much of it as possible + if match_width >= available_width { + // Match itself is too long, show from start of match + match_start_width + } else { + // Center the match in the available space + let desired_shift = match_start_width.saturating_sub((available_width - match_width) / 2); + // But don't shift more than necessary + let max_shift = full_width.saturating_sub(available_width); + desired_shift.min(max_shift) + } + }; + + // Apply manual horizontal scroll offset + // manual_hscroll can be positive (scroll right) or negative (scroll left) + // final_shift = base_shift + manual_hscroll + let proposed_shift = (base_shift as i32 + self.manual_hscroll).max(0) as usize; + + // Only clamp if the text is actually wider than the container + // This allows skip_to_pattern to work even for short text + let shift = if full_width > available_width { + let max_shift = full_width.saturating_sub(available_width); + proposed_shift.min(max_shift) + } else { + proposed_shift + }; + + let has_left_overflow = shift > 0; + let has_right_overflow = shift + available_width < full_width; + + (shift, full_width, has_left_overflow, has_right_overflow) + } + + /// Apply horizontal scrolling to a line, adding ".." indicators as needed + /// Also expands tabs to spaces according to tabstop setting + fn apply_hscroll<'a>(&self, line: Line<'a>, shift: usize, container_width: usize, full_width: usize) -> Line<'a> { + let has_left_overflow = shift > 0; + let has_right_overflow = shift + container_width < full_width; + + // Reserve space for overflow indicators + let left_indicator_width = if has_left_overflow { 2 } else { 0 }; + let right_indicator_width = if has_right_overflow { 2 } else { 0 }; + let content_width = container_width.saturating_sub(left_indicator_width + right_indicator_width); + + // Extract the visible portion of the line while preserving styling + let mut result = Line::default(); + + // Add left indicator if needed + if has_left_overflow { + result.push_span(Span::raw("..")); + } + + // Process spans to extract only the visible portion while preserving styles + let mut current_char_index = 0; + let mut current_width = 0; + let shift_char_start = self.char_index_at_width(&line, shift); + let shift_char_end = self.char_index_at_width(&line, shift + content_width); + + for span in line.spans { + let span_text = span.content.as_ref(); + let span_chars: Vec = span_text.chars().collect(); + + let span_start_char = current_char_index; + let span_end_char = current_char_index + span_chars.len(); + + // Check if this span intersects with our visible range + if span_end_char > shift_char_start && span_start_char < shift_char_end { + // Calculate which part of this span is visible + let visible_start = shift_char_start.saturating_sub(span_start_char); + + let visible_end = if span_end_char > shift_char_end { + shift_char_end - span_start_char + } else { + span_chars.len() + }; + + if visible_start < visible_end && visible_start < span_chars.len() { + let visible_chars: String = span_chars[visible_start..visible_end.min(span_chars.len())] + .iter() + .collect(); + + // Expand tabs to spaces and preserve styling + let processed_chars = if visible_chars.contains('\t') { + self.expand_tabs(&visible_chars, current_width) + } else { + visible_chars + }; + + if !processed_chars.is_empty() { + result.push_span(Span::styled(processed_chars, span.style)); + } + } + } + + current_char_index += span_chars.len(); + current_width += span_text.width_cjk(); + } + + // Add right indicator if needed + if has_right_overflow { + result.push_span(Span::raw("..")); + } + + result + } + + fn char_index_at_width(&self, line: &Line<'_>, target_width: usize) -> usize { + let mut current_width = 0; + let mut char_index = 0; + + for span in &line.spans { + for ch in span.content.chars() { + let ch_width = if ch == '\t' { + self.tabstop - (current_width % self.tabstop) + } else { + ch.width_cjk().unwrap_or_default() + }; + + if current_width >= target_width { + return char_index; + } + + current_width += ch_width; + char_index += 1; + } + } + + char_index + } + + fn expand_tabs(&self, text: &str, start_width: usize) -> String { + let mut result = String::new(); + let mut current_width = start_width; + + for ch in text.chars() { + if ch == '\t' { + let tab_width = self.tabstop - (current_width % self.tabstop); + result.push_str(&" ".repeat(tab_width)); + current_width += tab_width; + } else { + result.push(ch); + current_width += ch.to_string().width_cjk(); + } + } + + result + } + + /// Toggles the selection state of the item at the given index + pub fn toggle_at(&mut self, index: usize) { + if self.items.is_empty() { + return; + } + let item = &self.items[index]; + trace!("Toggled item {} at index {}", item.text(), index); + toggle_item(&mut self.selection, item); + trace!( + "Selection is now {:#?}", + self.selection.iter().map(|item| item.item.text()).collect::>() + ); + } + /// Toggles the selection state of the currently selected item + pub fn toggle(&mut self) { + self.toggle_at(self.cursor()); + } + /// Toggles the selection state of all items + pub fn toggle_all(&mut self) { + for item in &self.items { + toggle_item(&mut self.selection, item); + } + } + + /// Add row at cursor to selection + pub fn select(&mut self) { + debug!("{}", self.cursor()); + self.select_row(self.cursor()) + } + + /// Add row to selection + pub fn select_row(&mut self, index: usize) { + let item = self.items[index].clone(); + self.selection.insert(item); + } + /// Selects all items + pub fn select_all(&mut self) { + for item in self.items.clone() { + self.selection.insert(item.clone()); + } + } + /// Clears all selections + pub fn clear_selection(&mut self) { + self.selection.clear(); + } + /// Clears all items from the list + pub fn clear(&mut self) { + self.items.clear(); + self.selection.clear(); + self.current = 0; + self.offset = 0; + self.showing_stale_items = false; + } + /// Scrolls the list by the given offset + pub fn scroll_by(&mut self, offset: i32) { + self.current = self + .current + .saturating_add_signed(offset as isize) + .min(self.items.len().saturating_sub(1)) + .max(self.reserved); + debug!("Scrolled to {}", self.current); + debug!("Selection: {:?}", self.selection); + } + /// Selects the previous item in the list + pub fn select_previous(&mut self) { + self.current = self.current.min(self.items.len()).saturating_sub(1).max(self.reserved); + } + /// Selects the next item in the list + pub fn select_next(&mut self) { + self.current = self + .current + .saturating_add(1) + .min(self.items.len().saturating_sub(1)) + .max(self.reserved); + } + /// Jump to the first selectable item (respecting reserved header lines) + pub fn jump_to_first(&mut self) { + if self.items.len() > self.reserved { + self.current = self.reserved; + } + } + /// Jump to the last item in the list + pub fn jump_to_last(&mut self) { + if !self.items.is_empty() { + self.current = self.items.len().saturating_sub(1); + } + } +} + +impl SkimWidget for ItemList { + fn from_options(options: &SkimOptions, theme: Arc) -> Self { + use crate::helper::selector::DefaultSkimSelector; + use crate::util::read_file_lines; + + let skip_to_pattern = options + .skip_to_pattern + .as_ref() + .and_then(|pattern| Regex::new(pattern).ok()); + + // Build the selector from options and calculate pre-select target + let (selector, pre_select_target) = if options.pre_select_n > 0 + || !options.pre_select_pat.is_empty() + || !options.pre_select_items.is_empty() + || options.pre_select_file.is_some() + || options.selector.is_some() + { + match options.selector.clone() { + Some(s) => { + // For custom selectors, use a very large target (pre-select all matching) + (Some(s), usize::MAX) + } + None => { + let mut preset_items: Vec = options + .pre_select_items + .split('\n') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + + if let Some(ref pre_select_file) = options.pre_select_file + && let Ok(file_items) = read_file_lines(pre_select_file) + { + preset_items.extend(file_items); + } + + let selector = DefaultSkimSelector::default() + .first_n(options.pre_select_n) + .regex(&options.pre_select_pat) + .preset(preset_items.clone()); + + // Only use a target for --pre-select-n + // For pattern/items, the selector always returns the same matches regardless of timing + let target = if options.pre_select_n > 0 { + options.pre_select_n + } else { + usize::MAX // No target - keep selecting matching items + }; + + (Some(Rc::new(selector) as Rc), target) + } + } + } else { + (None, 0) + }; + + let (tx, rx) = unbounded_channel(); + let processed_items = Arc::new(SpinLock::new(None)); + + let interactive = options.interactive; + let no_clear_if_empty = options.no_clear_if_empty; + let multi_select = options.multi; + + // Spawn background processing thread with the appropriate configuration + let processed_items_clone = processed_items.clone(); + std::thread::spawn(move || { + Self::process_items_task(rx, processed_items_clone); + }); + + Self { + tx, + processed_items, + reserved: options.header_lines, + direction: match options.layout { + TuiLayout::Default => ratatui::widgets::ListDirection::BottomToTop, + TuiLayout::Reverse | TuiLayout::ReverseList => ratatui::widgets::ListDirection::TopToBottom, + }, + current: options.header_lines, + theme, + multi_select, + no_hscroll: options.no_hscroll, + keep_right: options.keep_right, + skip_to_pattern, + tabstop: options.tabstop.max(1), + selector, + pre_select_target, + no_clear_if_empty, + interactive, + showing_stale_items: false, + manual_hscroll: 0, + items: Default::default(), + selection: Default::default(), + offset: Default::default(), + height: Default::default(), + } + } + + fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender { + let this = &mut *self; + this.height = area.height; + if this.current < this.offset { + this.offset = this.current; + } else if this.offset + area.height as usize <= this.current { + this.offset = this.current - area.height as usize + 1; + } + + // Check for pre-processed items from background thread (non-blocking) + let items_updated = if let Some(processed) = this.processed_items.lock().take() { + debug!("Render: Got {} processed items", processed.items.len()); + + // Check if items are empty or blank for no_clear_if_empty handling + let items_are_empty_or_blank = + processed.items.is_empty() || processed.items.iter().all(|item| item.item.text().trim().is_empty()); + + if this.interactive && this.no_clear_if_empty && items_are_empty_or_blank && !this.items.is_empty() { + debug!( + "no_clear_if_empty: keeping {} old items for display (new items are empty/blank)", + this.items.len() + ); + this.showing_stale_items = true; + } else { + this.items = processed.items; + this.showing_stale_items = false; + + // Apply pre-selection only when new items arrive and only if we haven't reached target + // This runs once per item batch, not on every render + if this.multi_select && this.selector.is_some() && this.selection.len() < this.pre_select_target { + debug!( + "Applying pre-selection to {} items (currently {} selected, target {})", + this.items.len(), + this.selection.len(), + this.pre_select_target + ); + for (index, item) in this.items.iter().enumerate() { + if this.selection.len() >= this.pre_select_target { + break; + } + let should_select = this.selector.as_ref().unwrap().should_select(index, item.item.as_ref()); + if should_select { + debug!("Pre-selecting item[{}]: '{}'", index, item.item.text()); + this.selection.insert(item.clone()); + } + } + debug!("Pre-selected {} items total", this.selection.len()); + } + } + + true + } else { + false + }; + + if this.items.is_empty() { + return SkimRender { items_updated }; + } + + let theme = &this.theme; + + let list = List::new( + this.items + .iter() + .enumerate() + .skip(this.offset) + .take(area.height as usize) + .map(|(idx, item)| { + let is_current = idx == this.current; + let is_selected = this.selection.contains(item); + + // Reserve 2 characters for cursor indicators ("> " or " >") + let container_width = (area.width as usize).saturating_sub(2); + + // Get item text for hscroll calculation + let item_text = item.item.text(); + + // Calculate match positions for hscroll + let (match_start_char, match_end_char) = match &item.matched_range { + Some(MatchRange::Chars(matched_indices)) => { + if !matched_indices.is_empty() { + (matched_indices[0], matched_indices[matched_indices.len() - 1] + 1) + } else { + (0, 0) + } + } + Some(MatchRange::ByteRange(match_start, match_end)) => { + let match_start_char = item_text[..*match_start].chars().count(); + let diff = item_text[*match_start..*match_end].chars().count(); + (match_start_char, match_start_char + diff) + } + None => (0, 0), + }; + + // Calculate horizontal scroll + let (shift, full_width, _has_left, _has_right) = + this.calc_hscroll(&item_text, container_width, match_start_char, match_end_char); + + // Get display content from item + // Avoid cloning chars vector - use reference instead + let matches = match &item.matched_range { + Some(MatchRange::ByteRange(start, end)) => crate::Matches::ByteRange(*start, *end), + Some(MatchRange::Chars(chars)) => crate::Matches::CharIndices(chars.clone()), + None => crate::Matches::None, + }; + + let mut display_line = item.item.display(DisplayContext { + score: item.rank[0], + matches, + container_width, + style: if is_current { + theme.current_match() + } else { + theme.matched() + }, + }); + + // Apply horizontal scrolling to the display content + display_line = this.apply_hscroll(display_line, shift, container_width, full_width); + + // Prepend cursor indicators + // Pre-allocate capacity to avoid reallocation + let mut spans: Vec = Vec::with_capacity(2 + display_line.spans.len()); + spans.push(if is_current { + Span::styled(">", theme.selected().add_modifier(Modifier::BOLD)) + } else { + Span::raw(" ") + }); + spans.push(if this.multi_select && is_selected { + Span::raw(">") + } else { + Span::raw(" ") + }); + spans.extend(display_line.spans); + + Line::from(spans) + }) + .collect::>(), + ) + .direction(this.direction); + + Widget::render(Clear, area, buf); + StatefulWidget::render( + list, + area, + buf, + &mut ListState::default().with_selected(Some(this.current.saturating_sub(this.offset))), + ); + SkimRender { items_updated } + } +} + +fn toggle_item(sel: &mut IndexSet, item: &MatchedItem) { + if sel.contains(item) { + sel.shift_remove(item); + } else { + sel.insert(item.clone()); + } +} diff --git a/skim/src/tui/mod.rs b/skim/src/tui/mod.rs new file mode 100644 index 00000000..32ef0afe --- /dev/null +++ b/skim/src/tui/mod.rs @@ -0,0 +1,173 @@ +//! Terminal UI components and rendering. +//! +//! This module provides the terminal user interface components for skim, +//! including the application state, event handling, rendering widgets, +//! and layout management. + +use std::num::ParseIntError; + +pub use app::App; +pub use event::Event; +pub use preview::PreviewCallback; +use thiserror::Error; +pub use widget::{SkimRender, SkimWidget}; +mod app; +mod backend; +pub use backend::Tui; +/// Event handling and action definitions +pub mod event; +/// Header display components +pub mod header; +mod input; +/// Item list display and management +pub mod item_list; +/// TUI-specific options and configuration +pub mod options; +mod preview; +/// Status line display +pub mod statusline; +/// Widget rendering utilities +pub mod widget; + +/// Represents a size value, either as a percentage or fixed value +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Size { + /// Size as a percentage (0-100) + Percent(u16), + /// Fixed size in terminal cells + Fixed(u16), +} + +/// Direction for movement or layout +#[derive(PartialEq, Eq, Clone, Debug, Copy)] +pub enum Direction { + /// Upward direction + Up, + /// Downward direction + Down, + /// Left direction + Left, + /// Right direction + Right, +} + +impl TryFrom<&str> for Direction { + type Error = &'static str; + fn try_from(value: &str) -> Result { + match value.to_lowercase().as_str() { + "up" => Ok(Self::Up), + "down" => Ok(Self::Down), + "left" => Ok(Self::Left), + "right" => Ok(Self::Right), + _ => Err("Unknown direction {value}"), + } + } +} + +/// Error type for parsing size values +#[derive(Error, Debug, PartialEq, Eq)] +pub enum SizeParseError { + /// Error parsing the size string + #[error("Error parsing {0}: {1:?}")] + ParseError(String, ParseIntError), + /// Percentage value exceeds 100 + #[error("Invalid percentage {0}")] + InvalidPercent(u16), +} + +impl TryFrom<&str> for Size { + type Error = SizeParseError; + + fn try_from(value: &str) -> Result { + if value.ends_with("%") { + let percent = value + .strip_suffix("%") + .unwrap_or_default() + .parse::() + .map_err(|e| SizeParseError::ParseError(value.to_string(), e))?; + if percent > 100 { + return Err(SizeParseError::InvalidPercent(percent)); + } + Ok(Self::Percent(percent)) + } else { + Ok(Self::Fixed( + value + .parse::() + .map_err(|e| SizeParseError::ParseError(value.to_string(), e))?, + )) + } + } +} + +impl Default for Size { + fn default() -> Self { + Self::Percent(100) + } +} + +#[cfg(test)] +mod size_test { + use super::*; + use std::num::IntErrorKind; + #[test] + fn fixed_success() { + assert_eq!(Size::try_from("10"), Ok(Size::Fixed(10u16))); + } + #[test] + fn percent_success() { + assert_eq!(Size::try_from("10%"), Ok(Size::Percent(10u16))); + } + #[test] + fn fixed_neg() { + let SizeParseError::ParseError(err_value, internal_error) = Size::try_from("-10").unwrap_err() else { + assert!(false); + return; + }; + assert_eq!(internal_error.kind(), &IntErrorKind::InvalidDigit); + assert_eq!(err_value, String::from("-10")); + } + #[test] + fn percent_neg() { + let SizeParseError::ParseError(err_value, internal_error) = Size::try_from("-10%").unwrap_err() else { + assert!(false); + return; + }; + assert_eq!(internal_error.kind(), &IntErrorKind::InvalidDigit); + assert_eq!(err_value, String::from("-10%")); + } + #[test] + fn percent_over_100() { + let SizeParseError::InvalidPercent(internal_error) = Size::try_from("110%").unwrap_err() else { + assert!(false); + return; + }; + assert_eq!(internal_error, 110u16); + } + #[test] + fn fixed_invalid_char() { + let SizeParseError::ParseError(value, internal_error) = Size::try_from("1-0").unwrap_err() else { + assert!(false); + return; + }; + assert_eq!(internal_error.kind(), &IntErrorKind::InvalidDigit); + assert_eq!(value, String::from("1-0")); + } + #[test] + fn percent_invalid_char() { + let SizeParseError::ParseError(value, internal_error) = Size::try_from("1-0%").unwrap_err() else { + assert!(false); + return; + }; + assert_eq!(internal_error.kind(), &IntErrorKind::InvalidDigit); + assert_eq!(value, String::from("1-0%")); + } + #[test] + fn percent_empty() { + let SizeParseError::ParseError(value, internal_error) = Size::try_from("%").unwrap_err() else { + assert!(false); + return; + }; + assert_eq!(internal_error.kind(), &IntErrorKind::Empty); + assert_eq!(value, String::from("%")); + } +} diff --git a/skim/src/tui/options.rs b/skim/src/tui/options.rs new file mode 100644 index 00000000..a71e52f7 --- /dev/null +++ b/skim/src/tui/options.rs @@ -0,0 +1,159 @@ +use crate::tui::{Direction, Size}; + +/// Layout configuration for the TUI +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "cli", derive(clap::ValueEnum))] +pub enum TuiLayout { + /// Display from the bottom of the screen + #[default] + Default, + /// Display from the top of the screen + Reverse, + /// Display from the top of the screen, prompt at the bottom + ReverseList, +} + +/// Configuration for the preview pane layout +#[derive(Debug, Clone)] +pub struct PreviewLayout { + /// Direction where preview pane is positioned + pub direction: Direction, + /// Size of the preview pane + pub size: Size, + /// Whether the preview pane is hidden + pub hidden: bool, + /// Optional offset for preview position + pub offset: Option, +} + +impl Default for PreviewLayout { + fn default() -> Self { + Self { + direction: Direction::Right, + size: Size::Percent(50), + hidden: false, + offset: None, + } + } +} + +impl From<&str> for PreviewLayout { + fn from(value: &str) -> Self { + let mut res: Self = PreviewLayout::default(); + // Parse the remainder which can be: size:offset:hidden, offset:hidden, size:hidden, etc. + let parts: Vec<&str> = value.split(':').collect(); + + for part in parts { + if part.is_empty() { + continue; + } + + if part.starts_with('+') { + // This is an offset expression + res.offset = Some(part.to_string()); + } else if part == "hidden" { + res.hidden = true; + } else if part == "nohidden" { + res.hidden = false; + } else { + // Try to parse as size + if let Ok(size) = part.try_into() { + res.size = size; + } + if let Ok(dir) = part.try_into() { + res.direction = dir; + } + } + } + res + } +} + +// impl PreviewLayout { + +// } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_preview_layout_direction_only() { + let layout = PreviewLayout::from("left"); + assert_eq!(layout.direction, Direction::Left); + assert_eq!(layout.size, Size::Percent(50)); // default + assert_eq!(layout.hidden, false); + assert_eq!(layout.offset, None); + + let layout = PreviewLayout::from("right"); + assert_eq!(layout.direction, Direction::Right); + + let layout = PreviewLayout::from("up"); + assert_eq!(layout.direction, Direction::Up); + + let layout = PreviewLayout::from("down"); + assert_eq!(layout.direction, Direction::Down); + } + + #[test] + fn test_preview_layout_with_size() { + let layout = PreviewLayout::from("left:30%"); + assert_eq!(layout.direction, Direction::Left); + assert_eq!(layout.size, Size::Percent(30)); + assert_eq!(layout.hidden, false); + assert_eq!(layout.offset, None); + + let layout = PreviewLayout::from("right:40"); + assert_eq!(layout.direction, Direction::Right); + assert_eq!(layout.size, Size::Fixed(40)); + } + + #[test] + fn test_preview_layout_with_offset() { + let layout = PreviewLayout::from("left:+123"); + assert_eq!(layout.direction, Direction::Left); + assert_eq!(layout.offset, Some("+123".to_string())); + + let layout = PreviewLayout::from("left:+{2}"); + assert_eq!(layout.direction, Direction::Left); + assert_eq!(layout.offset, Some("+{2}".to_string())); + + let layout = PreviewLayout::from("left:+{2}-2"); + assert_eq!(layout.direction, Direction::Left); + assert_eq!(layout.offset, Some("+{2}-2".to_string())); + } + + #[test] + fn test_preview_layout_with_size_and_offset() { + let layout = PreviewLayout::from("left:50%:+{2}"); + assert_eq!(layout.direction, Direction::Left); + assert_eq!(layout.size, Size::Percent(50)); + assert_eq!(layout.offset, Some("+{2}".to_string())); + + let layout = PreviewLayout::from("right:40:+123"); + assert_eq!(layout.direction, Direction::Right); + assert_eq!(layout.size, Size::Fixed(40)); + assert_eq!(layout.offset, Some("+123".to_string())); + } + + #[test] + fn test_preview_layout_with_hidden() { + let layout = PreviewLayout::from("left:hidden"); + assert_eq!(layout.direction, Direction::Left); + assert_eq!(layout.hidden, true); + + let layout = PreviewLayout::from("right:50%:hidden"); + assert_eq!(layout.direction, Direction::Right); + assert_eq!(layout.size, Size::Percent(50)); + assert_eq!(layout.hidden, true); + } + + #[test] + fn test_preview_layout_complex() { + let layout = PreviewLayout::from("left:30%:+{2}-5:hidden"); + assert_eq!(layout.direction, Direction::Left); + assert_eq!(layout.size, Size::Percent(30)); + assert_eq!(layout.offset, Some("+{2}-5".to_string())); + assert_eq!(layout.hidden, true); + } +} diff --git a/skim/src/tui/preview.rs b/skim/src/tui/preview.rs new file mode 100644 index 00000000..791bf7d7 --- /dev/null +++ b/skim/src/tui/preview.rs @@ -0,0 +1,237 @@ +use ansi_to_tui::IntoText; +use color_eyre::eyre::Result; +use ratatui::{ + style::Stylize, + text::{Line, Text}, + widgets::{Block, Borders, Clear, Paragraph, Widget}, +}; +use std::process::Command; +use tokio::task::JoinHandle; + +use super::Direction; +use super::Event; +use super::backend::Tui; + +use crate::theme::ColorTheme; +use crate::tui::widget::{SkimRender, SkimWidget}; +use crate::{SkimItem, SkimOptions}; +use std::sync::Arc; + +// PreviewCallback for ratatui - returns Vec instead of AnsiString +pub type PreviewCallbackFn = dyn Fn(Vec>) -> Vec + Send + Sync + 'static; + +/// Callback function for generating preview content +#[derive(Clone)] +pub struct PreviewCallback { + inner: Arc, +} + +impl From for PreviewCallback +where + F: Fn(Vec>) -> Vec + Send + Sync + 'static, +{ + fn from(func: F) -> Self { + Self { inner: Arc::new(func) } + } +} + +impl std::ops::Deref for PreviewCallback { + type Target = dyn Fn(Vec>) -> Vec + Send + Sync + 'static; + + fn deref(&self) -> &Self::Target { + &*self.inner + } +} + +pub struct Preview<'a> { + pub content: Text<'a>, + pub cmd: String, + pub rows: u16, + pub cols: u16, + pub scroll_y: u16, + pub scroll_x: u16, + pub thread_handle: Option>, + pub theme: Arc, + pub border: bool, + pub direction: Direction, + pub wrap: bool, +} + +impl Default for Preview<'_> { + fn default() -> Self { + Self { + content: Text::default(), + cmd: String::default(), + rows: 0, + cols: 0, + scroll_y: 0, + scroll_x: 0, + thread_handle: None, + theme: Arc::new(ColorTheme::default()), + border: false, + direction: Direction::Right, + wrap: false, + } + } +} + +impl Preview<'_> { + /// Convert a Size value to an actual offset based on preview dimensions + fn size_to_offset(&self, size: super::Size, is_vertical: bool) -> u16 { + match size { + super::Size::Fixed(n) => n, + super::Size::Percent(p) => { + let dimension = if is_vertical { self.rows } else { self.cols }; + (dimension as u32 * p as u32 / 100) as u16 + } + } + } + + pub fn content(&mut self, content: Vec) -> Result<()> { + let text = content.to_owned().into_text()?; + self.content = text; + // Reset scroll when content changes + self.scroll_y = 0; + self.scroll_x = 0; + Ok(()) + } + + pub fn content_with_position(&mut self, content: Vec, position: crate::PreviewPosition) -> Result<()> { + let text = content.to_owned().into_text()?; + self.content = text; + // Apply position offsets + let v_scroll = self.size_to_offset(position.v_scroll, true); + let v_offset = self.size_to_offset(position.v_offset, true); + self.scroll_y = v_scroll.saturating_add(v_offset); + + let h_scroll = self.size_to_offset(position.h_scroll, false); + let h_offset = self.size_to_offset(position.h_offset, false); + self.scroll_x = h_scroll.saturating_add(h_offset); + Ok(()) + } + + pub fn scroll_up(&mut self, lines: u16) { + self.scroll_y = self.scroll_y.saturating_sub(lines); + } + + pub fn scroll_down(&mut self, lines: u16) { + self.scroll_y = self.scroll_y.saturating_add(lines); + } + + pub fn scroll_left(&mut self, cols: u16) { + self.scroll_x = self.scroll_x.saturating_sub(cols); + } + + pub fn scroll_right(&mut self, cols: u16) { + self.scroll_x = self.scroll_x.saturating_add(cols); + } + + pub fn set_offset(&mut self, offset: u16) { + self.scroll_y = offset.saturating_sub(1); // -1 because line numbers are 1-indexed + } + + pub fn page_up(&mut self) { + let page_size = self.rows.saturating_sub(2); // Account for borders + self.scroll_up(page_size); + } + + pub fn page_down(&mut self) { + let page_size = self.rows.saturating_sub(2); // Account for borders + self.scroll_down(page_size); + } + + pub fn run(&mut self, tui: &mut Tui, cmd: &str) { + self.cmd = cmd.to_string(); + let event_tx_clone = tui.event_tx.clone(); + let mut shell_cmd = Command::new("/bin/sh"); + shell_cmd + .env("ROWS", self.rows.to_string()) + .env("COLUMNS", self.cols.to_string()) + .env("PAGER", "") + .arg("-c") + .arg(cmd); + if let Some(th) = &self.thread_handle { + th.abort(); + } + self.thread_handle = Some(tokio::spawn(async move { + let try_out = shell_cmd.output(); + if try_out.is_err() { + println!("Shell cmd in error: {:?}", try_out); + // let _ = _event_tx.send(Event::Error(try_out.unwrap_err().to_string())); + return; + }; + + let out = try_out.unwrap(); + + if out.status.success() { + event_tx_clone + .send(Event::PreviewReady(out.stdout)) + .unwrap_or_else(|e| println!("Failed on success: {e}")); + } else { + event_tx_clone + .send(Event::PreviewReady(out.stderr)) + .unwrap_or_else(|e| println!("Failed on error: {e}")); + // .unwrap_or_else(|e| _event_tx.send(Event::Error(e.to_string())).unwrap()); + } + })); + } +} + +impl<'a> SkimWidget for Preview<'a> { + fn from_options(options: &SkimOptions, theme: Arc) -> Self { + Self { + theme, + border: options.border, + direction: options.preview_window.direction, + ..Default::default() + } + } + + fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender { + if self.rows != area.height || self.cols != area.width { + self.rows = area.height; + self.cols = area.width; + } + + // Calculate total lines in content + let total_lines = self.content.lines.len(); + + // Create paragraph with optional block + let mut paragraph = Paragraph::new(self.content.clone()).scroll((self.scroll_y, self.scroll_x)); + + // Enable wrapping if wrap is true + if self.wrap { + paragraph = paragraph.wrap(ratatui::widgets::Wrap { trim: false }); + } + let mut block = Block::new() + .style(self.theme.normal()) + .border_style(self.theme.border()); + + // Add scroll position indicator at bottom if scrolled + if self.scroll_y > 0 && total_lines > 0 { + let current_line = (self.scroll_y + 1) as usize; // +1 because scroll_y is 0-indexed but we want 1-indexed display + let title = format!("{}/{}", current_line, total_lines); + use ratatui::layout::Alignment; + + block = block.title_top(Line::from(title).alignment(Alignment::Right).reversed()); + } + + if self.border { + block = block.borders(Borders::ALL); + } else { + // No border on preview itself - separator will be drawn between areas + match self.direction { + Direction::Up => block = block.borders(Borders::BOTTOM), + Direction::Down => block = block.borders(Borders::TOP), + Direction::Left => block = block.borders(Borders::RIGHT), + Direction::Right => block = block.borders(Borders::LEFT), + }; + } + paragraph = paragraph.block(block); + + Clear.render(area, buf); + paragraph.render(area, buf); + + SkimRender::default() + } +} diff --git a/skim/src/tui/statusline.rs b/skim/src/tui/statusline.rs new file mode 100644 index 00000000..c89af9e2 --- /dev/null +++ b/skim/src/tui/statusline.rs @@ -0,0 +1,186 @@ +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use ratatui::layout::{Constraint, Layout}; +use ratatui::style::{Modifier, Styled}; +use ratatui::text::{Line, Span, Text, ToText}; +use ratatui::widgets::{Paragraph, Widget}; + +use crate::theme::ColorTheme; +use crate::tui::widget::{SkimRender, SkimWidget}; + +use crate::SkimOptions; + +#[cfg(feature = "cli")] +use clap::ValueEnum; +#[cfg(feature = "cli")] +use clap::builder::PossibleValue; + +/// Display mode for the info/status line +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub enum InfoDisplay { + /// Display info in a separate line (default) + #[default] + Default, + /// Display info inline with the input + Inline, + /// Hide the info display + Hidden, +} + +#[cfg(feature = "cli")] +impl ValueEnum for InfoDisplay { + fn value_variants<'a>() -> &'a [Self] { + use InfoDisplay::*; + &[Default, Inline, Hidden] + } + + fn to_possible_value(&self) -> Option { + use InfoDisplay::*; + match self { + Default => Some(PossibleValue::new("default")), + Inline => Some(PossibleValue::new("inline")), + Hidden => Some(PossibleValue::new("hidden")), + } + } +} + +const SPINNER_DURATION: u32 = 200; +// const SPINNERS: [char; 8] = ['-', '\\', '|', '/', '-', '\\', '|', '/']; +const SPINNERS_INLINE: [char; 2] = ['-', '<']; +const SPINNERS_UNICODE: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +/// Status line widget for displaying search statistics and state +#[derive(Clone)] +pub struct StatusLine { + /// Total number of items + pub total: usize, + /// Number of matched items + pub matched: usize, + /// Number of processed items + pub processed: usize, + /// Whether the matcher is currently running + pub matcher_running: bool, + /// Whether multi-selection mode is enabled + pub multi_selection: bool, + /// Number of selected items + pub selected: usize, + /// Index of the current item + pub current_item_idx: usize, + /// Horizontal scroll offset + pub hscroll_offset: i64, + /// Whether the reader is currently reading items + pub reading: bool, + /// Time elapsed since last read + pub time_since_read: Duration, + /// Time elapsed since last match + pub time_since_match: Duration, + /// Current matcher mode (e.g., "RE" for regex) + pub matcher_mode: String, + /// Color theme + pub theme: Arc, + /// Info display mode + pub info: InfoDisplay, + /// Start time for calculating elapsed time + pub start: Instant, + /// Whether to show the spinner (controlled by App with debouncing) + pub show_spinner: bool, +} + +impl Default for StatusLine { + fn default() -> Self { + let now = Instant::now(); + Self { + total: 0, + matched: 0, + processed: 0, + matcher_running: false, + multi_selection: false, + selected: 0, + current_item_idx: 0, + hscroll_offset: 0, + reading: false, + time_since_read: Duration::from_millis(0), + time_since_match: Duration::from_millis(0), + matcher_mode: String::new(), + theme: Arc::new(ColorTheme::default()), + info: InfoDisplay::Default, + start: now, + show_spinner: false, + } + } +} + +impl SkimWidget for StatusLine { + fn from_options(options: &SkimOptions, theme: Arc) -> Self { + Self { + theme, + info: options.info.clone(), + ..Default::default() + } + } + + fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender { + let info_attr = self.theme.info(); + let info_attr_bold = self.theme.info().add_modifier(Modifier::BOLD); + + // Show indicators during active collection phase or sustained matcher activity + // Show indicators when actively reading or when matcher is running + let show_progress_indicators = self.reading || self.matcher_running; + + // Compute spinner animation timing once for performance + let spinner_elapsed_ms = self.start.elapsed().as_millis(); + + let spinner_set: &[char] = match self.info { + InfoDisplay::Default => &SPINNERS_UNICODE, + InfoDisplay::Inline => &SPINNERS_INLINE, + InfoDisplay::Hidden => panic!("This should never happen"), + }; + + let layout = Layout::horizontal([Constraint::Max(1), Constraint::Min(3), Constraint::Fill(1)]); + let [spinner_a, matched_a, cursor_a] = layout.areas(area); + + // draw the spinner - use same logic as other indicators + if show_progress_indicators { + // use pre-computed elapsed time for stable animation + let index = ((spinner_elapsed_ms / (SPINNER_DURATION as u128)) % (spinner_set.len() as u128)) as usize; + let ch = spinner_set[index]; + Paragraph::new(ch.to_string()) + .style(self.theme.spinner()) + .render(spinner_a, buf); + } else if self.info == InfoDisplay::Inline { + let ch = spinner_set.last().unwrap(); + Paragraph::new(ch.to_string()) + .style(self.theme.spinner()) + .render(spinner_a, buf); + } else { + // Render a space when spinner is not shown to maintain layout + Paragraph::new(" ").render(spinner_a, buf); + } + + // build matched/total and extra info (mode, percentage, selection) + let mut parts: Vec = Vec::new(); + parts.push(Span::styled(format!(" {}/{}", self.matched, self.total), info_attr)); + if !self.matcher_mode.is_empty() { + parts.push(Span::styled(format!("/{}", &self.matcher_mode), info_attr)); + } + if show_progress_indicators && self.total > 0 { + let pct = self.processed.saturating_mul(100) / self.total; + parts.push(Span::styled(format!(" ({}%)", pct), info_attr)); + } + if self.multi_selection && self.selected > 0 { + parts.push(Span::styled(format!(" [{}]", self.selected), info_attr_bold)); + } + // create a Line from spans and convert to Text for Paragraph + let line = Line::from(parts); + Paragraph::new(Text::from(vec![line])).render(matched_a, buf); + + // item cursor (current index / hscroll) + let line_num_str = format!("{}/{}", self.current_item_idx, self.hscroll_offset); + Paragraph::new(line_num_str.to_text().set_style(info_attr_bold)) + .alignment(ratatui::layout::Alignment::Right) + .render(cursor_a, buf); + + SkimRender::default() + } +} diff --git a/skim/src/tui/widget.rs b/skim/src/tui/widget.rs new file mode 100644 index 00000000..758c0d26 --- /dev/null +++ b/skim/src/tui/widget.rs @@ -0,0 +1,22 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use std::sync::Arc; + +use crate::options::SkimOptions; +use crate::theme::ColorTheme; + +/// Result of rendering a SkimWidget +#[derive(Debug, Clone, Copy, Default)] +pub struct SkimRender { + /// Whether the items in the list have been updated + pub items_updated: bool, +} + +/// Trait for Skim TUI widgets +pub trait SkimWidget: Sized { + /// Create a widget from options and theme + fn from_options(options: &SkimOptions, theme: Arc) -> Self; + + /// Render the widget to the buffer + fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender; +} diff --git a/skim/src/util.rs b/skim/src/util.rs index 06996220..14b0050b 100644 --- a/skim/src/util.rs +++ b/skim/src/util.rs @@ -1,495 +1,229 @@ -use std::borrow::Cow; -use std::cmp::{max, min}; +use crate::SkimItem; +use crate::field::FieldRange; +use crate::field::get_string_by_field; +use regex::Regex; use std::fs::File; use std::io::{BufRead, BufReader}; use std::prelude::v1::*; -use std::str::FromStr; -use std::sync::LazyLock; +use std::sync::Arc; -use regex::{Captures, Regex}; -use skim_tuikit::prelude::*; -use unicode_width::UnicodeWidthChar; - -use crate::AnsiString; -use crate::field::get_string_by_range; - -static RE_ESCAPE: LazyLock = LazyLock::new(|| Regex::new(r"['\U{00}]").unwrap()); -static RE_NUMBER: LazyLock = LazyLock::new(|| Regex::new(r"[+|-]?\d+").unwrap()); - -pub fn clear_canvas(canvas: &mut dyn Canvas) -> DrawResult<()> { - let (screen_width, screen_height) = canvas.size()?; - for y in 0..screen_height { - for x in 0..screen_width { - canvas.print(y, x, " ")?; - } - } - Ok(()) -} - -pub fn escape_single_quote(text: &str) -> String { - RE_ESCAPE - .replace_all(text, |x: &Captures| match x.get(0).unwrap().as_str() { - "'" => "'\\''".to_string(), - "\0" => "\\0".to_string(), - _ => "".to_string(), - }) - .to_string() -} - -/// use to print a single line, properly handle the tabstop and shift of a string -/// e.g. a long line will be printed as `..some content` or `some content..` or `..some content..` -/// depends on the container's width and the size of the content. +#[cfg(feature = "cli")] +/// Unescape a delimiter string to handle escape sequences like \x00, \t, \n, etc. +/// +/// Supported escape sequences: +/// - `\x00` - `\xff`: hexadecimal byte values +/// - `\t`: tab +/// - `\n`: newline +/// - `\r`: carriage return +/// - `\\`: backslash +/// +/// # Examples /// -/// ```text -/// let's say we have a very long line with lots of useless information -/// |.. with lots of use..| // only to show this -/// |<- container width ->| -/// |<- shift -> | -/// |< hscroll >| /// ``` -pub struct LinePrinter { - start: usize, - end: usize, - current_pos: i32, - screen_col: usize, +/// use skim::util::unescape_delimiter; +/// +/// assert_eq!(unescape_delimiter(r"\x00"), "\0"); +/// assert_eq!(unescape_delimiter(r"\t"), "\t"); +/// assert_eq!(unescape_delimiter(r"\n"), "\n"); +/// assert_eq!(unescape_delimiter(r"\\"), "\\"); +/// ``` +pub fn unescape_delimiter(s: &str) -> String { + let mut result = String::new(); + let mut chars = s.chars(); - // start position - row: usize, - col: usize, - - tabstop: usize, - shift: usize, - text_width: usize, - container_width: usize, - hscroll_offset: i64, -} - -impl LinePrinter { - pub fn builder() -> Self { - LinePrinter { - start: 0, - end: 0, - current_pos: -1, - screen_col: 0, - - row: 0, - col: 0, - - tabstop: 8, - shift: 0, - text_width: 0, - container_width: 0, - hscroll_offset: 0, - } - } - - pub fn row(mut self, row: usize) -> Self { - self.row = row; - self - } - - pub fn col(mut self, col: usize) -> Self { - self.col = col; - self - } - - pub fn tabstop(mut self, tabstop: usize) -> Self { - self.tabstop = tabstop; - self - } - - pub fn hscroll_offset(mut self, offset: i64) -> Self { - self.hscroll_offset = offset; - self - } - - pub fn text_width(mut self, width: usize) -> Self { - self.text_width = width; - self - } - - pub fn container_width(mut self, width: usize) -> Self { - self.container_width = width; - self - } - - pub fn shift(mut self, shift: usize) -> Self { - self.shift = shift; - self - } - - pub fn build(mut self) -> Self { - self.reset(); - self - } - - pub fn reset(&mut self) { - self.current_pos = 0; - self.screen_col = self.col; - - self.start = max(self.shift as i64 + self.hscroll_offset, 0) as usize; - self.end = self.start + self.container_width; - } - - fn print_ch_to_canvas(&mut self, canvas: &mut dyn Canvas, ch: char, attr: Attr, skip: bool) { - let w = ch.width().unwrap_or(2); - - if !skip { - let _ = canvas.put_cell(self.row, self.screen_col, Cell::default().ch(ch).attribute(attr)); - } - - self.screen_col += w; - } - - fn print_char_raw(&mut self, canvas: &mut dyn Canvas, ch: char, attr: Attr, skip: bool) { - // hide the content that outside the screen, and show the hint(i.e. `..`) for overflow - // the hidden character - - let w = ch.width().unwrap_or(2); - - assert!(self.current_pos >= 0); - let current = self.current_pos as usize; - - if current < self.start || current >= self.end { - // pass if it is hidden - } else if current < self.start + 2 && self.start > 0 { - // print left ".." - for _ in 0..min(w, current - self.start + 1) { - self.print_ch_to_canvas(canvas, '.', attr, skip); - } - } else if self.end - current <= 2 && (self.text_width > self.end) { - // print right ".." - for _ in 0..min(w, self.end - current) { - self.print_ch_to_canvas(canvas, '.', attr, skip); - } - } else { - self.print_ch_to_canvas(canvas, ch, attr, skip); - } - - self.current_pos += w as i32; - } - - pub fn print_char(&mut self, canvas: &mut dyn Canvas, ch: char, attr: Attr, skip: bool) { - match ch { - '\u{08}' => { - // ignore \b character - } - '\t' => { - // handle tabstop - let rest = if self.current_pos < 0 { - self.tabstop - } else { - self.tabstop - (self.current_pos as usize) % self.tabstop - }; - for _ in 0..rest { - self.print_char_raw(canvas, ' ', attr, skip); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('x') => { + // Handle \xNN hex escape + let hex: String = chars.by_ref().take(2).collect(); + if hex.len() == 2 { + if let Ok(byte) = u8::from_str_radix(&hex, 16) { + // For null byte and other non-UTF8 safe bytes, we need to handle carefully + // Regex works with strings, so we push the byte as a char + result.push(byte as char); + } else { + // Invalid hex, keep as literal + result.push('\\'); + result.push('x'); + result.push_str(&hex); + } + } else { + // Not enough hex digits + result.push('\\'); + result.push('x'); + result.push_str(&hex); + } } + Some('t') => result.push('\t'), + Some('n') => result.push('\n'), + Some('r') => result.push('\r'), + Some('\\') => result.push('\\'), + Some(other) => { + // Unknown escape, keep both backslash and character + result.push('\\'); + result.push(other); + } + None => result.push('\\'), } - ch => self.print_char_raw(canvas, ch, attr, skip), - } - } -} - -pub fn print_item(canvas: &mut dyn Canvas, printer: &mut LinePrinter, content: AnsiString, default_attr: Attr) { - for (ch, attr) in content.iter() { - printer.print_char(canvas, ch, default_attr.extend(attr), false); - } -} - -/// return an array, arr[i] store the display width till char[i] -pub fn accumulate_text_width(text: &str, tabstop: usize) -> Vec { - let mut ret = Vec::new(); - let mut w = 0; - for ch in text.chars() { - w += if ch == '\t' { - tabstop - (w % tabstop) } else { - ch.width().unwrap_or(2) - }; - ret.push(w); - } - ret -} - -/// "smartly" calculate the "start" position of the string in order to show the matched contents -/// for example, if the match appear in the end of a long string, we need to show the right part. -/// ```text -/// xxxxxxxxxxxxxxxxxxxxxxxxxxMMxxxxxMxxxxx -/// shift ->| | -/// ``` -/// -/// return (left_shift, full_print_width) -pub fn reshape_string( - text: &str, - container_width: usize, - match_start: usize, - match_end: usize, - tabstop: usize, -) -> (usize, usize) { - if text.is_empty() { - return (0, 0); + result.push(c); + } } - let acc_width = accumulate_text_width(text, tabstop); - let full_width = acc_width[acc_width.len() - 1]; - if full_width <= container_width { - return (0, full_width); - } - - // w1, w2, w3 = len_before_matched, len_matched, len_after_matched - let w1 = if match_start == 0 { - 0 - } else { - acc_width[match_start - 1] - }; - let w2 = if match_end >= acc_width.len() { - full_width - w1 - } else { - acc_width[match_end] - w1 - }; - let w3 = acc_width[acc_width.len() - 1] - w1 - w2; - - if (w1 > w3 && w2 + w3 <= container_width) || (w3 <= 2) { - // right-fixed - //(right_fixed(&acc_width, container_width), full_width) - (full_width - container_width, full_width) - } else if w1 <= w3 && w1 + w2 <= container_width { - // left-fixed - (0, full_width) - } else { - // left-right - (acc_width[match_end] - container_width + 2, full_width) - } -} - -/// margin option string -> Size -/// 10 -> Size::Fixed(10) -/// 10% -> Size::Percent(10) -pub fn margin_string_to_size(margin: &str) -> Size { - if margin.ends_with('%') { - Size::Percent(min(100, margin[0..margin.len() - 1].parse::().unwrap_or(100))) - } else { - Size::Fixed(margin.parse::().unwrap_or(0)) - } -} - -/// Parse margin configuration, e.g. -/// - `TRBL` Same margin for top, right, bottom, and left -/// - `TB,RL` Vertical, horizontal margin -/// - `T,RL,B` Top, horizontal, bottom margin -/// - `T,R,B,L` Top, right, bottom, left margin -pub fn parse_margin(margin_option: &str) -> (Size, Size, Size, Size) { - let margins = margin_option.split(',').collect::>(); - - match margins.len() { - 1 => { - let margin = margin_string_to_size(margins[0]); - (margin, margin, margin, margin) - } - 2 => { - let margin_tb = margin_string_to_size(margins[0]); - let margin_rl = margin_string_to_size(margins[1]); - (margin_tb, margin_rl, margin_tb, margin_rl) - } - 3 => { - let margin_top = margin_string_to_size(margins[0]); - let margin_rl = margin_string_to_size(margins[1]); - let margin_bottom = margin_string_to_size(margins[2]); - (margin_top, margin_rl, margin_bottom, margin_rl) - } - 4 => { - let margin_top = margin_string_to_size(margins[0]); - let margin_right = margin_string_to_size(margins[1]); - let margin_bottom = margin_string_to_size(margins[2]); - let margin_left = margin_string_to_size(margins[3]); - (margin_top, margin_right, margin_bottom, margin_left) - } - _ => (Size::Fixed(0), Size::Fixed(0), Size::Fixed(0), Size::Fixed(0)), - } -} - -/// The context for injecting command. -#[derive(Copy, Clone)] -pub struct InjectContext<'a> { - pub delimiter: &'a Regex, - pub current_index: usize, - pub current_selection: &'a str, - pub indices: &'a [usize], - pub selections: &'a [&'a str], - pub query: &'a str, - pub cmd_query: &'a str, -} - -static RE_ITEMS: LazyLock = LazyLock::new(|| Regex::new(r"\\?(\{ *-?[0-9.+]*? *})").unwrap()); -static RE_FIELDS: LazyLock = LazyLock::new(|| Regex::new(r"\\?(\{ *-?[0-9.,cq+n]*? *})").unwrap()); - -/// Check if a command depends on item -/// e.g. contains `{}`, `{1..}`, `{+}` -pub fn depends_on_items(cmd: &str) -> bool { - RE_ITEMS.is_match(cmd) -} - -/// inject the fields into commands -/// cmd: `echo {1..}`, text: `a,b,c`, delimiter: `,` -/// => `echo b,c` -/// -/// * `{}` for current selection -/// * `{1..}`, etc. for fields -/// * `{+}` for all selections -/// * `{q}` for query -/// * `{cq}` for command query -pub fn inject_command<'a>(cmd: &'a str, context: InjectContext<'a>) -> Cow<'a, str> { - RE_FIELDS.replace_all(cmd, |caps: &Captures| { - // \{... - if &caps[0][0..1] == "\\" { - return caps[0].to_string(); - } - - // {1..} and other variant - let range = &caps[1]; - assert!(range.len() >= 2); - let range = &range[1..range.len() - 1]; - let range = range.trim(); - - if range.starts_with('+') { - let current_selection = vec![context.current_selection]; - let selections = if context.selections.is_empty() { - ¤t_selection - } else { - context.selections - }; - let current_index = vec![context.current_index]; - let indices = if context.indices.is_empty() { - ¤t_index - } else { - context.indices - }; - - return selections - .iter() - .zip(indices.iter()) - .map(|(&s, &i)| { - let rest = &range[1..]; - let index_str = format!("{i}"); - let replacement = match rest { - "" => s, - "n" => &index_str, - _ => get_string_by_range(context.delimiter, s, rest).unwrap_or(""), - }; - format!("'{}'", escape_single_quote(replacement)) - }) - .collect::>() - .join(" "); - } - - let index_str = format!("{}", context.current_index); - let replacement = match range { - "" => context.current_selection, - x if x.starts_with('+') => unreachable!(), - "n" => &index_str, - "q" => context.query, - "cq" => context.cmd_query, - _ => get_string_by_range(context.delimiter, context.current_selection, range).unwrap_or(""), - }; - - format!("'{}'", escape_single_quote(replacement)) - }) -} - -pub fn str_lines(string: &str) -> Vec<&str> { - string.trim_end().split('\n').collect() -} - -pub fn atoi(string: &str) -> Option { - RE_NUMBER.find(string).and_then(|mat| mat.as_str().parse::().ok()) + result } pub fn read_file_lines(filename: &str) -> std::result::Result, std::io::Error> { let file = File::open(filename)?; let ret = BufReader::new(file).lines().collect(); - debug!("file content: {ret:?}"); + debug!("file content: {:?}", ret); ret } +fn escape_arg(a: &str) -> String { + format!("'{}'", a.replace('\0', "\\0").replace("'", "'\\''")) +} + +/// Replace the fields in `pattern` with the items, expanding {...} patterns +/// +/// Replaces: +/// - `{}` -> currently selected item +/// - `{1..}` etc -> fields of currently selected item, whose index is `selected` +/// - `{+}` -> all items +/// - `{q}` -> current query +/// - `{cq}` -> current command query +/// +pub fn printf( + pattern: String, + delimiter: &Regex, + replstr: &str, + items: impl Iterator> + std::clone::Clone, + selected: Option>, + query: &str, + command_query: &str, +) -> String { + let (item_text, field_text) = match selected { + Some(ref s) => (s.output().into_owned(), s.output().into_owned()), + None => (String::default(), String::default()), + }; + // Replace static fields first + let mut res = pattern.replace(replstr, &escape_arg(&item_text)); + + res = res.replace( + "{+}", + &escape_arg( + &items + .clone() + .map(|i| i.output().into_owned()) + .collect::>() + .join(" "), + ), + ); + res = res.replace("{q}", &escape_arg(query)); + res = res.replace("{cq}", &escape_arg(command_query)); + if let Some(ref s) = selected { + res = res.replace("{n}", &format!("{}", &s.get_index())); + } + res = res.replace( + "{+n}", + &items + .map(|i| format!("'{}'", i.get_index())) + .fold(String::new(), |a: String, b| a.to_owned() + b.as_str() + " "), + ); + + let mut inside = false; + let mut pattern = String::new(); + let mut replaced = String::new(); + for c in res.chars() { + if inside { + if c == '}' { + if pattern.is_empty() { + replaced.push_str("{}"); + } else if let Some(range) = FieldRange::from_str(&pattern) { + let replacement = get_string_by_field(delimiter, &field_text, &range).unwrap_or_default(); + replaced.push_str(&escape_arg(replacement)); + } else { + log::warn!("Failed to build field range from {pattern}"); + } + + pattern = String::new(); + inside = false; + } else { + pattern.push(c); + } + } else if c == '{' { + inside = true; + } else { + replaced.push(c); + } + } + + replaced +} + #[cfg(test)] -mod tests { +mod test { use super::*; + use crate::SkimItem; + use regex::Regex; #[test] - fn test_accumulate_text_width() { - assert_eq!(accumulate_text_width("abcdefg", 8), vec![1, 2, 3, 4, 5, 6, 7]); - assert_eq!(accumulate_text_width("ab中de国g", 8), vec![1, 2, 4, 5, 6, 8, 9]); - assert_eq!(accumulate_text_width("ab\tdefg", 8), vec![1, 2, 8, 9, 10, 11, 12]); - assert_eq!(accumulate_text_width("ab中\te国g", 8), vec![1, 2, 4, 8, 9, 11, 12]); + fn test_unescape_delimiter() { + assert_eq!(unescape_delimiter(r"\x00"), "\0"); + assert_eq!(unescape_delimiter(r"\t"), "\t"); + assert_eq!(unescape_delimiter(r"\n"), "\n"); + assert_eq!(unescape_delimiter(r"\r"), "\r"); + assert_eq!(unescape_delimiter(r"\\"), "\\"); + assert_eq!(unescape_delimiter(r"\x09"), "\t"); + assert_eq!(unescape_delimiter(r"\x0a"), "\n"); + assert_eq!(unescape_delimiter(r"foo\x00bar"), "foo\0bar"); + assert_eq!(unescape_delimiter(r"[\t\n ]+"), "[\t\n ]+"); + // Invalid escape sequences should be kept as-is + assert_eq!(unescape_delimiter(r"\xGG"), r"\xGG"); + assert_eq!(unescape_delimiter(r"\x0"), r"\x0"); } #[test] - fn test_reshape_string() { - // no match, left fixed to 0 - assert_eq!(reshape_string("abc", 10, 0, 0, 8), (0, 3)); - assert_eq!(reshape_string("a\tbc", 8, 0, 0, 8), (0, 10)); - assert_eq!(reshape_string("a\tb\tc", 10, 0, 0, 8), (0, 17)); - assert_eq!(reshape_string("a\t中b\tc", 8, 0, 0, 8), (0, 17)); - assert_eq!(reshape_string("a\t中b\tc012345", 8, 0, 0, 8), (0, 23)); + fn test_regex_null_byte_matching() { + use regex::Regex; + + // Test that Regex can match null bytes + let delimiter = unescape_delimiter(r"\x00"); + let re = Regex::new(&delimiter).unwrap(); + let text = "a\x00b\x00c"; + + let matches: Vec<_> = re.find_iter(text).collect(); + assert_eq!(matches.len(), 2, "Should find 2 null byte delimiters"); + assert_eq!(matches[0].start(), 1); + assert_eq!(matches[0].end(), 2); + assert_eq!(matches[1].start(), 3); + assert_eq!(matches[1].end(), 4); } #[test] - fn test_inject_command() { - let delimiter = Regex::new(r",").unwrap(); - let current_selection = "a,b,c"; - let selections = vec!["a,b,c", "x,y,z"]; - let query = "query"; - let cmd_query = "cmd_query"; - - let default_context = InjectContext { - current_index: 0, - delimiter: &delimiter, - current_selection, - selections: &selections, - indices: &[0, 1], - query, - cmd_query, - }; - - assert_eq!("'a,b,c'", inject_command("{}", default_context)); - assert_eq!("'a,b,c'", inject_command("{ }", default_context)); - - assert_eq!("'a'", inject_command("{1}", default_context)); - assert_eq!("'b'", inject_command("{2}", default_context)); - assert_eq!("'c'", inject_command("{3}", default_context)); - assert_eq!("''", inject_command("{4}", default_context)); - assert_eq!("'c'", inject_command("{-1}", default_context)); - assert_eq!("'b'", inject_command("{-2}", default_context)); - assert_eq!("'a'", inject_command("{-3}", default_context)); - assert_eq!("''", inject_command("{-4}", default_context)); - assert_eq!("'a,b'", inject_command("{1..2}", default_context)); - assert_eq!("'b,c'", inject_command("{2..}", default_context)); - - assert_eq!("'query'", inject_command("{q}", default_context)); - assert_eq!("'cmd_query'", inject_command("{cq}", default_context)); - assert_eq!("'a,b,c' 'x,y,z'", inject_command("{+}", default_context)); - assert_eq!("'0'", inject_command("{n}", default_context)); - assert_eq!("'a' 'x'", inject_command("{+1}", default_context)); - assert_eq!("'b' 'y'", inject_command("{+2}", default_context)); - assert_eq!("'0' '1'", inject_command("{+n}", default_context)); - } - - #[test] - fn test_escape_single_quote() { - assert_eq!("'\\''a'\\''\\0", escape_single_quote("'a'\0")); - } - - #[test] - fn test_atoi() { - assert_eq!(None, atoi::("")); - assert_eq!(Some(1), atoi::("1")); - assert_eq!(Some(usize::MAX), atoi::(&format!("{}", usize::MAX))); - assert_eq!(Some(1), atoi::("a1")); - assert_eq!(Some(1), atoi::("1b")); - assert_eq!(Some(1), atoi::("a1b")); - assert_eq!(None, atoi::("-1")); - assert_eq!(Some(-1), atoi::("a-1b")); - assert_eq!(None, atoi::("8589934592")); - assert_eq!(Some(123), atoi::("+'123'")); + fn test_printf() { + let pattern = String::from("[1] {} [2] {..2} [3] {2..} [4] {+} [5] {q} [6] {cq}"); + let items: Vec> = vec![ + Arc::new("item 1"), + Arc::new("item 2"), + Arc::new("item 3"), + Arc::new("item 4"), + ]; + let delimiter = Regex::new(" ").unwrap(); + assert_eq!( + printf( + pattern, + &delimiter, + "{}", + items.iter().map(|x| x.clone()), + Some(Arc::new("item 2")), + "query", + "cmd query" + ), + String::from( + "[1] 'item 2' [2] 'item 2' [3] '2' [4] 'item 1 item 2 item 3 item 4' [5] 'query' [6] 'cmd query'" + ) + ); } } diff --git a/skim/tests/ansi.rs b/skim/tests/ansi.rs new file mode 100644 index 00000000..2fbeceb4 --- /dev/null +++ b/skim/tests/ansi.rs @@ -0,0 +1,40 @@ +#[allow(dead_code)] +#[macro_use] +mod common; + +use common::Keys::*; + +sk_test!(test_ansi_flag_enabled, @cmd "echo -e 'plain\\n\\x1b[31mred\\x1b[0m\\n\\x1b[32mgreen\\x1b[0m'", &["--ansi"], { + @lines |l| (l.len() >= 3 && l.iter().any(|line| line.contains("plain"))); + + @keys Key('d'); + @capture[2] starts_with("> red"); + + @capture_colored[*] contains("\u{1b}[38;5;1mre"); + @keys Enter; +}); + +sk_test!(test_ansi_flag_disabled, @cmd "echo -e 'plain\\n\\x1b[31mred\\x1b[0m\\n\\x1b[32mgreen\\x1b[0m'", &[], { + @capture[*] contains("plain"); + + @keys Str("red"); + + @capture[2] eq("> ?[31mred?[0m"); + + @keys Enter; +}); + +sk_test!(test_ansi_matching_on_stripped_text, @cmd "echo -e '\\x1b[32mgreen\\x1b[0m text\\n\\x1b[31mred\\x1b[0m text\\nplain text'", &["--ansi"], { + @lines |l| (l.len() >= 3 && l.iter().any(|line| line.contains("plain"))); + @keys Str("text"); + // Tiebreak will reorder items + @capture[2] contains("red text"); + @capture[3] contains("green text"); + @capture[4] contains("plain text"); + + + @keys Ctrl(&Key('u')), Str("green"); + @capture[2] contains("green"); + + @lines |l| (l.len() == 3); +}); diff --git a/skim/tests/binds.rs b/skim/tests/binds.rs new file mode 100644 index 00000000..94851730 --- /dev/null +++ b/skim/tests/binds.rs @@ -0,0 +1,96 @@ +#[allow(dead_code)] +#[macro_use] +mod common; + +use common::Keys::*; +use common::TmuxController; +use common::sk; +use std::io::Result; + +sk_test!(bind_execute_0_results, "", &["--bind", "'ctrl-f:execute(echo foo{})'"], { + @capture[0] eq(">"); + @keys Ctrl(&Key('f')), Enter; + @capture[0] ne(">"); + + @output[0] eq("foo"); +}); + +sk_test!(bind_execute_0_results_noref, "", &["--bind", "'ctrl-f:execute(echo foo)'"], { + @capture[0] eq(">"); + @keys Ctrl(&Key('f')), Enter; + @capture[0] ne(">"); + + @output[0] eq("foo"); +}); + +sk_test!(bind_if_non_matched, "a\\nb", &["--bind", "'enter:if-non-matched(backward-delete-char)'", "-q", "ab"], { + @capture[0] starts_with(">"); + @capture[0] starts_with("> ab"); + + @keys Enter; + @capture[0] eq("> a"); + @capture[2] eq("> a"); + + @keys Enter, Key('c'); + @capture[0] starts_with("> ac"); +}); + +sk_test!(bind_append_and_select, "a\\n\\nb\\nc", &["-m", "--bind", "'ctrl-f:append-and-select'"], { + @keys Str("xyz"), Ctrl(&Key('f')); + @capture[2] eq(">>xyz"); +}); + +#[test] +fn bind_reload_no_arg() -> Result<()> { + let tmux = TmuxController::new()?; + + let outfile = tmux.tempfile()?; + let sk_cmd = sk(&outfile, &["--bind", "'ctrl-a:reload'"]) + .replace("SKIM_DEFAULT_COMMAND=", "SKIM_DEFAULT_COMMAND='echo hello'"); + tmux.send_keys(&[Str(&sk_cmd), Enter])?; + tmux.until(|l| l[0].starts_with(">"))?; + + tmux.send_keys(&[Ctrl(&Key('a'))])?; + tmux.until(|l| l.len() > 2 && l[2] == "> hello")?; + + Ok(()) +} + +sk_test!(bind_reload_cmd, "a\\n\\nb\\nc", &["--bind", "'ctrl-a:reload(echo hello)'"], { + @capture[2] eq("> a"); + @keys Ctrl(&Key('a')); + @capture[2] eq("> hello"); +}); + +sk_test!(bind_first_last, @cmd "seq 1 10", &["--bind", "'ctrl-f:first,ctrl-l:last'"], { + @lines |l| (l.len() > 10); + + @keys Ctrl(&Key('f')); + @lines |l| (l.iter().any(|line| line == "> 1")); + + @keys Ctrl(&Key('l')); + @lines |l| (l.iter().any(|line| line == "> 10")); + + @keys Ctrl(&Key('f')); + @lines |l| (l.iter().any(|line| line == "> 1")); +}); + +sk_test!(bind_top_alias, @cmd "seq 1 10", &["--bind", "'ctrl-t:top,ctrl-l:last'"], { + @lines |l| (l.len() > 10); + + @keys Ctrl(&Key('l')); + @lines |l| (l.iter().any(|line| line == "> 10")); + + @keys Ctrl(&Key('t')); + @lines |l| (l.iter().any(|line| line == "> 1")); +}); + +sk_test!(bind_change, @cmd "printf '1\\n12\\n13\\n14\\n15\\n16\\n17\\n18\\n19\\n10'", &["--bind", "'change:first'"], { + @lines |l| (l.len() > 10); + + @keys Up, Up; + @lines |l| (l.iter().any(|x| x.starts_with("> 13"))); + + @keys Key('1'); + @lines |l| (l.iter().any(|x| x.starts_with("> 1"))); +}); diff --git a/skim/tests/case.rs b/skim/tests/case.rs new file mode 100644 index 00000000..371d9680 --- /dev/null +++ b/skim/tests/case.rs @@ -0,0 +1,55 @@ +#[allow(dead_code)] +#[macro_use] +mod common; + +use common::Keys::*; + +sk_test!(case_smart_lower, "aBcDeF", &["--case", "smart"], { + @keys Str("abc"); + @capture[1] contains("1/1"); +}); + +sk_test!(case_smart_exact, "aBcDeF", &["--case", "smart"], { + @keys Str("aBc"); + @capture[1] contains("1/1"); +}); + +sk_test!(case_smart_no_match, "aBcDeF", &["--case", "smart"], { + @keys Str("Abc"); + @capture[1] contains("0/1"); +}); + +sk_test!(case_ignore_lower, "aBcDeF", &["--case", "ignore"], { + @keys Str("abc"); + @capture[1] contains("1/1"); +}); + +sk_test!(case_ignore_exact, "aBcDeF", &["--case", "ignore"], { + @keys Str("aBc"); + @capture[1] contains("1/1"); +}); + +sk_test!(case_ignore_different, "aBcDeF", &["--case", "ignore"], { + @keys Str("Abc"); + @capture[1] contains("1/1"); +}); + +sk_test!(case_ignore_no_match, "aBcDeF", &["--case", "ignore"], { + @keys Str("z"); + @capture[1] contains("0/1"); +}); + +sk_test!(case_respect_lower, "aBcDeF", &["--case", "respect"], { + @keys Str("abc"); + @capture[1] contains("0/1"); +}); + +sk_test!(case_respect_exact, "aBcDeF", &["--case", "respect"], { + @keys Str("aBc"); + @capture[1] contains("1/1"); +}); + +sk_test!(case_respect_no_match, "aBcDeF", &["--case", "respect"], { + @keys Str("Abc"); + @capture[1] contains("0/1"); +}); diff --git a/skim/tests/common/mod.rs b/skim/tests/common/mod.rs new file mode 100644 index 00000000..2364874c --- /dev/null +++ b/skim/tests/common/mod.rs @@ -0,0 +1,695 @@ +use std::{ + fmt::{Display, Formatter}, + fs::File, + io::{BufReader, ErrorKind, Read, Result}, + path::Path, + process::Command, + thread::sleep, + time::Duration, +}; + +use rand::Rng; +use rand::distr::Alphanumeric; +use tempfile::{NamedTempFile, TempDir, tempdir}; +use which::which; + +#[cfg(debug_assertions)] +pub static SK: &str = "SKIM_DEFAULT_OPTIONS= SKIM_DEFAULT_COMMAND= ../target/debug/sk"; +#[cfg(not(debug_assertions))] +pub static SK: &str = "SKIM_DEFAULT_OPTIONS= SKIM_DEFAULT_COMMAND= ../target/release/sk"; + +pub fn sk(outfile: &str, opts: &[&str]) -> String { + format!( + "{} {} > {}.part; mv {}.part {}", + SK, + opts.join(" "), + outfile, + outfile, + outfile + ) +} + +pub fn wait(pred: F) -> Result +where + F: Fn() -> Result, +{ + for _ in 1..200 { + if let Ok(t) = pred() { + return Ok(t); + } + sleep(Duration::from_millis(10)); + } + Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "wait timed out")) +} + +pub enum Keys<'a> { + Str(&'a str), + Key(char), + Ctrl(&'a Keys<'a>), + Alt(&'a Keys<'a>), + Enter, + Tab, + BTab, + Left, + Right, + BSpace, + Up, + Down, +} + +impl Display for Keys<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { + use Keys::*; + match self { + Str(s) => write!(f, "{}", s), + Key(c) => write!(f, "{}", c), + Ctrl(k) => write!(f, "C-{}", k), + Alt(k) => write!(f, "M-{}", k), + Enter => write!(f, "Enter"), + Tab => write!(f, "Tab"), + BTab => write!(f, "BTab"), + Left => write!(f, "Left"), + Right => write!(f, "Right"), + BSpace => write!(f, "BSpace"), + Up => write!(f, "Up"), + Down => write!(f, "Down"), + } + } +} + +pub struct TmuxController { + window: String, + pub tempdir: TempDir, + pub outfile: Option, +} + +impl Default for TmuxController { + fn default() -> Self { + Self { + window: String::new(), + tempdir: tempfile::tempdir().expect("Failed to create tempdir"), + outfile: None, + } + } +} + +impl TmuxController { + pub fn run(args: &[&str]) -> Result> { + let output = Command::new(which("tmux").expect("Please install tmux to $PATH")) + .args(args) + .output()? + .stdout + .split(|c| *c == b'\n') + .map(|bytes| String::from_utf8(bytes.to_vec()).expect("Failed to parse bytes as UTF8 string")) + .collect::>(); + Ok(output[0..output.len() - 1].to_vec()) + } + + pub fn new() -> Result { + let unset_cmd = "unset SKIM_DEFAULT_COMMAND SKIM_DEFAULT_OPTIONS PS1 PROMPT_COMMAND HISTFILE"; + + let shell_cmd = "bash --rcfile None"; + + let name: String = rand::rng() + .sample_iter(&Alphanumeric) + .take(16) + .map(char::from) + .collect(); + + Self::run(&[ + "new-window", + "-d", + "-P", + "-F", + "#I", + "-n", + &name, + &format!("{}; {}", unset_cmd, shell_cmd), + ])?; + + Self::run(&["set-window-option", "-t", &name, "pane-base-index", "0"])?; + + Ok(Self { + window: name, + tempdir: tempdir()?, + outfile: None, + }) + } + + pub fn send_keys(&self, keys: &[Keys]) -> std::io::Result<()> { + print!("typing `"); + for key in keys { + Self::run(&["send-keys", "-t", &self.window, &key.to_string()])?; + print!("{}", key.to_string()); + } + println!("`"); + Ok(()) + } + + pub fn tempfile(&self) -> Result { + Ok(NamedTempFile::new_in(&self.tempdir)? + .path() + .to_str() + .unwrap() + .to_string()) + } + + // Returns the lines in reverted order + pub fn capture(&self) -> Result> { + let tempfile = wait(|| { + let tempfile = self.tempfile()?; + Self::run(&[ + "capture-pane", + "-J", + "-b", + &self.window, + "-t", + &format!("{}.0", self.window), + ])?; + Self::run(&["save-buffer", "-b", &self.window, &tempfile])?; + Ok(tempfile) + })?; + + let mut string_lines = String::new(); + BufReader::new(File::open(tempfile)?).read_to_string(&mut string_lines)?; + + let str_lines = string_lines.trim(); + Ok(str_lines + .split("\n") + .map(|s| s.to_string()) + .collect::>() + .into_iter() + .rev() + .collect()) + } + + // Capture with ANSI escape sequences preserved (using -e flag) + // Returns the lines in reverted order with ANSI codes + pub fn capture_colored(&self) -> Result> { + let tempfile = wait(|| { + let tempfile = self.tempfile()?; + Self::run(&[ + "capture-pane", + "-e", + "-J", + "-b", + &self.window, + "-t", + &format!("{}.0", self.window), + ])?; + Self::run(&["save-buffer", "-b", &self.window, &tempfile])?; + Ok(tempfile) + })?; + + let mut string_lines = String::new(); + BufReader::new(File::open(tempfile)?).read_to_string(&mut string_lines)?; + + let str_lines = string_lines.trim(); + Ok(str_lines + .split("\n") + .map(|s| s.to_string()) + .collect::>() + .into_iter() + .rev() + .collect()) + } + + pub fn until(&self, pred: F) -> std::io::Result<()> + where + F: Fn(&[String]) -> bool, + { + match wait(|| { + let lines = self.capture()?; + if pred(&lines) { + return Ok(true); + } + Err(std::io::Error::new(ErrorKind::Other, "pred not matched")) + }) { + Ok(true) => Ok(()), + Ok(false) => Err(std::io::Error::new(ErrorKind::Other, self.capture()?.join("\n"))), + _ => Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + self.capture()?.join("\n"), + )), + } + } + + /// Capture skim output without ANSI sequences + pub fn output(&self) -> Result> { + if let Some(ref outfile) = self.outfile { + self.output_from(outfile) + } else { + Err(std::io::Error::new( + ErrorKind::NotFound, + "You need to use start_sk to get an outfile", + )) + } + } + + /// Capture skim output from explicit outfile path + pub fn output_from(&self, outfile: &str) -> Result> { + wait(|| { + if Path::new(&outfile).exists() { + Ok(()) + } else { + Err(std::io::Error::new(ErrorKind::NotFound, "outfile does not exist yet")) + } + })?; + let mut string_lines = String::new(); + BufReader::new(File::open(outfile)?).read_to_string(&mut string_lines)?; + + let str_lines = string_lines.trim(); + Ok(str_lines + .split("\n") + .map(|s| s.to_string()) + .collect::>() + .into_iter() + .collect()) + } + + pub fn start_sk(&mut self, stdin_cmd: Option<&str>, opts: &[&str]) -> Result { + let outfile = self.tempfile()?; + let sk_cmd = sk(&outfile, opts); + let cmd = match stdin_cmd { + Some(s) => format!("{} | {}", s, sk_cmd), + None => sk_cmd, + }; + println!("--- starting up sk ---"); + self.send_keys(&[Keys::Str(&cmd), Keys::Enter])?; + println!("--- sk is running ---"); + self.outfile = Some(outfile.clone()); + Ok(outfile) + } +} + +impl Drop for TmuxController { + fn drop(&mut self) { + let _ = Self::run(&["kill-window", "-t", &self.window]); + } +} + +// ============================================================================ +// sk_test! - Macro for writing compact tmux-based integration tests +// ============================================================================ +// +// USAGE GUIDE +// ----------- +// +// 1. INPUT SYNTAX: +// - Echo string: "a\\nb\\nc" -> Runs: echo -n -e 'a\nb\nc' +// - Command: @cmd "seq 1 100" -> Runs: seq 1 100 (pipe to sk) +// +// 2. DSL SYNTAX (Only syntax supported): +// +// sk_test!(test_name, "input", &["--opts"], { +// @capture[0] eq(">"); // Wait until capture[0] == ">" +// @capture[1] trim().starts_with("3/3"); // Wait until capture[1].trim().starts_with("3/3") +// @capture[-1] eq("foo"); // Wait until last line == "foo" +// @capture[*] contains("bar"); // Wait until any line contains "bar" +// @output[0] eq("result"); // Wait until output[0] == "result" +// @output[-1] eq("last"); // Wait until output[-1] (last line) == "last" +// @output[*] starts_with("prefix"); // Wait until any output line starts with "prefix" +// @capture_colored[0] contains("\x1b"); // Wait until colored capture contains ANSI +// @lines |l| (l.len() > 5); // Complex assertion with closure +// @keys Enter, Tab; // Send multiple keys +// @dbg; // Debug print current capture +// }); +// +// NOTE: All methods use wait() for consistent retry behavior. Any TmuxController +// method that takes no args and returns Result> can be used: +// capture, output, capture_colored, etc. +// +// EXAMPLES +// -------- +// +// Example 1: Simple test with echo input (all methods wait/retry) +// sk_test!(simple, "a\\nb\\nc", &[], { +// @capture[0] eq(">"); // Waits until condition is met +// @keys Enter; +// @output[0] eq("a"); // Waits until output is available +// }); +// +// Example 2: Using command input with @cmd +// sk_test!(with_seq, @cmd "seq 1 10", &["--bind", "'ctrl-t:toggle-all'"], { +// @capture[0] eq(">"); +// @keys Ctrl(&Key('t')); +// @capture[2] eq(">>1"); +// }); +// +// Example 3: Complex closures with @lines +// sk_test!(complex, "apple\\nbanana", &[], { +// @lines |l| (l.len() > 4); +// @keys Str("ana"); +// @lines |l| (l.iter().any(|x| x.contains("banana"))); +// }); +// +// Example 4: Method chaining +// sk_test!(chaining, " foo \\n bar ", &[], { +// @capture[2] trim().eq("foo"); +// @keys Enter; +// @output[0] trim().eq("foo"); +// }); +// +// Example 5: Using wildcards and negative indices +// sk_test!(wildcards, "apple\\nbanana\\ncherry", &[], { +// @capture[*] contains("3/3"); // Any line contains "3/3" +// @capture[-1] starts_with(">"); // Last line starts with ">" +// @keys Str("ana"); +// @capture[*] contains("banana"); // Any line contains "banana" +// @keys Enter; +// @output[0] eq("banana"); // First output line +// @output[-1] eq("banana"); // Last output line +// @output[*] starts_with("b"); // Any output line starts with "b" +// }); +// +// Example 6: New array syntax test +// sk_test!(new_syntax_test, "foo\\nbar\\nbaz", &[], { +// @capture[0] starts_with(">"); +// @capture[1] contains("3/3"); +// @keys Enter; +// @output[0] eq("foo"); +// @output[- 1] eq("foo"); +// }); +// +// Example 7: Wildcard syntax test +// sk_test!(wildcard_syntax_test, "apple\\nbanana\\ncherry", &[], { +// @capture[*] contains("3/3"); +// @keys Str("ana"); +// @capture[*] contains("banana"); +// @keys Enter; +// @output[*] eq("banana"); +// }); +// +// Example 8: Comprehensive example showing all features +// sk_test!(comprehensive_example, "foo\\nbar\\nbaz\\nqux", &[], { +// // Positive index with simple method +// @capture[0] starts_with(">"); +// +// // Positive index with method chain +// @capture[1] trim().contains("4/4"); +// +// // Wildcard - check if any line matches +// @capture[*] contains("foo"); +// +// // Send keys +// @keys Str("ba"); +// +// // Negative index - last line +// @capture[- 1] contains("bar"); +// +// // Select first match +// @keys Enter; +// +// // Output assertions +// @output[0] eq("bar"); // First output line +// @output[- 1] eq("bar"); // Last output line +// @output[*] starts_with("b"); // Any output line starts with "b" +// }); +// +// Example 9: Using capture_colored for ANSI escape sequences +// sk_test!(ansi_test, @cmd "echo -e '\\x1b[31mred\\x1b[0m'", &["--ansi"], { +// @capture[*] contains("red"); +// @capture_colored[*] contains("\x1b[31m"); // Check for ANSI codes +// @keys Enter; +// }); +// +// DSL COMMAND REFERENCE +// --------------------- +// @METHOD[N] method_chain Wait until METHOD[N].method_chain is true (N = line number) +// @METHOD[-N] method_chain Wait until METHOD[-N].method_chain is true (negative index) +// @METHOD[*] method_chain Wait until any line matches (uses .iter().any()) +// where METHOD is any TmuxController method returning Result>: +// - capture: Wait until condition is true +// - output: Wait until condition is true +// - capture_colored: Wait until condition is true on colored capture +// All methods use wait() for consistent retry behavior +// @lines |l| (expr) Call tmux.until(|l| expr)? with closure +// @keys key1, key2 Send keys (automatically adds ?) +// @dbg Debug print current capture +// +// NOTES +// ----- +// - The `tmux` variable is implicitly available in DSL blocks +// - All variants automatically handle Result propagation and Ok(()) return +// - DSL closures must be wrapped in parentheses: |l| (expr) +// - Method chains support any String/&str method: eq(), starts_with(), contains(), trim(), etc. +// - You can chain methods: trim().starts_with("foo") +// - Negative indices work like Python: -1 is last element, -2 is second-to-last, etc. +// - ALL methods use wait() with retry logic - no immediate assertions +// - wait() retries every 10ms for up to 10 seconds before timing out +// +#[allow(unused_macros)] +macro_rules! sk_test { + // Standard variant with echo input: explicit variable name with block + ($name:tt, $input:expr, $options:expr, $tmux:ident => $content:block) => { + #[test] + #[allow(unused_variables)] + fn $name() -> std::io::Result<()> { + let mut $tmux = crate::common::TmuxController::new()?; + $tmux.start_sk(Some(&format!("echo -n -e '{}'", $input)), $options)?; + + $content + + Ok(()) + } + }; + + // Standard variant with arbitrary command: use @cmd marker + ($name:tt, @cmd $cmd:expr, $options:expr, $tmux:ident => $content:block) => { + #[test] + #[allow(unused_variables)] + fn $name() -> std::io::Result<()> { + let mut $tmux = crate::common::TmuxController::new()?; + $tmux.start_sk(Some($cmd), $options)?; + + $content + + Ok(()) + } + }; + + // DSL variant with echo input + ($name:tt, $input:expr, $options:expr, { $($content:tt)* }) => { + #[test] + #[allow(unused_variables)] + fn $name() -> std::io::Result<()> { + let mut tmux = crate::common::TmuxController::new()?; + tmux.start_sk(Some(&format!("echo -n -e '{}'", $input)), $options)?; + + sk_test!(@expand tmux; $($content)*); + + Ok(()) + } + }; + + // DSL variant with arbitrary command: use @cmd marker + ($name:tt, @cmd $cmd:expr, $options:expr, { $($content:tt)* }) => { + #[test] + #[allow(unused_variables)] + fn $name() -> std::io::Result<()> { + let mut tmux = crate::common::TmuxController::new()?; + tmux.start_sk(Some($cmd), $options)?; + + sk_test!(@expand tmux; $($content)*); + + Ok(()) + } + }; + + // Token processing rules + (@expand $tmux:ident; ) => {}; + + // Generic method patterns - works with any TmuxController method + // @method[*] - check if any line matches (uses .iter().any()) + (@expand $tmux:ident; @ $method:ident [ * ] $($rest:tt)*) => { + sk_test!(@method_any_collect $tmux, $method, [] ; $($rest)*); + }; + + // @method[-idx] for negative index - supports arbitrary method chains (must come before positive) + (@expand $tmux:ident; @ $method:ident [ - $idx:literal ] $($rest:tt)*) => { + sk_test!(@method_neg_collect $tmux, $method, $idx, [] ; $($rest)*); + }; + + // @method[idx] for positive index - supports arbitrary method chains + (@expand $tmux:ident; @ $method:ident [ $idx:literal ] $($rest:tt)*) => { + sk_test!(@method_pos_collect $tmux, $method, $idx, [] ; $($rest)*); + }; + + // Collect tokens until semicolon for positive index - dispatches to wait or assert + (@method_pos_collect $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*] ; ; $($rest:tt)*) => { + sk_test!(@method_pos_dispatch $tmux, $method, $idx, [$($methods)*]); + sk_test!(@expand $tmux; $($rest)*); + }; + (@method_pos_collect $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*] ; $next:tt $($rest:tt)*) => { + sk_test!(@method_pos_collect $tmux, $method, $idx, [$($methods)* $next] ; $($rest)*); + }; + + // Dispatch for positive index - all methods use wait() + (@method_pos_dispatch $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*]) => { + { + if crate::common::wait(|| { + let lines = $tmux.$method()?; + if lines.len() > $idx && lines[$idx].$($methods)* { + Ok(true) + } else { + Err(std::io::Error::new(std::io::ErrorKind::Other, "condition not met")) + } + }).is_err() { + let lines = $tmux.$method().unwrap_or_default(); + let actual = if lines.len() > $idx { &lines[$idx] } else { "" }; + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("Timed out waiting for {}[{}].{}, got: {}", stringify!($method), $idx, stringify!($($methods)*), actual) + )); + } + } + }; + + // Collect tokens until semicolon for negative index - dispatches to wait or assert + (@method_neg_collect $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*] ; ; $($rest:tt)*) => { + sk_test!(@method_neg_dispatch $tmux, $method, $idx, [$($methods)*]); + sk_test!(@expand $tmux; $($rest)*); + }; + (@method_neg_collect $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*] ; $next:tt $($rest:tt)*) => { + sk_test!(@method_neg_collect $tmux, $method, $idx, [$($methods)* $next] ; $($rest)*); + }; + + // Dispatch for negative index - all methods use wait() + (@method_neg_dispatch $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*]) => { + { + if crate::common::wait(|| { + let lines = $tmux.$method()?; + if lines.len() >= $idx { + let actual_idx = lines.len() - $idx; + if lines[actual_idx].$($methods)* { + Ok(true) + } else { + Err(std::io::Error::new(std::io::ErrorKind::Other, "condition not met")) + } + } else { + Err(std::io::Error::new(std::io::ErrorKind::Other, "not enough lines")) + } + }).is_err() { + let lines = $tmux.$method().unwrap_or_default(); + let actual_idx = lines.len().saturating_sub($idx); + let actual = if lines.len() >= $idx { &lines[actual_idx] } else { "" }; + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("Timed out waiting for {}[-{}].{}, got: {}", stringify!($method), $idx, stringify!($($methods)*), actual) + )); + } + } + }; + + // Collect tokens until semicolon for wildcard [*] - dispatches to wait or assert + (@method_any_collect $tmux:ident, $method:ident, [$($methods:tt)*] ; ; $($rest:tt)*) => { + sk_test!(@method_any_dispatch $tmux, $method, [$($methods)*]); + sk_test!(@expand $tmux; $($rest)*); + }; + (@method_any_collect $tmux:ident, $method:ident, [$($methods:tt)*] ; $next:tt $($rest:tt)*) => { + sk_test!(@method_any_collect $tmux, $method, [$($methods)* $next] ; $($rest)*); + }; + + // Dispatch for wildcard - all methods use wait() + (@method_any_dispatch $tmux:ident, $method:ident, [$($methods:tt)*]) => { + { + if crate::common::wait(|| { + let lines = $tmux.$method()?; + if lines.iter().any(|line| line.$($methods)*) { + Ok(true) + } else { + Err(std::io::Error::new(std::io::ErrorKind::Other, "condition not met")) + } + }).is_err() { + let lines = $tmux.$method().unwrap_or_default(); + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("Timed out waiting for {}[*] any line matching .{}, got: {:?}", stringify!($method), stringify!($($methods)*), lines) + )); + } + } + }; + + // @lines command for tmux.until with closure + (@expand $tmux:ident; @ lines | $param:ident | ( $($body:tt)* ) ; $($rest:tt)*) => { + $tmux.until(|$param| $($body)*)?; + sk_test!(@expand $tmux; $($rest)*); + }; + + // @keys command for send_keys - supports any number of keys + (@expand $tmux:ident; @ keys $($key:expr),+ ; $($rest:tt)*) => { + send_keys!($tmux, $($key),+)?; + sk_test!(@expand $tmux; $($rest)*); + }; + + // @dbg command for debug printing + (@expand $tmux:ident; @ dbg ; $($rest:tt)*) => { + println!("DBG: capture: {:?}", $tmux.capture()?); + println!("DBG: output: {:?}", $tmux.output()?); + sk_test!(@expand $tmux; $($rest)*); + }; + + // Pass through regular Rust statements that access tmux (catch-all, must be last) + (@expand $tmux:ident; $stmt:stmt ; $($rest:tt)*) => { + #[allow(redundant_semicolons)] + { + $stmt; + sk_test!(@expand $tmux; $($rest)*); + } + }; + +} + +#[allow(unused_macros)] +macro_rules! assert_line { + ($tmux:ident, $line_nr:literal $($expression:tt)+) => { + { + if $tmux.until(|l| l.len() > $line_nr && l[$line_nr] $($expression)+).is_err() { + let lines = $tmux.capture().unwrap_or_default(); + let actual = if lines.len() > $line_nr { &lines[$line_nr] } else { "" }; + Err(std::io::std::io::Error::new(std::io::std::io::ErrorKind::TimedOut, format!("Timed out waiting for condition on line {}, got {} but expected it to {}", $line_nr, actual, stringify!($($expression)+)))) + } else { + Ok(()) + } + }? + }; +} + +#[allow(unused_macros)] +macro_rules! send_keys { + ($tmux:ident, $($key:expr),+) => { + $tmux.send_keys(&[$($key),+]) + }; +} + +#[allow(unused_macros)] +macro_rules! assert_output_line { + ($tmux:ident, $line_nr:literal $($expression:tt)+) => { + let output = $tmux.output()?; + println!("Output: {output:?}"); + assert!(output[$line_nr] $($expression)+, "Timed out waiting for condition on output line {}, expected it to {}", $line_nr, stringify!($($expression)+)); + }; +} + +// Ultra-short aliases for compact test writing +// Usage: line!(t, 0 == ">") instead of assert_line!(t, 0 == ">") +#[allow(unused_macros)] +macro_rules! line { + ($tmux:ident, $line_nr:literal $($expression:tt)+) => { + assert_line!($tmux, $line_nr $($expression)+) + }; +} + +#[allow(unused_macros)] +macro_rules! keys { + ($tmux:ident, $($key:expr),+) => { + send_keys!($tmux, $($key),+) + }; +} + +#[allow(unused_macros)] +macro_rules! out { + ($tmux:ident, $line_nr:literal $($expression:tt)+) => { + assert_output_line!($tmux, $line_nr $($expression)+) + }; +} diff --git a/skim/tests/defaults.rs b/skim/tests/defaults.rs new file mode 100644 index 00000000..ab1969cf --- /dev/null +++ b/skim/tests/defaults.rs @@ -0,0 +1,65 @@ +#[allow(dead_code)] +#[macro_use] +mod common; + +use common::{Keys, TmuxController, sk}; +use std::io::Result; + +sk_test!(vanilla, @cmd "seq 1 100000", &[], { + @capture[0] eq(">"); + @capture[1] starts_with(" 100000"); + @capture[1] ends_with("0/0"); + @capture[2] eq("> 1"); + @capture[3] eq(" 2"); +}); + +#[test] +fn default_command() -> Result<()> { + let tmux = TmuxController::new()?; + + let outfile = tmux.tempfile()?; + let sk_cmd = sk(&outfile, &[]).replace("SKIM_DEFAULT_COMMAND=", "SKIM_DEFAULT_COMMAND='echo hello'"); + tmux.send_keys(&[Keys::Str(&sk_cmd), Keys::Enter])?; + tmux.until(|l| l[0].starts_with(">"))?; + tmux.until(|l| l.len() > 1 && l[1].starts_with(" 1/1"))?; + tmux.until(|l| l.len() > 2 && l[2] == "> hello")?; + + tmux.send_keys(&[Keys::Enter])?; + tmux.until(|l| !l[0].starts_with(">"))?; + + let output = tmux.output_from(&outfile)?; + + assert_eq!(output[0], "hello"); + + Ok(()) +} + +sk_test!(version_long, "", &["--version"], { + @output[0] starts_with("sk "); +}); +sk_test!(version_short, "", &["-V"], { + @output[0] starts_with("sk "); +}); + +sk_test!(interactive_mode_command_execution, "", &["-i", "--cmd=\"echo 'foo {q}'\""], { + @capture[0] starts_with("c>"); + @capture[2] starts_with("> foo"); + + @keys Keys::Str("bar"); + @capture[0] starts_with("c> bar"); + @capture[2] starts_with("> foo bar"); + + @keys Keys::Str("baz"); + @capture[0] starts_with("c> barbaz"); + @capture[2] starts_with("> foo barbaz"); +}); + +sk_test!(unicode_input, "", &["-q", "󰬈󰬉󰬊"], { + @capture[0] starts_with("> 󰬈󰬉󰬊"); + @keys Keys::Key('|'); + @capture[0] starts_with("> 󰬈󰬉󰬊|"); + @keys Keys::Left, Keys::Left, Keys::Key('|'); + @capture[0] starts_with("> 󰬈󰬉|󰬊|"); + @keys Keys::Key('󰬈'); + @capture[0] starts_with("> 󰬈󰬉|󰬈󰬊|"); +}); diff --git a/skim/tests/highlighting.rs b/skim/tests/highlighting.rs new file mode 100644 index 00000000..33018335 --- /dev/null +++ b/skim/tests/highlighting.rs @@ -0,0 +1,50 @@ +#[allow(dead_code)] +#[macro_use] +mod common; + +use common::Keys::*; + +sk_test!(highlight_match, @cmd "echo -e 'apple\\nbanana\\ngrape'", &["--color=matched:9,current_match:1"], { + @capture[2] contains("apple"); + @keys Str("pp"); + + // Wait for filtering to complete - should only show apple + @capture[1] contains("1/3"); + @capture[2] contains("apple"); + + @capture_colored[2] contains("a"); + @capture_colored[2] contains("pp"); + @capture_colored[2] contains("le"); + + // Check that the 'p' characters in "apple" have highlighting color codes + @capture_colored[2] contains("\x1b[38;5;1m"); + @capture_colored[2] contains("pp\x1b["); + + @keys Enter; + + @output[0] eq("apple"); +}); + +sk_test!(highlight_split_match, @cmd "echo -e 'apple\\nbanana\\ngrape'", &["--color=matched:9,current_match:1"], { + @capture[2] contains("apple"); + + @keys Str("aaa"); + + // Wait for filtering to complete - should only show banana + @capture[1] contains("1/3"); + @capture[2] contains("banana"); + + + @capture_colored[2] contains("b"); + @capture_colored[2] contains("a"); + @capture_colored[2] contains("n"); + + // Check that the 'p' characters in "apple" have highlighting color codes + @capture_colored[2] contains("\x1b[38;5;1m"); + let highlight_pattern = "\x1b[38;5;1m\x1b[48;5;236ma"; + @capture_colored[2] matches(highlight_pattern).count() == 3; + + @keys Enter; + + @output[0] eq("banana"); +}); diff --git a/e2e/tests/history.rs b/skim/tests/history.rs similarity index 92% rename from e2e/tests/history.rs rename to skim/tests/history.rs index 34cb8a56..92f2addb 100644 --- a/e2e/tests/history.rs +++ b/skim/tests/history.rs @@ -1,5 +1,8 @@ -use e2e::Keys::*; -use e2e::TmuxController; +#[allow(dead_code)] +mod common; + +use common::Keys::*; +use common::TmuxController; use std::fs::File; use std::io::Read; use std::io::Result; @@ -8,7 +11,7 @@ use std::path::Path; #[test] fn query_history() -> Result<()> { - let tmux = TmuxController::new()?; + let mut tmux = TmuxController::new()?; let histfile = tmux.tempfile()?; File::create(&histfile)?.write(b"a\nb\nc")?; @@ -49,7 +52,7 @@ fn query_history() -> Result<()> { #[test] fn cmd_history() -> Result<()> { - let tmux = TmuxController::new()?; + let mut tmux = TmuxController::new()?; let histfile = tmux.tempfile()?; File::create(&histfile)?.write(b"a\nb\nc")?; diff --git a/skim/tests/issues.rs b/skim/tests/issues.rs new file mode 100644 index 00000000..106f660e --- /dev/null +++ b/skim/tests/issues.rs @@ -0,0 +1,42 @@ +#[allow(dead_code)] +#[macro_use] +mod common; + +use crate::common::Keys::*; + +sk_test!(issue_359_multi_regex_unicode, @cmd "echo 'ああa'", &["--regex", "-q", "'a'"], { + @capture[0] eq("> a"); + @capture[2] eq("> ああa"); +}); + +sk_test!(issue_361_literal_space_control, "foo bar\\nfoo bar", &["-q", "'foo\\ bar'"], { + @lines |l| (l.len() == 4); + @capture[0] starts_with(">"); + @capture[2] eq("> foo bar"); +}); + +sk_test!(issue_361_literal_space_invert, "foo bar\\nfoo bar", &["-q", "'!foo\\ bar'"], { + @capture[0] starts_with(">"); + @capture[2] eq("> foo bar"); +}); + +sk_test!(issue_547_null_match, "\\0Test Test Test", &[], { + @keys Str("Test"); + @capture[0] starts_with(">"); + @capture[2] starts_with("> Test Test Test"); + + @capture_colored[2] starts_with("\u{1b}[1m\u{1b}[38;5;168m\u{1b}[48;5;236m>\u{1b}[0m \u{1b}[38;5;151m\u{1b}[48;5;236mTest"); +}); + +sk_test!(issue_xxx_null_delimiter_with_nth, "a\\0b\\0c", &["--delimiter", "'\\x00'", "--with-nth", "2"], { + @capture[0] starts_with(">"); + @capture[2] starts_with("> b"); +}); +sk_test!(issue_xxx_null_delimiter_nth, "a\\0b\\0c", &["--delimiter", "'\\x00'", "--nth", "2"], { + @keys Key('c'); + @capture[0] starts_with("> c"); + @capture[1] contains("0/1"); + @keys BSpace, Key('b'); + @capture[0] starts_with("> b"); + @capture[2] starts_with("> abc"); +}); diff --git a/skim/tests/keys.rs b/skim/tests/keys.rs new file mode 100644 index 00000000..46ac510f --- /dev/null +++ b/skim/tests/keys.rs @@ -0,0 +1,190 @@ +#[allow(dead_code)] +#[macro_use] +mod common; + +use common::Keys::*; + +sk_test!(keys_basic, @cmd "seq 1 100000", &[], { + @lines |l| (l.len() >= 2 && l[0].starts_with(">")); + @capture[1] starts_with(" 100000"); + @keys Str("99"); + @capture[0] eq("> 99"); + @lines |l| (l.len() >= 3 && l[1].starts_with(" 8146/100000")); + @capture[2] eq("> 99"); +}); + +// Input navigation keys + +sk_test!(keys_arrows, "", &["-q", "'foo bar foo-bar'"], { + @capture[0] starts_with(">"); + @keys Left, Key('|'); + @capture[0] eq("> foo bar foo-ba|r"); + @keys Right, Key('|'); + @capture[0] eq("> foo bar foo-ba|r|"); +}); + +sk_test!(keys_ctrl_arrows, "", &["-q", "'foo bar foo-bar'"], { + @capture[0] starts_with(">"); + @keys Ctrl(&Left), Key('|'); + @capture[0] eq("> foo bar foo-|bar"); + @keys Ctrl(&Left), Key('|'); + @capture[0] eq("> foo bar |foo-|bar"); + @keys Ctrl(&Right), Key('|'); + @capture[0] eq("> foo bar |foo-|bar|"); +}); + +sk_test!(keys_ctrl_a, "", &["-q", "'foo bar foo-bar'"], { + @capture[0] starts_with(">"); + @keys Ctrl(&Key('a')), Key('|'); + @capture[0] eq("> |foo bar foo-bar"); +}); + +sk_test!(keys_ctrl_b, "", &["-q", "'foo bar foo-bar'"], { + @capture[0] starts_with(">"); + @keys Ctrl(&Key('a')), Key('|'); + @capture[0] eq("> |foo bar foo-bar"); + @keys Ctrl(&Key('f')), Key('|'); + @capture[0] eq("> |f|oo bar foo-bar"); +}); + +sk_test!(keys_ctrl_e, "", &["-q", "'foo bar foo-bar'"], { + @capture[0] starts_with(">"); + @keys Ctrl(&Key('a')), Key('|'); + @capture[0] eq("> |foo bar foo-bar"); + @keys Ctrl(&Key('e')), Key('|'); + @capture[0] eq("> |foo bar foo-bar|"); +}); + +sk_test!(keys_ctrl_f, "", &["-q", "'foo bar foo-bar'"], { + @capture[0] starts_with(">"); + @keys Ctrl(&Key('a')), Key('|'); + @capture[0] eq("> |foo bar foo-bar"); + @keys Ctrl(&Key('f')), Key('|'); + @capture[0] eq("> |f|oo bar foo-bar"); +}); + +sk_test!(keys_ctrl_h, "", &["-q", "'foo bar foo-bar'"], { + @capture[0] starts_with(">"); + @keys Ctrl(&Key('h')), Key('|'); + @capture[0] eq("> foo bar foo-ba|"); +}); + +sk_test!(keys_alt_b, "", &["-q", "'foo bar foo-bar'"], { + @capture[0] starts_with(">"); + @keys Alt(&Key('b')), Key('|'); + @capture[0] eq("> foo bar foo-|bar"); +}); + +sk_test!(keys_alt_f, "", &["-q", "'foo bar foo-bar'"], { + @capture[0] starts_with(">"); + @keys Ctrl(&Key('a')), Key('|'); + @capture[0] eq("> |foo bar foo-bar"); + @keys Alt(&Key('f')), Key('|'); + @capture[0] eq("> |foo| bar foo-bar"); +}); + +// Input manipulation keys + +sk_test!(keys_bspace, "", &["-q", "'foo bar foo-bar'"], { + @capture[0] starts_with(">"); + @keys BSpace, Key('|'); + @capture[0] eq("> foo bar foo-ba|"); +}); + +sk_test!(keys_ctrl_d, "", &["-q", "'foo bar foo-bar'"], { + @capture[0] starts_with(">"); + @keys Ctrl(&Key('a')), Key('|'); + @capture[0] eq("> |foo bar foo-bar"); + @keys Ctrl(&Key('d')), Key('|'); + @capture[0] eq("> ||oo bar foo-bar"); +}); + +sk_test!(keys_ctrl_u, "", &["-q", "'foo bar foo-bar'"], { + @capture[0] starts_with(">"); + @keys Ctrl(&Key('u')), Key('|'); + @capture[0] eq("> |"); +}); + +sk_test!(keys_ctrl_w, "", &["-q", "'foo bar foo-bar'"], { + @capture[0] starts_with(">"); + @keys Ctrl(&Key('w')), Key('|'); + @capture[0] eq("> foo bar |"); +}); + +sk_test!(keys_ctrl_y, "", &["-q", "'foo bar foo-bar'"], { + @capture[0] starts_with(">"); + @keys Alt(&BSpace), Key('|'); + @capture[0] eq("> foo bar foo-|"); + @keys Ctrl(&Key('y')), Key('|'); + @capture[0] eq("> foo bar foo-|bar|"); +}); + +sk_test!(keys_alt_d, "", &["-q", "'foo bar foo-bar'"], { + @capture[0] starts_with(">"); + @keys Ctrl(&Left), Key('|'); + @capture[0] eq("> foo bar foo-|bar"); + @keys Ctrl(&Left), Key('|'); + @capture[0] eq("> foo bar |foo-|bar"); + @keys Alt(&Key('d')), Key('|'); + @capture[0] eq("> foo bar ||-|bar"); +}); + +sk_test!(keys_alt_bspace, "", &["-q", "'foo bar foo-bar'"], { + @capture[0] starts_with(">"); + @keys Alt(&BSpace), Key('|'); + @capture[0] eq("> foo bar foo-|"); +}); + +// Results navigation keys + +sk_test!(keys_ctrl_k, @cmd "seq 1 100000", &[], { + @capture[0] starts_with(">"); + @capture[1] starts_with(" 100000"); + @keys Ctrl(&Key('k')); + @capture[2] eq(" 1"); + @capture[3] eq("> 2"); +}); + +sk_test!(keys_tab, @cmd "seq 1 100000", &[], { + @capture[0] starts_with(">"); + @capture[1] starts_with(" 100000"); + @keys Ctrl(&Key('k')); + @capture[2] eq(" 1"); + @capture[3] eq("> 2"); + @keys Tab; + @capture[2] eq("> 1"); + @capture[3] eq(" 2"); +}); + +sk_test!(keys_btab, @cmd "seq 1 100000", &[], { + @capture[0] starts_with(">"); + @capture[1] starts_with(" 100000"); + @keys BTab; + @capture[2] eq(" 1"); + @capture[3] eq("> 2"); +}); + +sk_test!(keys_enter, @cmd "seq 1 100000", &[], { + @capture[0] starts_with(">"); + @capture[1] starts_with(" 100000"); + @keys Enter; + @capture[0] ne(">"); + @output[0] eq("1"); +}); + +sk_test!(keys_ctrl_m, @cmd "seq 1 100000", &[], { + @capture[0] starts_with(">"); + @capture[1] starts_with(" 100000"); + @keys Ctrl(&Key('m')); + @capture[0] ne(">"); + @output[0] eq("1"); +}); + +sk_test!(keys_tab_empty, "", &[], { + @capture[0] starts_with(">"); + @keys Tab; + @capture[0] starts_with(">"); + @keys Key('a'); + @capture[0] starts_with("> a"); + +}); diff --git a/skim/tests/keys_interactive.rs b/skim/tests/keys_interactive.rs new file mode 100644 index 00000000..e5fde137 --- /dev/null +++ b/skim/tests/keys_interactive.rs @@ -0,0 +1,181 @@ +#[allow(dead_code)] +#[macro_use] +mod common; + +use common::Keys::*; + +sk_test!(keys_interactive_basic, @cmd "seq 1 100000", &["-i"], { + @capture[0] starts_with("c>"); + @capture[1] starts_with(" 100000"); + @keys Str("99"); + @capture[0] eq("c> 99"); + @capture[1] starts_with(" 100000/100000"); + @capture[2] eq("> 1"); +}); + +// Input navigation keys + +sk_test!(keys_interactive_arrows, "", &["-i", "--cmd-query", "'foo bar foo-bar'"], { + @capture[0] starts_with("c>"); + @keys Left, Key('|'); + @capture[0] eq("c> foo bar foo-ba|r"); + @keys Right, Key('|'); + @capture[0] eq("c> foo bar foo-ba|r|"); +}); + +sk_test!(keys_interactive_ctrl_arrows, "", &["-i", "--cmd-query", "'foo bar foo-bar'"], { + @capture[0] starts_with("c>"); + @keys Ctrl(&Left), Key('|'); + @capture[0] eq("c> foo bar foo-|bar"); + @keys Ctrl(&Left), Key('|'); + @capture[0] eq("c> foo bar |foo-|bar"); + @keys Ctrl(&Right), Key('|'); + @capture[0] eq("c> foo bar |foo-|bar|"); +}); + +sk_test!(keys_interactive_ctrl_a, "", &["-i", "--cmd-query", "'foo bar foo-bar'"], { + @capture[0] starts_with("c>"); + @keys Ctrl(&Key('a')), Key('|'); + @capture[0] eq("c> |foo bar foo-bar"); +}); + +sk_test!(keys_interactive_ctrl_b, "", &["-i", "--cmd-query", "'foo bar foo-bar'"], { + @capture[0] starts_with("c>"); + @keys Ctrl(&Key('a')), Key('|'); + @capture[0] eq("c> |foo bar foo-bar"); + @keys Ctrl(&Key('f')), Key('|'); + @capture[0] eq("c> |f|oo bar foo-bar"); +}); + +sk_test!(keys_interactive_ctrl_e, "", &["-i", "--cmd-query", "'foo bar foo-bar'"], { + @capture[0] starts_with("c>"); + @keys Ctrl(&Key('a')), Key('|'); + @capture[0] eq("c> |foo bar foo-bar"); + @keys Ctrl(&Key('e')), Key('|'); + @capture[0] eq("c> |foo bar foo-bar|"); +}); + +sk_test!(keys_interactive_ctrl_f, "", &["-i", "--cmd-query", "'foo bar foo-bar'"], { + @capture[0] starts_with("c>"); + @keys Ctrl(&Key('a')), Key('|'); + @capture[0] eq("c> |foo bar foo-bar"); + @keys Ctrl(&Key('f')), Key('|'); + @capture[0] eq("c> |f|oo bar foo-bar"); +}); + +sk_test!(keys_interactive_ctrl_h, "", &["-i", "--cmd-query", "'foo bar foo-bar'"], { + @capture[0] starts_with("c>"); + @keys Ctrl(&Key('h')), Key('|'); + @capture[0] eq("c> foo bar foo-ba|"); +}); + +sk_test!(keys_interactive_alt_b, "", &["-i", "--cmd-query", "'foo bar foo-bar'"], { + @capture[0] starts_with("c>"); + @keys Alt(&Key('b')), Key('|'); + @capture[0] eq("c> foo bar foo-|bar"); +}); + +sk_test!(keys_interactive_alt_f, "", &["-i", "--cmd-query", "'foo bar foo-bar'"], { + @capture[0] starts_with("c>"); + @keys Ctrl(&Key('a')), Key('|'); + @capture[0] eq("c> |foo bar foo-bar"); + @keys Alt(&Key('f')), Key('|'); + @capture[0] eq("c> |foo| bar foo-bar"); +}); + +// Input manipulation keys + +sk_test!(keys_interactive_bspace, "", &["-i", "--cmd-query", "'foo bar foo-bar'"], { + @capture[0] starts_with("c>"); + @keys BSpace, Key('|'); + @capture[0] eq("c> foo bar foo-ba|"); +}); + +sk_test!(keys_interactive_ctrl_d, "", &["-i", "--cmd-query", "'foo bar foo-bar'"], { + @capture[0] starts_with("c>"); + @keys Ctrl(&Key('a')), Key('|'); + @capture[0] eq("c> |foo bar foo-bar"); + @keys Ctrl(&Key('d')), Key('|'); + @capture[0] eq("c> ||oo bar foo-bar"); +}); + +sk_test!(keys_interactive_ctrl_u, "", &["-i", "--cmd-query", "'foo bar foo-bar'"], { + @capture[0] starts_with("c>"); + @keys Ctrl(&Key('u')), Key('|'); + @capture[0] eq("c> |"); +}); + +sk_test!(keys_interactive_ctrl_w, "", &["-i", "--cmd-query", "'foo bar foo-bar'"], { + @capture[0] starts_with("c>"); + @keys Ctrl(&Key('w')), Key('|'); + @capture[0] eq("c> foo bar |"); +}); + +sk_test!(keys_interactive_ctrl_y, "", &["-i", "--cmd-query", "'foo bar foo-bar'"], { + @capture[0] starts_with("c>"); + @keys Alt(&BSpace), Key('|'); + @capture[0] eq("c> foo bar foo-|"); + @keys Ctrl(&Key('y')), Key('|'); + @capture[0] eq("c> foo bar foo-|bar|"); +}); + +sk_test!(keys_interactive_alt_d, "", &["-i", "--cmd-query", "'foo bar foo-bar'"], { + @capture[0] starts_with("c>"); + @keys Ctrl(&Left), Key('|'); + @capture[0] eq("c> foo bar foo-|bar"); + @keys Ctrl(&Left), Key('|'); + @capture[0] eq("c> foo bar |foo-|bar"); + @keys Alt(&Key('d')), Key('|'); + @capture[0] eq("c> foo bar ||-|bar"); +}); + +sk_test!(keys_interactive_alt_bspace, "", &["-i", "--cmd-query", "'foo bar foo-bar'"], { + @capture[0] starts_with("c>"); + @keys Alt(&BSpace), Key('|'); + @capture[0] eq("c> foo bar foo-|"); +}); + +// Results navigation keys + +sk_test!(keys_interactive_ctrl_k, @cmd "seq 1 100000", &["-i"], { + @capture[0] starts_with("c>"); + @capture[1] starts_with(" 100000"); + @keys Ctrl(&Key('k')); + @capture[2] eq(" 1"); + @capture[3] eq("> 2"); +}); + +sk_test!(keys_interactive_tab, @cmd "seq 1 100000", &["-i"], { + @capture[0] starts_with("c>"); + @capture[1] starts_with(" 100000"); + @keys Ctrl(&Key('k')); + @capture[2] eq(" 1"); + @capture[3] eq("> 2"); + @keys Tab; + @capture[2] eq("> 1"); + @capture[3] eq(" 2"); +}); + +sk_test!(keys_interactive_btab, @cmd "seq 1 100000", &["-i"], { + @capture[0] starts_with("c>"); + @capture[1] starts_with(" 100000"); + @keys BTab; + @capture[2] eq(" 1"); + @capture[3] eq("> 2"); +}); + +sk_test!(keys_interactive_enter, @cmd "seq 1 100000", &["-i"], { + @capture[0] starts_with("c>"); + @capture[1] starts_with(" 100000"); + @keys Enter; + @capture[0] ne("c>"); + @output[0] eq("1"); +}); + +sk_test!(keys_interactive_ctrl_m, @cmd "seq 1 100000", &["-i"], { + @capture[0] starts_with("c>"); + @capture[1] starts_with(" 100000"); + @keys Ctrl(&Key('m')); + @capture[0] ne("c>"); + @output[0] eq("1"); +}); diff --git a/skim/tests/options.rs b/skim/tests/options.rs new file mode 100644 index 00000000..fcd2e204 --- /dev/null +++ b/skim/tests/options.rs @@ -0,0 +1,623 @@ +#[allow(dead_code)] +#[macro_use] +mod common; + +use common::Keys::*; +use common::TmuxController; +use std::io::Result; +use std::io::Write; +use tempfile::NamedTempFile; + +fn setup(input: &str, opts: &[&str]) -> Result { + let mut tmux = TmuxController::new()?; + tmux.start_sk(Some(&format!("echo -n -e '{input}'")), opts)?; + tmux.until(|l| l.len() > 0 && l[0].starts_with(">"))?; + Ok(tmux) +} + +sk_test!(opt_read0, "a\\0b\\0c", &["--read0"], { + @capture[1] starts_with(" 3/3"); + @capture[2] starts_with("> a"); + @capture[3] ends_with("b"); + @capture[4] ends_with("c"); +}); + +sk_test!(opt_print0, "a\\nb\\nc", &["-m", "--print0"], { + @lines |l| (l.len() > 4); + @keys BTab, BTab, Enter; + @lines |l| (l.len() > 0 && !l[0].starts_with(">")); + @output[0] eq("a\0b\0"); +}); + +sk_test!(opt_with_nth_preview, "f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "2..", "--preview", "'echo X{1}Y'"], { + @capture[*] contains("Xf1Y"); +}); + +sk_test!(opt_min_query_length, "line1\\nline2\\nline3", &["--min-query-length", "3"], { + // With empty query, no results should be shown + @capture[1] contains("0/3"); + + @keys Str("li"); + @capture[0] starts_with("> li"); + @capture[1] contains("0/3"); + + @keys Key('n'); + @capture[0] starts_with("> lin"); + @capture[1] contains("3/3"); + @capture[*] contains("line"); +}); + +sk_test!(opt_min_query_length_interactive, "line1\\nline2\\nline3", &["--min-query-length", "3", "-i"], { + // With empty query, no results should be shown + @capture[1] contains("0/3"); + + @keys Str("li"); + @capture[0] starts_with("c> li"); + @capture[1] contains("0/3"); + + @keys Key('n'); + @capture[0] starts_with("c> lin"); + @capture[1] contains("3/3"); + @capture[*] contains("line"); +}); + +sk_test!(opt_with_nth_1, "f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "1"], { + @capture[2] eq("> f1,"); +}); +sk_test!(opt_with_nth_2, "f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "2"], { + @capture[2] eq("> f2,"); +}); +sk_test!(opt_with_nth_4, "f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "4"], { + @capture[2] eq("> f4"); +}); +sk_test!(opt_with_nth_oob, "f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "5"], { + @capture[2] eq(">"); +}); + +sk_test!(opt_with_nth_neg_1, "f1,f2,f3,f4", &["--delimiter", ",", "--with-nth=-1"], { + @capture[2] eq("> f4"); +}); +sk_test!(opt_with_nth_neg_2, "f1,f2,f3,f4", &["--delimiter", ",", "--with-nth=-2"], { + @capture[2] eq("> f3,"); +}); +sk_test!(opt_with_nth_neg_4, "f1,f2,f3,f4", &["--delimiter", ",", "--with-nth=-4"], { + @capture[2] eq("> f1,"); +}); +sk_test!(opt_with_nth_oob_4, "f1,f2,f3,f4", &["--delimiter", ",", "--with-nth=-5"], { + @capture[2] eq(">"); +}); +sk_test!(opt_with_nth_range_to_end, "f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "2.."], { + @capture[2] eq("> f2,f3,f4"); +}); +sk_test!(opt_with_nth_range_from_start, "f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "..3"], { + @capture[2] eq("> f1,f2,f3,"); +}); +sk_test!(opt_with_nth_range_closed, "f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "2..3"], { + @capture[2] eq("> f2,f3,"); +}); +sk_test!(opt_with_nth_range_desc, "f1,f2,f3,f4", &["--delimiter", ",", "--with-nth", "3..2"], { + @capture[2] eq(">"); +}); + +sk_test!(opt_nth_1, "f1,f2,f3,f4", &["--delimiter", ",", "--nth", "1"], { + @keys Key('1'); + @capture[0] eq("> 1"); + @capture[1] contains("1/1"); + @capture[2] eq("> f1,f2,f3,f4"); + + @keys Ctrl(&Key('w')); + @capture[0] eq(">"); + + @keys Str("2"); + @capture[0] eq("> 2"); + @capture[1] contains("0/1"); +}); +sk_test!(opt_nth_2, "f1,f2,f3,f4", &["--delimiter", ",", "--nth", "2"], { + @keys Str("2"); + @capture[0] eq("> 2"); + @capture[1] contains("1/1"); + @capture[2] eq("> f1,f2,f3,f4"); + + @keys Ctrl(&Key('w')); + @capture[0] eq(">"); + + @keys Str("1"); + @capture[0] eq("> 1"); + @capture[1] contains("0/1"); +}); + +sk_test!(opt_nth_4, "f1,f2,f3,f4", &["--delimiter", ",", "--nth", "4"], { + @keys Str("4"); + @capture[0] eq("> 4"); + @capture[1] contains("1/1"); + @capture[2] eq("> f1,f2,f3,f4"); + + @keys Ctrl(&Key('w')); + @capture[0] eq(">"); + + @keys Str("1"); + @capture[0] eq("> 1"); + @capture[1] contains("0/1"); +}); + +sk_test!(opt_nth_oob, "f1,f2,f3,f4", &["--delimiter", ",", "--nth", "5"], { + @capture[1] contains("1/1"); + @capture[2] eq("> f1,f2,f3,f4"); + + @keys Str("1"); + @capture[0] eq("> 1"); + @capture[1] contains("0/1"); +}); +sk_test!(opt_nth_neg_1, "f1,f2,f3,f4", &["--delimiter", ",", "--nth=-1"], { + @keys Str("4"); + @capture[0] eq("> 4"); + @capture[1] contains("1/1"); + @capture[2] eq("> f1,f2,f3,f4"); + + @keys Ctrl(&Key('w')); + @capture[0] eq(">"); + + @keys Str("1"); + @capture[0] eq("> 1"); + @capture[1] contains("0/1"); +}); + +sk_test!(opt_nth_neg_2, "f1,f2,f3,f4", &["--delimiter", ",", "--nth=-2"], { + @keys Str("3"); + @capture[0] eq("> 3"); + @capture[1] contains("1/1"); + @capture[2] eq("> f1,f2,f3,f4"); + + @keys Ctrl(&Key('w')); + @capture[0] eq(">"); + + @keys Str("1"); + @capture[0] eq("> 1"); + @capture[1] contains("0/1"); +}); + +sk_test!(opt_nth_neg_4, "f1,f2,f3,f4", &["--delimiter", ",", "--nth=-4"], { + @keys Str("1"); + @capture[0] eq("> 1"); + @capture[1] contains("1/1"); + @capture[2] eq("> f1,f2,f3,f4"); + + @keys Ctrl(&Key('w')); + @capture[0] eq(">"); + + @keys Str("2"); + @capture[0] eq("> 2"); + @capture[1] contains("0/1"); +}); + +sk_test!(opt_nth_neg_oob, "f1,f2,f3,f4", &["--delimiter", ",", "--nth=-5"], { + @capture[1] contains("1/1"); + @capture[2] eq("> f1,f2,f3,f4"); + + @keys Str("1"); + @capture[0] eq("> 1"); + @capture[1] contains("0/1"); +}); +sk_test!(opt_nth_range_to_end, "f1,f2,f3,f4", &["--delimiter", ",", "--nth", "2.."], { + @keys Str("3"); + @capture[0] eq("> 3"); + @capture[1] contains("1/1"); + @capture[2] eq("> f1,f2,f3,f4"); + + @keys Ctrl(&Key('w')); + @capture[0] eq(">"); + + @keys Str("1"); + @capture[0] eq("> 1"); + @capture[1] contains("0/1"); +}); + +sk_test!(opt_nth_range_from_start, "f1,f2,f3,f4", &["--delimiter", ",", "--nth", "..3"], { + @keys Str("1"); + @capture[0] eq("> 1"); + @capture[1] contains("1/1"); + @capture[2] eq("> f1,f2,f3,f4"); + + @keys Ctrl(&Key('w')); + @capture[0] eq(">"); + + @keys Str("4"); + @capture[0] eq("> 4"); + @capture[1] contains("0/1"); +}); + +sk_test!(opt_nth_range_closed, "f1,f2,f3,f4", &["--delimiter", ",", "--nth", "2..3"], { + @keys Str("2"); + @capture[0] eq("> 2"); + @capture[1] contains("1/1"); + @capture[2] eq("> f1,f2,f3,f4"); + + @keys Ctrl(&Key('w')); + @capture[0] eq(">"); + + @keys Str("3"); + @capture[0] eq("> 3"); + @capture[1] contains("1/1"); + @capture[2] eq("> f1,f2,f3,f4"); + + @keys Ctrl(&Key('w')); + @capture[0] eq(">"); + + @keys Str("1"); + @capture[0] eq("> 1"); + @capture[1] contains("0/1"); + + @keys Ctrl(&Key('w')); + @capture[0] eq(">"); + + @keys Str("4"); + @capture[0] eq("> 4"); + @capture[1] contains("0/1"); +}); + +sk_test!(opt_nth_range_dec, "f1,f2,f3,f4", &["--delimiter", ",", "--nth", "3..2"], { + @capture[1] contains("1/1"); + @capture[2] eq("> f1,f2,f3,f4"); + + @keys Str("1"); + @capture[0] eq("> 1"); + @capture[1] contains("0/1"); +}); + +sk_test!(opt_print_query, "10\\n20\\n30", &["-q", "2", "--print-query"], { + @capture[2] eq("> 20"); + @keys Enter; + @capture[0] ne("> 2"); + + @dbg; + @output[0] eq("2"); + @output[1] eq("20"); +}); + +sk_test!(opt_print_cmd, "1\\n2\\n3", &["--cmd-query", "cmd", "--print-cmd"], { + @lines |l| (l.len() > 4); + @capture[0] starts_with(">"); + @capture[2] eq("> 1"); + @keys Enter; + @output[0] eq("cmd"); + @output[1] eq("1"); +}); + +sk_test!(opt_print_cmd_and_query, "10\\n20\\n30", &["--cmd-query", "cmd", "--print-cmd", "-q", "2", "--print-query"], { + @capture[0] starts_with("> 2"); + @capture[2] eq("> 20"); + @keys Enter; + @output[0] eq("2"); + @output[1] eq("cmd"); + @output[2] eq("20"); +}); + +sk_test!(opt_hscroll_begin, &format!("b{}", &["a"; 1000].join("")), &["-q", "b"], { + @capture[2] ends_with(".."); +}); + +sk_test!(opt_hscroll_middle, &format!("{}b{}", &["a"; 1000].join(""), &["a"; 1000].join("")), &["-q", "b"], { + @capture[2] ends_with(".."); + @capture[2] starts_with("> .."); +}); + +sk_test!(opt_hscroll_end, &format!("{}b", &["a"; 1000].join("")), &["-q", "b"], { + @capture[2] starts_with("> .."); +}); + +sk_test!(opt_no_hscroll, &format!("{}b", &["a"; 1000].join("")), &["-q", "b", "--no-hscroll"], { + @lines |l| (l.len() > 2 && !l[2].starts_with("> ..")); + @capture[2] ends_with(".."); +}); + +sk_test!(opt_tabstop_default, "a\\tb", &[], { + @capture[2] starts_with("> a b"); +}); + +sk_test!(opt_tabstop_1, "a\\tb", &["--tabstop", "1"], { + @capture[2] starts_with("> a b"); +}); + +sk_test!(opt_tabstop_3, "aa\\tb", &["--tabstop", "3"], { + @capture[2] starts_with("> aa b"); +}); + +sk_test!(opt_info_control, "a\\nb\\nc", &[], { + @capture[0] starts_with(">"); + @capture[1] starts_with(" 3/3"); + @capture[1] ends_with("0/0"); + + @keys Key('a'); + @capture[1] starts_with(" 1/3"); + @capture[1] ends_with("0/0"); +}); + +sk_test!(opt_info_default, "a\\nb\\nc", &["--info", "default"], { + @capture[0] starts_with(">"); + @capture[1] starts_with(" 3/3"); + @capture[1] ends_with("0/0"); + + @keys Key('a'); + @capture[1] starts_with(" 1/3"); + @capture[1] ends_with("0/0"); +}); + +sk_test!(opt_no_info, "a\\nb\\nc", &["--no-info"], { + @capture[0] eq(">"); + @capture[1] eq("> a"); +}); + +sk_test!(opt_info_hidden, "a\\nb\\nc", &["--info", "hidden"], { + @capture[0] eq(">"); + @capture[1] eq("> a"); +}); + +sk_test!(opt_info_inline, "a\\nb\\nc", &["--info", "inline"], { + @lines |l| (l.len() > 0 && l[0].starts_with("> < 3/3") && l[0].ends_with("0/0")); + @capture[0] starts_with("> < 3/3"); + @capture[0] ends_with("0/0"); + + @keys Key('a'); + @capture[0] starts_with("> a < 1/3"); + @capture[0] ends_with("0/0"); +}); + +sk_test!(opt_inline_info, "a\\nb\\nc", &["--inline-info"], { + @capture[0] starts_with("> < 3/3"); + @capture[0] ends_with("0/0"); + + @keys Key('a'); + @capture[0] starts_with("> a < 1/3"); + @capture[0] ends_with("0/0"); +}); + +sk_test!(opt_header_only, "a\\nb\\nc", &["--header", "test_header"], { + @capture[2] trim().eq("test_header"); +}); + +sk_test!(opt_header_inline_info, "a\\nb\\nc", &["--header", "test_header", "--inline-info"], { + @capture[1] trim().eq("test_header"); +}); + +sk_test!(opt_header_reverse, @cmd "echo -e -n 'a\\nb\\nc'", &["--header", "test_header", "--reverse"], { + @capture[-1] starts_with(">"); + @capture[-3] trim().eq("test_header"); +}); + +sk_test!(opt_header_reverse_inline_info, @cmd "echo -e -n 'a\\nb\\nc'", &["--header", "test_header", "--reverse", "--inline-info"], { + @capture[-1] starts_with(">"); + @capture[-2] trim().eq("test_header"); +}); + +sk_test!(opt_header_lines_1, "a\\nb\\nc", &["--header-lines", "1"], { + @capture[2] trim().eq("a"); + @capture[3] starts_with(">"); +}); + +sk_test!(opt_header_lines_all, "a\\nb\\nc", &["--header-lines", "4"], { + @capture[2] trim().eq("a"); + @capture[3] trim().eq("b"); + @capture[4] trim().eq("c"); +}); + +sk_test!(opt_header_lines_inline_info, "a\\nb\\nc", &["--header-lines", "1", "--inline-info"], { + @capture[1] trim().eq("a"); +}); + +sk_test!(opt_header_lines_reverse, @cmd "echo -e -n 'a\\nb\\nc'", &["--header-lines", "1", "--reverse"], { + @capture[-1] starts_with(">"); + @capture[-3] trim().eq("a"); + @capture[-4] trim().eq("> b"); +}); + +sk_test!(opt_header_lines_reverse_inline_info, @cmd "echo -e -n 'a\\nb\\nc'", &["--header-lines", "1", "--reverse", "--inline-info"], { + @capture[-1] starts_with(">"); + @capture[-2] trim().eq("a"); + @capture[-3] trim().eq("> b"); +}); + +sk_test!(opt_reserved_options, "a\\nb", &[], tmux => { + let reserved_options = [ + "--extended", + "--literal", + "--no-mouse", + "--cycle", + "--hscroll-off=10", + "--filepath-word", + "--jump-labels=CHARS", + "--border", + "--inline-info", + "--header=STR", + "--header-lines=1", + "--no-bold", + "--history-size=10", + "--sync", + "--no-sort", + "--select-1", + "-1", + "--exit-0", + "-0", + ]; + + for option in reserved_options { + println!("Starting sk with opt {}", option); + setup("a\\nb", &[option])?; + } +}); + +sk_test!(opt_multiple_flags_basic, "a\\nb", &[], tmux => { + let basic_flags = [ + "--bind=ctrl-a:cancel --bind ctrl-b:cancel", + "--tiebreak=begin --tiebreak=score", + "--cmd asdf --cmd find", + "--query asdf -q xyz", + "--delimiter , --delimiter . -d ,", + "--nth 1,2 --nth=1,3 -n 1,3", + "--with-nth 1,2 --with-nth=1,3", + "-I {} -I XX", + "--color base --color light", + "--margin 30% --margin 0", + "--min-height 30% --min-height 10", + "--preview 'ls {}' --preview 'cat {}'", + "--preview-window up --preview-window down", + "--multi -m", + "--no-multi --no-multi", + "--tac --tac", + "--ansi --ansi", + "--exact -e", + "--regex --regex", + "--literal --literal", + "--no-mouse --no-mouse", + "--cycle --cycle", + "--no-hscroll --no-hscroll", + "--filepath-word --filepath-word", + "--border --border", + "--inline-info --inline-info", + "--no-bold --no-bold", + "--print-query --print-query", + "--print-cmd --print-cmd", + "--print0 --print0", + "--sync --sync", + "--extended --extended", + "--no-sort --no-sort", + "--select-1 --select-1", + "--exit-0 --exit-0", + ]; + + for cmd_flags in basic_flags { + setup("a\\nb", &[cmd_flags])?; + } +}); + +sk_test!(opt_multiple_flags_prompt, "", &["--prompt a", "--prompt b", "-p c"], { + @capture[0] starts_with("c"); +}); + +sk_test!(opt_multiple_flags_cmd_prompt, "", &["-i", "--cmd-prompt a", "--cmd-prompt c"], { + @capture[0] starts_with("c"); +}); + +sk_test!(opt_multiple_flags_cmd_query, "", &["-i", "--cmd-query a", "--cmd-query b"], { + @capture[0] starts_with("c> b"); +}); + +sk_test!(opt_multiple_flags_interactive, "", &["-i", "--interactive", "--interactive"], { + @capture[0] starts_with("c>"); +}); + +sk_test!(opt_multiple_flags_reverse, "", &["--reverse", "--reverse"], { + @capture[-1] starts_with(">"); +}); + +sk_test!(opt_multiple_flags_combined_nth, "a b c\\nd e f", &["--nth 1,2"], { + @keys Key('c'); + @capture[1] contains("0/2"); +}); + +sk_test!(opt_multiple_flags_combined_with_nth, "a b c\\nd e f", &["--with-nth 1,2"], { + @capture[2] ends_with("a b"); + @capture[3] ends_with("d e"); +}); + +sk_test!(opt_ansi_null, "a\\0b", &["--ansi"], { + @capture[1] trim().starts_with("1/1"); + @keys Enter; + @output[0] contains("\0"); +}); + +sk_test!(opt_skip_to_pattern, "a/b/c", &["--skip-to-pattern", "'[^/]*$'", "--bind", "ctrl-a:scroll-left", "--bind", "ctrl-x:scroll-right"], { + @capture[2] starts_with("> ..c"); + @keys Ctrl(&Key('a')); + @capture[2] starts_with("> ../c"); + @keys Ctrl(&Key('x')); + @capture[2] starts_with("> ..c"); +}); + +sk_test!(opt_multi, "a\\nb\\nc", &["--multi"], { + @capture[4] trim().eq("c"); + + @keys BTab; + @capture[2] trim().eq(">a"); + @capture[3] trim().eq("> b"); + @keys BTab; + @capture[2] trim().eq(">a"); + @capture[3] trim().eq(">b"); + @capture[4] trim().eq("> c"); + @keys Enter; + + @output[0] trim().eq("a"); + @output[1] trim().eq("b"); +}); + +sk_test!(opt_pre_select_n, "a\\nb\\nc", &["-m", "--pre-select-n", "2"], { + @capture[2] eq(">>a"); + @capture[3] trim().eq(">b"); +}); + +sk_test!(opt_pre_select_items, "a\\nb\\nc", &["-m", "--pre-select-items", "$'b\\nc'"], { + @capture[2] trim().eq("> a"); + @capture[3] trim().eq(">b"); + @capture[4] trim().eq(">c"); +}); + +sk_test!(opt_pre_select_pat, "a\\nb\\nc", &["-m", "--pre-select-pat", "'[b|c]'"], { + @capture[2] trim().eq("> a"); + @capture[3] trim().eq(">b"); + @capture[4] trim().eq(">c"); +}); + +sk_test!(opt_pre_select_file, "a\\nb\\nc", &[], tmux => { + let mut pre_select_file = NamedTempFile::new()?; + pre_select_file.write(b"b\nc")?; + let tmux = setup( + "a\\nb\\nc", + &["-m", "--pre-select-file", pre_select_file.path().to_str().unwrap()], + )?; + tmux.until(|l| l.len() > 4 && l[2] == "> a" && l[3].trim() == ">b" && l[4].trim() == ">c")?; +}); + +sk_test!(opt_no_clear_if_empty, @cmd "echo -ne 'a\\nb\\nc'", &["-i", "--no-clear-if-empty", "-c", "'echo -ne {}'"], { + @capture[0] trim().eq("c>"); + + @keys Str("xxxx"); + @capture[0] trim().eq("c> xxxx"); + @capture[1] trim().starts_with("1/1"); + + @keys Ctrl(&Key('w')); + @capture[0] trim().starts_with("c>"); + @capture[1] trim().starts_with("0/0"); + @capture[2] trim().starts_with("> xxxx"); +}); + +sk_test!(opt_accept_arg, "a\\nb", &["--bind", "ctrl-a:accept:hello"], { + @capture[1] trim().starts_with("2/2"); + @keys Ctrl(&Key('a')); + @output[0] eq("hello"); + @output[1] eq("a"); +}); + +sk_test!(opt_tac, "a\\nb", &["--tac"], { + @capture[1] trim().starts_with("2/2"); + @capture[2] starts_with("> b"); + @capture[3] contains("a"); +}); + +sk_test!(opt_tac_with_header_lines, "a\\nb\\nc\\nd\\ne", &["--tac", "--header-lines", "2"], { + // Should have 3 selectable items (c, d, e reversed to e, d, c) + // The count shows matched/total: 3 matched out of 3 selectable (5 total items with 2 headers) + @capture[1] trim().starts_with("5/3"); + + // Headers should be first 2 items from input (a, b) in original order + @capture[2] trim().eq("a"); + @capture[3] trim().eq("b"); + + // First selectable item should be 'e' (last from input, first in reversed order) + @capture[4] starts_with("> e"); +}); + +sk_test!(opt_replstr, "", &["-I", "..", "-i", "-c", "'echo foo {} ..'"], { + @capture[0] starts_with("c>"); + @capture[2] starts_with("> foo {}"); + @keys Key('a'); + @capture[2] starts_with("> foo {} a"); +}); diff --git a/skim/tests/preview.rs b/skim/tests/preview.rs new file mode 100644 index 00000000..f0d01c6c --- /dev/null +++ b/skim/tests/preview.rs @@ -0,0 +1,43 @@ +#[allow(dead_code)] +#[macro_use] +mod common; + +const PREVIEW: &'static str = "'printf \"=%.0s\\n\" $(seq 1 1000)'"; + +sk_test!(preview_preserve_quotes, @cmd "echo \"'\\\"ABC\\\"'\"", &["--preview", "\"echo X{}X\""], { + @capture[*] contains("X'\"ABC\"'X"); +}); + +sk_test!(preview_nul_char, @cmd "echo -ne 'a\\0b'", &["--preview", "'echo -en \"{}\" | hexdump -C'"], { + @capture[0] starts_with(">"); + @capture[*] contains("61 00 62"); +}); + +sk_test!(preview_window_left, @cmd "echo -ne 'a\\nb'", &["--preview", PREVIEW, "--preview-window", "left"], { + @capture[*] contains(">"); + @capture[0] starts_with("="); +}); + +sk_test!(preview_window_down, @cmd "echo -ne 'a\\nb'", &["--preview", PREVIEW, "--preview-window", "down"], { + @capture[0] starts_with("="); +}); + +sk_test!(preview_window_up, @cmd "echo -ne 'a\\nb'", &["--preview", PREVIEW, "--preview-window", "up"], { + @capture[0] starts_with(">"); + @capture[-1] eq("="); +}); + +sk_test!(preview_offset_fixed, @cmd "echo -ne 'a\\nb'", &["--preview", PREVIEW, "--preview-window", "left:+123"], { + @capture[-1] starts_with("123"); + @capture[-1] contains("123/1000"); +}); + +sk_test!(preview_offset_expr, @cmd "echo -ne '123 321'", &["--preview", PREVIEW, "--preview-window", "left:+{2}"], { + @capture[-1] starts_with("321"); + @capture[-1] contains("321/1000"); +}); + +sk_test!(preview_offset_fixed_and_expr, @cmd "echo -ne '123 321'", &["--preview", PREVIEW, "--preview-window", "left:+{2}-2"], { + @capture[-1] starts_with("319"); + @capture[-1] contains("319/1000"); +}); diff --git a/skim/tests/tiebreak.rs b/skim/tests/tiebreak.rs new file mode 100644 index 00000000..49fc376e --- /dev/null +++ b/skim/tests/tiebreak.rs @@ -0,0 +1,75 @@ +#[allow(dead_code)] +#[macro_use] +mod common; + +use common::Keys::*; + +sk_test!(tiebreak_default, @cmd "echo -en 'a\\nc\\nab\\nac\\nb'", &["--tiebreak=score,begin,end"], { + @lines |l| (l.len() >= 3 && l[0].starts_with(">")); + @capture[2] starts_with("> a"); + @keys Key('b'); + @capture[2] starts_with("> b"); +}); + +sk_test!(tiebreak_neg_score, @cmd "echo -en 'a\\nb\\nc\\nab\\nac'", &["--tiebreak=-score"], { + @lines |l| (l.len() >= 3 && l[0].starts_with(">")); + @capture[2] starts_with("> a"); + @keys Key('b'); + @capture[2] starts_with("> ab"); +}); + +sk_test!(tiebreak_index, @cmd "echo -en 'a\\nc\\nab\\nac\\nb'", &["--tiebreak=index,score"], { + @lines |l| (l.len() >= 3 && l[0].starts_with(">")); + @capture[2] starts_with("> a"); + @keys Key('b'); + @capture[2] starts_with("> ab"); +}); + +sk_test!(tiebreak_neg_index, @cmd "echo -en 'a\\nb\\nc\\nab\\nac'", &["--tiebreak=-index,score"], { + @lines |l| (l.len() >= 3 && l[0].starts_with(">")); + @capture[2] starts_with("> a"); + @keys Key('b'); + @capture[2] starts_with("> ab"); +}); + +sk_test!(tiebreak_begin, @cmd "echo -en 'aaba\\nb\\nc\\naba\\nac'", &["--tiebreak=begin,score"], { + @lines |l| (l.len() >= 3 && l[0].starts_with(">")); + @capture[2] starts_with("> aaba"); + @keys Str("ba"); + @capture[2] starts_with("> aba"); +}); + +sk_test!(tiebreak_neg_begin, @cmd "echo -en 'aba\\nb\\nc\\naaba\\nac'", &["--tiebreak=-begin,score"], { + @lines |l| (l.len() >= 3 && l[0].starts_with(">")); + @capture[2] starts_with("> a"); + @keys Key('b'); + @capture[2] starts_with("> aaba"); +}); + +sk_test!(tiebreak_end, @cmd "echo -en 'aaba\\nb\\nc\\naba\\nac'", &["--tiebreak=end,score"], { + @lines |l| (l.len() >= 3 && l[0].starts_with(">")); + @capture[2] starts_with("> aaba"); + @keys Str("ba"); + @capture[2] starts_with("> aba"); +}); + +sk_test!(tiebreak_neg_end, @cmd "echo -en 'aba\\nb\\nc\\naaba\\nac'", &["--tiebreak=-end,score"], { + @capture[0] starts_with(">"); + @lines |l| (l.len() == 7 && l[2].starts_with("> a")); + @keys Str("ba"); + @capture[2] starts_with("> aaba"); +}); + +sk_test!(tiebreak_length, @cmd "echo -en 'aaba\\nb\\nc\\naba\\nac'", &["--tiebreak=length,score"], { + @lines |l| (l.len() >= 3 && l[0].starts_with(">")); + @capture[2] starts_with("> b"); + @keys Str("ba"); + @capture[2] starts_with("> aba"); +}); + +sk_test!(tiebreak_neg_length, @cmd "echo -en 'aaba\\nb\\nc\\naba\\nac'", &["--tiebreak=-length,score"], { + @lines |l| (l.len() >= 3 && l[0].starts_with(">")); + @capture[2] starts_with("> aaba"); + @keys Key('c'); + @capture[2] starts_with("> ac"); +}); diff --git a/e2e/tests/tmux.rs b/skim/tests/tmux.rs similarity index 91% rename from e2e/tests/tmux.rs rename to skim/tests/tmux.rs index 7cbfb59d..e0cfdb03 100644 --- a/e2e/tests/tmux.rs +++ b/skim/tests/tmux.rs @@ -1,5 +1,8 @@ -use e2e::Keys::*; -use e2e::TmuxController; +#[allow(dead_code)] +mod common; + +use common::Keys::*; +use common::TmuxController; use std::fs::File; use std::fs::Permissions; use std::io::Read; @@ -40,7 +43,7 @@ fn get_tmux_cmd(outfile: &str) -> Result { #[test] fn tmux_vanilla() -> Result<()> { - let tmux = TmuxController::new()?; + let mut tmux = TmuxController::new()?; let outfile = setup_tmux_mock(&tmux)?; tmux.start_sk(None, &["--tmux"])?; tmux.until(|_| Path::new(&outfile).exists())?; @@ -53,7 +56,7 @@ fn tmux_vanilla() -> Result<()> { } #[test] fn tmux_stdin() -> Result<()> { - let tmux = TmuxController::new()?; + let mut tmux = TmuxController::new()?; let outfile = setup_tmux_mock(&tmux)?; tmux.start_sk(Some("ls"), &["--tmux"])?; tmux.until(|_| Path::new(&outfile).exists())?; @@ -66,7 +69,7 @@ fn tmux_stdin() -> Result<()> { #[test] fn tmux_quote_bash() -> Result<()> { - let tmux = TmuxController::new()?; + let mut tmux = TmuxController::new()?; let outfile = setup_tmux_mock(&tmux)?; tmux.send_keys(&[Str("export SHELL=/bin/bash"), Enter])?; tmux.start_sk(None, &["--tmux", "--bind 'ctrl-a:reload(ls /foo*)'"])?; @@ -80,7 +83,7 @@ fn tmux_quote_bash() -> Result<()> { } #[test] fn tmux_quote_zsh() -> Result<()> { - let tmux = TmuxController::new()?; + let mut tmux = TmuxController::new()?; let outfile = setup_tmux_mock(&tmux)?; tmux.send_keys(&[Str("export SHELL=/bin/zsh"), Enter])?; tmux.start_sk(None, &["--tmux", "--bind 'ctrl-a:reload(ls /foo*)'"])?; @@ -95,7 +98,7 @@ fn tmux_quote_zsh() -> Result<()> { } #[test] fn tmux_quote_sh() -> Result<()> { - let tmux = TmuxController::new()?; + let mut tmux = TmuxController::new()?; let outfile = setup_tmux_mock(&tmux)?; tmux.send_keys(&[Str("export SHELL=/bin/sh"), Enter])?; tmux.start_sk(None, &["--tmux", "--bind 'ctrl-a:reload(ls /foo*)'"])?; @@ -109,7 +112,7 @@ fn tmux_quote_sh() -> Result<()> { } #[test] fn tmux_quote_fish() -> Result<()> { - let tmux = TmuxController::new()?; + let mut tmux = TmuxController::new()?; let outfile = setup_tmux_mock(&tmux)?; tmux.send_keys(&[Str("export SHELL=/bin/sh"), Enter])?; tmux.start_sk(None, &["--tmux", "--bind 'ctrl-a:reload(ls /foo*)'"])?; diff --git a/test.dockerfile b/test.dockerfile new file mode 100644 index 00000000..ade33489 --- /dev/null +++ b/test.dockerfile @@ -0,0 +1,10 @@ +FROM rust:1-slim + +RUN apt-get update && apt-get install -y tmux bsdmainutils && apt-get clean +COPY rust-toolchain.toml . + +RUN cargo install cargo-nextest + +COPY . . + +CMD ["cargo", "nextest", "run", "--release"] \ No newline at end of file