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.
- checksums.yaml +7 -0
- data/LICENSE +674 -0
- data/README.md +2 -0
- data/lib/is-term/boolean.rb +36 -0
- data/lib/is-term/formats.rb +102 -0
- data/lib/is-term/functions.rb +476 -0
- data/lib/is-term/info.rb +23 -0
- data/lib/is-term/statustable.rb +410 -0
- data/lib/is-term/string_helpers.rb +198 -0
- data/lib/is-term.rb +0 -0
- metadata +130 -0
|
@@ -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
|