clack 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +6 -2
- data/lib/clack/box.rb +6 -6
- data/lib/clack/core/fuzzy_matcher.rb +3 -3
- data/lib/clack/core/key_reader.rb +11 -10
- data/lib/clack/core/options_helper.rb +34 -9
- data/lib/clack/core/selection_manager.rb +19 -1
- data/lib/clack/environment.rb +26 -0
- data/lib/clack/note.rb +4 -3
- data/lib/clack/prompts/autocomplete.rb +6 -6
- data/lib/clack/prompts/autocomplete_multiselect.rb +7 -7
- data/lib/clack/prompts/group_multiselect.rb +30 -31
- data/lib/clack/prompts/multiselect.rb +14 -14
- 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: 01366d1d6cc81f2514adc5c7bbdc726b3dec22d6697ae2bf26077c2db84efa1b
|
|
4
|
+
data.tar.gz: e1e96b35ef913ee377ed38c0fd98612aa1d758760991ee39ba27ac1cf8dc6136
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ac79a1a3887e261b9d2f14b989381430c35796a1acac47a449e44c773314bcea8985b294880a646213c53bd004900e9e749f9a0d69db55a19dfecd2b4d6eab34
|
|
7
|
+
data.tar.gz: 891cea3cc9b0047602a627396c40950c4397a968aa2ca021b4e2f71719ce40463d62b01db19c3b59ea92fd9efd9e0807b9120afa6cfacbbe5df90fcb391d328a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.1] - 2026-06-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `CLACK_ESCAPE_TIMEOUT` env var to tune the Escape-sequence detection window (milliseconds) for high-latency links like slow SSH, where arrow keys could otherwise be misread as a standalone Escape
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
- `multiselect`, `group_multiselect`, and `autocomplete_multiselect` now warn on stderr when `initial_values` contains values that don't match any option (still dropped, but no longer silently)
|
|
10
|
+
|
|
11
|
+
## [0.6.0] - 2026-05-30
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `Option`, `SelectKeyOption`, `GroupHeader`, and `GroupOption` value objects for normalized prompt options
|
|
15
|
+
- Hash-style `[]` access on option value objects for compatibility with existing custom autocomplete filters
|
|
16
|
+
- Display-width coverage for CJK, fullwidth forms, Hangul, emoji, ZWJ emoji sequences, flag emoji, combining marks, and ANSI-colored text
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Option-based prompts now normalize strings, symbols, and hashes into typed value objects instead of bare hashes
|
|
20
|
+
- Autocomplete and multiselect filtering now operate on normalized option objects while preserving `opt[:key]` access patterns
|
|
21
|
+
- Text wrapping, truncation, box titles, and note layout now measure terminal display columns instead of Ruby string length
|
|
22
|
+
- Progress rendering skips duplicate intermediate frames during rapid updates
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- CJK, emoji, combining characters, and ANSI sequences no longer throw off prompt alignment, wrapping, truncation, note padding, or box title width
|
|
26
|
+
- `Tasks#run` restores cursor visibility even when task execution raises
|
|
27
|
+
- `SelectKey` option normalization resolves the value object through the correct namespace
|
|
28
|
+
|
|
3
29
|
## [0.5.0] - 2026-03-27
|
|
4
30
|
|
|
5
31
|
### Breaking
|
data/README.md
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
<i>CLI prompts for Ruby. Zero dependencies.</i>
|
|
8
8
|
<br>
|
|
9
9
|
<br>
|
|
10
|
+
<sub>A faithful Ruby port of <a href="https://github.com/bombshell-dev/clack"><code>@clack/prompts</code></a>.</sub>
|
|
11
|
+
<br>
|
|
12
|
+
<br>
|
|
10
13
|
<a href="https://rubygems.org/gems/clack"><img src="https://img.shields.io/gem/v/clack?style=flat-square&color=cc342d" alt="Gem Version"></a>
|
|
11
14
|
<a href="https://github.com/swhitt/clackrb/actions"><img src="https://img.shields.io/github/actions/workflow/status/swhitt/clackrb/ci.yml?style=flat-square&label=tests" alt="Tests"></a>
|
|
12
15
|
<a href="https://www.ruby-lang.org"><img src="https://img.shields.io/badge/ruby-3.2%2B-cc342d?style=flat-square" alt="Ruby 3.2+"></a>
|
|
@@ -238,7 +241,8 @@ color = Clack.autocomplete(
|
|
|
238
241
|
max_items: 5 # Default; scrollable via up/down arrows
|
|
239
242
|
)
|
|
240
243
|
|
|
241
|
-
# Custom filter logic (receives option
|
|
244
|
+
# Custom filter logic (receives the option and query string).
|
|
245
|
+
# The option is a value object; opt.label and opt[:label] both work.
|
|
242
246
|
cmd = Clack.autocomplete(
|
|
243
247
|
message: "Select command",
|
|
244
248
|
options: commands,
|
|
@@ -781,7 +785,7 @@ COVERAGE=true bundle exec rake spec # With coverage
|
|
|
781
785
|
|
|
782
786
|
## Credits
|
|
783
787
|
|
|
784
|
-
This is a Ruby port of [
|
|
788
|
+
This is a Ruby port of [`@clack/prompts`](https://github.com/bombshell-dev/clack), created by [Nate Moore](https://github.com/natemoo-re) and the [Astro](https://astro.build) team.
|
|
785
789
|
|
|
786
790
|
## License
|
|
787
791
|
|
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
|
|
@@ -6,15 +6,10 @@ module Clack
|
|
|
6
6
|
module Core
|
|
7
7
|
# Reads single keystrokes from the terminal in raw mode.
|
|
8
8
|
# Handles escape sequences for arrow keys and other special keys.
|
|
9
|
+
#
|
|
10
|
+
# The Escape detection window is tunable via the +CLACK_ESCAPE_TIMEOUT+
|
|
11
|
+
# env var (see {Environment.escape_timeout}) for high-latency links.
|
|
9
12
|
module KeyReader
|
|
10
|
-
# Timeout for detecting if Escape is part of a sequence (50ms).
|
|
11
|
-
# If no follow-up character arrives, treat Escape as a standalone key.
|
|
12
|
-
ESCAPE_TIMEOUT = 0.05
|
|
13
|
-
|
|
14
|
-
# Timeout for reading additional characters in a CSI sequence (10ms).
|
|
15
|
-
# Short because subsequent bytes in a sequence arrive almost instantly.
|
|
16
|
-
SEQUENCE_TIMEOUT = 0.01
|
|
17
|
-
|
|
18
13
|
class << self
|
|
19
14
|
# Read a single keystroke in raw mode.
|
|
20
15
|
# When input is an IO backed by a console, uses raw mode.
|
|
@@ -42,14 +37,20 @@ module Clack
|
|
|
42
37
|
return char if char.nil? # EOF
|
|
43
38
|
return char unless char == "\e"
|
|
44
39
|
|
|
40
|
+
escape_timeout = Environment.escape_timeout
|
|
41
|
+
# Subsequent bytes in a sequence normally arrive almost instantly, so
|
|
42
|
+
# the inter-byte wait stays much shorter, but it scales with the
|
|
43
|
+
# escape timeout so high-latency links still assemble full sequences.
|
|
44
|
+
sequence_timeout = escape_timeout / 5.0
|
|
45
|
+
|
|
45
46
|
# Check for escape sequence - wait briefly for follow-up
|
|
46
|
-
return char unless io.respond_to?(:wait_readable) && io.wait_readable(
|
|
47
|
+
return char unless io.respond_to?(:wait_readable) && io.wait_readable(escape_timeout)
|
|
47
48
|
|
|
48
49
|
seq = io.getc.to_s
|
|
49
50
|
return "\e#{seq}" unless seq == "["
|
|
50
51
|
|
|
51
52
|
# Read CSI sequence until no more characters arrive
|
|
52
|
-
while io.respond_to?(:wait_readable) && io.wait_readable(
|
|
53
|
+
while io.respond_to?(:wait_readable) && io.wait_readable(sequence_timeout)
|
|
53
54
|
seq += io.getc.to_s
|
|
54
55
|
end
|
|
55
56
|
"\e[#{seq[1..]}"
|
|
@@ -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
|
|
@@ -11,6 +11,24 @@ module Clack
|
|
|
11
11
|
module SelectionManager
|
|
12
12
|
REQUIRED_ERROR = "Please select at least one option. Press %s to select, %s to submit"
|
|
13
13
|
|
|
14
|
+
# Resolve requested pre-selections against the selectable option values.
|
|
15
|
+
#
|
|
16
|
+
# Values that don't match any current option are dropped and reported on
|
|
17
|
+
# stderr, since they're almost always a typo in +initial_values+ rather
|
|
18
|
+
# than an intentional no-op.
|
|
19
|
+
#
|
|
20
|
+
# @param initial_values [Array] the requested pre-selections
|
|
21
|
+
# @param valid_values [Set] the set of selectable option values
|
|
22
|
+
# @return [Set] the subset of initial_values that map to real options
|
|
23
|
+
def resolve_initial_selection(initial_values, valid_values)
|
|
24
|
+
requested = Set.new(initial_values)
|
|
25
|
+
unknown = requested - valid_values
|
|
26
|
+
unless unknown.empty?
|
|
27
|
+
warn "[clack] ignoring unknown initial_values: #{unknown.to_a.inspect}"
|
|
28
|
+
end
|
|
29
|
+
requested & valid_values
|
|
30
|
+
end
|
|
31
|
+
|
|
14
32
|
# Toggle a value in the selection set.
|
|
15
33
|
# @param value [Object] the value to toggle
|
|
16
34
|
def toggle_value(value)
|
|
@@ -42,7 +60,7 @@ module Clack
|
|
|
42
60
|
# @param all_options [Array<Hash>] the full options list to match against
|
|
43
61
|
# @return [String] comma-separated labels
|
|
44
62
|
def selected_labels(all_options)
|
|
45
|
-
all_options.select { |o| @selected.include?(o
|
|
63
|
+
all_options.select { |o| @selected.include?(o.value) }.map { |o| o.label }.join(", ")
|
|
46
64
|
end
|
|
47
65
|
end
|
|
48
66
|
end
|
data/lib/clack/environment.rb
CHANGED
|
@@ -6,6 +6,9 @@ module Clack
|
|
|
6
6
|
# Environment detection utilities for cross-platform compatibility
|
|
7
7
|
# and CI/terminal environment awareness.
|
|
8
8
|
module Environment
|
|
9
|
+
# Default Escape-sequence detection timeout, in seconds.
|
|
10
|
+
DEFAULT_ESCAPE_TIMEOUT = 0.05
|
|
11
|
+
|
|
9
12
|
class << self
|
|
10
13
|
# Check if running on Windows
|
|
11
14
|
# @return [Boolean]
|
|
@@ -127,6 +130,29 @@ module Clack
|
|
|
127
130
|
end
|
|
128
131
|
end
|
|
129
132
|
|
|
133
|
+
# Escape-sequence detection timeout, in seconds.
|
|
134
|
+
#
|
|
135
|
+
# After an Escape byte arrives, the key reader waits this long for a
|
|
136
|
+
# follow-up byte to decide whether it's a standalone Escape or the start
|
|
137
|
+
# of an arrow-key / CSI sequence. The 50ms default is fine locally but too
|
|
138
|
+
# tight over high-latency links (slow SSH, mosh), where the follow-up
|
|
139
|
+
# bytes lag and arrow keys get misread as a bare Escape (cancelling the
|
|
140
|
+
# prompt). Override with the +CLACK_ESCAPE_TIMEOUT+ env var, in
|
|
141
|
+
# milliseconds, e.g. +CLACK_ESCAPE_TIMEOUT=250+ for a slow connection.
|
|
142
|
+
#
|
|
143
|
+
# Invalid or non-positive values fall back to the default.
|
|
144
|
+
#
|
|
145
|
+
# @return [Float] timeout in seconds
|
|
146
|
+
def escape_timeout
|
|
147
|
+
raw = ENV["CLACK_ESCAPE_TIMEOUT"]
|
|
148
|
+
return DEFAULT_ESCAPE_TIMEOUT unless raw
|
|
149
|
+
|
|
150
|
+
ms = Float(raw, exception: false)
|
|
151
|
+
return DEFAULT_ESCAPE_TIMEOUT unless ms&.positive?
|
|
152
|
+
|
|
153
|
+
ms / 1000.0
|
|
154
|
+
end
|
|
155
|
+
|
|
130
156
|
# Reset cached environment checks (useful for testing)
|
|
131
157
|
def reset!
|
|
132
158
|
remove_instance_variable(:@windows) if defined?(@windows)
|
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,8 +55,8 @@ 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
|
|
59
|
-
@selected =
|
|
58
|
+
valid_values = Set.new(@all_options.map { |o| o.value })
|
|
59
|
+
@selected = resolve_initial_selection(initial_values, valid_values)
|
|
60
60
|
update_filtered
|
|
61
61
|
end
|
|
62
62
|
|
|
@@ -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,8 +60,8 @@ 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
|
|
64
|
-
@selected =
|
|
63
|
+
valid_values = Set.new(@flat_items.select { |item| item.is_a?(Core::GroupOption) }.map(&:value))
|
|
64
|
+
@selected = resolve_initial_selection(initial_values, valid_values)
|
|
65
65
|
@required = required
|
|
66
66
|
@selectable_groups = selectable_groups
|
|
67
67
|
@group_spacing = group_spacing
|
|
@@ -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,8 +44,8 @@ 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
|
|
48
|
-
@selected =
|
|
47
|
+
valid_values = Set.new(@options.map { |o| o.value })
|
|
48
|
+
@selected = resolve_initial_selection(initial_values, valid_values)
|
|
49
49
|
@required = required
|
|
50
50
|
@max_items = max_items
|
|
51
51
|
@scroll_offset = 0
|
|
@@ -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.1
|
|
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.
|
|
106
|
+
rubygems_version: 4.0.10
|
|
107
107
|
specification_version: 4
|
|
108
108
|
summary: Beautiful, minimal CLI prompts
|
|
109
109
|
test_files: []
|