natty-ui 0.7.0 → 0.9.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +7 -3
  3. data/README.md +25 -47
  4. data/examples/24bit-colors.rb +27 -0
  5. data/examples/3bit-colors.rb +14 -0
  6. data/examples/8bit-colors.rb +31 -0
  7. data/examples/animate.rb +24 -0
  8. data/examples/attributes.rb +25 -159
  9. data/examples/demo.rb +53 -0
  10. data/examples/illustration.png +0 -0
  11. data/examples/illustration.rb +29 -0
  12. data/examples/{list_in_columns.rb → ls.rb} +13 -20
  13. data/examples/message.rb +30 -0
  14. data/examples/progress.rb +25 -30
  15. data/examples/query.rb +26 -28
  16. data/examples/read_key.rb +13 -0
  17. data/examples/table.rb +36 -0
  18. data/lib/natty-ui/ansi.rb +351 -305
  19. data/lib/natty-ui/ansi_constants.rb +73 -0
  20. data/lib/natty-ui/ansi_wrapper.rb +136 -162
  21. data/lib/natty-ui/features.rb +11 -18
  22. data/lib/natty-ui/key_map.rb +119 -0
  23. data/lib/natty-ui/line_animation/default.rb +35 -0
  24. data/lib/natty-ui/line_animation/matrix.rb +28 -0
  25. data/lib/natty-ui/line_animation/rainbow.rb +30 -0
  26. data/lib/natty-ui/line_animation/test.rb +29 -0
  27. data/lib/natty-ui/line_animation/type_writer.rb +64 -0
  28. data/lib/natty-ui/line_animation.rb +54 -0
  29. data/lib/natty-ui/version.rb +2 -2
  30. data/lib/natty-ui/wrapper/animate.rb +17 -0
  31. data/lib/natty-ui/wrapper/ask.rb +21 -21
  32. data/lib/natty-ui/wrapper/element.rb +19 -23
  33. data/lib/natty-ui/wrapper/framed.rb +29 -19
  34. data/lib/natty-ui/wrapper/heading.rb +26 -53
  35. data/lib/natty-ui/wrapper/horizontal_rule.rb +37 -0
  36. data/lib/natty-ui/wrapper/list_in_columns.rb +71 -12
  37. data/lib/natty-ui/wrapper/message.rb +20 -27
  38. data/lib/natty-ui/wrapper/progress.rb +40 -13
  39. data/lib/natty-ui/wrapper/query.rb +34 -31
  40. data/lib/natty-ui/wrapper/quote.rb +25 -0
  41. data/lib/natty-ui/wrapper/request.rb +21 -10
  42. data/lib/natty-ui/wrapper/section.rb +55 -39
  43. data/lib/natty-ui/wrapper/table.rb +298 -0
  44. data/lib/natty-ui/wrapper/task.rb +6 -7
  45. data/lib/natty-ui/wrapper.rb +123 -41
  46. data/lib/natty-ui.rb +65 -40
  47. metadata +28 -9
  48. data/examples/basic.rb +0 -62
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'element'
4
+
5
+ module NattyUI
6
+ module Features
7
+ #
8
+ # Table view of data.
9
+ #
10
+ # @note Tables do not support text attributes yet and are still under
11
+ # construction. This means table features are not complete defined and
12
+ # may change in near future.
13
+ #
14
+ # Defined values for `type` are
15
+ # :double, :heavy, :semi, :simple
16
+ #
17
+ # @overload table(type: simple)
18
+ # Construct and display a table.
19
+ #
20
+ # @param [Symbol] type frame type
21
+ # @yieldparam [Table] table construction helper
22
+ # @return [Wrapper::Section, Wrapper] it's parent object
23
+ #
24
+ # @example
25
+ # ui.table do |table|
26
+ # table.add('name', 'price', 'origin')
27
+ # table.add('apple', '1$', 'California')
28
+ # table.add('banana', '2$', 'Brasil')
29
+ # table.add('kiwi', '1.5$', 'Newzeeland')
30
+ # end
31
+ #
32
+ # # output:
33
+ # # name │ price │ origin
34
+ # # ───────┼───────┼───────────
35
+ # # apple │ 1$ │ California
36
+ # # ───────┼───────┼───────────
37
+ # # banana │ 2$ │ Brasil
38
+ # # ───────┼───────┼───────────
39
+ # # kiwi │ 1.5$ │ Newzeeland
40
+ #
41
+ # @overload table(*args, type: simple)
42
+ # Display the given arrays as rows of a table.
43
+ #
44
+ # @param [Array<#to_s>] args one or more arrays representing rows of the table
45
+ # @param [Symbol] type frame type
46
+ # @return [Wrapper::Section, Wrapper] it's parent object
47
+ #
48
+ # @example
49
+ # ui.table(
50
+ # %w[name price origin],
51
+ # %w[apple 1$ California],
52
+ # %w[banana 2$ Brasil],
53
+ # %w[kiwi 1.5$ Newzeeland]
54
+ # )
55
+ def table(*table, type: :simple)
56
+ table = Table.new(*table)
57
+ yield(table) if block_given?
58
+ _element(:Table, table.rows, type)
59
+ end
60
+
61
+ #
62
+ # Table-like display of key/value pairs.
63
+ #
64
+ # @param [#to_s] seperator
65
+ # @param [Hash<#to_s,#to_s>] kwargs
66
+ # @return [Wrapper::Section, Wrapper] it's parent object
67
+ #
68
+ # @example
69
+ # ui.pairs(apple: '1$', banana: '2$', kiwi: '1.5$')
70
+ #
71
+ # # output:
72
+ # # apple: 1$
73
+ # # banana: 2$
74
+ # # kiwi: 1.5$
75
+ #
76
+ def pairs(seperator = ': ', **kwargs)
77
+ _element(:Pairs, Table.new(**kwargs).rows, seperator)
78
+ end
79
+
80
+ class Table
81
+ attr_reader :rows
82
+
83
+ def add_row(*columns)
84
+ @rows << columns
85
+ self
86
+ end
87
+ alias add add_row
88
+
89
+ def add_col(*columns)
90
+ columns.each_with_index do |col, row_idx|
91
+ (@rows[row_idx] ||= []) << col
92
+ end
93
+ self
94
+ end
95
+
96
+ def initialize(*args, **kwargs)
97
+ @rows = []
98
+ args.each { add_row(*_1) }
99
+ kwargs.each_pair { add_row(*_1) }
100
+ end
101
+ end
102
+ private_constant :Table
103
+ end
104
+
105
+ class Wrapper
106
+ # An {Element} to print a table.
107
+ #
108
+ # @see Features#table
109
+ class Table < Element
110
+ protected
111
+
112
+ def call(rows, type)
113
+ TableGenerator.each_line(
114
+ rows,
115
+ @parent.available_width - 1,
116
+ ORNAMENTS[type] ||
117
+ raise(ArgumentError, "invalid table type - #{type.inspect}"),
118
+ Ansi[39],
119
+ Ansi::RESET
120
+ ) { @parent.puts(_1) }
121
+ @parent
122
+ end
123
+
124
+ def coloring = [nil, nil]
125
+
126
+ ORNAMENTS = {
127
+ rounded: '│─┼',
128
+ simple: '│─┼',
129
+ heavy: '┃━╋',
130
+ double: '║═╬',
131
+ semi: '║╴╫'
132
+ }.compare_by_identity.freeze
133
+ end
134
+
135
+ # An {Element} to print key/value pairs.
136
+ #
137
+ # @see Features#pairs
138
+ class Pairs < Element
139
+ protected
140
+
141
+ def call(rows, seperator)
142
+ TableGenerator.each_simple_line(
143
+ rows,
144
+ @parent.available_width - 1,
145
+ seperator,
146
+ NattyUI.plain(seperator, ansi: false)[-1] == ' '
147
+ ) { @parent.puts(_1) }
148
+ @parent
149
+ end
150
+ end
151
+
152
+ class TableGenerator
153
+ def self.each_line(rows, max_width, ornament, opref, osuff)
154
+ return if rows.empty?
155
+ gen = new(rows, max_width, 3)
156
+ return unless gen.ok?
157
+ last_row = 0
158
+ col_div = " #{opref}#{ornament[0]}#{osuff} "
159
+ row_div = "#{ornament[1]}#{ornament[2]}#{ornament[1]}"
160
+ row_div =
161
+ "#{opref}#{gen.widths.map { ornament[1] * _1 }.join(row_div)}#{osuff}"
162
+ gen.each do |line, number|
163
+ if last_row != number
164
+ last_row = number
165
+ yield(row_div)
166
+ end
167
+ yield(line.join(col_div))
168
+ end
169
+ end
170
+
171
+ def self.each_simple_line(rows, max_width, col_div, first_right)
172
+ return if rows.empty?
173
+ gen = new(rows, max_width, NattyUI.display_width(col_div))
174
+ return unless gen.ok?
175
+ gen.aligns[0] = :right if first_right
176
+ gen.each { yield(_1.join(col_div)) }
177
+ end
178
+
179
+ attr_reader :widths, :aligns
180
+
181
+ def initialize(rows, max_width, col_div_size)
182
+ @rows =
183
+ rows.map do |row|
184
+ row.map do |col|
185
+ col = NattyUI.embellish(col).each_line(chomp: true).to_a
186
+ col.empty? ? col << '' : col
187
+ end
188
+ end
189
+ @max_width = max_width
190
+ @col_div_size = col_div_size
191
+ @widths = create_widths.freeze
192
+ @aligns = Array.new(@widths.size, :left)
193
+ end
194
+
195
+ def ok? = (@widths != nil)
196
+
197
+ def each
198
+ return unless @widths
199
+ col_empty = @widths.map { ' ' * _1 }
200
+ @rows.each_with_index do |row, row_idx|
201
+ row
202
+ .max_by(&:size)
203
+ .size
204
+ .times do |line_nr|
205
+ col_idx = -1
206
+ yield(
207
+ @widths.map do |col_width|
208
+ cell = row[col_idx += 1] or next col_empty[col_idx]
209
+ next col_empty[col_idx] if (line = cell[line_nr]).nil?
210
+ align(line.to_s, col_width, @aligns[col_idx])
211
+ end,
212
+ row_idx
213
+ )
214
+ end
215
+ end
216
+ end
217
+
218
+ private
219
+
220
+ def align(str, width, alignment)
221
+ return str unless (width -= NattyUI.display_width(str)).positive?
222
+ return str + (' ' * width) if alignment == :left
223
+ (' ' * width) << str
224
+ end
225
+
226
+ def create_widths
227
+ matrix = create_matrix
228
+ col_widths = find_col_widths(matrix)
229
+ adjusted = adjusted_widths(col_widths)
230
+ return if adjusted.empty? # nothing to draw
231
+ return adjusted if col_widths == adjusted # all fine
232
+ if (size = adjusted.size) != col_widths.size
233
+ @rows.map! { _1.take(size) }
234
+ matrix.map! { _1.take(size) }
235
+ col_widths = col_widths.take(size)
236
+ end
237
+ diff = diff(col_widths, adjusted)
238
+ @rows.each_with_index do |row, row_idx|
239
+ diff.each do |col_idx|
240
+ adjust_to = adjusted[col_idx]
241
+ next if matrix[row_idx][col_idx] <= adjust_to
242
+ ary = NattyUI.each_line(*row[col_idx], max_width: adjust_to).to_a
243
+ ary.pop if ary.last.empty?
244
+ row[col_idx] = ary
245
+ end
246
+ end
247
+ adjusted
248
+ end
249
+
250
+ def create_matrix
251
+ ret =
252
+ @rows.map do |row|
253
+ row.map { |col| col.map { NattyUI.display_width(_1) }.max }
254
+ end
255
+ cc = ret.max_by(&:size).size
256
+ ret.each { (add = cc - _1.size).nonzero? and _1.fill(0, _1.size, add) }
257
+ end
258
+
259
+ def find_col_widths(matrix)
260
+ ret = nil
261
+ matrix.each do |row|
262
+ next ret = row.dup unless ret
263
+ row.each_with_index do |size, idx|
264
+ hs = ret[idx]
265
+ ret[idx] = size if hs < size
266
+ end
267
+ end
268
+ ret
269
+ end
270
+
271
+ def adjusted_widths(col_widths)
272
+ ret = col_widths.dup
273
+ left = @max_width - (@col_div_size * (col_widths.size - 1))
274
+ return ret if ret.sum <= left
275
+ indexed = ret.each_with_index.to_a
276
+ # TODO: optimize this!
277
+ until ret.sum <= left
278
+ indexed.sort! { |b, a| (a[0] <=> b[0]).nonzero? || (a[1] <=> b[1]) }
279
+ pair = indexed[0]
280
+ next ret[pair[1]] = pair[0] if (pair[0] -= 1).nonzero?
281
+ indexed.shift
282
+ return [] if indexed.empty?
283
+ ret.pop
284
+ end
285
+ ret
286
+ end
287
+
288
+ def diff(col_widths, adjusted)
289
+ ret = []
290
+ col_widths.each_with_index do |val, idx|
291
+ ret << idx if val != adjusted[idx]
292
+ end
293
+ ret
294
+ end
295
+ end
296
+ private_constant :TableGenerator
297
+ end
298
+ end
@@ -15,29 +15,28 @@ module NattyUI
15
15
  # @return [Object] the result of the code block
