cli-ui 1.2.1 → 1.5.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/cli/ui/ansi.rb CHANGED
@@ -21,6 +21,8 @@ module CLI
21
21
  when 0x200d # zero-width joiner
22
22
  zwj = true
23
23
  acc
24
+ when "\n"
25
+ acc
24
26
  else
25
27
  acc + 1
26
28
  end
@@ -45,7 +47,7 @@ module CLI
45
47
  # - +cmd+ - ANSI control sequence Command
46
48
  #
47
49
  def self.control(args, cmd)
48
- ESC + "[" + args + cmd
50
+ ESC + '[' + args + cmd
49
51
  end
50
52
 
51
53
  # https://en.wikipedia.org/wiki/ANSI_escape_code#graphics
@@ -106,19 +108,21 @@ module CLI
106
108
  # * +n+ - The column to move to
107
109
  #
108
110
  def self.cursor_horizontal_absolute(n = 1)
109
- control(n.to_s, 'G')
111
+ cmd = control(n.to_s, 'G')
112
+ cmd += control('1', 'D') if CLI::UI::OS.current.shift_cursor_on_line_reset?
113
+ cmd
110
114
  end
111
115
 
112
116
  # Show the cursor
113
117
  #
114
118
  def self.show_cursor
115
- control('', "?25h")
119
+ control('', '?25h')
116
120
  end
117
121
 
118
122
  # Hide the cursor
119
123
  #
120
124
  def self.hide_cursor
121
- control('', "?25l")
125
+ control('', '?25l')
122
126
  end
123
127
 
124
128
  # Save the cursor position
@@ -136,13 +140,13 @@ module CLI
136
140
  # Move to the next line
137
141
  #
138
142
  def self.next_line
139
- cursor_down + control('1', 'G')
143
+ cursor_down + cursor_horizontal_absolute
140
144
  end
141
145
 
142
146
  # Move to the previous line
143
147
  #
144
148
  def self.previous_line
145
- cursor_up + control('1', 'G')
149
+ cursor_up + cursor_horizontal_absolute
146
150
  end
147
151
 
148
152
  def self.clear_to_end_of_line
data/lib/cli/ui/color.rb CHANGED
@@ -31,19 +31,24 @@ module CLI
31
31
  BOLD = new('1', :bold)
32
32
  WHITE = new('97', :white)
33
33
 
34
+ # 240 is very dark gray; 255 is very light gray. 244 is somewhat dark.
35
+ GRAY = new('38;5;244', :grey)
36
+
34
37
  MAP = {
35
- red: RED,
36
- green: GREEN,
37
- yellow: YELLOW,
38
- blue: BLUE,
38
+ red: RED,
39
+ green: GREEN,
40
+ yellow: YELLOW,
41
+ blue: BLUE,
39
42
  magenta: MAGENTA,
40
- cyan: CYAN,
41
- reset: RESET,
42
- bold: BOLD,
43
+ cyan: CYAN,
44
+ reset: RESET,
45
+ bold: BOLD,
46
+ gray: GRAY,
43
47
  }.freeze
44
48
 
45
49
  class InvalidColorName < ArgumentError
46
50
  def initialize(name)
51
+ super
47
52
  @name = name
48
53
  end
49
54
 
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
-
3
- require 'cli/ui'
4
- require 'strscan'
2
+ require('cli/ui')
3
+ require('strscan')
5
4
 
6
5
  module CLI
7
6
  module UI
@@ -13,39 +12,40 @@ module CLI
13
12
  #
14
13
  SGR_MAP = {
15
14
  # presentational
16
- 'red' => '31',
17
- 'green' => '32',
18
- 'yellow' => '33',
19
- # default blue is low-contrast against black in some default terminal color scheme
20
- 'blue' => '94', # 9x = high-intensity fg color x
21
- 'magenta' => '35',
22
- 'cyan' => '36',
23
- 'bold' => '1',
24
- 'italic' => '3',
15
+ 'red' => '31',
16
+ 'green' => '32',
17
+ 'yellow' => '33',
18
+ # default blue is low-contrast against black in some default terminal color scheme
19
+ 'blue' => '94', # 9x = high-intensity fg color x
20
+ 'magenta' => '35',
21
+ 'cyan' => '36',
22
+ 'bold' => '1',
23
+ 'italic' => '3',
25
24
  'underline' => '4',
26
- 'reset' => '0',
25
+ 'reset' => '0',
27
26
 
28
27
  # semantic
29
- 'error' => '31', # red
28
+ 'error' => '31', # red
30
29
  'success' => '32', # success
31
30
  'warning' => '33', # yellow
32
- 'info' => '94', # bright blue
31
+ 'info' => '94', # bright blue
33
32
  'command' => '36', # cyan
34
33
  }.freeze
