dev-ui 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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