16
16
  # @return [Wrapper::Task] itself, when no code block is given
17
17
  def task(title, *args, &block)
18
- _section(self, :Task, args, title: title, &block)
18
+ _section(:Task, args, title: title, &block)
19
19
  end
20
20
  end
21
21
 
22
22
  module TaskMethods
23
23
  protected
24
24
 
25
- def initialize(parent, title:, **opts)
26
- @parent = parent
27
- @temp = wrapper.temporary
25
+ def initialize(parent, title:)
26
+ @temp = parent.wrapper.temporary
28
27
  @final_text = [title]
29
- super(parent, title: title, symbol: :task, **opts)
28
+ super(parent, title: title, glyph: :task)
30
29
  end
31
30
 
32
31
  def finish
33
32
  return @parent.failed(*@final_text) if failed?
34
33
  @temp.call
35
34
  _section(
36
- @parent,
37
35
  :Message,
38
36
  @final_text,
37
+ owner: @parent,
39
38
  title: @final_text.shift,
40
- symbol: @status = :completed
39
+ glyph: @status = :completed
41
40
  )
42
41
  end
43
42
  end
@@ -1,15 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'io/console'
4
+ require_relative 'ansi'
5
+ require_relative 'wrapper/animate'
4
6
  require_relative 'wrapper/ask'
