dev-ui 0.0.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.
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dev/ui'
4
+ require 'strscan'
5
+
6
+ module Dev
7
+ module UI
8
+ class Formatter
9
+ SGR_MAP = {
10
+ # presentational
11
+ 'red' => '31',
12
+ 'green' => '32',
13
+ 'yellow' => '33',
14
+ 'blue' => '34',
15
+ 'magenta' => '35',
16
+ 'cyan' => '36',
17
+ 'bold' => '1',
18
+ 'reset' => '0',
19
+
20
+ # semantic
21
+ 'error' => '31', # red
22
+ 'success' => '32', # success
23
+ 'warning' => '33', # yellow
24
+ 'info' => '34', # blue
25
+ 'command' => '36', # cyan
26
+ }.freeze
27
+
28
+ BEGIN_EXPR = '{{'
29
+ END_EXPR = '}}'
30
+
31
+ SCAN_FUNCNAME = /\w+:/
32
+ SCAN_GLYPH = /.}}/
33
+ SCAN_BODY = /
34
+ .*?
35
+ (
36
+ #{BEGIN_EXPR} |
37
+ #{END_EXPR} |
38
+ \z
39
+ )
40
+ /mx
41
+
42
+ DISCARD_BRACES = 0..-3
43
+
44
+ LITERAL_BRACES = :__literal_braces__
45
+
46
+ class FormatError < StandardError
47
+ attr_accessor :input, :index
48
+
49
+ def initialize(message = nil, input = nil, index = nil)
50
+ super(message)
51
+ @input = input
52
+ @index = index
53
+ end
54
+ end
55
+
56
+ def initialize(text)
57
+ @text = text
58
+ end
59
+
60
+ def format(sgr_map = SGR_MAP, enable_color: true)
61
+ @nodes = []
62
+ stack = parse_body(StringScanner.new(@text))
63
+ prev_fmt = nil
64
+ content = @nodes.each_with_object(String.new) do |(text, fmt), str|
65
+ if prev_fmt != fmt && enable_color
66
+ text = apply_format(text, fmt, sgr_map)
67
+ end
68
+ str << text
69
+ prev_fmt = fmt
70
+ end
71
+
72
+ stack.reject! { |e| e == LITERAL_BRACES }
73
+
74
+ return content unless enable_color
75
+ return content if stack == prev_fmt
76
+
77
+ unless stack.empty? && (@nodes.size.zero? || @nodes.last[1].empty?)
78
+ content << apply_format('', stack, sgr_map)
79
+ end
80
+ content
81
+ end
82
+
83
+ private
84
+
85
+ def apply_format(text, fmt, sgr_map)
86
+ sgr = fmt.each_with_object(String.new('0')) do |name, str|
87
+ next if name == LITERAL_BRACES
88
+ begin
89
+ str << ';' << sgr_map.fetch(name)
90
+ rescue KeyError
91
+ raise FormatError.new(
92
+ "invalid format specifier: #{name}",
93
+ @text,
94
+ -1
95
+ )
96
+ end
97
+ end
98
+ Dev::UI::ANSI.sgr(sgr) + text
99
+ end
100
+
101
+ def parse_expr(sc, stack)
102
+ if match = sc.scan(SCAN_GLYPH)
103
+ glyph_handle = match[0]
104
+ begin
105
+ glyph = Glyph.lookup(glyph_handle)
106
+ emit(glyph.char, [glyph.color.name.to_s])
107
+ rescue Glyph::InvalidGlyphHandle
108
+ index = sc.pos - 2 # rewind past '}}'
109
+ raise FormatError.new(
110
+ "invalid glyph handle at index #{index}: '#{glyph_handle}'",
111
+ @text,
112
+ index
113
+ )
114
+ end
115
+ elsif match = sc.scan(SCAN_FUNCNAME)
116
+ funcname = match.chop
117
+ stack.push(funcname)
118
+ else
119
+ # We read a {{ but it's not apparently Formatter syntax.
120
+ # We could error, but it's nicer to just pass through as text.
121
+ # We do kind of assume that the text will probably have balanced
122
+ # pairs of {{ }} at least.
123
+ emit('{{', stack)
124
+ stack.push(LITERAL_BRACES)
125
+ end
126
+ parse_body(sc, stack)
127
+ stack
128
+ end
129
+
130
+ def parse_body(sc, stack = [])
131
+ match = sc.scan(SCAN_BODY)
132
+ if match && match.end_with?(BEGIN_EXPR)
133
+ emit(match[DISCARD_BRACES], stack)
134
+ parse_expr(sc, stack)
135
+ elsif match && match.end_with?(END_EXPR)
136
+ emit(match[DISCARD_BRACES], stack)
137
+ if stack.pop == LITERAL_BRACES
138
+ emit('}}', stack)
139
+ end
140
+ parse_body(sc, stack)
141
+ elsif match
142
+ emit(match, stack)
143
+ else
144
+ emit(sc.rest, stack)
145
+ end
146
+ stack
147
+ end
148
+
149
+ def emit(text, stack)
150
+ return if text.nil? || text.empty?
151
+ @nodes << [text, stack.reject { |n| n == LITERAL_BRACES }]
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,176 @@
1
+ require 'dev/ui'
2
+
3
+ module Dev
4
+ module UI
5
+ module Frame
6
+ class << self
7
+ DEFAULT_FRAME_COLOR = Dev::UI.resolve_color(:cyan)
8
+
9
+ # 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.
15
+ def open(
16
+ text,
17
+ color: DEFAULT_FRAME_COLOR,
18
+ failure_text: nil,
19
+ success_text: nil,
20
+ timing: nil
21
+ )
22
+ color = Dev::UI.resolve_color(color)
23
+
24
+ unless block_given?
25
+ if failure_text
26
+ raise ArgumentError, "failure_text is not compatible with blockless invocation"
27
+ elsif success_text
28
+ raise ArgumentError, "success_text is not compatible with blockless invocation"
29
+ elsif !timing.nil?
30
+ raise ArgumentError, "timing is not compatible with blockless invocation"
31
+ end
32
+ end
33
+
34
+ timing = true if timing.nil?
35
+
36
+ t_start = Time.now.to_f
37
+ Dev::UI.raw do
38
+ puts edge(text, color: color, first: Dev::UI::Box::Heavy::TL)
39
+ end
40
+ FrameStack.push(color)
41
+
42
+ return unless block_given?
43
+
44
+ closed = false
45
+ begin
46
+ success = false
47
+ success = yield
48
+ rescue Exception
49
+ closed = true
50
+ t_diff = timing ? (Time.now.to_f - t_start) : nil
51
+ close(failure_text, color: :red, elapsed: t_diff)
52
+ raise
53
+ else
54
+ success
55
+ ensure
56
+ unless closed
57
+ t_diff = timing ? (Time.now.to_f - t_start) : nil
58
+ if success != false
59
+ close(success_text, color: color, elapsed: t_diff)
60
+ else
61
+ close(failure_text, color: :red, elapsed: t_diff)
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ def close(text, color: DEFAULT_FRAME_COLOR, elapsed: nil)
68
+ color = Dev::UI.resolve_color(color)
69
+
70
+ FrameStack.pop
71
+ kwargs = {}
72
+ if elapsed
73
+ kwargs[:right_text] = "(#{elapsed.round(2)}s)"
74
+ end
75
+ Dev::UI.raw do
76
+ puts edge(text, color: color, first: Dev::UI::Box::Heavy::BL, **kwargs)
77
+ end
78
+ end
79
+
80
+ def divider(text, color: nil)
81
+ color = Dev::UI.resolve_color(color)
82
+ item = Dev::UI.resolve_color(FrameStack.pop)
83
+
84
+ Dev::UI.raw do
85
+ puts edge(text, color: (color || item), first: Dev::UI::Box::Heavy::DIV)
86
+ end
87
+ FrameStack.push(item)
88
+ end
89
+
90
+ def prefix(color: nil)
91
+ pfx = String.new
92
+ items = FrameStack.items
93
+ items[0..-2].each do |item|
94
+ pfx << Dev::UI.resolve_color(item).code << Dev::UI::Box::Heavy::VERT
95
+ end
96
+ if item = items.last
97
+ c = Thread.current[:devui_frame_color_override] || color || item
98
+ pfx << Dev::UI.resolve_color(c).code \
99
+ << Dev::UI::Box::Heavy::VERT << ' ' << Dev::UI::Color::RESET.code
100
+ end
101
+ pfx
102
+ end
103
+
104
+ def with_frame_color_override(color)
105
+ prev = Thread.current[:devui_frame_color_override]
106
+ Thread.current[:devui_frame_color_override] = color
107
+ yield
108
+ ensure
109
+ Thread.current[:devui_frame_color_override] = prev
110
+ end
111
+
112
+ def prefix_width
113
+ w = FrameStack.items.size
114
+ w.zero? ? 0 : w + 1
115
+ end
116
+
117
+ private
118
+
119
+ def edge(text, color: raise, first: raise, right_text: nil)
120
+ color = Dev::UI.resolve_color(color)
121
+ text = Dev::UI.resolve_text("{{#{color.name}:#{text}}}")
122
+
123
+ prefix = String.new
124
+ FrameStack.items.each do |item|
125
+ prefix << Dev::UI.resolve_color(item).code << Dev::UI::Box::Heavy::VERT
126
+ end
127
+ prefix << color.code << first << (Dev::UI::Box::Heavy::HORZ * 2)
128
+ text ||= ''
129
+ unless text.empty?
130
+ prefix << ' ' << text << ' '
131
+ end
132
+
133
+ suffix = String.new
134
+ if right_text
135
+ suffix << ' ' << right_text << ' ' << color.code << (Dev::UI::Box::Heavy::HORZ * 2)
136
+ end
137
+
138
+ textwidth = Dev::UI::ANSI.printing_width(prefix + suffix)
139
+ termwidth = Dev::UI::Terminal.width
140
+ termwidth = 30 if termwidth < 30
141
+
142
+ if textwidth > termwidth
143
+ suffix = ''
144
+ prefix = prefix[0...termwidth]
145
+ textwidth = termwidth
146
+ end
147
+ padwidth = termwidth - textwidth
148
+ pad = Dev::UI::Box::Heavy::HORZ * padwidth
149
+
150
+ prefix + color.code + pad + suffix + Dev::UI::Color::RESET.code + "\n"
151
+ end
152
+
153
+ module FrameStack
154
+ ENVVAR = 'DEV_FRAME_STACK'
155
+
156
+ def self.items
157
+ ENV.fetch(ENVVAR, '').split(':').map(&:to_sym)
158
+ end
159
+
160
+ def self.push(item)
161
+ curr = items
162
+ curr << item.name
163
+ ENV[ENVVAR] = curr.join(':')
164
+ end
165
+
166
+ def self.pop
167
+ curr = items
168
+ ret = curr.pop
169
+ ENV[ENVVAR] = curr.join(':')
170
+ ret.nil? ? nil : ret.to_sym
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,49 @@
1
+ require 'dev/ui'
2
+
3
+ module Dev
4
+ module UI
5
+ class Glyph
6
+ MAP = {}
7
+
8
+ attr_reader :handle, :codepoint, :color, :char, :to_s, :fmt
9
+ def initialize(handle, codepoint, color)
10
+ @handle = handle
11
+ @codepoint = codepoint
12
+ @color = color
13
+ @char = [codepoint].pack('U')
14
+ @to_s = color.code + char + Color::RESET.code
15
+ @fmt = "{{#{color.name}:#{char}}}"
16
+
17
+ MAP[handle] = self
18
+ end
19
+
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
37
+
38
+ def self.lookup(name)
39
+ MAP.fetch(name.to_s)
40
+ rescue KeyError
41
+ raise InvalidGlyphHandle, name
42
+ end
43
+
44
+ def self.available
45
+ MAP.keys
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,114 @@
1
+ require 'io/console'
2
+
3
+ module Dev
4
+ module UI
5
+ class InteractivePrompt
6
+ def self.call(options)
7
+ list = new(options)
8
+ options[list.call - 1]
9
+ end
10
+
11
+ def initialize(options)
12
+ @options = options
13
+ @active = 1
14
+ @marker = '>'
15
+ @answer = nil
16
+ end
17
+
18
+ def call
19
+ Dev::UI.raw { print(ANSI.hide_cursor) }
20
+ while @answer.nil?
21
+ render_options
22
+ wait_for_user_input
23
+
24
+ # This will put us back at the beginning of the options
25
+ # When we redraw the options, they will be overwritten
26
+ Dev::UI.raw do
27
+ @options.size.times { print(ANSI.previous_line) }
28
+ print(ANSI.previous_line + ANSI.end_of_line + "\n")
29
+ end
30
+ end
31
+ render_options
32
+ @answer
33
+ ensure
34
+ Dev::UI.raw do
35
+ print(ANSI.show_cursor)
36
+ puts(ANSI.previous_line + ANSI.end_of_line)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def wait_for_user_input
43
+ 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
62
+ end
63
+ end
64
+
65
+ # Will handle 2-3 character sequences like arrow keys and control-c
66
+ 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
83
+ rescue IOError
84
+ "\e"
85
+ end
86
+
87
+ def raw_tty!
88
+ begin
89
+ $stdin.raw! unless ENV['TEST']
90
+ rescue Errno::ENOTTY
91
+ ''
92
+ end
93
+ yield
94
+ ensure
95
+ begin
96
+ $stdin.cooked!
97
+ rescue Errno::ENOTTY
98
+ ''
99
+ end
100
+ end
101
+
102
+ def render_options
103
+ @options.each_with_index do |choice, index|
104
+ num = index + 1
105
+ message = " #{num}. {{bold:#{choice}}}"
106
+ message = "{{blue:> #{message.strip}}}" if num == @active
107
+ Dev::UI.with_frame_color(:blue) do
108
+ puts Dev::UI.fmt(message)
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end