dev-ui 0.1.0 → 0.1.1

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.
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