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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4fbbb7aa9fa9b375b70b10c7a7c17124f1187d2fe1443a9cad6358087570e876
4
- data.tar.gz: 006b53ead379e5b550e5da0735a8085224c1531937db9de750128d9f25ced3a8
3
+ metadata.gz: b4723c11dc0a0944ea3a56337800aac60ec1ae3db29094f7f98e77f87b38caa2
4
+ data.tar.gz: 8b9bc87d58955147a0d000e94db0094832cb02dea4a92fd13d11618322fe5578
5
5
  SHA512:
6
- metadata.gz: 49dfca31d9b51ea57e5e68c74fe48fa217e5cba22c9db94529eae50f39edbb2488021559285641e943f9e1445964fc7ca02a7b59a026ae36da630cb1cc4e3576
7
- data.tar.gz: d0e77aaaa330de1b4bace456d082ae7b93b024c293c51db4a4d4647cef3ed4939145ea89e52817c4e6220225860bdfde95e527e96281da6a5cce8e49c4ee90d4
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 hash and query string)
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.length, title_padding, content_padding, width)
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.length > max_title_len) ? "#{title[0, max_title_len - 3]}..." : 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.length, ctx[:inner_width], content_padding, content_align)
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(&:length).max || 0
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.length, inner_width, title_padding, title_align)
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[:label], q_down: q_down),
113
- score(query, opt[:value].to_s, q_down: q_down)
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[:hint], q_down: q_down) if opt[:hint]
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 a consistent hash format.
53
+ # Normalize a single option to an Option value object.
29
54
  # @param opt [Hash, String, Symbol] Raw option
30
- # @return [Hash] Normalized option hash
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
- {value: opt, label: opt.to_s, hint: nil, disabled: false}
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][:disabled]
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][:disabled] ? first_enabled_index : 0
148
+ return items[0].disabled ? first_enabled_index : 0
124
149
  end
125
150
 
126
- idx = items.find_index { |o| o[:value] == initial_value }
127
- (idx && !items[idx][:disabled]) ? idx : first_enabled_index
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[:value]) }.map { |o| o[:label] }.join(", ")
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&.length || 0
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
- padded = line.ljust(width)
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(&:length).max || 0
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[:label].start_with?(query) }
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][:value]
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]&.[](:label) || @value
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[:hint] && active) ? Colors.dim(" (#{opt[:hint]})") : ""
145
+ hint = (opt.hint && active) ? Colors.dim(" (#{opt.hint})") : ""
146
146
  if active
147
- "#{Colors.green(Symbols::S_RADIO_ACTIVE)} #{opt[:label]}#{hint}"
147
+ "#{Colors.green(Symbols::S_RADIO_ACTIVE)} #{opt.label}#{hint}"
148
148
  else
149
- "#{Colors.dim(Symbols::S_RADIO_INACTIVE)} #{Colors.dim(opt[:label])}"
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[:value] })
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[:disabled]
91
+ return if opt.disabled
92
92
 
93
- toggle_value(opt[:value])
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[:value])
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[:label] : Colors.dim(opt[:label])
180
- hint = (opt[:hint] && active) ? Colors.dim(" (#{opt[:hint]})") : ""
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[:type] == :option }.map { |item| item[:value] })
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[:type] == :group
97
- is_last_in_group = item[:last_in_group]
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 { |o| o[:label] }.join(", ")
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 = {type: :group, label: group[:label], options: group[:options]}
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
- type: :option,
144
- value: opt[:value],
145
- label: opt[:label],
146
- hint: opt[:hint],
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[:type] == :option && @selected.include?(item[:value]) }
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[:value] == cursor_at }
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[:disabled]
172
- return @selectable_groups if item[:type] == :group
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[:type] == :group
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[:options].reject { |o| o[:disabled] }.map { |o| o[:value] }
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[:value])
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[:options].reject { |o| o[:disabled] }.all? { |o| @selected.include?(o[:value]) }
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[:label] : Colors.dim(item[:label])
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[:label])}\n"
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[:value])
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[:hint] && active) ? " #{Colors.dim("(#{item[:hint]})")}" : ""
236
+ hint = (item.hint && active) ? " #{Colors.dim("(#{item.hint})")}" : ""
238
237
 
239
- if item[:disabled]
240
- "#{active_bar} #{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.strikethrough(Colors.dim(item[:label]))}\n"
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[:label]}#{hint}\n"
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[:label]}#{hint}\n"
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[:label])}\n"
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[:label])}\n"
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[:value] })
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[:disabled]
113
+ return if opt.disabled
114
114
 
115
- toggle_value(opt[:value])
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[:disabled] }.map { |o| o[:value] }
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[:disabled]
131
+ next if opt.disabled
132
132
 
133
- toggle_value(opt[:value])
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[:value])
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[:disabled]
156
+ if opt.disabled
157
157
  return [Colors.dim(Symbols::S_CHECKBOX_INACTIVE),
158
- Colors.strikethrough(Colors.dim(opt[:label]))]
158
+ Colors.strikethrough(Colors.dim(opt.label))]
159
159
  end
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
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[:label])]
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
- @output.print "\r\e[2K" # Return to start of line and clear it
109
- @output.print "#{symbol} #{progress_bar} #{percentage}#{message_text}"
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
 
@@ -55,7 +55,7 @@ module Clack
55
55
  end
56
56
  end
57
57
 
58
- def can_submit? = !current_option[:disabled]
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[:label]
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[:value]
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[:disabled]
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[:label]))
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[:hint] ? " #{Colors.dim("(#{opt[:hint]})")}" : ""
98
- "#{symbol} #{opt[:label]}#{hint}"
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[:label])}"
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[:key]&.downcase == key&.downcase }
44
+ opt = @options.find { |o| o.key&.downcase == key&.downcase }
45
45
  return unless opt
46
46
 
47
- @value = opt[:value]
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[:value] == @value }&.dig(:label).to_s
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[:key]}]")
83
- hint = opt[:hint] ? " #{Colors.dim("(#{opt[:hint]})")}" : ""
84
- "#{key_display} #{opt[:label]}#{hint}"
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
@@ -87,8 +87,13 @@ module Clack
87
87
 
88
88
  run_task(task)
89
89
  end
90
- @output.print Core::Cursor.show
91
90
  @results
91
+ ensure
92
+ begin
93
+ @output.print Core::Cursor.show
94
+ rescue IOError, SystemCallError
95
+ # output stream already closed
96
+ end
92
97
  end
93
98
 
94
99
  private
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 (excluding ANSI codes)
15
- # @param text [String] Text potentially containing ANSI codes
16
- # @return [Integer] Visible character count
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).length
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.length
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
- stripped = strip_ansi(word)
102
- position = 0
103
-
104
- while position < stripped.length
105
- lines << stripped[position, width]
106
- position += width
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
- visible_count = 0
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 && visible_count < target_len
118
- if text[position] == "\e" && (match = text[position..].match(/\A\e\[[0-9;]*[a-zA-Z]/))
119
- # ANSI sequence - include it but don't count
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
- result += text[position]
124
- visible_count += 1
125
- position += 1
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
- # Add reset if we have unclosed ANSI codes
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Clack
4
4
  # Current gem version.
5
- VERSION = "0.5.0"
5
+ VERSION = "0.6.0"
6
6
  end
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.5.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: 4.0.8
106
+ rubygems_version: 3.6.9
107
107
  specification_version: 4
108
108
  summary: Beautiful, minimal CLI prompts
109
109
  test_files: []