clack 0.2.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b2beeffde4af3c88491d8b659dd781e8bd3e652e06f8c159a3ca7910fc69c87
4
- data.tar.gz: 86b779a202099d27cf702009ee649e19a49b11305e10d2033cf7a0425fe38e10
3
+ metadata.gz: c0622f234b9f83906c6440e54c29a12715ecfc7a2b3777ec68efaf3882defcb7
4
+ data.tar.gz: 450c6376c40ad88a27683294e97292f227e2081ccc90a0c076949bba3e5afd1d
5
5
  SHA512:
6
- metadata.gz: 9f8b20f670252b17132fcb664eeeaa3ba06b8a8fa0abbee5fdb8e40d17460f2e59badeda5b11b20aaa82b7e9b4b0fef6d06524933794dd20a3c6fcd159cc2115
7
- data.tar.gz: 21642c6a8cea0671cff152932e806ea7a83163612ecd35739fa2e2bd98179b5e5b714ae6de3e5a2153206ac5f3b52f02ec2e28fc82541056a726d60071afbdab
6
+ metadata.gz: '08a7d471122e503168817430b65ad3618488eef6a2c79229a50e20a0a3ff3f2df741faef330a81b57875ddca3a37a68d0aebd19e770b7bbe73addd9a737bc9ec'
7
+ data.tar.gz: 76e01f5eef142fd64069e1d9ba705edebc5694834ab68d5f7a8370eb345f1bf1473f2f1771d0178ed80e0b6c977c05d032d0059112bf36e72dd2fbe18f3850a3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.1] - 2026-02-20
4
+
5
+ ### Fixed
6
+ - `AutocompleteMultiselect` now renders warning validation messages (was error-only)
7
+ - CI mode no longer writes ANSI escape codes to non-TTY output
8
+ - CI mode prints a warning when validation fails instead of silently returning
9
+ - Spinner animation restarts from frame 0 when reused
10
+ - `Environment.raw_mode_supported?` catches specific exceptions instead of bare rescue
11
+ - `FuzzyMatcher.filter` pre-computes downcased query (performance optimization for large lists)
12
+ - Removed dead instance variables in Path, Testing, and Spinner
13
+ - Simplified `Range#clamp` by removing unreachable branch
14
+
15
+ ### Changed
16
+ - Updated gem dependencies (rubocop 1.84, standard 1.54, prism 1.9, bigdecimal 4.0)
17
+ - Autocomplete YARD docs corrected: default filter is fuzzy matching, not substring
18
+ - Expanded YARD `@option` documentation for spinner, multiselect, group_multiselect, path, tasks
19
+ - README and ARCHITECTURE.md updated to cover all v0.3.0-v0.4.0 features
20
+
21
+ ## [0.4.0] - 2026-02-19
22
+
23
+ ### Added
24
+ - `range` slider prompt for numeric selection (`Clack.range(message:, min:, max:, step:, default:)`)
25
+ - Tab completion on `text` prompt via `completions:` parameter (array or proc)
26
+ - Minimum terminal width warning (non-blocking, 40 columns)
27
+
28
+ ### Changed
29
+ - Path prompt caches directory listings to avoid repeated filesystem scans on every keystroke
30
+
31
+ ## [0.3.0] - 2026-02-19
32
+
33
+ ### Added
34
+ - `Clack::Testing` module with first-class test helpers (`simulate`, `simulate_with_output`, `PromptDriver`)
35
+ - `Clack::Core::FuzzyMatcher` with scored fuzzy matching (consecutive/boundary/start bonuses)
36
+ - CI / non-interactive mode: `Clack.update_settings(ci_mode: true)` or `:auto` to auto-detect
37
+ - `autocomplete_multiselect` now accepts `filter:` proc for custom matching logic
38
+
39
+ ### Changed
40
+ - Autocomplete prompts default to fuzzy matching instead of substring matching
41
+ - Spinner is now thread-safe: guards against double-finish, protects `@cancelled` reads with mutex
42
+
43
+ ## [0.2.1] - 2026-02-19
44
+
45
+ ### Added
46
+ - `Core::ScrollHelper` mixin extracted from scroll/filter logic across 3 prompts
47
+
48
+ ### Changed
49
+ - `TextInputHelper` parameterized via `text_value`/`text_value=` for custom backing stores
50
+ - Tasks prompt now reuses `Core::Spinner` instead of inline spinner implementation
51
+ - Removed redundant `@value = nil` from SelectKey
52
+
3
53
  ## [0.2.0] - 2026-02-19
