is-term 0.8.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.
@@ -0,0 +1,410 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'singleton'
5
+ require 'tty-screen'
6
+
7
+ require_relative 'info'
8
+ require_relative 'boolean'
9
+ require_relative 'string_helpers'
10
+
11
+ class IS::Term::Error < StandardError; end
12
+ class IS::Term::StateError < IS::Term::Error; end
13
+
14
+ class IS::Term::StatusTable
15
+
16
+ # module Formats; end
17
+ # module Functions; end
18
+
19
+ include Singleton
20
+
21
+ using IS::Term::StringHelpers
22
+
23
+ INVERT = "\e[7m"
24
+
25
+ DEFAULT_IO = $stdout
26
+ DEFAULT_SUMMARY_PREFIX = INVERT
27
+
28
+ # @private
29
+ def initialize
30
+ @mutex = Thread::Mutex::new
31
+ @in_configure = true
32
+ reset!
33
+ @in_configure = nil
34
+ end
35
+
36
+ # @group Configuration Info
37
+
38
+ # @return [IO]
39
+ attr_reader :term
40
+
41
+ # @return [Array<Hash|String>]
42
+ attr_reader :defs
43
+
44
+ # @endgroup
45
+
46
+ # @group Data Manipulation
47
+
48
+ # @return [Array<Hash>]
49
+ def rows
50
+ @rows ||= []
51
+ @rows.dup.freeze
52
+ end
53
+
54
+ # @return [Hash, nil]
55
+ def row row_id
56
+ return nil if @id_field.nil?
57
+ @rows.find { |r| r[@id_field] == row_id }.dup.freeze
58
+ end
59
+
60
+ # @endgroup
61
+
62
+ # @group State
63
+
64
+ def configured?
65
+ !!@id_field && !@defs.empty?
66
+ end
67
+
68
+ def available?
69
+ !!@term && @term.is_a?(IO) && File.chardev?(@term)
70
+ end
71
+
72
+ # @return [self]
73
+ def reset!
74
+ if @in_configure
75
+ @defs = []
76
+ @id_field = nil
77
+ @inactivate_if = nil
78
+ @term = DEFAULT_IO
79
+ @show_summary = nil
80
+ @summary_prefix = DEFAULT_SUMMARY_PREFIX
81
+ @summary_values = {}
82
+ @started = Time::now
83
+ @rows = []
84
+ else
85
+ @mutex.synchronize do
86
+ @started = Time::now
87
+ @rows = []
88
+ end
89
+ end
90
+ self
91
+ end
92
+
93
+ # @endgroup
94
+
95
+ # @group Data Manipulation
96
+
97
+ # @yield
98
+ # @yieldparam [Hash] row
99
+ # @return [Hash]
100
+ def append **data
101
+ raise IS::Term::StateError, "StatusTable is not ready for work", caller_locations unless available? && configured?
102
+ data.transform_keys!(&:to_sym)
103
+ id = data[@id_field]
104
+ raise ArgumentError, "Row Id must be specified", caller_locations if id.nil?
105
+ raise ArgumentError, "Row with Id = #{ id.inspect } already exists", caller_locations if @rows.any? { |r| r[@id_field] == id }
106
+ row = {}
107
+ row[:_active] = true
108
+ row[:_started] = Time::now
109
+ row[:_mutex] = Thread::Mutex::new
110
+ row.merge! data
111
+ yield row if block_given?
112
+ @mutex.synchronize do
113
+ @rows << row
114
+ @term.puts ''
115
+ render_table
116
+ end
117
+ row
118
+ end
119
+
120
+ # @yield
121
+ # @yieldparam [Hash] row
122
+ # @return [Hash]
123
+ def update **data
124
+ raise IS::Term::StateError, "StatusTable is not ready for work", caller_locations unless available? && configured?
125
+ data.transform_keys!(&:to_sym)
126
+ id = data[@id_field]
127
+ raise ArgumentError, "Row Id must be specified", caller_locations if id.nil?
128
+ row = find_row id
129
+ raise ArgumentError, "Row with Id = #{id.inspect} must be exists", caller_locations if row.nil?
130
+ row[:_mutex].synchronize do
131
+ row.merge! data
132
+ yield row if block_given?
133
+ if row[:_active] && @inactivate_if && @inactivate_if.call(row)
134
+ row[:_active] = false
135
+ row[:_finished] = Time::now
136
+ render_table
137
+ else
138
+ render_line row
139
+ end
140
+ end
141
+ row
142
+ end
143
+
144
+ # @endgroup
145
+
146
+ # @group Configuration
147
+
148
+ # @yield
149
+ # @return [self]
150
+ def configure &block
151
+ if block_given?
152
+ @mutex.synchronize do
153
+ @in_configure = true
154
+ instance_eval(&block)
155
+ @in_configure = nil
156
+ end
157
+ end
158
+ @term.puts ''
159
+ self
160
+ end
161
+
162
+ # @endgroup
163
+
164
+ private
165
+
166
+ # @private
167
+ def find_row id
168
+ @rows.find { |r| r[@id_field] == id }
169
+ end
170
+
171
+ # @private
172
+ def apply_format value, format
173
+ case format
174
+ when String
175
+ format % value
176
+ when Proc
177
+ format[value]
178
+ else
179
+ raise ArgumentError, "Unknown format value: #{ format.inspect }", caller_locations
180
+ end
181
+ end
182
+
183
+ # @private
184
+ def prerender_line row
185
+ result = []
186
+ @defs.each do |definition|
187
+ case definition
188
+ when String
189
+ result << definition
190
+ when Hash
191
+ value = if definition[:func]
192
+ definition[:func].call row
193
+ else
194
+ row[definition[:name]]
195
+ end
196
+ skip_width = value.is_a?(Array)
197
+ value = value.first if skip_width
198
+ if definition[:format]
199
+ value = apply_format value, definition[:format]
200
+ else
201
+ value = value.to_s
202
+ end
203
+ if definition[:width] && !skip_width
204
+ value = value.ellipsis definition[:width]
205
+ end
206
+ return false if !skip_width && value.width > definition[:_width] && !@in_table_render
207
+ result << value
208
+ break if skip_width
209
+ else
210
+ raise IS::Term::StateError, "Invalid column definition: #{ definition.inspect }", caller_locations
211
+ end
212
+ end
213
+ result
214
+ end
215
+
216
+ # @private
217
+ def prerender_table
218
+ result = @rows.map { |r| prerender_line r }
219
+ result << prerender_summary if @show_summary
220
+ result
221
+ end
222
+
223
+ # @private
224
+ def prerender_summary
225
+ result = []
226
+ @defs.each do |definition|
227
+ case definition
228
+ when String
229
+ result << definition
230
+ when Hash
231
+ name = definition[:name]
232
+ value = case definition[:summary]
233
+ when nil
234
+ ''
235
+ when :value
236
+ @summary_values[name]
237
+ when Proc
238
+ definition[:summary].call
239
+ end
240
+ skip_width = value.is_a?(Array)
241
+ value = value.first if skip_width
242
+ if definition[:format] && !value.nil? && value != ''
243
+ value = apply_format value, definition[:format]
244
+ else
245
+ value = value.to_s
246
+ end
247
+ if definition[:width] && !skip_width
248
+ value = value.ellipsis definition[:width]
249
+ end
250
+ return false if !skip_width && value.width > definition[:_width] && !@in_table_render
251
+ result << value
252
+ break if skip_width
253
+ else
254
+ raise IS::Term::StateError, "Invalid column definition: #{definition.inspect}", caller_locations
255
+ end
256
+ end
257
+ result
258
+ end
259
+
260
+ # @private
261
+ def render_line row
262
+ prerendered = prerender_line row
263
+ summary = @show_summary ? prerender_summary : :skip
264
+ if prerendered && summary
265
+ line = ''
266
+ (0 .. prerendered.size - 1).each do |idx|
267
+ value = prerendered[idx]
268
+ definition = @defs[idx]
269
+ value = value.align definition[:_width], (definition[:align] || IS::Term::StringHelpers::ALIGN_LEFT) if !definition.is_a?(String)
270
+ line += value
271
+ end
272
+ shift = row[:_shift]
273
+ @term.print "\e[0m\e[#{ shift }A\e[0m#{ line.ellipsis TTY::Screen::width }\e[0m\e[K\r\e[#{ shift }B"
274
+ if @show_summary
275
+ line = ''
276
+ (0 .. summary.size - 1).each do |idx|
277
+ value = summary[idx]
278
+ definition = @defs[idx]
279
+ value = value.align definition[:_width], (definition[:align] || IS::Term::StringHelpers::ALIGN_LEFT) if !definition.is_a?(String)
280
+ line += value
281
+ end
282
+ @term.print "\e[0m\e[1A#{ @summary_prefix }#{ line.ellipsis TTY::Screen::width }\e[0m\e[K\r\e[1B"
283
+ end
284
+ else
285
+ @mutex.synchronize { render_table } unless @in_table_render
286
+ end
287
+ end
288
+
289
+ # @private
290
+ def render_table
291
+ @in_table_render = true
292
+ @rows.sort_by! { |r| [ r[:_active], r[:_started] ] }
293
+ size = @rows.size
294
+ size += 1 if @show_summary
295
+ @rows.each_with_index { |row, idx| row[:_shift] = size - idx }
296
+ prerendered = prerender_table
297
+ @defs.each_with_index do |definition, idx|
298
+ case definition
299
+ when Hash
300
+ definition[:_width] = prerendered.map { |row| row[idx]&.width }.compact.max
301
+ end
302
+ end
303
+ text = ''
304
+ last = prerendered.size - 1
305
+ prerendered.each_with_index do |ln, i|
306
+ if @show_summary && i == last
307
+ line = @summary_prefix
308
+ else
309
+ line = ''
310
+ end
311
+ ln.each_with_index do |value, idx|
312
+ definition = @defs[idx]
313
+ value = value.align definition[:_width], (definition[:align] || IS::Term::StringHelpers::ALIGN_LEFT) if !definition.is_a?(String)
314
+ line += value
315
+ end
316
+ text += "\e[0m\e[K#{ line.ellipsis TTY::Screen::width }\e[0m\e[K\n"
317
+ end
318
+ @term.print "\e[0m\e[#{ prerendered.size }A#{ text }\e[0m\e[1B"
319
+ @in_table_render = nil
320
+ end
321
+
322
+ # @group Configuration DSL
323
+
324
+ # @private
325
+ SUMMARY_NONE = [ :none, false ].freeze
326
+ # @private
327
+ SUMMARY_VALS = [ :value ].freeze
328
+
329
+ # @return [void]
330
+ def column name, id: false, func: nil, format: nil, width: nil, align: nil, summary: nil
331
+ raise ArgumentError, "Invalid name value: #{ name.inspect }", caller_locations unless name.is_a?(String) || name.is_a?(Symbol)
332
+ raise ArgumentError, "Name can not be empty", caller_locations if name == '' || name == :''
333
+ name = name.to_sym
334
+ raise ArgumentError, "Column name already exists: #{ name }", caller_locations if @defs.any? { |d| d.is_a?(Hash) && d[:name] == name }
335
+ raise ArgumentError, "Invalid id value: #{ id.inspect }", caller_locations unless id.nil? || id == true || id == false
336
+ id = nil if id == false
337
+ raise ArgumentError, "Id field already exists (#{ @id_field })" if @id_field && id
338
+ func_keys = Set[]
339
+ if self.class.const_defined?(:Functions)
340
+ func_keys |= Set[*Functions::ROW_METHODS]
341
+ end
342
+ raise ArgumentError, "Invalid func value: #{ func.inspect }", caller_locations unless func.nil? || func.respond_to?(:call) || func_keys.include?(func)
343
+ if self.class.const_defined?(:Functions) && func.is_a?(Symbol)
344
+ func = Functions::RF func
345
+ end
346
+ format_keys = Set[]
347
+ if self.class.const_defined?(:Formats)
348
+ format_keys |= Set[*Formats::SPECIAL_FORMATS]
349
+ end
350
+ raise ArgumentError, "Invalid format value: #{ format.inspect }", caller_locations unless format.nil? || format.is_a?(String) || format.is_a?(Array) || format.respond_to?(:call) || format_keys.include?(format)
351
+ if self.class.const_defined?(:Formats) && format && !format.respond_to?(:call)
352
+ format = Formats::fmt format if format.is_a?(Symbol)
353
+ format = Formats::bar(*format) if format.is_a?(Array)
354
+ end
355
+ raise ArgumentError, "Invalid width value: #{ width.inspect }", caller_locations unless width.nil? || width.is_a?(Integer)
356
+ raise ArgumentError, "Invalid align value: #{ align.inspect }", caller_locations unless align.nil? || IS::Term::StringHelpers::ALIGN_MODES.include?(align)
357
+ summary_keys = Set[*(SUMMARY_NONE + SUMMARY_VALS)]
358
+ if self.class.const_defined?(:Functions)
359
+ summary_keys |= Set[*(Functions::TABLE_METHODS + Functions::AGGREGATE_METHODS)]
360
+ end
361
+ raise ArgumentError, "Invalid summary value: #{ summary.inspect }", caller_locations unless summary_keys.include?(summary) || summary.is_a?(Proc) || summary.nil?
362
+ summary = nil if SUMMARY_NONE.include?(summary) || summary.nil?
363
+ if self.class.const_defined?(:Functions)
364
+ summary = Functions::TF summary if Functions::TABLE_METHODS.include?(summary)
365
+ summary = Functions::AF summary, name if Functions::AGGREGATE_METHODS.include?(summary)
366
+ end
367
+ definition = { name: name, id: id, func: func, format: format, width: width, _width: (width || 0), align: align, summary: summary }.compact
368
+ @defs << definition
369
+ @id_field = name if id
370
+ self
371
+ end
372
+
373
+ # @return [void]
374
+ def separator str = ' '
375
+ raise ArgumentError, "Invalid separator: #{ str.inspect }", caller_locations unless str.is_a?(String)
376
+ @defs << str
377
+ self
378
+ end
379
+
380
+ # @yield
381
+ # @yieldparam [Hash] row
382
+ # @return [void]
383
+ def inactivate_if &block
384
+ raise ArgumentError, "Block condition must be specified", caller_locations unless block_given?
385
+ @inactivate_if = block
386
+ self
387
+ end
388
+
389
+ # @return [void]
390
+ def terminal io
391
+ raise ArgumentError, "Invalid terminal value: #{ io.inspect }", caller_locations unless io.is_a?(IO) && File.chardev?(io)
392
+ @term = io
393
+ self
394
+ end
395
+
396
+ public
397
+
398
+ # @return [void]
399
+ def summary show = nil, prefix: nil, **values
400
+ @show_summary = show unless show.nil?
401
+ @show_summary = true if @show_summary.nil?
402
+ @summary_prefix = prefix unless prefix.nil?
403
+ @summary_values.merge! values.transform_keys(&:to_sym)
404
+ self
405
+ end
406
+
407
+ # @endgroup
408
+
409
+ end
410
+
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'info'
4
+
5
+ # Terminal string rendering utilities with proper width calculation for Unicode
6
+ # characters, emoji, East Asian characters, and ANSI escape sequences.
7
+ #
8
+ # This module provides essential string manipulation methods for terminal UIs,
9
+ # handling complex Unicode display widths correctly while ignoring ANSI color/style
10
+ # codes. It extends +String+ via Refinements to provide
11
+ # a clean, chainable API.
12
+ #
13
+ # == Features
14
+ #
15
+ # * Correct width calculation (emoji=2, CJK=2, ASCII=1, ANSI=0)
16
+ # * Safe truncation preserving ANSI codes
17
+ # * Flexible alignment (left, right, center)
18
+ # * Ellipsis with configurable marker
19
+ #
20
+ # == Usage
21
+ #
22
+ # require 'is-term/string_helpers'
23
+ #
24
+ # Standalone (no refinements, no includes):
25
+ # IS::Term::StringHelpers.str_width("中👨‍⚕️")
26
+ # # => 4
27
+ #
28
+ # Include private methods:
29
+ # include IS::Term::StringHelpers
30
+ # str_width("中👨‍⚕️")
31
+ # # => 4
32
+ #
33
+ # With refinements (recommended):
34
+ # using IS::Term::StringHelpers
35
+ # "中👨‍⚕️".width # => 4
36
+ # "中👨‍⚕️".ellipsis(3) # => "中…"
37
+ #
38
+ module IS::Term::StringHelpers
39
+
40
+ # @private
41
+ ESC_CODES = /\e\[[0-9;]*[a-zA-Z]/.freeze
42
+ # @private
43
+ EMOJI = /\p{Emoji_Presentation}/.freeze
44
+ # @private
45
+ EAST_ASIA = /\p{Han}|\p{Hiragana}|\p{Katakana}|\p{Hangul}/.freeze
46
+ # @private
47
+ TOKEN = /#{ ESC_CODES }|\X/.freeze
48
+
49
+ private_constant :ESC_CODES, :EMOJI, :EAST_ASIA, :TOKEN
50
+
51
+ ALIGN_LEFT = :left
52
+ ALIGN_RIGHT = :right
53
+ ALIGN_CENTER = :center
54
+ ALIGN_MODES = [ ALIGN_LEFT, ALIGN_RIGHT, ALIGN_CENTER ].freeze
55
+
56
+ DEFAULT_ELLIPSIS_MARKER = '…'
57
+ DEFAULT_ALIGN_MODE = ALIGN_LEFT
58
+
59
+ # Calculates the display width of a string in terminal context.
60
+ #
61
+ # Handles Unicode display rules:
62
+ # * ANSI escape sequences: width 0
63
+ # * Emoji: width 2
64
+ # * East Asian characters (Han, Hiragana, Katakana, Hangul): width 2
65
+ # * Other characters: width 1
66
+ #
67
+ # @param str [String] Input string
68
+ # @return [Integer] Display width in columns
69
+ # @example
70
+ # IS::Term::StringHelpers.str_width("中👨‍⚕️A") # => 5
71
+ # IS::Term::StringHelpers.str_width("\e[31mHi\e[0m") # => 2
72
+ def str_width str
73
+ current = 0
74
+ str.scan(TOKEN) do |match|
75
+ w = case match
76
+ when ESC_CODES
77
+ 0
78
+ when EMOJI, EAST_ASIA
79
+ 2
80
+ else
81
+ 1
82
+ end
83
+ current += w
84
+ end
85
+ current
86
+ end
87
+
88
+ # Truncates string to specified display width, preserving ANSI escape sequences.
89
+ #
90
+ # ANSI codes are fully skipped (width 0), truncation occurs only on visible
91
+ # characters. Returns original string if already within width or empty string
92
+ # for non-positive widths.
93
+ #
94
+ # @param str [String] Input string
95
+ # @param width [Integer] Maximum display width
96
+ # @return [String] Truncated string (may be empty)
97
+ # @raise +ArgumentError+ if width is invalid
98
+ # @example
99
+ # IS::Term::StringHelpers.str_truncate("中ABC", 2) # => "中"
100
+ # IS::Term::StringHelpers.str_truncate("\e[31mHello\e[0m", 3) # => "\e[31mHel"
101
+ def str_truncate str, width
102
+ return str if str.length <= width
103
+ return '' if width <= 0
104
+ current = 0
105
+ position = 0
106
+ str.scan(TOKEN) do |match|
107
+ w = case match
108
+ when ESC_CODES
109
+ 0
110
+ when EMOJI, EAST_ASIA
111
+ 2
112
+ else
113
+ 1
114
+ end
115
+ current += w
116
+ return str[0, position] if current > width
117
+ position += match.length
118
+ end
119
+ str
120
+ end
121
+
122
+ # Truncates string to fit within width, appending ellipsis marker if truncated.
123
+ #
124
+ # Raises +ArgumentError+ if marker display width exceeds target width.
125
+ # Preserves ANSI codes and handles Unicode widths correctly.
126
+ #
127
+ # @param str [String] Input string
128
+ # @param width [Integer] Target display width
129
+ # @param marker [String] Ellipsis marker (default: +…+)
130
+ # @return [String] Truncated string with marker or original string
131
+ # @raise +ArgumentError+ if {str_width} of +marker+ > +width+
132
+ # @example
133
+ # IS::Term::StringHelpers.str_ellipsis("中ABC", 3) # => "中…"
134
+ # IS::Term::StringHelpers.str_ellipsis("short", 10) # => "short"
135
+ def str_ellipsis str, width, marker = DEFAULT_ELLIPSIS_MARKER
136
+ marker_width = str_width marker
137
+ raise ArgumentError, "Marker too long: #{ marker.inspect }", caller_locations if marker_width > width
138
+ if str_width(str) > width
139
+ str_truncate(str, width - marker_width) + marker
140
+ else
141
+ str
142
+ end
143
+ end
144
+
145
+ # Aligns string within specified display width using spaces.
146
+ #
147
+ # Returns original string if source width is greater than or equal to target.
148
+ # Supports left, right, and center alignment.
149
+ #
150
+ # @param str [String] Input string
151
+ # @param width [Integer] Target display width
152
+ # @param mode [:left, :right, :center] Alignment mode
153
+ # @return [String] Aligned string padded with spaces
154
+ # @raise +ArgumentError+ if invalid alignment +mode+
155
+ # @example
156
+ # IS::Term::StringHelpers.str_align("hi", 6) # => "hi "
157
+ # IS::Term::StringHelpers.str_align("hi", 6, :right) # => " hi"
158
+ # IS::Term::StringHelpers.str_align("hi", 6, :center) # => " hi "
159
+ def str_align str, width, mode = DEFAULT_ALIGN_MODE
160
+ src_width = str_width str
161
+ return str if src_width >= width
162
+ case mode
163
+ when ALIGN_LEFT
164
+ str + ' ' * (width - src_width)
165
+ when ALIGN_RIGHT
166
+ ' ' * (width - src_width) + str
167
+ when ALIGN_CENTER
168
+ left = (width - src_width) / 2
169
+ right = width - src_width - left
170
+ ' ' * left + str + ' ' * right
171
+ else
172
+ raise ArgumentError, "Invalid align value: #{ mode.inspect }", caller_locations
173
+ end
174
+ end
175
+
176
+ module_function :str_width, :str_truncate, :str_ellipsis, :str_align
177
+
178
+ refine String do
179
+
180
+ def width
181
+ IS::Term::StringHelpers::str_width self
182
+ end
183
+
184
+ def truncate width
185
+ IS::Term::StringHelpers::str_truncate self, width
186
+ end
187
+
188
+ def ellipsis width, marker = DEFAULT_ELLIPSIS_MARKER
189
+ IS::Term::StringHelpers::str_ellipsis self, width, marker
190
+ end
191
+
192
+ def align width, mode = DEFAULT_ALIGN_MODE
193
+ IS::Term::StringHelpers::str_align self, width, mode
194
+ end
195
+
196
+ end
197
+
198
+ end
data/lib/is-term.rb ADDED
File without changes