35
34
 
36
35
  BEGIN_EXPR = '{{'
37
36
  END_EXPR = '}}'
38
37
 
38
+ SCAN_WIDGET = %r[@widget/(?<handle>\w+):(?<args>.*?)}}]
39
39
  SCAN_FUNCNAME = /\w+:/
40
40
  SCAN_GLYPH = /.}}/
41
- SCAN_BODY = /
41
+ SCAN_BODY = %r{
42
42
  .*?
43
43
  (
44
44
  #{BEGIN_EXPR} |
45
45
  #{END_EXPR} |
46
46
  \z
47
47
  )
48
- /mx
48
+ }mx
49
49
 
50
50
  DISCARD_BRACES = 0..-3
51
51
 
@@ -123,7 +123,7 @@ module CLI
123
123
  end
124
124
 
125
125
  def parse_expr(sc, stack)
126
- if match = sc.scan(SCAN_GLYPH)
126
+ if (match = sc.scan(SCAN_GLYPH))
127
127
  glyph_handle = match[0]
128
128
  begin
129
129
  glyph = Glyph.lookup(glyph_handle)
@@ -136,7 +136,20 @@ module CLI
136
136
  index
137
137
  )
138
138
  end
139
- elsif match = sc.scan(SCAN_FUNCNAME)
139
+ elsif (match = sc.scan(SCAN_WIDGET))
140
+ match_data = SCAN_WIDGET.match(match) # Regexp.last_match doesn't work here
141
+ widget_handle = match_data['handle']
142
+ begin
143
+ widget = Widgets.lookup(widget_handle)
144
+ emit(widget.call(match_data['args']), stack)
145
+ rescue Widgets::InvalidWidgetHandle
146
+ index = sc.pos - 2 # rewind past '}}'
147
+ raise(FormatError.new(
148
+ "invalid widget handle at index #{index}: '#{widget_handle}'",
149
+ @text, index,
150
+ ))
151
+ end
152
+ elsif (match = sc.scan(SCAN_FUNCNAME))
140
153
  funcname = match.chop
141
154
  stack.push(funcname)
142
155
  else
@@ -153,10 +166,10 @@ module CLI
153
166
 
154
167
  def parse_body(sc, stack = [])
155
168
  match = sc.scan(SCAN_BODY)
156
- if match && match.end_with?(BEGIN_EXPR)
169
+ if match&.end_with?(BEGIN_EXPR)
157
170
  emit(match[DISCARD_BRACES], stack)
158
171
  parse_expr(sc, stack)
159
- elsif match && match.end_with?(END_EXPR)
172
+ elsif match&.end_with?(END_EXPR)
160
173
  emit(match[DISCARD_BRACES], stack)
161
174
  if stack.pop == LITERAL_BRACES
162
175
  emit('}}', stack)
data/lib/cli/ui/frame.rb CHANGED
@@ -1,4 +1,7 @@
1
+ # coding: utf-8
1
2
  require 'cli/ui'
3
+ require 'cli/ui/frame/frame_stack'
4
+ require 'cli/ui/frame/frame_style'
2
5
 
3
6
  module CLI
4
7
  module UI
@@ -7,6 +10,22 @@ module CLI
7
10
  class << self
8
11
  DEFAULT_FRAME_COLOR = CLI::UI.resolve_color(:cyan)
9
12
 
13
+ def frame_style
14
+ @frame_style ||= FrameStyle::Box
15
+ end
16
+
17
+ # Set the default frame style.
18
+ #
19
+ # Raises ArgumentError if +frame_style+ is not valid
20
+ #
21
+ # ==== Attributes
22
+ #
23
+ # * +symbol+ or +FrameStyle+ - the default frame style to use for frames
24
+ #
25
+ def frame_style=(frame_style)
26
+ @frame_style = CLI::UI.resolve_style(frame_style)
27
+ end
28
+
10
29
  # Opens a new frame. Can be nested
11
30
  # Can be invoked in two ways: block and blockless
12
31
  # * In block form, the frame is closed automatically when the block returns
@@ -27,6 +46,7 @@ module CLI
27
46
  # * +:failure_text+ - If the block failed, what do we output? Defaults to nil
28
47
  # * +:success_text+ - If the block succeeds, what do we output? Defaults to nil
29
48
  # * +:timing+ - How long did the frame content take? Invalid for blockless. Defaults to true for the block form
49
+ # * +frame_style+ - The frame style to use for this frame
30
50
  #