4
54
 
5
55
  ### Added
data/README.md CHANGED
@@ -193,7 +193,24 @@ name = Clack.text(
193
193
  placeholder: "my-project", # Shown when empty (dim)
194
194
  default_value: "untitled", # Used if submitted empty
195
195
  initial_value: "hello-world", # Pre-filled, editable
196
- validate: ->(v) { "Required!" if v.empty? }
196
+ validate: ->(v) { "Required!" if v.empty? },
197
+ help: "Letters, numbers, and dashes only" # Contextual help text
198
+ )
199
+ ```
200
+
201
+ **Tab completion** - press `Tab` to cycle through matching candidates:
202
+
203
+ ```ruby
204
+ # Tab completion from a static list
205
+ cmd = Clack.text(
206
+ message: "Command?",
207
+ completions: %w[build test deploy lint format]
208
+ )
209
+
210
+ # Dynamic tab completion
211
+ file = Clack.text(
212
+ message: "File?",
213
+ completions: ->(input) { Dir.glob("#{input}*") }
197
214
  )
198
215
  ```
199
216
 
@@ -276,7 +293,7 @@ features = Clack.multiselect(
276
293
 
277
294
  ### Autocomplete
278
295
 
279
- Type to filter from a list of options.
296
+ Type to filter from a list of options. Filtering uses **fuzzy matching** by default -- characters must appear in order but don't need to be consecutive (e.g. "fb" matches "foobar"). Pass `filter:` to override with custom logic.
280
297
 
281
298
  ```ruby
