clack 0.4.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.4.6"
5
+ VERSION = "0.6.0"
6
6
  end
data/lib/clack.rb CHANGED
@@ -13,6 +13,7 @@ require_relative "clack/core/options_helper"
13
13
  require_relative "clack/core/text_input_helper"
14
14
  require_relative "clack/core/scroll_helper"
15
15
  require_relative "clack/core/fuzzy_matcher"
16
+ require_relative "clack/core/selection_manager"
16
17
  require_relative "clack/core/ci_mode"
17
18
  require_relative "clack/prompts/text"
18
19
  require_relative "clack/prompts/multiline_text"
@@ -505,47 +506,80 @@ module Clack
505
506
  load demo_path
506
507
  run_demo
507
508
  end
508
- end
509
- end
510
509
 
511
- # Terminal cleanup on exit show cursor if it was hidden.
512
- # Uses raw write(2) for async-signal safety in trap handlers.
513
- CURSOR_SHOW = "\e[?25h"
510
+ # Install signal handlers for clean terminal cleanup.
511
+ # Call this once in your CLI entry point. Handles INT, TERM, and SIGWINCH.
512
+ # Without calling this, Ctrl+C may leave the cursor hidden.
513
+ #
514
+ # @return [void]
515
+ def setup!
516
+ return if @setup_done
514
517
 
515
- at_exit do
516
- $stdout.print Clack::Core::Cursor.show
517
- rescue IOError, SystemCallError
518
- # Output unavailable
519
- end
518
+ @setup_done = true
519
+ install_signal_handlers
520
+ install_at_exit
521
+ Core::Prompt.setup_signal_handler
522
+ end
520
523
 
521
- # Chain INT handler to restore cursor before passing to previous handler.
522
- previous_int_handler = trap("INT") do
523
- begin
524
- $stdout.write_nonblock(CURSOR_SHOW)
525
- rescue IOError, SystemCallError
526
- # Output unavailable — nothing we can do
527
- end
528
- case previous_int_handler
529
- when Proc
530
- begin
531
- previous_int_handler.call
532
- rescue
533
- exit(130)
534
- end
535
- when "DEFAULT", "SYSTEM_DEFAULT" then exit(130)
536
- else exit(130)
537
- end
538
- end
524
+ # @return [Boolean] whether setup! has been called
525
+ def setup? = !!@setup_done
539
526
 
540
- # Handle SIGTERM similarly to INT — restore cursor on graceful kill.
541
- trap("TERM") do
542
- begin
543
- $stdout.write_nonblock(CURSOR_SHOW)
544
- rescue IOError, SystemCallError
545
- # Output unavailable
527
+ private
528
+
529
+ CURSOR_SHOW = "\e[?25h"
530
+ private_constant :CURSOR_SHOW
531
+
532
+ def install_at_exit
533
+ at_exit do
534
+ $stdout.print Core::Cursor.show
535
+ rescue IOError, SystemCallError
536
+ # Output unavailable
537
+ end
538
+ end
539
+
540
+ def install_signal_handlers
541
+ # Chain INT handler to restore cursor before passing to previous handler.
542
+ previous_int_handler = trap("INT") do
543
+ begin
544
+ $stdout.write_nonblock(CURSOR_SHOW)
545
+ rescue IOError, SystemCallError
546
+ # Output unavailable
547
+ end
548
+ case previous_int_handler
549
+ when Proc
550
+ begin
551
+ previous_int_handler.call
552
+ rescue
553
+ exit(130)
554
+ end
555
+ when "DEFAULT", "SYSTEM_DEFAULT" then exit(130)
556
+ else exit(130)
557
+ end
558
+ end
559
+
560
+ # Chain TERM handler to restore cursor, then delegate to previous handler.
561
+ previous_term_handler = trap("TERM") do
562
+ begin
563
+ $stdout.write_nonblock(CURSOR_SHOW)
564
+ rescue IOError, SystemCallError
565
+ # Output unavailable
566
+ end
567
+ case previous_term_handler
568
+ when Proc
569
+ begin
570
+ previous_term_handler.call
571
+ rescue
572
+ exit(143)
573
+ end
574
+ when "DEFAULT", "SYSTEM_DEFAULT" then exit(143)
575
+ else exit(143)
576
+ end
577
+ end
578
+ end
546
579
  end
547
- exit(143)
548
580
  end
549
581
 
550
- # Set up SIGWINCH handler for terminal resize
551
- Clack::Core::Prompt.setup_signal_handler
582
+ # Auto-setup for backwards compatibility.
583
+ # Libraries that require 'clack' get signal handlers installed automatically.
584
+ # To opt out, set CLACK_NO_AUTO_SETUP=1 before requiring clack.
585
+ Clack.setup! unless ENV["CLACK_NO_AUTO_SETUP"]
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.6
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steve Whittaker
@@ -50,6 +50,7 @@ files:
50
50
  - lib/clack/core/options_helper.rb
51
51
  - lib/clack/core/prompt.rb
52
52
  - lib/clack/core/scroll_helper.rb
53
+ - lib/clack/core/selection_manager.rb
53
54
  - lib/clack/core/settings.rb
54
55
  - lib/clack/core/text_input_helper.rb
55
56
  - lib/clack/environment.rb
@@ -84,7 +85,6 @@ homepage: https://github.com/swhitt/clackrb
84
85
  licenses:
85
86
  - MIT
86
87
  metadata:
87
- homepage_uri: https://github.com/swhitt/clackrb
88
88
  source_code_uri: https://github.com/swhitt/clackrb
89
89
  changelog_uri: https://github.com/swhitt/clackrb/blob/main/CHANGELOG.md
90
90
  bug_tracker_uri: https://github.com/swhitt/clackrb/issues
@@ -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: []