31
51
  # ==== Example
32
52
  #
@@ -34,7 +54,7 @@ module CLI
34
54
  #
35
55
  # CLI::UI::Frame.open('Open') { puts 'hi' }
36
56
  #
37
- # Output:
57
+ # Default Output:
38
58
  # ┏━━ Open ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
39
59
  # ┃ hi
40
60
  # ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ (0.0s) ━━
@@ -43,7 +63,7 @@ module CLI
43
63
  #
44
64
  # CLI::UI::Frame.open('Open')
45
65
  #
46
- # Output:
66
+ # Default Output:
47
67
  # ┏━━ Open ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48
68
  #
49
69
  #
@@ -52,27 +72,28 @@ module CLI
52
72
  color: DEFAULT_FRAME_COLOR,
53
73
  failure_text: nil,
54
74
  success_text: nil,
55
- timing: nil
75
+ timing: nil,
76
+ frame_style: self.frame_style
56
77
  )
78
+ frame_style = CLI::UI.resolve_style(frame_style)
57
79
  color = CLI::UI.resolve_color(color)
58
80
 
59
81
  unless block_given?
60
82
  if failure_text
61
- raise ArgumentError, "failure_text is not compatible with blockless invocation"
83
+ raise ArgumentError, 'failure_text is not compatible with blockless invocation'
62
84
  elsif success_text
63
- raise ArgumentError, "success_text is not compatible with blockless invocation"
64
- elsif !timing.nil?
65
- raise ArgumentError, "timing is not compatible with blockless invocation"
85
+ raise ArgumentError, 'success_text is not compatible with blockless invocation'
86
+ elsif timing
87
+ raise ArgumentError, 'timing is not compatible with blockless invocation'
66
88
  end
67
89
  end
68
90
 
69
- timing = true if timing.nil?
70
-
71
- t_start = Time.now.to_f
91
+ t_start = Time.now
72
92
  CLI::UI.raw do
73
- puts edge(text, color: color, first: CLI::UI::Box::Heavy::TL)
93
+ print(prefix.chop)
94
+ puts frame_style.open(text, color: color)
74
95
  end
75
- FrameStack.push(color)
96
+ FrameStack.push(color: color, style: frame_style)
76
97
 
77
98
  return unless block_given?
78
99
 
@@ -82,14 +103,14 @@ module CLI
82
103
  success = yield
83
104
  rescue
84
105
  closed = true
85
- t_diff = timing ? (Time.now.to_f - t_start) : nil
106
+ t_diff = elasped(t_start, timing)
86
107
  close(failure_text, color: :red, elapsed: t_diff)
87
108
  raise
88
109
  else
89
110
  success
90
111
  ensure
91
112
  unless closed
92
- t_diff = timing ? (Time.now.to_f - t_start) : nil
113
+ t_diff = elasped(t_start, timing)
93
114
  if success != false
94
115
  close(success_text, color: color, elapsed: t_diff)
95
116
  else
@@ -99,8 +120,8 @@ module CLI
99
120
  end
100
121
  end
101
122
 
102
- # Closes a frame
103
- # Automatically called for a block-form +open+
123
+ # Adds a divider in a frame
124
+ # Used to separate information within a single frame
104
125
  #
105
126
  # ==== Attributes
106
127
  #
@@ -109,31 +130,38 @@ module CLI
109
130
  # ==== Options
110
131
  #
111
132
  # * +:color+ - The color of the frame. Defaults to +DEFAULT_FRAME_COLOR+
112
- # * +:elapsed+ - How long did the frame take? Defaults to nil
133
+ # * +frame_style+ - The frame style to use for this frame
113
134
  #
114
135
  # ==== Example
115
136
  #
116
- # CLI::UI::Frame.close('Close')
137
+ # CLI::UI::Frame.open('Open') { CLI::UI::Frame.divider('Divider') }
117
138
  #
118
- # Output:
119
- # ┗━━ Close ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
139
+ # Default Output:
140
+ # ┏━━ Open ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
141
+ # ┣━━ Divider ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
142
+ # ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
120
143
  #
144
+ # ==== Raises
121
145
  #
122
- def close(text, color: DEFAULT_FRAME_COLOR, elapsed: nil)
123
- color = CLI::UI.resolve_color(color)
146
+ # MUST be inside an open frame or it raises a +UnnestedFrameException+
147
+ #
148
+ def divider(text, color: nil, frame_style: nil)
149
+ fs_item = FrameStack.pop
150
+ raise UnnestedFrameException, 'No frame nesting to unnest' unless fs_item
151
+
152
+ color = CLI::UI.resolve_color(color) || fs_item.color
153
+ frame_style = CLI::UI.resolve_style(frame_style) || fs_item.frame_style
124
154
 