5
7
  require_relative 'wrapper/framed'
6
8
  require_relative 'wrapper/heading'
9
+ require_relative 'wrapper/horizontal_rule'
7
10
  require_relative 'wrapper/list_in_columns'
8
11
  require_relative 'wrapper/message'
9
12
  require_relative 'wrapper/progress'
10
13
  require_relative 'wrapper/query'
14
+ require_relative 'wrapper/quote'
11
15
  require_relative 'wrapper/request'
12
16
  require_relative 'wrapper/section'
17
+ require_relative 'wrapper/table'
13
18
  require_relative 'wrapper/task'
14
19
 
15
20
  module NattyUI
@@ -28,60 +33,60 @@ module NattyUI
28
33
 
29
34
  # @attribute [r] screen_size
30
35
  # @return [[Integer, Integer]] screen size as rows and columns
31
- def screen_size
32
- return @stream.winsize if @ws
33
- [ENV['LINES'].to_i.nonzero? || 24, ENV['COLUMNS'].to_i.nonzero? || 80]
34
- end
36
+ def screen_size = (@screen_size ||= determine_screen_size)
35
37
 
36
38
  # @attribute [r] screen_rows
37
39
  # @return [Integer] number of screen rows
38
- def screen_rows
39
- @ws ? @stream.winsize[0] : (ENV['LINES'].to_i.nonzero? || 24)
40
- end
40
+ def screen_rows = screen_size[0]
41
41
 
