clack 0.4.6 → 0.6.0
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 +38 -0
- data/README.md +267 -197
- data/lib/clack/box.rb +6 -6
- data/lib/clack/core/fuzzy_matcher.rb +3 -3
- data/lib/clack/core/key_reader.rb +30 -20
- data/lib/clack/core/options_helper.rb +96 -29
- data/lib/clack/core/prompt.rb +45 -12
- data/lib/clack/core/scroll_helper.rb +10 -41
- data/lib/clack/core/selection_manager.rb +49 -0
- data/lib/clack/note.rb +4 -3
- data/lib/clack/prompts/autocomplete.rb +21 -15
- data/lib/clack/prompts/autocomplete_multiselect.rb +19 -26
- data/lib/clack/prompts/confirm.rb +8 -30
- data/lib/clack/prompts/date.rb +1 -14
- data/lib/clack/prompts/group_multiselect.rb +48 -67
- data/lib/clack/prompts/multiline_text.rb +33 -53
- data/lib/clack/prompts/multiselect.rb +27 -38
- data/lib/clack/prompts/password.rb +1 -14
- data/lib/clack/prompts/path.rb +9 -23
- data/lib/clack/prompts/progress.rb +6 -2
- data/lib/clack/prompts/range.rb +1 -14
- data/lib/clack/prompts/select.rb +18 -32
- data/lib/clack/prompts/select_key.rb +8 -8
- data/lib/clack/prompts/spinner.rb +15 -20
- data/lib/clack/prompts/tasks.rb +6 -1
- data/lib/clack/prompts/text.rb +1 -14
- data/lib/clack/testing.rb +31 -37
- data/lib/clack/utils.rb +106 -22
- data/lib/clack/version.rb +1 -1
- data/lib/clack.rb +71 -37
- metadata +3 -3
data/lib/clack/box.rb
CHANGED
|
@@ -39,10 +39,10 @@ module Clack
|
|
|
39
39
|
format_border ||= ->(text) { Colors.gray(text) }
|
|
40
40
|
symbols = corner_symbols(rounded).map(&format_border)
|
|
41
41
|
lines = message.to_s.lines.map(&:chomp)
|
|
42
|
-
box_width = calculate_width(lines, title
|
|
42
|
+
box_width = calculate_width(lines, Clack::Utils.visible_length(title), title_padding, content_padding, width)
|
|
43
43
|
inner_width = box_width - 2
|
|
44
44
|
max_title_len = inner_width - (title_padding * 2)
|
|
45
|
-
display_title = (title
|
|
45
|
+
display_title = Clack::Utils.truncate(title, max_title_len)
|
|
46
46
|
|
|
47
47
|
{
|
|
48
48
|
symbols: symbols,
|
|
@@ -56,7 +56,7 @@ module Clack
|
|
|
56
56
|
|
|
57
57
|
def render_content_lines(output, ctx, content_align, content_padding)
|
|
58
58
|
ctx[:lines].each do |line|
|
|
59
|
-
left_pad, right_pad = padding_for_line(line
|
|
59
|
+
left_pad, right_pad = padding_for_line(Clack::Utils.visible_length(line), ctx[:inner_width], content_padding, content_align)
|
|
60
60
|
output.puts "#{ctx[:v_symbol]}#{" " * left_pad}#{line}#{" " * right_pad}#{ctx[:v_symbol]}"
|
|
61
61
|
end
|
|
62
62
|
end
|
|
@@ -82,8 +82,8 @@ module Clack
|
|
|
82
82
|
def calculate_width(lines, title_len, title_padding, content_padding, width)
|
|
83
83
|
return width + 2 if width.is_a?(Integer) # Add 2 for borders
|
|
84
84
|
|
|
85
|
-
# Auto width: fit to content
|
|
86
|
-
max_line = lines.map(
|
|
85
|
+
# Auto width: fit to content using display width
|
|
86
|
+
max_line = lines.map { |l| Clack::Utils.visible_length(l) }.max || 0
|
|
87
87
|
title_with_padding = title_len + (title_padding * 2)
|
|
88
88
|
content_with_padding = max_line + (content_padding * 2)
|
|
89
89
|
|
|
@@ -94,7 +94,7 @@ module Clack
|
|
|
94
94
|
if title.empty?
|
|
95
95
|
"#{symbols[0]}#{h_symbol * inner_width}#{symbols[1]}"
|
|
96
96
|
else
|
|
97
|
-
left_pad, right_pad = padding_for_line(title
|
|
97
|
+
left_pad, right_pad = padding_for_line(Clack::Utils.visible_length(title), inner_width, title_padding, title_align)
|
|
98
98
|
"#{symbols[0]}#{h_symbol * left_pad}#{title}#{h_symbol * right_pad}#{symbols[1]}"
|
|
99
99
|
end
|
|
100
100
|
end
|
|
@@ -109,10 +109,10 @@ module Clack
|
|
|
109
109
|
|
|
110
110
|
def best_score(opt, query, q_down)
|
|
111
111
|
scores = [
|
|
112
|
-
score(query, opt
|
|
113
|
-
score(query, opt
|
|
112
|
+
score(query, opt.label, q_down: q_down),
|
|
113
|
+
score(query, opt.value.to_s, q_down: q_down)
|
|
114
114
|
]
|
|
115
|
-
scores << score(query, opt
|
|
115
|
+
scores << score(query, opt.hint, q_down: q_down) if opt.hint
|
|
116
116
|
scores.max
|
|
117
117
|
end
|
|
118
118
|
end
|
|
@@ -16,34 +16,44 @@ module Clack
|
|
|
16
16
|
SEQUENCE_TIMEOUT = 0.01
|
|
17
17
|
|
|
18
18
|
class << self
|
|
19
|
-
# Read a single keystroke
|
|
20
|
-
#
|
|
19
|
+
# Read a single keystroke in raw mode.
|
|
20
|
+
# When input is an IO backed by a console, uses raw mode.
|
|
21
|
+
# When input is a StringIO or test double, reads directly.
|
|
21
22
|
#
|
|
23
|
+
# @param input [IO, nil] input stream (defaults to IO.console)
|
|
22
24
|
# @return [String, nil] the key code, or nil on EOF
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
console
|
|
26
|
-
raise IOError, "No console available (not a TTY?)" unless console
|
|
25
|
+
def read(input = nil)
|
|
26
|
+
io = input || IO.console
|
|
27
|
+
raise IOError, "No console available (not a TTY?)" unless io
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return char if char.nil? # EOF
|
|
31
|
-
return char unless char == "\e"
|
|
29
|
+
# StringIO / test doubles don't support raw mode
|
|
30
|
+
return read_from(io) unless io.respond_to?(:raw)
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
return char unless IO.select([io], nil, nil, ESCAPE_TIMEOUT)
|
|
35
|
-
|
|
36
|
-
seq = io.getc.to_s
|
|
37
|
-
return "\e#{seq}" unless seq == "["
|
|
38
|
-
|
|
39
|
-
# Read CSI sequence until no more characters arrive
|
|
40
|
-
seq += io.getc.to_s while IO.select([io], nil, nil, SEQUENCE_TIMEOUT)
|
|
41
|
-
"\e[#{seq[1..]}"
|
|
42
|
-
end
|
|
32
|
+
io.raw { |raw_io| read_from(raw_io) }
|
|
43
33
|
rescue Errno::EIO, Errno::EBADF, IOError
|
|
44
34
|
# Terminal disconnected or closed - treat as cancel
|
|
45
35
|
"\u0003" # Ctrl+C
|
|
46
36
|
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def read_from(io)
|
|
41
|
+
char = io.getc
|
|
42
|
+
return char if char.nil? # EOF
|
|
43
|
+
return char unless char == "\e"
|
|
44
|
+
|
|
45
|
+
# Check for escape sequence - wait briefly for follow-up
|
|
46
|
+
return char unless io.respond_to?(:wait_readable) && io.wait_readable(ESCAPE_TIMEOUT)
|
|
47
|
+
|
|
48
|
+
seq = io.getc.to_s
|
|
49
|
+
return "\e#{seq}" unless seq == "["
|
|
50
|
+
|
|
51
|
+
# Read CSI sequence until no more characters arrive
|
|
52
|
+
while io.respond_to?(:wait_readable) && io.wait_readable(SEQUENCE_TIMEOUT)
|
|
53
|
+
seq += io.getc.to_s
|
|
54
|
+
end
|
|
55
|
+
"\e[#{seq[1..]}"
|
|
56
|
+
end
|
|
47
57
|
end
|
|
48
58
|
end
|
|
49
59
|
end
|
|
@@ -2,8 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
module Clack
|
|
4
4
|
module Core
|
|
5
|
-
#
|
|
5
|
+
# Value object for normalized select-style options.
|
|
6
|
+
Option = Data.define(:value, :label, :hint, :disabled) do
|
|
7
|
+
def to_s = label.to_s
|
|
8
|
+
|
|
9
|
+
# Hash-style read access for backward compatibility with code (e.g.
|
|
10
|
+
# custom autocomplete filters) written against the old option hashes.
|
|
11
|
+
def [](key) = to_h[key]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Value object for select_key options (includes :key).
|
|
15
|
+
SelectKeyOption = Data.define(:value, :label, :key, :hint) do
|
|
16
|
+
def to_s = label.to_s
|
|
17
|
+
def [](key) = to_h[key]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Value objects for group_multiselect flat list (replaces :type discriminator hashes).
|
|
21
|
+
# GroupOption carries extra fields only used within GroupMultiselect for layout.
|
|
22
|
+
GroupHeader = Data.define(:label, :options) do
|
|
23
|
+
def [](key) = to_h[key]
|
|
24
|
+
end
|
|
25
|
+
GroupOption = Data.define(:value, :label, :hint, :disabled, :group, :last_in_group) do
|
|
26
|
+
def to_s = label.to_s
|
|
27
|
+
def [](key) = to_h[key]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Shared functionality for option-based prompts (Select, Multiselect, Autocomplete, etc.).
|
|
6
31
|
# Handles option normalization, cursor navigation, and scrolling.
|
|
32
|
+
#
|
|
33
|
+
# Including classes must define:
|
|
34
|
+
# - +@max_items+ [Integer, nil] maximum visible items (nil = show all)
|
|
35
|
+
# - +@option_index+ [Integer] current selection index
|
|
36
|
+
# - +@scroll_offset+ [Integer] current scroll position
|
|
37
|
+
#
|
|
38
|
+
# Including classes must implement:
|
|
39
|
+
# - +navigable_items+ [Array] returns the current list to navigate
|
|
7
40
|
module OptionsHelper
|
|
8
41
|
# Normalize options to a consistent hash format.
|
|
9
42
|
# Accepts strings, symbols, or hashes with value/label/hint/disabled keys.
|
|
@@ -14,18 +47,23 @@ module Clack
|
|
|
14
47
|
def normalize_options(options)
|
|
15
48
|
raise ArgumentError, "options cannot be empty" if options.nil? || options.empty?
|
|
16
49
|
|
|
17
|
-
options.map
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
50
|
+
options.map { |opt| OptionsHelper.normalize_option(opt) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Normalize a single option to an Option value object.
|
|
54
|
+
# @param opt [Hash, String, Symbol] Raw option
|
|
55
|
+
# @return [Option] Normalized option
|
|
56
|
+
def self.normalize_option(opt)
|
|
57
|
+
case opt
|
|
58
|
+
when Hash
|
|
59
|
+
Option.new(
|
|
60
|
+
value: opt[:value],
|
|
61
|
+
label: opt[:label] || opt[:value].to_s,
|
|
62
|
+
hint: opt[:hint],
|
|
63
|
+
disabled: opt[:disabled] || false
|
|
64
|
+
)
|
|
65
|
+
else
|
|
66
|
+
Option.new(value: opt, label: opt.to_s, hint: nil, disabled: false)
|
|
29
67
|
end
|
|
30
68
|
end
|
|
31
69
|
|
|
@@ -36,11 +74,12 @@ module Clack
|
|
|
36
74
|
# @param delta [Integer] Direction (+1 for forward, -1 for backward)
|
|
37
75
|
# @return [Integer] Index of next enabled option, or from if all disabled
|
|
38
76
|
def find_next_enabled(from, delta)
|
|
39
|
-
|
|
77
|
+
items = navigable_items
|
|
78
|
+
max = items.length
|
|
40
79
|
idx = (from + delta) % max
|
|
41
80
|
|
|
42
81
|
max.times do
|
|
43
|
-
return idx unless
|
|
82
|
+
return idx unless items[idx].disabled
|
|
44
83
|
|
|
45
84
|
idx = (idx + delta) % max
|
|
46
85
|
end
|
|
@@ -48,11 +87,29 @@ module Clack
|
|
|
48
87
|
from
|
|
49
88
|
end
|
|
50
89
|
|
|
51
|
-
#
|
|
90
|
+
# Index of the first enabled option.
|
|
91
|
+
# @return [Integer]
|
|
92
|
+
def first_enabled_index
|
|
93
|
+
find_next_enabled(-1, 1)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Move option_index in the given direction, skipping disabled options.
|
|
52
97
|
#
|
|
53
98
|
# @param delta [Integer] Direction (+1 for down/right, -1 for up/left)
|
|
54
99
|
def move_cursor(delta)
|
|
55
|
-
@
|
|
100
|
+
@option_index = find_next_enabled(@option_index, delta)
|
|
101
|
+
update_scroll
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Move the selection index by delta, wrapping around.
|
|
105
|
+
# Unlike move_cursor, does not skip disabled items.
|
|
106
|
+
#
|
|
107
|
+
# @param delta [Integer] direction (+1 for down, -1 for up)
|
|
108
|
+
def move_selection(delta)
|
|
109
|
+
items = navigable_items
|
|
110
|
+
return if items.empty?
|
|
111
|
+
|
|
112
|
+
@option_index = (@option_index + delta) % items.length
|
|
56
113
|
update_scroll
|
|
57
114
|
end
|
|
58
115
|
|
|
@@ -60,19 +117,21 @@ module Clack
|
|
|
60
117
|
#
|
|
61
118
|
# @return [Array<Hash>] Visible options
|
|
62
119
|
def visible_options
|
|
63
|
-
|
|
120
|
+
items = navigable_items
|
|
121
|
+
return items unless @max_items && items.length > @max_items
|
|
64
122
|
|
|
65
|
-
|
|
123
|
+
items[@scroll_offset, @max_items]
|
|
66
124
|
end
|
|
67
125
|
|
|
68
|
-
# Update scroll offset to keep
|
|
126
|
+
# Update scroll offset to keep option_index visible within the window.
|
|
69
127
|
def update_scroll
|
|
70
|
-
|
|
128
|
+
items = navigable_items
|
|
129
|
+
return unless @max_items && items.length > @max_items
|
|
71
130
|
|
|
72
|
-
if @
|
|
73
|
-
@scroll_offset = @
|
|
74
|
-
elsif @
|
|
75
|
-
@scroll_offset = @
|
|
131
|
+
if @option_index < @scroll_offset
|
|
132
|
+
@scroll_offset = @option_index
|
|
133
|
+
elsif @option_index >= @scroll_offset + @max_items
|
|
134
|
+
@scroll_offset = @option_index - @max_items + 1
|
|
76
135
|
end
|
|
77
136
|
end
|
|
78
137
|
|
|
@@ -81,15 +140,23 @@ module Clack
|
|
|
81
140
|
# @param initial_value [Object, nil] Initial value to select
|
|
82
141
|
# @return [Integer] Cursor position
|
|
83
142
|
def find_initial_cursor(initial_value)
|
|
84
|
-
|
|
143
|
+
items = navigable_items
|
|
144
|
+
return 0 if items.empty?
|
|
85
145
|
|
|
86
146
|
if initial_value.nil?
|
|
87
147
|
# Start at first enabled option
|
|
88
|
-
return
|
|
148
|
+
return items[0].disabled ? first_enabled_index : 0
|
|
89
149
|
end
|
|
90
150
|
|
|
91
|
-
idx =
|
|
92
|
-
(idx &&
|
|
151
|
+
idx = items.find_index { |o| o.value == initial_value }
|
|
152
|
+
(idx && !items[idx].disabled) ? idx : first_enabled_index
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The list of items to navigate. Override in subclasses that use
|
|
156
|
+
# a filtered or dynamic list (e.g., Autocomplete uses @filtered).
|
|
157
|
+
# @return [Array]
|
|
158
|
+
def navigable_items
|
|
159
|
+
@options
|
|
93
160
|
end
|
|
94
161
|
end
|
|
95
162
|
end
|
data/lib/clack/core/prompt.rb
CHANGED
|
@@ -33,9 +33,12 @@ module Clack
|
|
|
33
33
|
|
|
34
34
|
# Track active prompts for SIGWINCH notification.
|
|
35
35
|
@active_prompts = []
|
|
36
|
+
# Signal-safe flag set by SIGWINCH handler; checked in render loop.
|
|
37
|
+
@resize_pending = false
|
|
36
38
|
|
|
37
39
|
class << self
|
|
38
40
|
attr_reader :active_prompts
|
|
41
|
+
attr_accessor :resize_pending
|
|
39
42
|
|
|
40
43
|
# Register a prompt instance for resize notifications
|
|
41
44
|
def register(prompt)
|
|
@@ -47,14 +50,23 @@ module Clack
|
|
|
47
50
|
@active_prompts.delete(prompt)
|
|
48
51
|
end
|
|
49
52
|
|
|
53
|
+
# Notify all active prompts of a pending resize.
|
|
54
|
+
# Called from the render loop, not from the signal handler.
|
|
55
|
+
def flush_resize
|
|
56
|
+
return unless @resize_pending
|
|
57
|
+
|
|
58
|
+
@resize_pending = false
|
|
59
|
+
@active_prompts.each(&:request_redraw)
|
|
60
|
+
end
|
|
61
|
+
|
|
50
62
|
# Set up SIGWINCH handler (called once on load).
|
|
51
|
-
# Signal
|
|
63
|
+
# Signal handler only sets a flag -- no allocation or iteration.
|
|
52
64
|
def setup_signal_handler
|
|
53
65
|
return if Clack::Environment.windows?
|
|
54
66
|
return unless Signal.list.key?("WINCH")
|
|
55
67
|
|
|
56
68
|
Signal.trap("WINCH") do
|
|
57
|
-
@
|
|
69
|
+
@resize_pending = true
|
|
58
70
|
end
|
|
59
71
|
end
|
|
60
72
|
end
|
|
@@ -87,7 +99,6 @@ module Clack
|
|
|
87
99
|
@warning_message = nil
|
|
88
100
|
@warning_confirmed = false
|
|
89
101
|
@prev_frame = nil
|
|
90
|
-
@cursor = 0
|
|
91
102
|
@needs_redraw = false
|
|
92
103
|
end
|
|
93
104
|
|
|
@@ -117,7 +128,8 @@ module Clack
|
|
|
117
128
|
@state = :active
|
|
118
129
|
|
|
119
130
|
loop do
|
|
120
|
-
|
|
131
|
+
Prompt.flush_resize
|
|
132
|
+
key = KeyReader.read(@input)
|
|
121
133
|
dispatch_key(key)
|
|
122
134
|
render
|
|
123
135
|
|
|
@@ -175,14 +187,13 @@ module Clack
|
|
|
175
187
|
# Process a keypress and update state accordingly.
|
|
176
188
|
# Delegates to {#handle_input} for prompt-specific behavior.
|
|
177
189
|
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
190
|
+
# Extension points (simplest to most powerful):
|
|
191
|
+
# - Override {#handle_input} for navigation/printable input only
|
|
192
|
+
# - Override {#can_submit?} to guard enter (e.g., disabled options)
|
|
193
|
+
# - Override {#handle_key} for full custom key dispatch
|
|
182
194
|
#
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
# preserves the default cancel/enter behavior from this method.
|
|
195
|
+
# The warning state and error-clearing are handled by {#dispatch_key}
|
|
196
|
+
# before this method is called.
|
|
186
197
|
#
|
|
187
198
|
# @param key [String] the key code from {KeyReader}
|
|
188
199
|
def handle_key(key)
|
|
@@ -194,12 +205,17 @@ module Clack
|
|
|
194
205
|
when :cancel
|
|
195
206
|
@state = :cancel
|
|
196
207
|
when :enter
|
|
197
|
-
submit
|
|
208
|
+
submit if can_submit?
|
|
198
209
|
else
|
|
199
210
|
handle_input(key, action)
|
|
200
211
|
end
|
|
201
212
|
end
|
|
202
213
|
|
|
214
|
+
# Guard hook for submit. Override to prevent submission in certain states
|
|
215
|
+
# (e.g., when cursor is on a disabled option in Select).
|
|
216
|
+
# @return [Boolean] true if submit should proceed
|
|
217
|
+
def can_submit? = true
|
|
218
|
+
|
|
203
219
|
# Handle prompt-specific input. Override in subclasses.
|
|
204
220
|
#
|
|
205
221
|
# This is the simplest extension point for prompts that only need to handle
|
|
@@ -348,6 +364,7 @@ module Clack
|
|
|
348
364
|
submit
|
|
349
365
|
if @state == :error
|
|
350
366
|
$stderr.print "#{Colors.yellow("!")} #{Colors.yellow("CI mode: validation failed for")} \"#{@message}\": #{@error_message}\n"
|
|
367
|
+
return CANCEL
|
|
351
368
|
end
|
|
352
369
|
@value
|
|
353
370
|
end
|
|
@@ -418,6 +435,22 @@ module Clack
|
|
|
418
435
|
end
|
|
419
436
|
end
|
|
420
437
|
|
|
438
|
+
# Common frame header: bar + symbol/message + help line.
|
|
439
|
+
# @return [String] header lines
|
|
440
|
+
def frame_header
|
|
441
|
+
"#{bar}\n#{symbol_for_state} #{@message}\n#{help_line}"
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Common frame footer: validation messages or bar_end.
|
|
445
|
+
# Replaces the repeated pattern of splicing validation lines.
|
|
446
|
+
# @return [String] footer lines
|
|
447
|
+
def frame_footer
|
|
448
|
+
vlns = validation_message_lines
|
|
449
|
+
return vlns.join if vlns.any?
|
|
450
|
+
|
|
451
|
+
"#{bar_end}\n"
|
|
452
|
+
end
|
|
453
|
+
|
|
421
454
|
# Build validation message lines for error or warning states.
|
|
422
455
|
# Returns array of lines to append, or empty array if no validation message.
|
|
423
456
|
def validation_message_lines
|
|
@@ -2,52 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
module Clack
|
|
4
4
|
module Core
|
|
5
|
-
#
|
|
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
|
|
5
|
+
# @deprecated Use {OptionsHelper} directly. ScrollHelper is now an alias
|
|
6
|
+
# for backwards compatibility.
|
|
18
7
|
module ScrollHelper
|
|
19
|
-
|
|
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]
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.include(OptionsHelper) unless base.ancestors.include?(OptionsHelper)
|
|
27
10
|
end
|
|
28
11
|
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
12
|
+
# Alias for visible_options (old name from when ScrollHelper was separate)
|
|
13
|
+
def visible_items
|
|
14
|
+
visible_options
|
|
38
15
|
end
|
|
39
16
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
17
|
+
# Alias for navigable_items (old name from when ScrollHelper was separate)
|
|
18
|
+
def scroll_items
|
|
19
|
+
navigable_items
|
|
51
20
|
end
|
|
52
21
|
end
|
|
53
22
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
module Core
|
|
5
|
+
# Shared selection management for multi-select prompts.
|
|
6
|
+
# Handles toggle, toggle-all, required validation, and value tracking.
|
|
7
|
+
#
|
|
8
|
+
# Including classes must define:
|
|
9
|
+
# - +@selected+ [Set] the set of selected values
|
|
10
|
+
# - +@required+ [Boolean] whether at least one selection is required
|
|
11
|
+
module SelectionManager
|
|
12
|
+
REQUIRED_ERROR = "Please select at least one option. Press %s to select, %s to submit"
|
|
13
|
+
|
|
14
|
+
# Toggle a value in the selection set.
|
|
15
|
+
# @param value [Object] the value to toggle
|
|
16
|
+
def toggle_value(value)
|
|
17
|
+
if @selected.include?(value)
|
|
18
|
+
@selected.delete(value)
|
|
19
|
+
else
|
|
20
|
+
@selected.add(value)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Validate that selection meets requirements before submit.
|
|
25
|
+
# Sets error state if required and empty.
|
|
26
|
+
# @return [Boolean] true if valid to proceed with submit
|
|
27
|
+
def validate_selection
|
|
28
|
+
if @required && @selected.empty?
|
|
29
|
+
@error_message = format(REQUIRED_ERROR, Colors.cyan("space"), Colors.cyan("enter"))
|
|
30
|
+
@state = :error
|
|
31
|
+
return false
|
|
32
|
+
end
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Update @value from the current selection.
|
|
37
|
+
def update_selection_value
|
|
38
|
+
@value = @selected.to_a
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Build the final display string from selected options.
|
|
42
|
+
# @param all_options [Array<Hash>] the full options list to match against
|
|
43
|
+
# @return [String] comma-separated labels
|
|
44
|
+
def selected_labels(all_options)
|
|
45
|
+
all_options.select { |o| @selected.include?(o.value) }.map { |o| o.label }.join(", ")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/clack/note.rb
CHANGED
|
@@ -14,14 +14,15 @@ module Clack
|
|
|
14
14
|
lines = message.to_s.lines.map(&:chomp)
|
|
15
15
|
# Add empty lines at start and end like original
|
|
16
16
|
lines = ["", *lines, ""]
|
|
17
|
-
title_len = title
|
|
17
|
+
title_len = Clack::Utils.visible_length(title)
|
|
18
18
|
width = calculate_width(lines, title_len)
|
|
19
19
|
|
|
20
20
|
output.puts Colors.gray(Symbols::S_BAR)
|
|
21
21
|
output.puts build_top_border(title, title_len, width)
|
|
22
22
|
|
|
23
23
|
lines.each do |line|
|
|
24
|
-
|
|
24
|
+
pad = width - Clack::Utils.visible_length(line)
|
|
25
|
+
padded = pad.positive? ? line + (" " * pad) : line
|
|
25
26
|
output.puts "#{Colors.gray(Symbols::S_BAR)} #{Colors.dim(padded)}#{Colors.gray(Symbols::S_BAR)}"
|
|
26
27
|
end
|
|
27
28
|
|
|
@@ -31,7 +32,7 @@ module Clack
|
|
|
31
32
|
private
|
|
32
33
|
|
|
33
34
|
def calculate_width(lines, title_len)
|
|
34
|
-
max_line = lines.map(
|
|
35
|
+
max_line = lines.map { |line| Clack::Utils.visible_length(line) }.max || 0
|
|
35
36
|
[max_line, title_len].max + 2
|
|
36
37
|
end
|
|
37
38
|
|
|
@@ -29,13 +29,12 @@ module Clack
|
|
|
29
29
|
# Clack.autocomplete(
|
|
30
30
|
# message: "Select command",
|
|
31
31
|
# options: commands,
|
|
32
|
-
# filter: ->(opt, query) { opt
|
|
32
|
+
# filter: ->(opt, query) { opt.label.start_with?(query) }
|
|
33
33
|
# )
|
|
34
34
|
#
|
|
35
35
|
class Autocomplete < Core::Prompt
|
|
36
36
|
include Core::OptionsHelper
|
|
37
37
|
include Core::TextInputHelper
|
|
38
|
-
include Core::ScrollHelper
|
|
39
38
|
|
|
40
39
|
# @param message [String] the prompt message
|
|
41
40
|
# @param options [Array<Hash, String>] list of options to filter
|
|
@@ -51,9 +50,9 @@ module Clack
|
|
|
51
50
|
@max_items = max_items
|
|
52
51
|
@placeholder = placeholder
|
|
53
52
|
@filter = filter
|
|
54
|
-
@
|
|
53
|
+
@search_text = ""
|
|
55
54
|
@cursor = 0
|
|
56
|
-
@
|
|
55
|
+
@option_index = 0
|
|
57
56
|
@scroll_offset = 0
|
|
58
57
|
update_filtered
|
|
59
58
|
end
|
|
@@ -81,10 +80,17 @@ module Clack
|
|
|
81
80
|
end
|
|
82
81
|
end
|
|
83
82
|
|
|
83
|
+
# Use @search_text as text input backing store
|
|
84
|
+
def text_value = @search_text
|
|
85
|
+
|
|
86
|
+
def text_value=(val)
|
|
87
|
+
@search_text = val
|
|
88
|
+
end
|
|
89
|
+
|
|
84
90
|
def handle_text_input(key)
|
|
85
91
|
return unless super
|
|
86
92
|
|
|
87
|
-
@
|
|
93
|
+
@option_index = 0
|
|
88
94
|
@scroll_offset = 0
|
|
89
95
|
update_filtered
|
|
90
96
|
end
|
|
@@ -96,7 +102,7 @@ module Clack
|
|
|
96
102
|
return
|
|
97
103
|
end
|
|
98
104
|
|
|
99
|
-
@value = @filtered[@
|
|
105
|
+
@value = @filtered[@option_index].value
|
|
100
106
|
submit
|
|
101
107
|
end
|
|
102
108
|
|
|
@@ -107,9 +113,9 @@ module Clack
|
|
|
107
113
|
lines << help_line
|
|
108
114
|
lines << "#{active_bar} #{input_display}\n"
|
|
109
115
|
|
|
110
|
-
|
|
116
|
+
visible_options.each_with_index do |opt, idx|
|
|
111
117
|
actual_idx = @scroll_offset + idx
|
|
112
|
-
lines << "#{bar} #{option_display(opt, actual_idx == @
|
|
118
|
+
lines << "#{bar} #{option_display(opt, actual_idx == @option_index)}\n"
|
|
113
119
|
end
|
|
114
120
|
|
|
115
121
|
if @state in :error | :warning
|
|
@@ -121,26 +127,26 @@ module Clack
|
|
|
121
127
|
lines.join
|
|
122
128
|
end
|
|
123
129
|
|
|
124
|
-
def final_display = @filtered[@
|
|
130
|
+
def final_display = @filtered[@option_index]&.label || @value
|
|
125
131
|
|
|
126
132
|
private
|
|
127
133
|
|
|
128
134
|
def update_filtered
|
|
129
135
|
@filtered = if @filter
|
|
130
|
-
@all_options.select { |opt| @filter.call(opt, @
|
|
136
|
+
@all_options.select { |opt| @filter.call(opt, @search_text) }
|
|
131
137
|
else
|
|
132
|
-
Core::FuzzyMatcher.filter(@all_options, @
|
|
138
|
+
Core::FuzzyMatcher.filter(@all_options, @search_text)
|
|
133
139
|
end
|
|
134
140
|
end
|
|
135
141
|
|
|
136
|
-
def
|
|
142
|
+
def navigable_items = @filtered
|
|
137
143
|
|
|
138
144
|
def option_display(opt, active)
|
|
139
|
-
hint = (opt
|
|
145
|
+
hint = (opt.hint && active) ? Colors.dim(" (#{opt.hint})") : ""
|
|
140
146
|
if active
|
|
141
|
-
"#{Colors.green(Symbols::S_RADIO_ACTIVE)} #{opt
|
|
147
|
+
"#{Colors.green(Symbols::S_RADIO_ACTIVE)} #{opt.label}#{hint}"
|
|
142
148
|
else
|
|
143
|
-
"#{Colors.dim(Symbols::S_RADIO_INACTIVE)} #{Colors.dim(opt
|
|
149
|
+
"#{Colors.dim(Symbols::S_RADIO_INACTIVE)} #{Colors.dim(opt.label)}"
|
|
144
150
|
end
|
|
145
151
|
end
|
|
146
152
|
end
|