125
- FrameStack.pop
126
- kwargs = {}
127
- if elapsed
128
- kwargs[:right_text] = "(#{elapsed.round(2)}s)"
129
- end
130
155
  CLI::UI.raw do
131
- puts edge(text, color: color, first: CLI::UI::Box::Heavy::BL, **kwargs)
156
+ print(prefix.chop)
157
+ puts frame_style.divider(text, color: color)
132
158
  end
159
+
160
+ FrameStack.push(fs_item)
133
161
  end
134
162
 
135
- # Adds a divider in a frame
136
- # Used to separate information within a single frame
163
+ # Closes a frame
164
+ # Automatically called for a block-form +open+
137
165
  #
138
166
  # ==== Attributes
139
167
  #
@@ -141,51 +169,70 @@ module CLI
141
169
  #
142
170
  # ==== Options
143
171
  #
144
- # * +:color+ - The color of the frame. Defaults to +DEFAULT_FRAME_COLOR+
172
+ # * +:color+ - The color of the frame. Defaults to nil
173
+ # * +:elapsed+ - How long did the frame take? Defaults to nil
174
+ # * +frame_style+ - The frame style to use for this frame. Defaults to nil
145
175
  #
146
176
  # ==== Example
147
177
  #
148
- # CLI::UI::Frame.open('Open') { CLI::UI::Frame.divider('Divider') }
178
+ # CLI::UI::Frame.close('Close')
149
179
  #
150
- # Output:
151
- # ┏━━ Open ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
152
- # ┣━━ Divider ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
153
- # ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
180
+ # Default Output:
181
+ # ┗━━ Close ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
154
182
  #
155
183
  # ==== Raises
156
184
  #
157
185
  # MUST be inside an open frame or it raises a +UnnestedFrameException+
158
186
  #
159
- def divider(text, color: nil)
187
+ def close(text, color: nil, elapsed: nil, frame_style: nil)
160
188
  fs_item = FrameStack.pop
161
- raise UnnestedFrameException, "no frame nesting to unnest" unless fs_item
162
- color = CLI::UI.resolve_color(color)
163
- item = CLI::UI.resolve_color(fs_item)
189
+ raise UnnestedFrameException, 'No frame nesting to unnest' unless fs_item
190
+
191
+ color = CLI::UI.resolve_color(color) || fs_item.color
192
+ frame_style = CLI::UI.resolve_style(frame_style) || fs_item.frame_style
193
+
194
+ kwargs = {}
195
+ if elapsed
196
+ kwargs[:right_text] = "(#{elapsed.round(2)}s)"
197
+ end
164
198
 
165
199
  CLI::UI.raw do
166
- puts edge(text, color: (color || item), first: CLI::UI::Box::Heavy::DIV)
200
+ print(prefix.chop)
201
+ puts frame_style.close(text, color: color, **kwargs)
167
202
  end
168
- FrameStack.push(item)
169
203
  end
170
204
 
171
205
  # Determines the prefix of a frame entry taking multi-nested frames into account
172
206
  #
173
207
  # ==== Options
174
208
  #
175
- # * +:color+ - The color of the prefix. Defaults to +Thread.current[:cliui_frame_color_override]+ or nil
209
+ # * +:color+ - The color of the prefix. Defaults to +Thread.current[:cliui_frame_color_override]+
176
210
  #
177
- def prefix(color: nil)
178
- pfx = +''
179
- items = FrameStack.items
180
- items[0..-2].each do |item|
181
- pfx << CLI::UI.resolve_color(item).code << CLI::UI::Box::Heavy::VERT
211
+ def prefix(color: Thread.current[:cliui_frame_color_override])
212
+ +''.tap do |output|
213
+ items = FrameStack.items
214
+
215
+ items[0..-2].each do |item|
216
+ output << item.color.code << item.frame_style.prefix
217
+ end
218
+
219
+ if (item = items.last)
220
+ final_color = color || item.color
221
+ output << CLI::UI.resolve_color(final_color).code \
222
+ << item.frame_style.prefix \
223
+ << ' ' \
224
+ << CLI::UI::Color::RESET.code
225
+ end
182
226
  end
183
- if item = items.last
184
- c = Thread.current[:cliui_frame_color_override] || color || item
185
- pfx << CLI::UI.resolve_color(c).code \
186
- << CLI::UI::Box::Heavy::VERT << ' ' << CLI::UI::Color::RESET.code
227
+ end
228
+
229
+ # The width of a prefix given the number of Frames in the stack
230
+ def prefix_width
231
+ w = FrameStack.items.reduce(0) do |width, item|
232
+ width + item.frame_style.prefix_width
187
233
  end
