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 +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +76 -2
- data/lib/clack/core/ci_mode.rb +35 -0
- data/lib/clack/core/fuzzy_matcher.rb +121 -0
- data/lib/clack/core/prompt.rb +47 -13
- data/lib/clack/core/scroll_helper.rb +54 -0
- data/lib/clack/core/settings.rb +7 -3
- data/lib/clack/core/text_input_helper.rb +24 -8
- data/lib/clack/environment.rb +1 -1
- data/lib/clack/prompts/autocomplete.rb +8 -33
- data/lib/clack/prompts/autocomplete_multiselect.rb +22 -64
- data/lib/clack/prompts/path.rb +22 -25
- data/lib/clack/prompts/range.rb +112 -0
- data/lib/clack/prompts/select_key.rb +0 -1
- data/lib/clack/prompts/spinner.rb +11 -5
- data/lib/clack/prompts/tasks.rb +10 -66
- data/lib/clack/prompts/text.rb +57 -2
- data/lib/clack/testing.rb +171 -0
- data/lib/clack/version.rb +1 -1
- data/lib/clack.rb +53 -4
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c0622f234b9f83906c6440e54c29a12715ecfc7a2b3777ec68efaf3882defcb7
|
|
4
|
+
data.tar.gz: 450c6376c40ad88a27683294e97292f227e2081ccc90a0c076949bba3e5afd1d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/clack/core/prompt.rb
CHANGED
|
@@ -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
|
-
|
|
108
|
-
|
|
109
|
-
@state = :active
|
|
113
|
+
warn_narrow_terminal
|
|
114
|
+
terminal_setup = false
|
|
110
115
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
116
|
+
begin
|
|
117
|
+
setup_terminal
|
|
118
|
+
terminal_setup = true
|
|
114
119
|
render
|
|
120
|
+
@state = :active
|
|
115
121
|
|
|
116
|
-
|
|
117
|
-
|
|
122
|
+
loop do
|
|
123
|
+
key = KeyReader.read
|
|
124
|
+
dispatch_key(key)
|
|
125
|
+
render
|
|
118
126
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
data/lib/clack/core/settings.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
49
|
-
|
|
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 =
|
|
71
|
+
chars = text_value.grapheme_clusters
|
|
68
72
|
chars.insert(@cursor, key)
|
|
69
|
-
|
|
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 =
|
|
95
|
+
chars = text_value.grapheme_clusters
|
|
80
96
|
chars.delete_at(@cursor - 1)
|
|
81
|
-
|
|
97
|
+
self.text_value = chars.join
|
|
82
98
|
@cursor -= 1
|
|
83
99
|
true
|
|
84
100
|
end
|
data/lib/clack/environment.rb
CHANGED
|
@@ -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
|
|
11
|
-
#
|
|
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
|
|
45
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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]})") : ""
|