dev-ui 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/dev/ui/color.rb CHANGED
@@ -4,6 +4,16 @@ module Dev
4
4
  module UI
5
5
  class Color
6
6
  attr_reader :sgr, :name, :code
7
+
8
+ # Creates a new color mapping
9
+ # Signatures can be found here:
10
+ # https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
11
+ #
12
+ # ==== Attributes
13
+ #
14
+ # * +sgr+ - The color signature
15
+ # * +name+ - The name of the color
16
+ #
7
17
  def initialize(sgr, name)
8
18
  @sgr = sgr
9
19
  @code = Dev::UI::ANSI.sgr(sgr)
@@ -44,12 +54,23 @@ module Dev
44
54
  end
45
55
  end
46
56
 
57
+ # Looks up a color code by name
58
+ #
59
+ # ==== Raises
60
+ # Raises a InvalidColorName if the color is not available
61
+ # You likely need to add it to the +MAP+ or you made a typo
62
+ #
63
+ # ==== Returns
64
+ # Returns a color code
65
+ #
47
66
  def self.lookup(name)
48
67
  MAP.fetch(name)
49
68
  rescue KeyError
50
69
  raise InvalidColorName, name
51
70
  end
52
71
 
72
+ # All available colors by name
73
+ #
53
74
  def self.available
54
75
  MAP.keys
55
76
  end
@@ -6,6 +6,11 @@ require 'strscan'
6
6
  module Dev
7
7
  module UI
8
8
  class Formatter
9
+ # Available mappings of formattings
10
+ # To use any of them, you can use {{<key>:<string>}}
11
+ # There are presentational (colours and formatters)
12
+ # and semantic (error, info, command) formatters available
13
+ #
9
14
  SGR_MAP = {
10
15
  # presentational
11
16
  'red' => '31',
@@ -32,14 +37,14 @@ module Dev
32
37
 
33
38
  SCAN_FUNCNAME = /\w+:/
34
39
  SCAN_GLYPH = /.}}/
35
- SCAN_BODY = /
40
+ SCAN_BODY = %r{
36
41
  .*?
37
42
  (
38
43
  #{BEGIN_EXPR} |
39
44
  #{END_EXPR} |
40
45
  \z
41
46
  )
42
- /mx
47
+ }mx
43
48
 
44
49
  DISCARD_BRACES = 0..-3
45
50
 
@@ -55,10 +60,26 @@ module Dev
55
60
  end
56
61
  end
57
62
 
63
+ # Initialize a formatter with text.
64
+ #
65
+ # ===== Attributes
66
+ #
67
+ # * +text+ - the text to format
68
+ #
58
69
  def initialize(text)
59
70
  @text = text
60
71
  end
61
72
 
73
+ # Format the text using a map.
74
+ #
75
+ # ===== Attributes
76
+ #
77
+ # * +sgr_map+ - the mapping of the formattings. Defaults to +SGR_MAP+
78
+ #
79
+ # ===== Options
80
+ #
81
+ # * +:enable_color+ - enable color output? Default is true
82
+ #
62
83
  def format(sgr_map = SGR_MAP, enable_color: true)
63
84
  @nodes = []
64
85
  stack = parse_body(StringScanner.new(@text))
data/lib/dev/ui/frame.rb CHANGED
@@ -3,15 +3,50 @@ require 'dev/ui'
3
3
  module Dev
4
4
  module UI
5
5
  module Frame
6
+ class UnnestedFrameException < StandardError; end
6
7
  class << self
7
8
  DEFAULT_FRAME_COLOR = Dev::UI.resolve_color(:cyan)
8
9
 
10
+ # Opens a new frame. Can be nested
9
11
  # Can be invoked in two ways: block and blockless