188
- pfx
234
+
235
+ w.zero? ? w : w + 1
189
236
  end
190
237
 
191
238
  # Override a color for a given thread.
@@ -202,107 +249,19 @@ module CLI
202
249
  Thread.current[:cliui_frame_color_override] = prev
203
250
  end
204
251
 
205
- # The width of a prefix given the number of Frames in the stack
206
- #
207
- def prefix_width
208
- w = FrameStack.items.size
209
- w.zero? ? 0 : w + 1
210
- end
211
-
212
252
  private
213
253
 
214
- def edge(text, color: raise, first: raise, right_text: nil)
215
- color = CLI::UI.resolve_color(color)
216
- text = CLI::UI.resolve_text("{{#{color.name}:#{text}}}")
217
-
218
- prefix = +''
219
- FrameStack.items.each do |item|
220
- prefix << CLI::UI.resolve_color(item).code << CLI::UI::Box::Heavy::VERT
221
- end
222
- prefix << color.code << first << (CLI::UI::Box::Heavy::HORZ * 2)
223
- text ||= ''
224
- unless text.empty?
225
- prefix << ' ' << text << ' '
226
- end
227
-
228
- termwidth = CLI::UI::Terminal.width
229
-
230
- suffix = +''
231
- if right_text
232
- suffix << ' ' << right_text << ' '
233
- end
234
-
235
- suffix_width = CLI::UI::ANSI.printing_width(suffix)
236
- suffix_end = termwidth - 2
237
- suffix_start = suffix_end - suffix_width
238
-
239
- prefix_width = CLI::UI::ANSI.printing_width(prefix)
240
- prefix_start = 0
241
- prefix_end = prefix_start + prefix_width
242
-
243
- if prefix_end > suffix_start
244
- suffix = ''
245
- # if prefix_end > termwidth
246
- # we *could* truncate it, but let's just let it overflow to the
247
- # next line and call it poor usage of this API.
248
- end
249
-
250
- o = +''
251
-
252
- is_ci = ![0, '', nil].include?(ENV['CI'])
253
-
254
- # Jumping around the line can cause some unwanted flashes
255
- o << CLI::UI::ANSI.hide_cursor
256
-
257
- o << if is_ci
258
- # In CI, we can't use absolute horizontal positions because of timestamps.
259
- # So we move around the line by offset from this cursor position.
260
- CLI::UI::ANSI.cursor_save
261
- else
262
- # Outside of CI, we reset to column 1 so that things like ^C don't
263
- # cause output misformatting.
264
- "\r"
265
- end
266
-
267
- o << color.code
268
- o << CLI::UI::Box::Heavy::HORZ * termwidth # draw a full line
269
- o << print_at_x(prefix_start, prefix, is_ci)
270
- o << color.code
271
- o << print_at_x(suffix_start, suffix, is_ci)
272
- o << CLI::UI::Color::RESET.code
273
- o << CLI::UI::ANSI.show_cursor
274
- o << "\n"
275
-
276
- o
277
- end
278
-
279
- def print_at_x(x, str, is_ci)
280
- if is_ci
281
- CLI::UI::ANSI.cursor_restore + CLI::UI::ANSI.cursor_forward(x) + str
282
- else
283
- CLI::UI::ANSI.cursor_horizontal_absolute(1 + x) + str
284
- end
285
- end
286
-
287
- module FrameStack
288
- ENVVAR = 'CLI_FRAME_STACK'
289
-
290
- def self.items
291
- ENV.fetch(ENVVAR, '').split(':').map(&:to_sym)
292
- end
293
-
294
- def self.push(item)
295
- curr = items
296
- curr << item.name
297
- ENV[ENVVAR] = curr.join(':')
298
- end
299
-
300
- def self.pop
301
- curr = items
302
- ret = curr.pop
303
- ENV[ENVVAR] = curr.join(':')
304
- ret.nil? ? nil : ret.to_sym
305
- end
254
+ # If timing is:
255
+ # Numeric: return it
256
+ # false: return nil
257
+ # true or nil: defaults to Time.new
258
+ # Time: return the difference with start
259
+ def elasped(start, timing)
260
+ return timing if timing.is_a?(Numeric)
261
+ return if timing.is_a?(FalseClass)
262
+
263
+ timing = Time.new if timing.is_a?(TrueClass) || timing.nil?
264
+ timing - start
306
265
  end
307
266
  end
308
267
  end