42
42
  # @attribute [r] screen_columns
43
43
  # @return [Integer] number of screen columns
44
- def screen_columns
45
- @ws ? @stream.winsize[-1] : (ENV['COLUMNS'].to_i.nonzero? || 80)
46
- end
44
+ def screen_columns = screen_size[1]
47
45
 
48
46
  # @!group Tool functions
49
47
 
50
- # Print given arguments as lines to the output stream.
51
- # Optionally limit the line width to given `max_width`.
48
+ # Print given arguments line-wise to the output stream.
52
49
  #
53
- # @overload puts(..., max_width: nil)
50
+ # @overload puts(...)
54
51
  # @param [#to_s] ... objects to print
55
- # @param [Integer, nil] max_width maximum line width
56
- # @comment @param [#to_s, nil] prefix line prefix
57
- # @comment @param [#to_s, nil] suffix line suffix
58
52
  # @return [Wrapper] itself
59
- def puts(*args, max_width: nil, prefix: nil, suffix: nil)
60
- if args.empty?
61
- @stream.puts(embellish("#{prefix}#{suffix}"))
62
- @lines_written += 1
63
- else
64
- NattyUI.each_line(*args, max_width: max_width) do |line|
65
- @stream.puts(embellish("#{prefix}#{line}#{suffix}"))
66
- @lines_written += 1
67
- end
68
- end
53
+ def puts(*args, **kwargs)
54
+ args = prepare_print(args, kwargs)
55
+ @lines_written += args.size
56
+ @stream.puts(args)
57
+ @stream.flush
58
+ self
59
+ end
60
+
61
+ # Print given arguments to the output stream.
62
+ #
63
+ # @overload print(...)
64
+ # @param [#to_s] ... objects to print
65
+ # @return [Wrapper] itself
66
+ def print(*args, **kwargs)
67
+ args = prepare_print(args, kwargs).to_a
68
+ @lines_written += args.size - 1
69
+ @stream.print(*args)
69
70
  @stream.flush