10
- # In block form, the frame is closed automatically when the block returns
11
- # In blockless form, caller MUST call Frame.close when the frame is
12
- # logically done.
13
- # blockless form is strongly discouraged in cases where block form can be
14
- # made to work.
12
+ # * In block form, the frame is closed automatically when the block returns
13
+ # * In blockless form, caller MUST call +Frame.close+ when the frame is logically done
14
+ # * Blockless form is strongly discouraged in cases where block form can be made to work
15
+ #
16
+ # https://user-images.githubusercontent.com/3074765/33799861-cb5dcb5c-dd01-11e7-977e-6fad38cee08c.png
17
+ #
18
+ # The return value of the block determines if the block is a "success" or a "failure"
19
+ #
20
+ # ==== Attributes
21
+ #
22
+ # * +text+ - (required) the text/title to output in the frame
23
+ #
24
+ # ==== Options
25
+ #
26
+ # * +:color+ - The color of the frame. Defaults to +DEFAULT_FRAME_COLOR+
27
+ # * +:failure_text+ - If the block failed, what do we output? Defaults to nil
28
+ # * +:success_text+ - If the block succeeds, what do we output? Defaults to nil
29
+ # * +:timing+ - How long did the frame content take? Invalid for blockless. Defaults to true for the block form
30
+ #
31
+ # ==== Example
32
+ #
33
+ # ===== Block Form (Assumes +Dev::UI::StdoutRouter.enable+ has been called)
34
+ #
35
+ # Dev::UI::Frame.open('Open') { puts 'hi' }
36
+ #
37
+ # Output:
38
+ # ┏━━ Open ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
39
+ # ┃ hi
40
+ # ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ (0.0s) ━━
41
+ #
42
+ # ===== Blockless Form
43
+ #
44
+ # Dev::UI::Frame.open('Open')
45
+ #
46
+ # Output:
47
+ # ┏━━ Open ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48
+ #
49
+ #
15
50
  def open(
16
51
  text,
17
52
  color: DEFAULT_FRAME_COLOR,
@@ -64,6 +99,26 @@ module Dev
64
99
  end
65
100
  end
66
101
 
102
+ # Closes a frame
103
+ # Automatically called for a block-form +open+
104
+ #
105
+ # ==== Attributes
106
+ #
107
+ # * +text+ - (required) the text/title to output in the frame
108
+ #
109
+ # ==== Options
110
+ #
111
+ # * +:color+ - The color of the frame. Defaults to +DEFAULT_FRAME_COLOR+
112
+ # * +:elapsed+ - How long did the frame take? Defaults to nil
113
+ #
114
+ # ==== Example
115
+ #
116
+ # Dev::UI::Frame.close('Close')
117
+ #
118
+ # Output:
119
+ # ┗━━ Close ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
120
+ #
121
+ #
67
122
  def close(text, color: DEFAULT_FRAME_COLOR, elapsed: nil)
68
123
  color = Dev::UI.resolve_color(color)
69
124
 
@@ -77,9 +132,35 @@ module Dev
77
132
  end
78
133
  end
79
134
 
135
+ # Adds a divider in a frame
136
+ # Used to separate information within a single frame
137
+ #
138
+ # ==== Attributes
139
+ #
140
+ # * +text+ - (required) the text/title to output in the frame
141
+ #
142
+ # ==== Options
143
+ #
144
+ # * +:color+ - The color of the frame. Defaults to +DEFAULT_FRAME_COLOR+
145
+ #
146
+ # ==== Example
147
+ #
148
+ # Dev::UI::Frame.open('Open') { Dev::UI::Frame.divider('Divider') }
149
+ #
150
+ # Output:
151
+ # ┏━━ Open ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
152
+ # ┣━━ Divider ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
153
+ # ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
154
+ #
155
+ # ==== Raises
156
+ #
157
+ # MUST be inside an open frame or it raises a +UnnestedFrameException+
158
+ #
80
159
  def divider(text, color: nil)
160
+ fs_item = FrameStack.pop
161
+ raise UnnestedFrameException, "no frame nesting to unnest" unless fs_item
81
162
  color = Dev::UI.resolve_color(color)
82
- item = Dev::UI.resolve_color(FrameStack.pop)
163
+ item = Dev::UI.resolve_color(fs_item)
83
164
 
84
165
  Dev::UI.raw do
85
166
  puts edge(text, color: (color || item), first: Dev::UI::Box::Heavy::DIV)
@@ -87,6 +168,12 @@ module Dev
87
168
  FrameStack.push(item)
88
169
  end
89
170
 
171
+ # Determines the prefix of a frame entry taking multi-nested frames into account
172
+ #
173
+ # ==== Options
174
+ #
175
+ # * +:color+ - The color of the prefix. Defaults to +Thread.current[:devui_frame_color_override]+ or nil
176
+ #
90
177
  def prefix(color: nil)
91
178
  pfx = String.new
92
179
  items = FrameStack.items
@@ -101,6 +188,12 @@ module Dev
101
188
  pfx
102
189
  end
103
190
 
191
+ # Override a color for a given thread.
192
+ #
193
+ # ==== Attributes
194
+ #
195
+ # * +color+ - The color to override to
196
+ #
104
197
  def with_frame_color_override(color)
105
198
  prev = Thread.current[:devui_frame_color_override]
106
199
  Thread.current[:devui_frame_color_override] = color
@@ -109,6 +202,8 @@ module Dev
109
202
  Thread.current[:devui_frame_color_override] = prev
110
203
  end
111
204
 
205
+ # The width of a prefix given the number of Frames in the stack
206
+ #
112
207
  def prefix_width
113
208
  w = FrameStack.items.size
114
209
  w.zero? ? 0 : w + 1
@@ -154,18 +249,41 @@ module Dev
154
249
 
155
250
  o = String.new
156
251
 
252
+ is_ci = ![0, '', nil].include?(ENV['CI'])
253
+
254
+ # Jumping around the line can cause some unwanted flashes
255
+ o << Dev::UI::ANSI.hide_cursor
256
+
257
+ 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
+ o << Dev::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
+ o << "\r"
265
+ end
266
+
157
267
  o << color.code
158
268
  o << Dev::UI::Box::Heavy::HORZ * termwidth # draw a full line
159
- o << Dev::UI::ANSI.cursor_horizontal_absolute(1 + prefix_start)
160
- o << prefix
161
- o << Dev::UI::ANSI.cursor_horizontal_absolute(1 + suffix_start)
162
- o << suffix
269
+ o << print_at_x(prefix_start, prefix, is_ci)
270
+ o << color.code
271
+ o << print_at_x(suffix_start, suffix, is_ci)
163
272
  o << Dev::UI::Color::RESET.code
273
+ o << Dev::UI::ANSI.show_cursor
164
274
  o << "\n"
165
275
 
166
276
  o
167
277
  end
168
278
 
279
+ def print_at_x(x, str, is_ci)
280
+ if is_ci
281
+ Dev::UI::ANSI.cursor_restore + Dev::UI::ANSI.cursor_forward(x) + str
282
+ else
283
+ Dev::UI::ANSI.cursor_horizontal_absolute(1 + x) + str
284
+ end
285
+ end
286
+
169
287
  module FrameStack
170
288
  ENVVAR = 'DEV_FRAME_STACK'
171
289
 
data/lib/dev/ui/glyph.rb CHANGED
@@ -3,9 +3,28 @@ require 'dev/ui'
3
3
  module Dev
4
4
  module UI
5
5
  class Glyph
6
- MAP = {}
6
+ class InvalidGlyphHandle < ArgumentError
7
+ def initialize(handle)
8
+ @handle = handle
9
+ end
10
+
11
+ def message
12
+ keys = Glyph.available.join(',')
13
+ "invalid glyph handle: #{@handle} " \
14
+ "-- must be one of Dev::UI::Glyph.available (#{keys})"
15
+ end
16
+ end
7
17
 
8
18
  attr_reader :handle, :codepoint, :color, :char, :to_s, :fmt
19
+
20
+ # Creates a new glyph
21
+ #
22
+ # ==== Attributes
23
+ #
24
+ # * +handle+ - The handle in the +MAP+ constant
25
+ # * +codepoint+ - The codepoint used to create the glyph (e.g. +0x2717+ for a ballot X)
26
+ # * +color+ - What color to output the glyph. Check +Dev::UI::Color+ for options.
27
+ #
9
28
  def initialize(handle, codepoint, color)
10
29
  @handle = handle
11
30
  @codepoint = codepoint
@@ -17,30 +36,36 @@ module Dev
17
36
  MAP[handle] = self
18
37
  end
19
38
 
20
- STAR = new('*', 0x2b51, Color::YELLOW) # BLACK SMALL STAR
21
- INFO = new('i', 0x1d4be, Color::BLUE) # MATHEMATICAL SCRIPT SMALL I
22
- QUESTION = new('?', 0x003f, Color::BLUE) # QUESTION MARK
23
- CHECK = new('v', 0x2713, Color::GREEN) # CHECK MARK
24
- X = new('x', 0x2717, Color::RED) # BALLOT X
25
-
26
- class InvalidGlyphHandle < ArgumentError
27
- def initialize(handle)
28
- @handle = handle
29
- end
30
-
31
- def message
32
- keys = Glyph.available.join(',')
33
- "invalid glyph handle: #{@handle} " \
34
- "-- must be one of Dev::UI::Glyph.available (#{keys})"
35
- end
36
- end
39
+ # Mapping of glyphs to terminal output
40
+ MAP = {}
41
+ # YELLOw SMALL STAR ()
42
+ STAR = new('*', 0x2b51, Color::YELLOW)
43
+ # BLUE MATHEMATICAL SCRIPT SMALL i (𝒾)
44
+ INFO = new('i', 0x1d4be, Color::BLUE)
45
+ # BLUE QUESTION MARK (?)
46
+ QUESTION = new('?', 0x003f, Color::BLUE)
47
+ # GREEN CHECK MARK (✓)
48
+ CHECK = new('v', 0x2713, Color::GREEN)
49
+ # RED BALLOT X (✗)
50
+ X = new('x', 0x2717, Color::RED)
37
51
 
52
+ # Looks up a glyph by name
53
+ #
54
+ # ==== Raises
55
+ # Raises a InvalidGlyphHandle if the glyph is not available
56
+ # You likely need to create it with +.new+ or you made a typo
57
+ #
58
+ # ==== Returns
59
+ # Returns a terminal output-capable string
60
+ #
38
61
  def self.lookup(name)
39
62
  MAP.fetch(name.to_s)
40
63
  rescue KeyError
41
64
  raise InvalidGlyphHandle, name
42
65
  end
43
66
 
67
+ # All available glyphs by name
68
+ #
44
69
  def self.available
45
70
  MAP.keys
46
71
  end
@@ -3,18 +3,40 @@ require 'io/console'
3
3
  module Dev
4
4
  module UI
5
5
  class InteractivePrompt
6
+ # Prompts the user with options
7
+ # Uses an interactive session to allow the user to pick an answer
8
+ # Can use arrows, y/n, numbers (1/2), and vim bindings to control
9
+ #
10
+ # https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif
11
+ #
12
+ # ==== Example Usage:
13
+ #
14
+ # Ask an interactive question
15
+ # Dev::UI::InteractivePrompt.call(%w(rails go python))
16
+ #
6
17
  def self.call(options)
7
18
  list = new(options)
8
19
  options[list.call - 1]
9
20
  end
10
21
 
22
+ # Initializes a new +InteractivePrompt+
23
+ # Usually called from +self.call+
24
+ #
25
+ # ==== Example Usage:
26
+ #
27
+ # Dev::UI::InteractivePrompt.new(%w(rails go python))
28
+ #
11
29
  def initialize(options)
12
30
  @options = options
13
31
  @active = 1
14
32
  @marker = '>'
15
33
  @answer = nil
34
+ @state = :root
16
35
  end
17
36
 
37
+ # Calls the +InteractivePrompt+ and asks the question
38
+ # Usually used from +self.call+
39
+ #
18
40
  def call
19
41
  Dev::UI.raw { print(ANSI.hide_cursor) }
20
42
  while @answer.nil?
@@ -24,7 +46,8 @@ module Dev
24
46
  # This will put us back at the beginning of the options
25
47
  # When we redraw the options, they will be overwritten
26
48
  Dev::UI.raw do
27
- @options.size.times { print(ANSI.previous_line) }
49
+ num_lines = @options.join("\n").split("\n").reject(&:empty?).size
50
+ num_lines.times { print(ANSI.previous_line) }
28
51
  print(ANSI.previous_line + ANSI.end_of_line + "\n")
29
52
  end
30
53
  end
@@ -39,47 +62,60 @@ module Dev
39
62
 
40
63
  private
41
64
 
65
+ ESC = "\e"
66
+
67
+ def up
68
+ @active = @active - 1 >= 1 ? @active - 1 : @options.length
69
+ end
70
+
71
+ def down
72
+ @active = @active + 1 <= @options.length ? @active + 1 : 1
73
+ end
74
+
75
+ def select_n(n)
76
+ @active = n
77
+ @answer = n
78
+ end
79
+
80
+ def select_bool(char)
81
+ return unless (@options - %w(yes no)).empty?
82
+ opt = @options.detect { |o| o.start_with?(char) }
83
+ @active = @options.index(opt) + 1
84
+ @answer = @options.index(opt) + 1
85
+ end
86
+
87
+ # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon
42
88
  def wait_for_user_input
43
89
  char = read_char
44
- char = char.chomp unless char.chomp.empty?
45
- case char
46
- when "\e[A", 'k' # up
47
- @active = @active - 1 >= 1 ? @active - 1 : @options.length
48
- when "\e[B", 'j' # down
49
- @active = @active + 1 <= @options.length ? @active + 1 : 1
50
- when " ", "\r" # enter/select
51
- @answer = @active
52
- when ('1'..@options.size.to_s)
53
- @active = char.to_i
54
- @answer = char.to_i
55
- when 'y', 'n'
56
- return unless (@options - %w(yes no)).empty?
57
- opt = @options.detect { |o| o.start_with?(char) }
58
- @active = @options.index(opt) + 1
59
- @answer = @options.index(opt) + 1
60
- when "\u0003", "\e" # Control-C or escape
61
- raise Interrupt
90
+ case @state
91
+ when :root
92
+ case char
93
+ when ESC ; @state = :esc
94
+ when 'k' ; up
95
+ when 'j' ; down
96
+ when ('1'..@options.size.to_s) ; select_n(char.to_i)
97
+ when 'y', 'n' ; select_bool(char)
98
+ when " ", "\r", "\n" ; @answer = @active # <enter>
99
+ when "\u0003" ; raise Interrupt # Ctrl-c
100
+ end
101
+ when :esc
102
+ case char
103
+ when '[' ; @state = :esc_bracket
104
+ else ; raise Interrupt # unhandled escape sequence.
105
+ end
106
+ when :esc_bracket
107
+ @state = :root
108
+ case char
109
+ when 'A' ; up
110
+ when 'B' ; down
111
+ else ; raise Interrupt # unhandled escape sequence.
112
+ end
62
113
  end
63
114
  end
115
+ # rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon
64
116
 
65
- # Will handle 2-3 character sequences like arrow keys and control-c
66
117
  def read_char
67
- raw_tty! do
68
- input = $stdin.getc.chr
69
- return input unless input == "\e"
70
-
71
- input << begin
72
- $stdin.read_nonblock(3)
73
- rescue
74
- ''
75
- end
76
- input << begin
77
- $stdin.read_nonblock(2)
78
- rescue
79
- ''
80
- end
81
- input
82
- end
118
+ raw_tty! { $stdin.getc.chr }
83
119
  rescue IOError
84
120
  "\e"
85
121
  end
@@ -95,8 +131,15 @@ module Dev
95
131
  def render_options
96
132
  @options.each_with_index do |choice, index|
97
133
  num = index + 1
98
- message = " #{num}. {{bold:#{choice}}}"
99
- message = "{{blue:> #{message.strip}}}" if num == @active
134
+ message = " #{num}."
135
+ message += choice.split("\n").map { |l| " {{bold:#{l}}}" }.join("\n")
136
+
137
+ if num == @active
138
+ message = message.split("\n").map.with_index do |l, idx|
139
+ idx == 0 ? "{{blue:> #{l.strip}}}" : "{{blue:>#{l.strip}}}"
140
+ end.join("\n")
141
+ end
142
+
100
143
  Dev::UI.with_frame_color(:blue) do
101
144
  puts Dev::UI.fmt(message)
102
145
  end