clack 0.5.0 → 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 +18 -0
- data/README.md +2 -1
- data/lib/clack/box.rb +6 -6
- data/lib/clack/core/fuzzy_matcher.rb +3 -3
- data/lib/clack/core/options_helper.rb +34 -9
- data/lib/clack/core/selection_manager.rb +1 -1
- data/lib/clack/note.rb +4 -3
- data/lib/clack/prompts/autocomplete.rb +6 -6
- data/lib/clack/prompts/autocomplete_multiselect.rb +6 -6
- data/lib/clack/prompts/group_multiselect.rb +29 -30
- data/lib/clack/prompts/multiselect.rb +13 -13
- data/lib/clack/prompts/progress.rb +6 -2
- data/lib/clack/prompts/select.rb +8 -8
- data/lib/clack/prompts/select_key.rb +8 -8
- data/lib/clack/prompts/tasks.rb +6 -1
- data/lib/clack/utils.rb +106 -22
- data/lib/clack/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b4723c11dc0a0944ea3a56337800aac60ec1ae3db29094f7f98e77f87b38caa2
|
|
4
|
+
data.tar.gz: 8b9bc87d58955147a0d000e94db0094832cb02dea4a92fd13d11618322fe5578
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 70f8abc4ace88dd272abb4022b29819acc7ce3be50b6d1fa0926a4e3e70cc46f48de71ec3840229b55ac7f4cf857d7551d5ff55d35063e96992e1d73e418d1ff
|
|
7
|
+
data.tar.gz: 82950f8271b8e672c95d4f05c77749566e54d01db88fe62635ff7dd45b75fb846076497e8d5d6fee41fbf45f3088939f1f4eab13020fcbcc482ad0e7d2366e66
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.0] - 2026-05-30
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `Option`, `SelectKeyOption`, `GroupHeader`, and `GroupOption` value objects for normalized prompt options
|
|
7
|
+
- Hash-style `[]` access on option value objects for compatibility with existing custom autocomplete filters
|
|
8
|
+
- Display-width coverage for CJK, fullwidth forms, Hangul, emoji, ZWJ emoji sequences, flag emoji, combining marks, and ANSI-colored text
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Option-based prompts now normalize strings, symbols, and hashes into typed value objects instead of bare hashes
|
|
12
|
+
- Autocomplete and multiselect filtering now operate on normalized option objects while preserving `opt[:key]` access patterns
|
|
13
|
+
- Text wrapping, truncation, box titles, and note layout now measure terminal display columns instead of Ruby string length
|
|
14
|
+
- Progress rendering skips duplicate intermediate frames during rapid updates
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- CJK, emoji, combining characters, and ANSI sequences no longer throw off prompt alignment, wrapping, truncation, note padding, or box title width
|
|
18
|
+
- `Tasks#run` restores cursor visibility even when task execution raises
|
|
19
|
+
- `SelectKey` option normalization resolves the value object through the correct namespace
|
|
20
|
+
|
|
3
21
|
## [0.5.0] - 2026-03-27
|
|
4
22
|
|
|
5
23
|
### Breaking
|
data/README.md
CHANGED
|
@@ -238,7 +238,8 @@ color = Clack.autocomplete(
|
|
|
238
238
|
max_items: 5 # Default; scrollable via up/down arrows
|
|
239
239
|
)
|
|
240
240
|
|
|
241
|
-
# Custom filter logic (receives option
|
|
241
|
+
# Custom filter logic (receives the option and query string).
|
|
242
|
+
# The option is a value object; opt.label and opt[:label] both work.
|
|
242
243
|
cmd = Clack.autocomplete(
|
|
243
244
|
message: "Select command",
|
|
244
245
|
options: commands,
|
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
|
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
module Clack
|
|
4
4
|
module Core
|
|
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
|
+
|
|
5
30
|
# Shared functionality for option-based prompts (Select, Multiselect, Autocomplete, etc.).
|
|
6
31
|
# Handles option normalization, cursor navigation, and scrolling.
|
|
7
32
|
#
|
|
@@ -25,20 +50,20 @@ module Clack
|
|
|
25
50
|
options.map { |opt| OptionsHelper.normalize_option(opt) }
|
|
26
51
|
end
|
|
27
52
|
|
|
28
|
-
# Normalize a single option to
|
|
53
|
+
# Normalize a single option to an Option value object.
|
|
29
54
|
# @param opt [Hash, String, Symbol] Raw option
|
|
30
|
-
# @return [
|
|
55
|
+
# @return [Option] Normalized option
|
|
31
56
|
def self.normalize_option(opt)
|
|
32
57
|
case opt
|
|
33
58
|
when Hash
|
|
34
|
-
|
|
59
|
+
Option.new(
|
|
35
60
|
value: opt[:value],
|
|
36
61
|
label: opt[:label] || opt[:value].to_s,
|
|
37
62
|
hint: opt[:hint],
|
|
38
63
|
disabled: opt[:disabled] || false
|
|
39
|
-
|
|
64
|
+
)
|
|
40
65
|
else
|
|
41
|
-
|
|
66
|
+
Option.new(value: opt, label: opt.to_s, hint: nil, disabled: false)
|
|
42
67
|
end
|
|
43
68
|
end
|
|
44
69
|
|
|
@@ -54,7 +79,7 @@ module Clack
|
|
|
54
79
|
idx = (from + delta) % max
|
|
55
80
|
|
|
56
81
|
max.times do
|
|
57
|
-
return idx unless items[idx]
|
|
82
|
+
return idx unless items[idx].disabled
|
|
58
83
|
|
|
59
84
|
idx = (idx + delta) % max
|
|
60
85
|
end
|
|
@@ -120,11 +145,11 @@ module Clack
|
|
|
120
145
|
|
|
121
146
|
if initial_value.nil?
|
|
122
147
|
# Start at first enabled option
|
|
123
|
-
return items[0]
|
|
148
|
+
return items[0].disabled ? first_enabled_index : 0
|
|
124
149
|
end
|
|
125
150
|
|
|
126
|
-
idx = items.find_index { |o| o
|
|
127
|
-
(idx && !items[idx]
|
|
151
|
+
idx = items.find_index { |o| o.value == initial_value }
|
|
152
|
+
(idx && !items[idx].disabled) ? idx : first_enabled_index
|
|
128
153
|
end
|
|
129
154
|
|
|
130
155
|
# The list of items to navigate. Override in subclasses that use
|
|
@@ -42,7 +42,7 @@ module Clack
|
|
|
42
42
|
# @param all_options [Array<Hash>] the full options list to match against
|
|
43
43
|
# @return [String] comma-separated labels
|
|
44
44
|
def selected_labels(all_options)
|
|
45
|
-
all_options.select { |o| @selected.include?(o
|
|
45
|
+
all_options.select { |o| @selected.include?(o.value) }.map { |o| o.label }.join(", ")
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
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,7 +29,7 @@ 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
|
|
@@ -102,7 +102,7 @@ module Clack
|
|
|
102
102
|
return
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
-
@value = @filtered[@option_index]
|
|
105
|
+
@value = @filtered[@option_index].value
|
|
106
106
|
submit
|
|
107
107
|
end
|
|
108
108
|
|
|
@@ -127,7 +127,7 @@ module Clack
|
|
|
127
127
|
lines.join
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
-
def final_display = @filtered[@option_index]&.
|
|
130
|
+
def final_display = @filtered[@option_index]&.label || @value
|
|
131
131
|
|
|
132
132
|
private
|
|
133
133
|
|
|
@@ -142,11 +142,11 @@ module Clack
|
|
|
142
142
|
def navigable_items = @filtered
|
|
143
143
|
|
|
144
144
|
def option_display(opt, active)
|
|
145
|
-
hint = (opt
|
|
145
|
+
hint = (opt.hint && active) ? Colors.dim(" (#{opt.hint})") : ""
|
|
146
146
|
if active
|
|
147
|
-
"#{Colors.green(Symbols::S_RADIO_ACTIVE)} #{opt
|
|
147
|
+
"#{Colors.green(Symbols::S_RADIO_ACTIVE)} #{opt.label}#{hint}"
|
|
148
148
|
else
|
|
149
|
-
"#{Colors.dim(Symbols::S_RADIO_INACTIVE)} #{Colors.dim(opt
|
|
149
|
+
"#{Colors.dim(Symbols::S_RADIO_INACTIVE)} #{Colors.dim(opt.label)}"
|
|
150
150
|
end
|
|
151
151
|
end
|
|
152
152
|
end
|
|
@@ -55,7 +55,7 @@ module Clack
|
|
|
55
55
|
@cursor = 0
|
|
56
56
|
@option_index = 0
|
|
57
57
|
@scroll_offset = 0
|
|
58
|
-
valid_values = Set.new(@all_options.map { |o| o
|
|
58
|
+
valid_values = Set.new(@all_options.map { |o| o.value })
|
|
59
59
|
@selected = Set.new(initial_values) & valid_values
|
|
60
60
|
update_filtered
|
|
61
61
|
end
|
|
@@ -88,9 +88,9 @@ module Clack
|
|
|
88
88
|
return if @filtered.empty?
|
|
89
89
|
|
|
90
90
|
opt = @filtered[@option_index]
|
|
91
|
-
return if opt
|
|
91
|
+
return if opt.disabled
|
|
92
92
|
|
|
93
|
-
toggle_value(opt
|
|
93
|
+
toggle_value(opt.value)
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
def submit
|
|
@@ -169,15 +169,15 @@ module Clack
|
|
|
169
169
|
def navigable_items = @filtered
|
|
170
170
|
|
|
171
171
|
def option_display(opt, active)
|
|
172
|
-
selected = @selected.include?(opt
|
|
172
|
+
selected = @selected.include?(opt.value)
|
|
173
173
|
checkbox = if selected
|
|
174
174
|
Colors.green(Symbols::S_CHECKBOX_SELECTED)
|
|
175
175
|
else
|
|
176
176
|
Colors.dim(Symbols::S_CHECKBOX_INACTIVE)
|
|
177
177
|
end
|
|
178
178
|
|
|
179
|
-
label = active ? opt
|
|
180
|
-
hint = (opt
|
|
179
|
+
label = active ? opt.label : Colors.dim(opt.label)
|
|
180
|
+
hint = (opt.hint && active) ? Colors.dim(" (#{opt.hint})") : ""
|
|
181
181
|
|
|
182
182
|
"#{checkbox} #{label}#{hint}"
|
|
183
183
|
end
|
|
@@ -60,7 +60,7 @@ module Clack
|
|
|
60
60
|
super(message:, **opts)
|
|
61
61
|
@groups = normalize_groups(options)
|
|
62
62
|
@flat_items = build_flat_items
|
|
63
|
-
valid_values = Set.new(@flat_items.select { |item| item
|
|
63
|
+
valid_values = Set.new(@flat_items.select { |item| item.is_a?(Core::GroupOption) }.map(&:value))
|
|
64
64
|
@selected = Set.new(initial_values) & valid_values
|
|
65
65
|
@required = required
|
|
66
66
|
@selectable_groups = selectable_groups
|
|
@@ -93,8 +93,8 @@ module Clack
|
|
|
93
93
|
|
|
94
94
|
prev_was_group = false
|
|
95
95
|
@flat_items.each_with_index do |item, idx|
|
|
96
|
-
is_group = item
|
|
97
|
-
is_last_in_group = item
|
|
96
|
+
is_group = item.is_a?(Core::GroupHeader)
|
|
97
|
+
is_last_in_group = item.is_a?(Core::GroupOption) ? item.last_in_group : false
|
|
98
98
|
|
|
99
99
|
# Add group spacing before groups (except first)
|
|
100
100
|
if is_group && !prev_was_group && idx.positive? && @group_spacing.positive?
|
|
@@ -117,7 +117,7 @@ module Clack
|
|
|
117
117
|
lines.join
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
-
def final_display = selected_options.map
|
|
120
|
+
def final_display = selected_options.map(&:label).join(", ")
|
|
121
121
|
|
|
122
122
|
private
|
|
123
123
|
|
|
@@ -137,30 +137,29 @@ module Clack
|
|
|
137
137
|
def build_flat_items = @groups.flat_map { |group| flatten_group(group) }
|
|
138
138
|
|
|
139
139
|
def flatten_group(group)
|
|
140
|
-
group_item =
|
|
140
|
+
group_item = Core::GroupHeader.new(label: group[:label], options: group[:options])
|
|
141
141
|
option_items = group[:options].each_with_index.map do |opt, idx|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
disabled: opt[:disabled],
|
|
142
|
+
Core::GroupOption.new(
|
|
143
|
+
value: opt.value,
|
|
144
|
+
label: opt.label,
|
|
145
|
+
hint: opt.hint,
|
|
146
|
+
disabled: opt.disabled,
|
|
148
147
|
group: group,
|
|
149
148
|
last_in_group: idx == group[:options].length - 1
|
|
150
|
-
|
|
149
|
+
)
|
|
151
150
|
end
|
|
152
151
|
[group_item, *option_items]
|
|
153
152
|
end
|
|
154
153
|
|
|
155
154
|
def selected_options
|
|
156
|
-
@flat_items.select { |item| item
|
|
155
|
+
@flat_items.select { |item| item.is_a?(Core::GroupOption) && @selected.include?(item.value) }
|
|
157
156
|
end
|
|
158
157
|
|
|
159
158
|
def find_initial_cursor(cursor_at)
|
|
160
159
|
return 0 if @flat_items.empty?
|
|
161
160
|
|
|
162
161
|
if cursor_at
|
|
163
|
-
idx = @flat_items.find_index { |item| item
|
|
162
|
+
idx = @flat_items.find_index { |item| item.respond_to?(:value) && item.value == cursor_at }
|
|
164
163
|
return idx if idx
|
|
165
164
|
end
|
|
166
165
|
|
|
@@ -168,8 +167,8 @@ module Clack
|
|
|
168
167
|
end
|
|
169
168
|
|
|
170
169
|
def can_select?(item)
|
|
171
|
-
return false if item
|
|
172
|
-
return @selectable_groups if item
|
|
170
|
+
return false if item.is_a?(Core::GroupOption) && item.disabled
|
|
171
|
+
return @selectable_groups if item.is_a?(Core::GroupHeader)
|
|
173
172
|
|
|
174
173
|
true
|
|
175
174
|
end
|
|
@@ -191,7 +190,7 @@ module Clack
|
|
|
191
190
|
item = @flat_items[@option_index]
|
|
192
191
|
return unless can_select?(item)
|
|
193
192
|
|
|
194
|
-
if item
|
|
193
|
+
if item.is_a?(Core::GroupHeader)
|
|
195
194
|
toggle_group(item)
|
|
196
195
|
else
|
|
197
196
|
toggle_option(item)
|
|
@@ -200,7 +199,7 @@ module Clack
|
|
|
200
199
|
end
|
|
201
200
|
|
|
202
201
|
def toggle_group(group_item)
|
|
203
|
-
group_values = group_item
|
|
202
|
+
group_values = group_item.options.reject { |o| o.disabled }.map(&:value)
|
|
204
203
|
all_selected = group_values.all? { |v| @selected.include?(v) }
|
|
205
204
|
|
|
206
205
|
if all_selected
|
|
@@ -211,41 +210,41 @@ module Clack
|
|
|
211
210
|
end
|
|
212
211
|
|
|
213
212
|
def toggle_option(item)
|
|
214
|
-
toggle_value(item
|
|
213
|
+
toggle_value(item.value)
|
|
215
214
|
end
|
|
216
215
|
|
|
217
216
|
def update_value = update_selection_value
|
|
218
217
|
|
|
219
218
|
def group_display(item, active)
|
|
220
219
|
if @selectable_groups
|
|
221
|
-
all_selected = item
|
|
220
|
+
all_selected = item.options.reject { |o| o.disabled }.all? { |o| @selected.include?(o.value) }
|
|
222
221
|
checkbox = all_selected ? Colors.green(Symbols::S_CHECKBOX_SELECTED) : Colors.dim(Symbols::S_CHECKBOX_INACTIVE)
|
|
223
|
-
label = active ? item
|
|
222
|
+
label = active ? item.label : Colors.dim(item.label)
|
|
224
223
|
"#{active_bar} #{checkbox} #{label}\n"
|
|
225
224
|
else
|
|
226
|
-
"#{active_bar} #{Colors.dim(item
|
|
225
|
+
"#{active_bar} #{Colors.dim(item.label)}\n"
|
|
227
226
|
end
|
|
228
227
|
end
|
|
229
228
|
|
|
230
229
|
def option_display(item, active, is_last)
|
|
231
|
-
selected = @selected.include?(item
|
|
230
|
+
selected = @selected.include?(item.value)
|
|
232
231
|
prefix = if @selectable_groups
|
|
233
232
|
"#{is_last ? Symbols::S_BAR_END : Symbols::S_BAR} "
|
|
234
233
|
else
|
|
235
234
|
" "
|
|
236
235
|
end
|
|
237
|
-
hint = (item
|
|
236
|
+
hint = (item.hint && active) ? " #{Colors.dim("(#{item.hint})")}" : ""
|
|
238
237
|
|
|
239
|
-
if item
|
|
240
|
-
"#{active_bar} #{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.strikethrough(Colors.dim(item
|
|
238
|
+
if item.disabled
|
|
239
|
+
"#{active_bar} #{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.strikethrough(Colors.dim(item.label))}\n"
|
|
241
240
|
elsif active && selected
|
|
242
|
-
"#{active_bar} #{Colors.dim(prefix)}#{Colors.green(Symbols::S_CHECKBOX_SELECTED)} #{item
|
|
241
|
+
"#{active_bar} #{Colors.dim(prefix)}#{Colors.green(Symbols::S_CHECKBOX_SELECTED)} #{item.label}#{hint}\n"
|
|
243
242
|
elsif active
|
|
244
|
-
"#{active_bar} #{Colors.dim(prefix)}#{Colors.cyan(Symbols::S_CHECKBOX_ACTIVE)} #{item
|
|
243
|
+
"#{active_bar} #{Colors.dim(prefix)}#{Colors.cyan(Symbols::S_CHECKBOX_ACTIVE)} #{item.label}#{hint}\n"
|
|
245
244
|
elsif selected
|
|
246
|
-
"#{active_bar} #{Colors.dim(prefix)}#{Colors.green(Symbols::S_CHECKBOX_SELECTED)} #{Colors.dim(item
|
|
245
|
+
"#{active_bar} #{Colors.dim(prefix)}#{Colors.green(Symbols::S_CHECKBOX_SELECTED)} #{Colors.dim(item.label)}\n"
|
|
247
246
|
else
|
|
248
|
-
"#{active_bar} #{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.dim(item
|
|
247
|
+
"#{active_bar} #{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.dim(item.label)}\n"
|
|
249
248
|
end
|
|
250
249
|
end
|
|
251
250
|
end
|
|
@@ -44,7 +44,7 @@ module Clack
|
|
|
44
44
|
end
|
|
45
45
|
super(message:, **opts)
|
|
46
46
|
@options = normalize_options(options)
|
|
47
|
-
valid_values = Set.new(@options.map { |o| o
|
|
47
|
+
valid_values = Set.new(@options.map { |o| o.value })
|
|
48
48
|
@selected = Set.new(initial_values) & valid_values
|
|
49
49
|
@required = required
|
|
50
50
|
@max_items = max_items
|
|
@@ -110,14 +110,14 @@ module Clack
|
|
|
110
110
|
|
|
111
111
|
def toggle_current
|
|
112
112
|
opt = @options[@option_index]
|
|
113
|
-
return if opt
|
|
113
|
+
return if opt.disabled
|
|
114
114
|
|
|
115
|
-
toggle_value(opt
|
|
115
|
+
toggle_value(opt.value)
|
|
116
116
|
update_selection_value
|
|
117
117
|
end
|
|
118
118
|
|
|
119
119
|
def toggle_all
|
|
120
|
-
enabled = @options.reject { |o| o
|
|
120
|
+
enabled = @options.reject { |o| o.disabled }.map { |o| o.value }
|
|
121
121
|
if enabled.all? { |v| @selected.include?(v) }
|
|
122
122
|
@selected.clear
|
|
123
123
|
else
|
|
@@ -128,9 +128,9 @@ module Clack
|
|
|
128
128
|
|
|
129
129
|
def invert_selection
|
|
130
130
|
@options.each do |opt|
|
|
131
|
-
next if opt
|
|
131
|
+
next if opt.disabled
|
|
132
132
|
|
|
133
|
-
toggle_value(opt
|
|
133
|
+
toggle_value(opt.value)
|
|
134
134
|
end
|
|
135
135
|
update_selection_value
|
|
136
136
|
end
|
|
@@ -146,22 +146,22 @@ module Clack
|
|
|
146
146
|
|
|
147
147
|
def option_display(opt, idx)
|
|
148
148
|
active = idx == @option_index
|
|
149
|
-
selected = @selected.include?(opt
|
|
149
|
+
selected = @selected.include?(opt.value)
|
|
150
150
|
|
|
151
151
|
symbol, label = option_parts(opt, active, selected)
|
|
152
152
|
"#{symbol} #{label}"
|
|
153
153
|
end
|
|
154
154
|
|
|
155
155
|
def option_parts(opt, active, selected)
|
|
156
|
-
if opt
|
|
156
|
+
if opt.disabled
|
|
157
157
|
return [Colors.dim(Symbols::S_CHECKBOX_INACTIVE),
|
|
158
|
-
Colors.strikethrough(Colors.dim(opt
|
|
158
|
+
Colors.strikethrough(Colors.dim(opt.label))]
|
|
159
159
|
end
|
|
160
|
-
return [Colors.green(Symbols::S_CHECKBOX_SELECTED), opt
|
|
161
|
-
return [Colors.cyan(Symbols::S_CHECKBOX_ACTIVE), opt
|
|
162
|
-
return [Colors.green(Symbols::S_CHECKBOX_SELECTED), Colors.dim(opt
|
|
160
|
+
return [Colors.green(Symbols::S_CHECKBOX_SELECTED), opt.label] if active && selected
|
|
161
|
+
return [Colors.cyan(Symbols::S_CHECKBOX_ACTIVE), opt.label] if active
|
|
162
|
+
return [Colors.green(Symbols::S_CHECKBOX_SELECTED), Colors.dim(opt.label)] if selected
|
|
163
163
|
|
|
164
|
-
[Colors.dim(Symbols::S_CHECKBOX_INACTIVE), Colors.dim(opt
|
|
164
|
+
[Colors.dim(Symbols::S_CHECKBOX_INACTIVE), Colors.dim(opt.label)]
|
|
165
165
|
end
|
|
166
166
|
end
|
|
167
167
|
end
|
|
@@ -36,6 +36,7 @@ module Clack
|
|
|
36
36
|
@output = output
|
|
37
37
|
@started = false
|
|
38
38
|
@width = 40
|
|
39
|
+
@last_frame = nil
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
# Start displaying the progress bar.
|
|
@@ -105,8 +106,11 @@ module Clack
|
|
|
105
106
|
def render
|
|
106
107
|
return unless @started
|
|
107
108
|
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
frame = "#{symbol} #{progress_bar} #{percentage}#{message_text}"
|
|
110
|
+
return if frame == @last_frame
|
|
111
|
+
|
|
112
|
+
@last_frame = frame
|
|
113
|
+
@output.print "\r\e[2K#{frame}"
|
|
110
114
|
@output.flush
|
|
111
115
|
end
|
|
112
116
|
|
data/lib/clack/prompts/select.rb
CHANGED
|
@@ -55,7 +55,7 @@ module Clack
|
|
|
55
55
|
end
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
def can_submit? = !current_option
|
|
58
|
+
def can_submit? = !current_option.disabled
|
|
59
59
|
|
|
60
60
|
def build_frame
|
|
61
61
|
option_lines = visible_options.each_with_index.map do |opt, idx|
|
|
@@ -66,7 +66,7 @@ module Clack
|
|
|
66
66
|
"#{frame_header}#{option_lines}#{frame_footer}"
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
-
def final_display = current_option
|
|
69
|
+
def final_display = current_option.label
|
|
70
70
|
|
|
71
71
|
private
|
|
72
72
|
|
|
@@ -75,12 +75,12 @@ module Clack
|
|
|
75
75
|
update_value
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
def update_value = @value = current_option
|
|
78
|
+
def update_value = @value = current_option.value
|
|
79
79
|
|
|
80
80
|
def current_option = @options[@option_index]
|
|
81
81
|
|
|
82
82
|
def option_display(opt, active)
|
|
83
|
-
return disabled_option_display(opt) if opt
|
|
83
|
+
return disabled_option_display(opt) if opt.disabled
|
|
84
84
|
return active_option_display(opt) if active
|
|
85
85
|
|
|
86
86
|
inactive_option_display(opt)
|
|
@@ -88,19 +88,19 @@ module Clack
|
|
|
88
88
|
|
|
89
89
|
def disabled_option_display(opt)
|
|
90
90
|
symbol = Colors.dim(Symbols::S_RADIO_INACTIVE)
|
|
91
|
-
label = Colors.strikethrough(Colors.dim(opt
|
|
91
|
+
label = Colors.strikethrough(Colors.dim(opt.label))
|
|
92
92
|
"#{symbol} #{label}"
|
|
93
93
|
end
|
|
94
94
|
|
|
95
95
|
def active_option_display(opt)
|
|
96
96
|
symbol = Colors.green(Symbols::S_RADIO_ACTIVE)
|
|
97
|
-
hint = opt
|
|
98
|
-
"#{symbol} #{opt
|
|
97
|
+
hint = opt.hint ? " #{Colors.dim("(#{opt.hint})")}" : ""
|
|
98
|
+
"#{symbol} #{opt.label}#{hint}"
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
def inactive_option_display(opt)
|
|
102
102
|
symbol = Colors.dim(Symbols::S_RADIO_INACTIVE)
|
|
103
|
-
"#{symbol} #{Colors.dim(opt
|
|
103
|
+
"#{symbol} #{Colors.dim(opt.label)}"
|
|
104
104
|
end
|
|
105
105
|
end
|
|
106
106
|
end
|
|
@@ -41,10 +41,10 @@ module Clack
|
|
|
41
41
|
when :cancel
|
|
42
42
|
@state = :cancel
|
|
43
43
|
else
|
|
44
|
-
opt = @options.find { |o| o
|
|
44
|
+
opt = @options.find { |o| o.key&.downcase == key&.downcase }
|
|
45
45
|
return unless opt
|
|
46
46
|
|
|
47
|
-
@value = opt
|
|
47
|
+
@value = opt.value
|
|
48
48
|
@state = :submit
|
|
49
49
|
end
|
|
50
50
|
end
|
|
@@ -63,25 +63,25 @@ module Clack
|
|
|
63
63
|
lines.join
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
def final_display = @options.find { |o| o
|
|
66
|
+
def final_display = @options.find { |o| o.value == @value }&.label.to_s
|
|
67
67
|
|
|
68
68
|
private
|
|
69
69
|
|
|
70
70
|
def normalize_options(options)
|
|
71
71
|
options.map do |opt|
|
|
72
|
-
|
|
72
|
+
Clack::Core::SelectKeyOption.new(
|
|
73
73
|
value: opt[:value],
|
|
74
74
|
label: opt[:label] || opt[:value].to_s,
|
|
75
75
|
key: opt[:key] || opt[:value].to_s[0],
|
|
76
76
|
hint: opt[:hint]
|
|
77
|
-
|
|
77
|
+
)
|
|
78
78
|
end
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
def option_display(opt)
|
|
82
|
-
key_display = Colors.cyan("[#{opt
|
|
83
|
-
hint = opt
|
|
84
|
-
"#{key_display} #{opt
|
|
82
|
+
key_display = Colors.cyan("[#{opt.key}]")
|
|
83
|
+
hint = opt.hint ? " #{Colors.dim("(#{opt.hint})")}" : ""
|
|
84
|
+
"#{key_display} #{opt.label}#{hint}"
|
|
85
85
|
end
|
|
86
86
|
end
|
|
87
87
|
end
|
data/lib/clack/prompts/tasks.rb
CHANGED
data/lib/clack/utils.rb
CHANGED
|
@@ -11,11 +11,28 @@ module Clack
|
|
|
11
11
|
text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
# Get visible length of text
|
|
15
|
-
#
|
|
16
|
-
# @
|
|
14
|
+
# Get visible length (display width in columns) of text after stripping ANSI.
|
|
15
|
+
# Uses display_width to correctly measure CJK, emoji, combining chars.
|
|
16
|
+
# @param text [String]
|
|
17
|
+
# @return [Integer] display columns
|
|
17
18
|
def visible_length(text)
|
|
18
|
-
strip_ansi(text)
|
|
19
|
+
display_width(strip_ansi(text))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Calculate the terminal display width (columns) of a string.
|
|
23
|
+
# ASCII and most chars: width 1. CJK ideographs, fullwidth forms, common emoji: width 2.
|
|
24
|
+
# Zero-width joiners, combining marks, variation selectors: width 0.
|
|
25
|
+
# @param string [String]
|
|
26
|
+
# @return [Integer]
|
|
27
|
+
def display_width(string)
|
|
28
|
+
str = string.to_s
|
|
29
|
+
return 0 if str.empty?
|
|
30
|
+
|
|
31
|
+
width = 0
|
|
32
|
+
str.grapheme_clusters.each do |cluster|
|
|
33
|
+
width += grapheme_width(cluster)
|
|
34
|
+
end
|
|
35
|
+
width
|
|
19
36
|
end
|
|
20
37
|
|
|
21
38
|
# Wrap text to a specified width, preserving ANSI codes
|
|
@@ -54,7 +71,7 @@ module Clack
|
|
|
54
71
|
def truncate(text, width, ellipsis: "...")
|
|
55
72
|
return text if visible_length(text) <= width
|
|
56
73
|
|
|
57
|
-
target = width - ellipsis
|
|
74
|
+
target = width - visible_length(ellipsis)
|
|
58
75
|
return ellipsis if target <= 0
|
|
59
76
|
|
|
60
77
|
# Handle ANSI codes: we need to truncate visible chars while preserving codes
|
|
@@ -98,38 +115,105 @@ module Clack
|
|
|
98
115
|
|
|
99
116
|
def break_long_word(word, width)
|
|
100
117
|
lines = []
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
clusters = strip_ansi(word).grapheme_clusters
|
|
119
|
+
|
|
120
|
+
chunk = +""
|
|
121
|
+
chunk_width = 0
|
|
122
|
+
clusters.each do |gc|
|
|
123
|
+
gw = grapheme_width(gc)
|
|
124
|
+
if chunk_width + gw > width && !chunk.empty?
|
|
125
|
+
lines << chunk
|
|
126
|
+
chunk = +""
|
|
127
|
+
chunk_width = 0
|
|
128
|
+
end
|
|
129
|
+
chunk << gc
|
|
130
|
+
chunk_width += gw
|
|
131
|
+
# Force include first grapheme even if its width exceeds limit
|
|
132
|
+
if gw > width && chunk_width == gw
|
|
133
|
+
lines << chunk
|
|
134
|
+
chunk = +""
|
|
135
|
+
chunk_width = 0
|
|
136
|
+
end
|
|
107
137
|
end
|
|
138
|
+
lines << chunk unless chunk.empty?
|
|
108
139
|
|
|
109
140
|
lines
|
|
110
141
|
end
|
|
111
142
|
|
|
112
143
|
def truncate_visible(text, target_len)
|
|
113
|
-
result = ""
|
|
114
|
-
|
|
144
|
+
result = +""
|
|
145
|
+
visible_width = 0
|
|
115
146
|
position = 0
|
|
147
|
+
ansi_re = /\A\e\[[0-9;]*[a-zA-Z]/
|
|
116
148
|
|
|
117
|
-
while position < text.length &&
|
|
118
|
-
if text[position] == "\e" && (match = text[position..].match(
|
|
119
|
-
|
|
120
|
-
result += match[0]
|
|
149
|
+
while position < text.length && visible_width < target_len
|
|
150
|
+
if text[position] == "\e" && (match = text[position..].match(ansi_re))
|
|
151
|
+
result << match[0]
|
|
121
152
|
position += match[0].length
|
|
122
153
|
else
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
154
|
+
# Extract the grapheme cluster starting at this position
|
|
155
|
+
gc = text[position..].grapheme_clusters.first
|
|
156
|
+
break unless gc
|
|
157
|
+
|
|
158
|
+
gw = grapheme_width(gc)
|
|
159
|
+
break if visible_width + gw > target_len
|
|
160
|
+
result << gc
|
|
161
|
+
visible_width += gw
|
|
162
|
+
position += gc.length
|
|
126
163
|
end
|
|
127
164
|
end
|
|
128
165
|
|
|
129
|
-
|
|
130
|
-
result += "\e[0m" if result.include?("\e[") && !result.end_with?("\e[0m")
|
|
166
|
+
result << "\e[0m" if result.include?("\e[") && !result.end_with?("\e[0m")
|
|
131
167
|
result
|
|
132
168
|
end
|
|
169
|
+
|
|
170
|
+
# Width of a grapheme cluster: the max char_width among its codepoints.
|
|
171
|
+
# Handles ZWJ emoji sequences, combining marks, and flag sequences correctly.
|
|
172
|
+
def grapheme_width(cluster)
|
|
173
|
+
max_w = 0
|
|
174
|
+
cluster.each_char do |char|
|
|
175
|
+
w = char_width(char)
|
|
176
|
+
max_w = w if w > max_w
|
|
177
|
+
end
|
|
178
|
+
max_w
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def char_width(char)
|
|
182
|
+
code = char.ord
|
|
183
|
+
return 0 if zero_width_code?(code)
|
|
184
|
+
return 2 if wide_char_code?(code)
|
|
185
|
+
1
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def zero_width_code?(code)
|
|
189
|
+
return true if (0x0300..0x036F).cover?(code)
|
|
190
|
+
return true if (0x1AB0..0x1AFF).cover?(code)
|
|
191
|
+
return true if (0x20D0..0x20FF).cover?(code)
|
|
192
|
+
return true if [0x200B, 0x200C, 0x200D, 0xFEFF].include?(code)
|
|
193
|
+
return true if (0xFE00..0xFE0F).cover?(code)
|
|
194
|
+
false
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def wide_char_code?(code)
|
|
198
|
+
# CJK Unified + extensions + compatibility
|
|
199
|
+
return true if (0x4E00..0x9FFF).cover?(code)
|
|
200
|
+
return true if (0x3400..0x4DBF).cover?(code)
|
|
201
|
+
return true if (0xF900..0xFAFF).cover?(code)
|
|
202
|
+
# Korean Hangul syllables
|
|
203
|
+
return true if (0xAC00..0xD7AF).cover?(code)
|
|
204
|
+
# Japanese kana
|
|
205
|
+
return true if (0x3040..0x309F).cover?(code)
|
|
206
|
+
return true if (0x30A0..0x30FF).cover?(code)
|
|
207
|
+
# Fullwidth and wide punctuation
|
|
208
|
+
return true if (0x3000..0x303F).cover?(code)
|
|
209
|
+
return true if (0xFF01..0xFF5E).cover?(code)
|
|
210
|
+
return true if (0xFFE0..0xFFE6).cover?(code)
|
|
211
|
+
# Common emoji / symbols blocks that render as wide
|
|
212
|
+
return true if (0x1F000..0x1F9FF).cover?(code)
|
|
213
|
+
return true if (0x2600..0x26FF).cover?(code)
|
|
214
|
+
return true if (0x2700..0x27BF).cover?(code)
|
|
215
|
+
false
|
|
216
|
+
end
|
|
133
217
|
end
|
|
134
218
|
end
|
|
135
219
|
end
|
data/lib/clack/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: clack
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Steve Whittaker
|
|
@@ -103,7 +103,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
103
103
|
- !ruby/object:Gem::Version
|
|
104
104
|
version: '0'
|
|
105
105
|
requirements: []
|
|
106
|
-
rubygems_version:
|
|
106
|
+
rubygems_version: 3.6.9
|
|
107
107
|
specification_version: 4
|
|
108
108
|
summary: Beautiful, minimal CLI prompts
|
|
109
109
|
test_files: []
|