282
299
  color = Clack.autocomplete(
@@ -340,6 +357,20 @@ date = Clack.date(
340
357
 
341
358
  **Navigation:** `Tab`/`←→` between segments | `↑↓` adjust value | type digits directly
342
359
 
360
+ ### Range
361
+
362
+ Numeric selection with a visual slider track. Navigate with `←→` or `↑↓` arrow keys (or `hjkl`).
363
+
364
+ ```ruby
365
+ volume = Clack.range(
366
+ message: "Set volume",
367
+ min: 0,
368
+ max: 100,
369
+ step: 5,
370
+ default: 50
371
+ )
372
+ ```
373
+
343
374
  ### Select Key
344
375
 
345
376
  Quick selection using keyboard shortcuts.
@@ -583,8 +614,51 @@ Clack.update_settings(aliases: { "y" => :enter, "n" => :cancel })
583
614
 
584
615
  # Disable guide bars
585
616
  Clack.update_settings(with_guide: false)
617
+
618
+ # CI / non-interactive mode (prompts auto-submit with defaults)
619
+ Clack.update_settings(ci_mode: true) # Always on
620
+ Clack.update_settings(ci_mode: :auto) # Auto-detect (non-TTY or CI env vars)
621
+ ```
622
+
623
+ When CI mode is active, prompts immediately submit with their default values instead of waiting for input. Useful for CI pipelines and scripted environments where stdin is not a TTY.
624
+
625
+ Clack also warns when terminal width is below 40 columns, since prompts may not render cleanly in very narrow terminals.
626
+
627
+ ## Testing
628
+
629
+ Clack ships with first-class test helpers. Require `clack/testing` explicitly (it is not auto-loaded):
630
+
631
+ ```ruby
632
+ require "clack/testing"
633
+
634
+ # Simulate a text prompt
635
+ result = Clack::Testing.simulate(Clack.method(:text), message: "Name?") do |prompt|
636
+ prompt.type("Alice")
637
+ prompt.submit
638
+ end
639
+ # => "Alice"
640
+
641
+ # Capture rendered output alongside the result
642
+ result, output = Clack::Testing.simulate_with_output(Clack.method(:confirm), message: "Sure?") do |prompt|
643
+ prompt.left # switch to "No"
644
+ prompt.submit
645
+ end
586
646
  ```
587
647
 
648
+ The `PromptDriver` yielded to the block provides these methods:
649
+
650
+ | Method | Description |
651
+ |---|---|
652
+ | `type(text)` | Type a string character by character |
653
+ | `submit` | Press Enter |
654
+ | `cancel` | Press Escape |
655
+ | `up` / `down` / `left` / `right` | Arrow keys |
656
+ | `toggle` | Press Space (for multiselect) |
657
+ | `tab` | Press Tab |
658
+ | `backspace` | Press Backspace |
659
+ | `ctrl_d` | Press Ctrl+D (submit multiline text) |
660
+ | `key(sym_or_char)` | Press an arbitrary key by symbol (e.g. `:escape`) or raw character |
661
+
588
662
  ## Requirements
589
663
 
590
664
  - Ruby 3.2+
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Core
5
+ # Non-interactive mode for CI environments and piped input.
6
+ #
7
+ # When enabled, prompts auto-submit with their default values instead
8
+ # of waiting for user input. Useful for CI pipelines, automated testing,
9
+ # and scripted usage where stdin isn't a TTY.
10
+ #
11
+ # Enable explicitly:
12
+ # Clack.update_settings(ci_mode: true)
13
+ #
14
+ # Or auto-detect (non-TTY stdin or CI environment variable):
15
+ # Clack.update_settings(ci_mode: :auto)
16
+ module CiMode
17
+ class << self
18
+ # Check if CI mode is currently active.
19
+ #
20
+ # @return [Boolean]
21
+ def active?
22
+ setting = Settings.config[:ci_mode]
23
+ case setting
24
+ when true
25
+ true
26
+ when :auto
27
+ !Environment.tty?($stdin) || Environment.ci?
28
+ else
29
+ false
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Core
5
+ # Simple scored fuzzy matcher for autocomplete prompts.
6
+ #
7
+ # Matches query characters in order within the target string, scoring
8
+ # higher for consecutive matches and matches at word boundaries.
9
+ # Dependency-free alternative to Levenshtein distance that's fast
10
+ # enough for interactive use.
11
+ #
12
+ # @example Basic matching
13
+ # FuzzyMatcher.match?("fb", "foobar") # => true
14
+ # FuzzyMatcher.match?("zz", "foobar") # => false
15
+ #
16
+ # @example Scoring
17
+ # FuzzyMatcher.score("fb", "foobar") # => 2 (non-consecutive)
18
+ # FuzzyMatcher.score("foo", "foobar") # => 9 (consecutive + start)
19
+ #
20
+ # @example Filtering and sorting options
21
+ # FuzzyMatcher.filter(options, "fb") # => sorted by relevance
22
+ module FuzzyMatcher
23
+ # Bonus for match at the very start of the string
24
+ START_BONUS = 3
25
+ # Bonus for each consecutive character matched
26
+ CONSECUTIVE_BONUS = 2
27
+ # Bonus for match at a word boundary (after space, _, -)
28
+ BOUNDARY_BONUS = 2
29
+ # Base score per matched character
30
+ BASE_SCORE = 1
31
+
32
+ class << self
33
+ # Check if query fuzzy-matches the target string.
34
+ #
35
+ # @param query [String] the search query
36
+ # @param target [String] the string to match against
37
+ # @return [Boolean] true if all query chars appear in order in target
38
+ def match?(query, target)
39
+ return true if query.empty?
40
+
41
+ qi = 0
42
+ q_chars = query.downcase
43
+ t_chars = target.downcase
44
+
45
+ t_chars.each_char do |tc|
46
+ qi += 1 if tc == q_chars[qi]
47
+ return true if qi >= q_chars.length
48
+ end
49
+
50
+ false
51
+ end
52
+
53
+ # Score a fuzzy match. Higher is better. Returns 0 if no match.
54
+ #
55
+ # @param query [String] the search query
56
+ # @param target [String] the string to score against
57
+ # @param q_down [String, nil] pre-downcased query (optimization for batch use)
58
+ # @return [Integer] match score (0 = no match)
59
+ def score(query, target, q_down: nil)
60
+ return 0 if query.empty?
61
+
62
+ q_down ||= query.downcase
63
+ t_down = target.downcase
64
+ qi = 0
65
+ total = 0
66
+ prev_match_idx = -2 # -2 so first match at 0 isn't consecutive
67
+
68
+ t_down.each_char.with_index do |tc, ti|
69
+ next unless qi < q_down.length && tc == q_down[qi]
70
+
71
+ total += BASE_SCORE
72
+ total += START_BONUS if ti.zero?
73
+ total += CONSECUTIVE_BONUS if ti == prev_match_idx + 1
74
+ total += BOUNDARY_BONUS if ti.positive? && boundary?(t_down[ti - 1])
75
+
76
+ prev_match_idx = ti
77
+ qi += 1
78
+ end
79
+
80
+ (qi >= q_down.length) ? total : 0
81
+ end
82
+
83
+ # Filter and sort option hashes by fuzzy relevance.
84
+ #
85
+ # Matches against label, value (as string), and hint fields.
86
+ # Returns options sorted by best match score (descending).
87
+ #
88
+ # @param options [Array<Hash>] normalized option hashes
89
+ # @param query [String] the search query
90
+ # @return [Array<Hash>] matching options sorted by relevance
91
+ def filter(options, query)
92
+ return options if query.empty?
93
+
94
+ q_down = query.downcase
95
+
96
+ scored = options.filter_map do |opt|
97
+ s = best_score(opt, query, q_down)
98
+ [opt, s] if s.positive?
99
+ end
100
+
101
+ scored.sort_by { |_, s| -s }.map(&:first)
102
+ end
103
+
104
+ private
105
+
106
+ def boundary?(char)
107
+ char == " " || char == "_" || char == "-" || char == "/"
108
+ end
109
+
110
+ def best_score(opt, query, q_down)
111
+ scores = [
112
+ score(query, opt[:label], q_down: q_down),
113
+ score(query, opt[:value].to_s, q_down: q_down)
114
+ ]
115
+ scores << score(query, opt[:hint], q_down: q_down) if opt[:hint]
116
+ scores.max
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -27,6 +27,10 @@ module Clack
27
27
  # end
28
28
  #
29
29
  class Prompt
30
+ # Minimum terminal width for clean rendering.
31
+ # Prompts warn (non-blocking) if the terminal is narrower.
32
+ MIN_TERMINAL_WIDTH = 40
33
+
30
34
  # Track active prompts for SIGWINCH notification.
31
35
  # Signal handler may fire during register/unregister. We can't use
32
36
  # .dup (allocates, forbidden in trap context) so we accept a benign
@@ -103,24 +107,32 @@ module Clack
103
107
  #
104
108
  # @return [Object, Clack::CANCEL] the submitted value or CANCEL sentinel
105
109
  def run
110
+ return run_ci_mode if CiMode.active?
111
+
106
112
  Prompt.register(self)
107
- setup_terminal
108
- render
109
- @state = :active
113
+ warn_narrow_terminal
114
+ terminal_setup = false
110
115
 
111
- loop do
112
- key = KeyReader.read
113
- dispatch_key(key)
116
+ begin
117
+ setup_terminal
118
+ terminal_setup = true
114
119
  render
120
+ @state = :active
115
121
 
116
- break if terminal_state?
117
- end
122
+ loop do
123
+ key = KeyReader.read
124
+ dispatch_key(key)
125
+ render
118
126
 
119
- finalize
120
- (terminal_state? && @state == :cancel) ? CANCEL : @value
121
- ensure
122
- Prompt.unregister(self)
123
- cleanup_terminal
127
+ break if terminal_state?
128
+ end
129
+
130
+ finalize
131
+ (terminal_state? && @state == :cancel) ? CANCEL : @value
132
+ ensure
133
+ Prompt.unregister(self)
134
+ cleanup_terminal if terminal_setup
135
+ end
124
136
  end
125
137
 
126
138
  protected
@@ -321,8 +333,30 @@ module Clack
321
333
  %i[submit cancel].include?(@state)
322
334
  end
323
335
 
336
+ # Auto-submit with current defaults in CI/non-interactive mode.
337
+ # Subclass submit overrides (e.g., Text applying default_value) run normally.
338
+ # Validation and transforms are applied. Returns the value regardless of
339
+ # validation outcome since there's no interactive way to fix input.
340
+ def run_ci_mode
341
+ submit
342
+ if @state == :error
343
+ @output.print "#{Colors.yellow("!")} #{Colors.yellow("CI mode: validation failed for")} \"#{@message}\": #{@error_message}\n"
344
+ end
345
+ @value
346
+ end
347
+
324
348
  private
325
349
 
350
+ def warn_narrow_terminal
351
+ return unless Environment.tty?(@output)
352
+
353
+ cols = Environment.columns(@output)
354
+ return if cols >= MIN_TERMINAL_WIDTH
355
+
356
+ @output.print "#{Colors.yellow("!")} #{Colors.yellow("Terminal is narrow")} " \
357
+ "(#{cols} cols, #{MIN_TERMINAL_WIDTH} recommended)\n"
358
+ end
359
+
326
360
  def setup_terminal
327
361
  @output.print Cursor.hide
328
362
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Core
5
+ # Shared scroll/navigation logic for filterable option lists.
6
+ #
7
+ # Used by prompts that have a dynamically filtered list (Autocomplete,
8
+ # AutocompleteMultiselect, Path) where the user navigates a subset of
9
+ # options that changes as they type.
10
+ #
11
+ # Including classes must define:
12
+ # - +@max_items+ [Integer] maximum visible items
13
+ # - +@selected_index+ [Integer] current selection index
14
+ # - +@scroll_offset+ [Integer] current scroll position
15
+ #
16
+ # Including classes must implement:
17
+ # - +scroll_items+ [Array] returns the current filterable list
18
+ module ScrollHelper
19
+ # Get the currently visible slice of items based on scroll position.
20
+ #
21
+ # @return [Array] visible items
22
+ def visible_items
23
+ items = scroll_items
24
+ return items if items.length <= @max_items
25
+
26
+ items[@scroll_offset, @max_items]
27
+ end
28
+
29
+ # Move the selection index by delta, wrapping around.
30
+ #
31
+ # @param delta [Integer] direction (+1 for down, -1 for up)
32
+ def move_selection(delta)
33
+ items = scroll_items
34
+ return if items.empty?
35
+
36
+ @selected_index = (@selected_index + delta) % items.length
37
+ update_selection_scroll
38
+ end
39
+
40
+ private
41
+
42
+ # Update scroll offset to keep the selected index visible.
43
+ def update_selection_scroll
44
+ return unless scroll_items.length > @max_items
45
+
46
+ if @selected_index < @scroll_offset
47
+ @scroll_offset = @selected_index
48
+ elsif @selected_index >= @scroll_offset + @max_items
49
+ @scroll_offset = @selected_index - @max_items + 1
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -40,7 +40,8 @@ module Clack
40
40
  # Global configuration (mutable)
41
41
  @config = {
42
42
  aliases: ALIASES.dup,
43
- with_guide: true
43
+ with_guide: true,
44
+ ci_mode: false
44
45
  }
45
46
  @config_mutex = Mutex.new
46
47
 
@@ -54,11 +55,13 @@ module Clack
54
55
  # Update global settings
55
56
  # @param aliases [Hash, nil] Custom key to action mappings (merged with defaults)
56
57
  # @param with_guide [Boolean, nil] Whether to show guide bars
58
+ # @param ci_mode [Boolean, Symbol, nil] CI mode: true (always), :auto (detect), false (never)
57
59
  # @return [Hash] Updated configuration
58
- def update(aliases: nil, with_guide: nil)
60
+ def update(aliases: nil, with_guide: nil, ci_mode: nil)
59
61
  @config_mutex.synchronize do
60
62
  @config[:aliases] = ALIASES.merge(aliases) if aliases
61
63
  @config[:with_guide] = with_guide unless with_guide.nil?
64
+ @config[:ci_mode] = ci_mode unless ci_mode.nil?
62
65
  @config.dup
63
66
  end
64
67
  end
@@ -68,7 +71,8 @@ module Clack
68
71
  @config_mutex.synchronize do
69
72
  @config = {
70
73
  aliases: ALIASES.dup,
71
- with_guide: true
74
+ with_guide: true,
75
+ ci_mode: false
72
76
  }
73
77
  end
74
78
  end
@@ -4,12 +4,16 @@ module Clack
4
4
  module Core
5
5
  # Shared functionality for text input prompts (Text, Autocomplete, Path).
6
6
  # Handles cursor display, placeholder rendering, and text manipulation.
7
+ #
8
+ # By default operates on +@value+ and +@cursor+. Override
9
+ # +text_value+ and +text_value=+ in your class to use a different
10
+ # backing store (e.g. +@search_text+ in AutocompleteMultiselect).
7
11
  module TextInputHelper
8
12
  # Display the input field with cursor or placeholder.
9
13
  #
10
14
  # @return [String] Formatted input display
11
15
  def input_display
12
- return placeholder_display if @value.empty?
16
+ return placeholder_display if text_value.empty?
13
17
 
14
18
  value_with_cursor
15
19
  end
@@ -45,8 +49,9 @@ module Clack
45
49
  #
46
50
  # @return [String] Value with cursor
47
51
  def value_with_cursor
48
- chars = @value.grapheme_clusters
49
- return "#{@value}#{cursor_block}" if @cursor >= chars.length
52
+ val = text_value
53
+ chars = val.grapheme_clusters
54
+ return "#{val}#{cursor_block}" if @cursor >= chars.length
50
55
 
51
56
  before = chars[0...@cursor].join
52
57
  current = Colors.inverse(chars[@cursor])
@@ -55,7 +60,6 @@ module Clack
55
60
  end
56
61
 
57
62
  # Handle text input key (backspace/delete or printable character).
58
- # Requires @value and @cursor instance variables.
59
63
  # Uses grapheme clusters for proper Unicode handling.
60
64
  #
61
65
  # @param key [String] The key pressed
@@ -64,21 +68,33 @@ module Clack
64
68
  return handle_backspace if Core::Settings.backspace?(key)
65
69
  return false unless Core::Settings.printable?(key)
66
70
 
67
- chars = @value.grapheme_clusters
71
+ chars = text_value.grapheme_clusters
68
72
  chars.insert(@cursor, key)
69
- @value = chars.join
73
+ self.text_value = chars.join
70
74
  @cursor += 1
71
75
  true
72
76
  end
73
77
 
78
+ # The text value being edited. Override to use a different backing store.
79
+ # @return [String]
80
+ def text_value
81
+ @value
82
+ end
83
+
84
+ # Set the text value. Override to use a different backing store.
85
+ # @param val [String]
86
+ def text_value=(val)
87
+ @value = val
88
+ end
89
+
74
90
  private
75
91
 
76
92
  def handle_backspace
77
93
  return false if @cursor.zero?
78
94
 
79
- chars = @value.grapheme_clusters
95
+ chars = text_value.grapheme_clusters
80
96
  chars.delete_at(@cursor - 1)
81
- @value = chars.join
97
+ self.text_value = chars.join
82
98
  @cursor -= 1
83
99
  true
84
100
  end
@@ -119,7 +119,7 @@ module Clack
119
119
  if windows? && !windows_terminal?
120
120
  begin
121
121
  IO.console&.respond_to?(:raw)
122
- rescue
122
+ rescue IOError, SystemCallError
123
123
  false
124
124
  end
125
125
  else
@@ -7,8 +7,8 @@ module Clack
7
7
  # Combines text input with a filtered option list. Type to filter,
8
8
  # use arrow keys to navigate matches, Enter to select.
9
9
  #
10
- # By default, filtering searches across value, label, and hint fields
11
- # using a case-insensitive substring match. Supply a custom +filter+
10
+ # By default, filtering uses fuzzy matching across value, label, and
11
+ # hint fields, sorted by relevance score. Supply a custom +filter+
12
12
  # proc to override this behavior.
13
13
  #
14
14
  # @example Basic usage
@@ -35,14 +35,15 @@ module Clack
35
35
  class Autocomplete < Core::Prompt
36
36
  include Core::OptionsHelper
37
37
  include Core::TextInputHelper
38
+ include Core::ScrollHelper
38
39
 
39
40
  # @param message [String] the prompt message
40
41
  # @param options [Array<Hash, String>] list of options to filter
41
42
  # @param max_items [Integer] max visible options (default: 5)
42
43
  # @param placeholder [String, nil] placeholder text when empty
43
44
  # @param filter [Proc, nil] custom filter proc receiving (option_hash, query_string)
44
- # and returning true/false. When nil, the default case-insensitive substring
45
- # match across label, value, and hint is used.
45
+ # and returning true/false. When nil, the default fuzzy matching
46
+ # across label, value, and hint is used.
46
47
  # @param opts [Hash] additional options passed to {Core::Prompt}
47
48
  def initialize(message:, options:, max_items: 5, placeholder: nil, filter: nil, **opts)
48
49
  super(message:, **opts)
@@ -104,7 +105,7 @@ module Clack
104
105
  lines << help_line
105
106
  lines << "#{active_bar} #{input_display}\n"
106
107
 
107
- visible_options.each_with_index do |opt, idx|
108
+ visible_items.each_with_index do |opt, idx|
108
109
  actual_idx = @scroll_offset + idx
109
110
  lines << "#{bar} #{option_display(opt, actual_idx == @selected_index)}\n"
110
111
  end
@@ -136,37 +137,11 @@ module Clack
136
137
  @filtered = if @filter
137
138
  @all_options.select { |opt| @filter.call(opt, @value) }
138
139
  else
139
- query = @value.downcase
140
- @all_options.select do |opt|
141
- opt[:label].downcase.include?(query) ||
142
- opt[:value].to_s.downcase.include?(query) ||
143
- opt[:hint]&.downcase&.include?(query)
144
- end
140
+ Core::FuzzyMatcher.filter(@all_options, @value)
145
141
  end
146
142
  end
147
143
 
148
- def visible_options
149
- return @filtered if @filtered.length <= @max_items
150
-
151
- @filtered[@scroll_offset, @max_items]
152
- end
153
-
154
- def move_selection(delta)
155
- return if @filtered.empty?
156
-
157
- @selected_index = (@selected_index + delta) % @filtered.length
158
- update_scroll
159
- end
160
-
161
- def update_scroll
162
- return unless @filtered.length > @max_items
163
-
164
- if @selected_index < @scroll_offset
165
- @scroll_offset = @selected_index
166
- elsif @selected_index >= @scroll_offset + @max_items
167
- @scroll_offset = @selected_index - @max_items + 1
168
- end
169
- end
144
+ def scroll_items = @filtered
170
145
 
171
146
  def option_display(opt, active)
172
147
  hint = (opt[:hint] && active) ? Colors.dim(" (#{opt[:hint]})") : ""