70
71
  self
71
72
  end
72
- alias add puts
73
73
 
74
74
  # Add at least one empty line
75
75
  #
76
76
  # @param [#to_i] lines count of lines
77
77
  # @return [Wrapper] itself
78
78
  def space(lines = 1)
79
- lines = [lines.to_i, 1].max
80
- @lines_written += lines
79
+ lines = [1, lines.to_i].max
81
80
  (@stream << ("\n" * lines)).flush
81
+ @lines_written += lines
82
82
  self
83
83
  end
84
84
 
85
+ # Clear Screen
86
+ #
87
+ # @return [Wrapper] itself
88
+ def cls = self
89
+
85
90
  # @note The screen manipulation is only available in ANSI mode see {#ansi?}
86
91
  #
87
92
  # Saves current screen, deletes all screen content and moves the cursor
@@ -136,8 +141,53 @@ module NattyUI
136
141
  # @!visibility private
137
142
  alias inspect to_s
138
143
 
144
+ # @attribute [r] wrapper
145
+ # @return [Wrapper] self
146
+ alias wrapper itself
147
+
148
+ # @!visibility private
149
+ alias available_width screen_columns
150
+
151
+ # @!visibility private
152
+ alias rcol screen_columns
153
+
154
+ # @!visibility private
155
+ def prefix = nil
156
+
157
+ # @return [Array<Symbol>] available glyph names
158
+ def glyph_names = GLYPHS.keys
159
+
160
+ #
161
+ # Get a pre-defined glyph
162
+ #
163
+ # @param [Symbol] name glyph name
164
+ # @return [String] the named glyph
165
+ # @return [nil] when glyph is not defined
166
+ def glyph(name) = GLYPHS[name]
167
+
139
168
  protected
140
169
 
170
+ def prepare_print(args, kwargs)
171
+ _prepare_print(args, kwargs) { NattyUI.plain(_1, ansi: false) }
172
+ end
173
+
174
+ def _prepare_print(args, kwargs, &cvt)
175
+ prefix = kwargs[:prefix] and prefix = prefix.empty? ? '' : cvt[prefix]
176
+ suffix = kwargs[:suffix] and suffix = suffix.empty? ? '' : cvt[suffix]
177
+ return ["#{prefix}#{suffix}"] if args.empty?
178
+ NattyUI
179
+ .each_line(
180
+ *args.map!(&cvt),
181
+ max_width:
182
+ kwargs.fetch(:max_width) do
183
+ screen_columns -
184
+ kwargs.fetch(:prefix_width) { NattyUI.display_width(prefix) } -
185
+ kwargs.fetch(:suffix_width) { NattyUI.display_width(suffix) }
186
+ end
187
+ )
188
+ .map { "#{prefix}#{_1}#{suffix}" }
189
+ end
190
+
141
191
  def temp_func
142
192
  lambda do
143
193
  @stream.flush
@@ -148,23 +198,55 @@ module NattyUI
148
198
  def initialize(stream)
149
199
  @stream = stream
150
200
  @lines_written = 0
151
- @ws = stream.respond_to?(:winsize) && stream.winsize&.all?(&:positive?)
152
- rescue Errno::ENOTTY
153
- @ws = false
154
201
  end
155
202
 
156
- def embellish(obj) = (obj = NattyUI.plain(obj)).empty? ? nil : obj
203
+ private_class_method :new
157
204
 
158
- def wrapper = self
159
- def prefix = nil
160
- alias suffix prefix
205
+ private
161
206
 
162
- def prefix_width = 0
163
- alias suffix_width prefix_width
164
- alias width prefix_width
207
+ def determine_screen_size
208
+ return @stream.winsize if @ws
209
+ if @ws.nil?
210
+ ret = try_fetch_winsize
211
+ if ret
212
+ @ws = true
213
+ Signal.trap('WINCH') { @screen_size = nil }
214
+ return ret
215
+ end
216
+ @ws = false
217
+ end
218
+ [ENV['LINES'].to_i.nonzero? || 24, ENV['COLUMNS'].to_i.nonzero? || 80]
219
+ end
165
220
 
166
- alias available_width screen_columns
221
+ def try_fetch_winsize
222
+ return unless @stream.respond_to?(:winsize)
223
+ ret = @stream.winsize
224
+ ret&.all?(&:positive?) ? ret : nil
225
+ rescue SystemCallError
226
+ nil
227
+ end
167
228
 
168
- private_class_method :new
229
+ GLYPHS = {
230
+ default: "#{Ansi[:bold, 255]}•#{Ansi::RESET}",
231
+ information: "#{Ansi[:bold, 119]}𝒊#{Ansi::RESET}",
232
+ warning: "#{Ansi[:bold, 221]}!#{Ansi::RESET}",
233
+ error: "#{Ansi[:bold, 208]}𝙓#{Ansi::RESET}",
234
+ completed: "#{Ansi[:bold, 82]}✓#{Ansi::RESET}",
235
+ failed: "#{Ansi[:bold, 196]}𝑭#{Ansi::RESET}",
236
+ task: "#{Ansi[:bold, 39]}➔#{Ansi::RESET}",
237
+ query: "#{Ansi[:bold, 39]}▸#{Ansi::RESET}"
238
+ }.compare_by_identity.freeze
239
+
240
+ # GLYPHS = {
241
+ # default: '●',
242
+ # information: '🅸 ',
243
+ # warning: '🆆 ',
244
+ # error: '🅴 ',
245
+ # completed: '✓',
246
+ # failed: '🅵 ',
247
+ # task: '➔',
248
+ # query: '🆀 '
249
+ # }.compare_by_identity.freeze
250
+ private_constant :GLYPHS
169
251
  end
170
252
  end