cli-ui 1.5.1 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,10 +1,14 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
3
+
2
4
  require('cli/ui')
3
5
  require('strscan')
4
6
 
5
7
  module CLI
6
8
  module UI
7
9
  class Formatter
10
+ extend T::Sig
11
+
8
12
  # Available mappings of formattings
9
13
  # To use any of them, you can use {{<key>:<string>}}
10
14
  # There are presentational (colours and formatters)
@@ -19,6 +23,8 @@ module CLI
19
23
  'blue' => '94', # 9x = high-intensity fg color x
20
24
  'magenta' => '35',
21
25
  'cyan' => '36',
26
+ 'gray' => '38;5;244',
27
+ 'white' => '97',
22
28
  'bold' => '1',
23
29
  'italic' => '3',
24
30
  'underline' => '4',
@@ -49,12 +55,21 @@ module CLI
49
55
 
50
56
  DISCARD_BRACES = 0..-3
51
57
 
52
- LITERAL_BRACES = :__literal_braces__
58
+ LITERAL_BRACES = Class.new
59
+
60
+ Stack = T.type_alias { T::Array[T.any(String, LITERAL_BRACES)] }
53
61
 
54
62
  class FormatError < StandardError
55
- attr_accessor :input, :index
63
+ extend T::Sig
64
+
65
+ sig { returns(String) }
66
+ attr_accessor :input
56
67
 
57
- def initialize(message = nil, input = nil, index = nil)
68
+ sig { returns(Integer) }
69
+ attr_accessor :index
70
+
71
+ sig { params(message: String, input: String, index: Integer).void }
72
+ def initialize(message, input, index)
58
73
  super(message)
59
74
  @input = input
60
75
  @index = index
@@ -67,8 +82,10 @@ module CLI
67
82
  #
68
83
  # * +text+ - the text to format
69
84
  #
85
+ sig { params(text: String).void }
70
86
  def initialize(text)
71
87
  @text = text
88
+ @nodes = T.let([], T::Array[[String, Stack]])
72
89
  end
73
90
 
74
91
  # Format the text using a map.
@@ -81,10 +98,11 @@ module CLI
81
98
  #
82
99
  # * +:enable_color+ - enable color output? Default is true unless output is redirected
83
100
  #
101
+ sig { params(sgr_map: T::Hash[String, String], enable_color: T::Boolean).returns(String) }
84
102
  def format(sgr_map = SGR_MAP, enable_color: CLI::UI.enable_color?)
85
- @nodes = []
103
+ @nodes.replace([])
86
104
  stack = parse_body(StringScanner.new(@text))
87
- prev_fmt = nil
105
+ prev_fmt = T.let(nil, T.nilable(Stack))
88
106
  content = @nodes.each_with_object(+'') do |(text, fmt), str|
89
107
  if prev_fmt != fmt && enable_color
90
108
  text = apply_format(text, fmt, sgr_map)
@@ -93,12 +111,12 @@ module CLI
93
111
  prev_fmt = fmt
94
112
  end
95
113
 
96
- stack.reject! { |e| e == LITERAL_BRACES }
114
+ stack.reject! { |e| e.is_a?(LITERAL_BRACES) }
97
115
 
98
116
  return content unless enable_color
99
117
  return content if stack == prev_fmt
100
118
 
101
- unless stack.empty? && (@nodes.size.zero? || @nodes.last[1].empty?)
119
+ unless stack.empty? && (@nodes.size.zero? || T.must(@nodes.last)[1].empty?)
102
120
  content << apply_format('', stack, sgr_map)
103
121
  end
104
122
  content
@@ -106,25 +124,28 @@ module CLI
106
124
 
107
125
  private
108
126
 
127
+ sig { params(text: String, fmt: Stack, sgr_map: T::Hash[String, String]).returns(String) }
109
128
  def apply_format(text, fmt, sgr_map)
110
129
  sgr = fmt.each_with_object(+'0') do |name, str|
111
- next if name == LITERAL_BRACES
130
+ next if name.is_a?(LITERAL_BRACES)
131
+
112
132
  begin
113
133
  str << ';' << sgr_map.fetch(name)
114
134
  rescue KeyError
115
135
  raise FormatError.new(
116
136
  "invalid format specifier: #{name}",
117
137
  @text,
118
- -1
138
+ -1,
119
139
  )
120
140
  end
121
141
  end
122
142
  CLI::UI::ANSI.sgr(sgr) + text
123
143
  end
124
144
 
145
+ sig { params(sc: StringScanner, stack: Stack).returns(Stack) }
125
146
  def parse_expr(sc, stack)
126
147
  if (match = sc.scan(SCAN_GLYPH))
127
- glyph_handle = match[0]
148
+ glyph_handle = T.must(match[0])
128
149
  begin
129
150
  glyph = Glyph.lookup(glyph_handle)
130
151
  emit(glyph.char, [glyph.color.name.to_s])
@@ -133,20 +154,20 @@ module CLI
133
154
  raise FormatError.new(
134
155
  "invalid glyph handle at index #{index}: '#{glyph_handle}'",
135
156
  @text,
136
- index
157
+ index,
137
158
  )
138
159
  end
139
160
  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']
161
+ match_data = T.must(SCAN_WIDGET.match(match)) # Regexp.last_match doesn't work here
162
+ widget_handle = T.must(match_data['handle'])
142
163
  begin
143
164
  widget = Widgets.lookup(widget_handle)
144
- emit(widget.call(match_data['args']), stack)
165
+ emit(widget.call(T.must(match_data['args'])), stack)
145
166
  rescue Widgets::InvalidWidgetHandle
146
167
  index = sc.pos - 2 # rewind past '}}'
147
168
  raise(FormatError.new(
148
169
  "invalid widget handle at index #{index}: '#{widget_handle}'",
149
- @text, index,
170
+ @text, index
150
171
  ))
151
172
  end
152
173
  elsif (match = sc.scan(SCAN_FUNCNAME))
@@ -158,20 +179,21 @@ module CLI
158
179
  # We do kind of assume that the text will probably have balanced
159
180
  # pairs of {{ }} at least.
160
181
  emit('{{', stack)
161
- stack.push(LITERAL_BRACES)
182
+ stack.push(LITERAL_BRACES.new)
162
183
  end
163
184
  parse_body(sc, stack)
164
185
  stack
165
186
  end
166
187
 
188
+ sig { params(sc: StringScanner, stack: Stack).returns(Stack) }
167
189
  def parse_body(sc, stack = [])
168
190
  match = sc.scan(SCAN_BODY)
169
191
  if match&.end_with?(BEGIN_EXPR)
170
- emit(match[DISCARD_BRACES], stack)
192
+ emit(T.must(match[DISCARD_BRACES]), stack)
171
193
  parse_expr(sc, stack)
172
194
  elsif match&.end_with?(END_EXPR)
173
- emit(match[DISCARD_BRACES], stack)
174
- if stack.pop == LITERAL_BRACES
195
+ emit(T.must(match[DISCARD_BRACES]), stack)
196
+ if stack.pop.is_a?(LITERAL_BRACES)
175
197
  emit('}}', stack)
176
198
  end
177
199
  parse_body(sc, stack)
@@ -183,9 +205,11 @@ module CLI
183
205
  stack
184
206
  end
185
207
 
208
+ sig { params(text: String, stack: Stack).void }
186
209
  def emit(text, stack)
187
- return if text.nil? || text.empty?
188
- @nodes << [text, stack.reject { |n| n == LITERAL_BRACES }]
210
+ return if text.empty?
211
+
212
+ @nodes << [text, stack.reject { |n| n.is_a?(LITERAL_BRACES) }]
189
213
  end
190
214
  end
191
215
  end
@@ -1,3 +1,5 @@
1
+ # typed: true
2
+
1
3
  module CLI
2
4
  module UI
3
5
  module Frame
@@ -6,8 +8,18 @@ module CLI
6
8
  STYLE_ENVVAR = 'CLI_STYLE_STACK'
7
9
 
8
10
  class StackItem
9
- attr_reader :color, :frame_style
11
+ extend T::Sig
12
+
13
+ sig { returns(CLI::UI::Color) }
14
+ attr_reader :color
15
+
16
+ sig { returns(CLI::UI::Frame::FrameStyle) }
17
+ attr_reader :frame_style
10
18
 
19
+ sig do
20
+ params(color_name: CLI::UI::Colorable, style_name: FrameStylable)
21
+ .void
22
+ end
11
23
  def initialize(color_name, style_name)
12
24
  @color = CLI::UI.resolve_color(color_name)
13
25
  @frame_style = CLI::UI.resolve_style(style_name)
@@ -15,13 +27,16 @@ module CLI
15
27
  end
16
28
 
17
29
  class << self
30
+ extend T::Sig
31
+
18
32
  # Fetch all items off the frame stack
33
+ sig { returns(T::Array[StackItem]) }
19
34
  def items
20
35
  colors = ENV.fetch(COLOR_ENVVAR, '').split(':').map(&:to_sym)
21
36
  styles = ENV.fetch(STYLE_ENVVAR, '').split(':').map(&:to_sym)
22
37
 
23
- colors.length.times.map do |i|
24
- StackItem.new(colors[i], styles[i] || Frame.frame_style)
38
+ colors.each_with_index.map do |color, i|
39
+ StackItem.new(color, styles[i] || Frame.frame_style)
25
40
  end
26
41
  end
27
42
 
@@ -43,18 +58,20 @@ module CLI
43
58
  # If both an item and a color/style pair are given, raises an +ArgumentError+
44
59
  # If the given item is not a +StackItem+, raises an +ArgumentError+
45
60
  #
61
+ sig do
62
+ params(
63
+ item: T.nilable(StackItem),
64
+ color: T.nilable(CLI::UI::Color),
65
+ style: T.nilable(CLI::UI::Frame::FrameStyle),
66
+ )
67
+ .void
68
+ end
46
69
  def push(item = nil, color: nil, style: nil)
47
- unless item.nil?
48
- unless item.is_a?(StackItem)
49
- raise ArgumentError, 'item must be a StackItem'
50
- end
51
-
52
- unless color.nil? && style.nil?
53
- raise ArgumentError, 'Must give one of item or color: and style:'
54
- end
70
+ if color.nil? != style.nil? || item.nil? == color.nil?
71
+ raise ArgumentError, 'Must give one of item or color: and style:'
55
72
  end
56
73
 
57
- item ||= StackItem.new(color, style)
74
+ item ||= StackItem.new(T.must(color), T.must(style))
58
75
 
59
76
  curr = items
60
77
  curr << item
@@ -63,6 +80,7 @@ module CLI
63
80
  end
64
81
 
65
82
  # Removes and returns the last stack item off the stack
83
+ sig { returns(T.nilable(StackItem)) }
66
84
  def pop
67
85
  curr = items
68
86
  ret = curr.pop
@@ -79,13 +97,14 @@ module CLI
79
97
  # This is done to preserve backward compatibility with earlier versions of cli/ui.
80
98
  # This ensures that any code that relied upon previous stack behavior should continue
81
99
  # to work.
100
+ sig { params(items: T::Array[StackItem]).void }
82
101
  def serialize(items)
83
102
  colors = []
84
103
  styles = []
85
104
 
86
105
  items.each do |item|
87
106
  colors << item.color.name
88
- styles << item.frame_style.name
107
+ styles << item.frame_style.style_name
89
108
  end
90
109
 
91
110
  ENV[COLOR_ENVVAR] = colors.join(':')
@@ -1,3 +1,5 @@
1
+ # typed: true
2
+
1
3
  module CLI
2
4
  module UI
3
5
  module Frame
@@ -12,10 +14,14 @@ module CLI
12
14
  BOTTOM_LEFT = '┗'
13
15
 
14
16
  class << self
15
- def name
16
- 'box'
17
+ extend T::Sig
18
+
19
+ sig { override.returns(Symbol) }
20
+ def style_name
21
+ :box
17
22
  end
18
23
 
24
+ sig { override.returns(String) }
19
25
  def prefix
20
26
  VERTICAL
21
27
  end
@@ -34,7 +40,8 @@ module CLI
34
40
  #
35
41
  # ┏━━ Open ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
36
42
  #
37
- def open(text, color:)
43
+ sig { override.params(text: String, color: CLI::UI::Color).returns(String) }
44
+ def start(text, color:)
38
45
  edge(text, color: color, first: TOP_LEFT)
39
46
  end
40
47
 
@@ -52,6 +59,7 @@ module CLI
52
59
  #
53
60
  # ┣━━ Divider ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
54
61
  #
62
+ sig { override.params(text: String, color: CLI::UI::Color).returns(String) }
55
63
  def divider(text, color:)
56
64
  edge(text, color: color, first: DIVIDER)
57
65
  end
@@ -71,12 +79,16 @@ module CLI
71
79
  #
72
80
  # ┗━━ Close ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
73
81
  #
82
+ sig { override.params(text: String, color: CLI::UI::Color, right_text: T.nilable(String)).returns(String) }
74
83
  def close(text, color:, right_text: nil)
75
84
  edge(text, color: color, right_text: right_text, first: BOTTOM_LEFT)
76
85
  end
77
86
 
78
87
  private
79
88
 
89
+ sig do
90
+ params(text: String, color: CLI::UI::Color, first: String, right_text: T.nilable(String)).returns(String)
91
+ end
80
92
  def edge(text, color:, first:, right_text: nil)
81
93
  color = CLI::UI.resolve_color(color)
82
94
 
@@ -84,7 +96,6 @@ module CLI
84
96
 
85
97
  preamble << color.code << first << (HORIZONTAL * 2)
86
98
 
87
- text ||= ''
88
99
  unless text.empty?
89
100
  preamble << ' ' << CLI::UI.resolve_text("{{#{color.name}:#{text}}}") << ' '
90
101
  end
@@ -1,3 +1,5 @@
1
+ # typed: true
2
+
1
3
  module CLI
2
4
  module UI
3
5
  module Frame
@@ -12,10 +14,14 @@ module CLI
12
14
  BOTTOM_LEFT = '┗'
13
15
 
14
16
  class << self
15
- def name
16
- 'bracket'
17
+ extend T::Sig
18
+
19
+ sig { override.returns(Symbol) }
20
+ def style_name
21
+ :bracket
17
22
  end
18
23
 
24
+ sig { override.returns(String) }
19
25
  def prefix
20
26
  VERTICAL
21
27
  end
@@ -34,7 +40,8 @@ module CLI
34
40
  #
35
41
  # ┏━━ Open
36
42
  #
37
- def open(text, color:)
43
+ sig { override.params(text: String, color: CLI::UI::Color).returns(String) }
44
+ def start(text, color:)
38
45
  edge(text, color: color, first: TOP_LEFT)
39
46
  end
40
47
 
@@ -52,6 +59,7 @@ module CLI
52
59
  #
53
60
  # ┣━━ Divider
54
61
  #
62
+ sig { override.params(text: String, color: CLI::UI::Color).returns(String) }
55
63
  def divider(text, color:)
56
64
  edge(text, color: color, first: DIVIDER)
57
65
  end
@@ -71,12 +79,16 @@ module CLI
71
79
  #
72
80
  # ┗━━ Close
73
81
  #
82
+ sig { override.params(text: String, color: CLI::UI::Color, right_text: T.nilable(String)).returns(String) }
74
83
  def close(text, color:, right_text: nil)
75
84
  edge(text, color: color, right_text: right_text, first: BOTTOM_LEFT)
76
85
  end
77
86
 
78
87
  private
79
88
 
89
+ sig do
90
+ params(text: String, color: CLI::UI::Color, first: String, right_text: T.nilable(String)).returns(String)
91
+ end
80
92
  def edge(text, color:, first:, right_text: nil)
81
93
  color = CLI::UI.resolve_color(color)
82
94
 
@@ -84,7 +96,6 @@ module CLI
84
96
 
85
97
  preamble << color.code << first << (HORIZONTAL * 2)
86
98
 
87
- text ||= ''
88
99
  unless text.empty?
89
100
  preamble << ' ' << CLI::UI.resolve_text("{{#{color.name}:#{text}}}") << ' '
90
101
  end
@@ -117,16 +128,16 @@ module CLI
117
128
  # the final space, since we're going to write over it.
118
129
  preamble_start -= 1 unless preamble_start.zero?
119
130
 
120
- # Prefix_width includes the width of the terminal space, which we
121
- # want to remove. The clamping is done to avoid a negative
122
- # preamble start which can occur for the first frame.
131
+ # Jumping around the line can cause some unwanted flashes
123
132
  o << CLI::UI::ANSI.hide_cursor
124
133
 
125
134
  # reset to column 1 so that things like ^C don't ruin formatting
126
135
  o << "\r"
136
+
127
137
  o << color.code
128
138
  o << print_at_x(preamble_start, preamble + color.code + suffix)
129
139
  o << CLI::UI::Color::RESET.code
140
+ o << CLI::UI::ANSI.show_cursor
130
141
  o << "\n"
131
142
 
132
143
  o
@@ -1,120 +1,117 @@
1
+ # typed: true
2
+
1
3
  require 'cli/ui/frame'
2
4
 
3
5
  module CLI
4
6
  module UI
5
7
  module Frame
6
8
  module FrameStyle
7
- class << self
8
- # rubocop:disable Style/ClassVars
9
- @@loaded_styles = []
9
+ include Kernel
10
+ extend T::Sig
11
+ extend T::Helpers
12
+ abstract!
10
13
 
11
- def loaded_styles
12
- @@loaded_styles.map(&:name)
13
- end
14
+ autoload(:Box, 'cli/ui/frame/frame_style/box')
15
+ autoload(:Bracket, 'cli/ui/frame/frame_style/bracket')
16
+
17
+ MAP = {
18
+ box: -> { FrameStyle::Box },
19
+ bracket: -> { FrameStyle::Bracket },
20
+ }
21
+
22
+ class << self
23
+ extend T::Sig
14
24
 
15
25
  # Lookup a frame style via its name
16
26
  #
17
27
  # ==== Attributes
18
28
  #
19
29
  # * +symbol+ - frame style name to lookup
30
+ sig { params(name: T.any(String, Symbol)).returns(FrameStyle) }
20
31
  def lookup(name)
21
- @@loaded_styles
22
- .find { |style| style.name.to_sym == name }
23
- .tap { |style| raise InvalidFrameStyleName, name if style.nil? }
32
+ MAP.fetch(name.to_sym).call
33
+ rescue KeyError
34
+ raise(InvalidFrameStyleName, name)
24
35
  end
25
-
26
- def extended(base)
27
- @@loaded_styles << base
28
- base.extend(Interface)
29
- end
30
- # rubocop:enable Style/ClassVars
31
36
  end
32
37
 
33
- class InvalidFrameStyleName < ArgumentError
34
- def initialize(name)
35
- super
36
- @name = name
37
- end
38
+ sig { abstract.returns(Symbol) }
39
+ def style_name; end
38
40
 
39
- def message
40
- keys = FrameStyle.loaded_styles.map(&:inspect).join(',')
41
- "invalid frame style: #{@name.inspect}" \
42
- ' -- must be one of CLI::UI::Frame::FrameStyle.loaded_styles ' \
43
- "(#{keys})"
44
- end
41
+ # Returns the character(s) that should be printed at the beginning
42
+ # of lines inside this frame
43
+ sig { abstract.returns(String) }
44
+ def prefix; end
45
+
46
+ # Returns the printing width of the prefix
47
+ sig { returns(Integer) }
48
+ def prefix_width
49
+ CLI::UI::ANSI.printing_width(prefix)
45
50
  end
46
51
 
47
- # Public interface for FrameStyles
48
- # Applied by extending FrameStyle
49
- module Interface
50
- def name
51
- raise NotImplementedError
52
- end
52
+ # Draws the "Open" line for this frame style
53
+ #
54
+ # ==== Attributes
55
+ #
56
+ # * +text+ - (required) the text/title to output in the frame
57
+ #
58
+ # ==== Options
59
+ #
60
+ # * +:color+ - (required) The color of the frame.
61
+ #
62
+ sig { abstract.params(text: String, color: CLI::UI::Color).returns(String) }
63
+ def start(text, color:); end
53
64
 
54
- # Returns the character(s) that should be printed at the beginning
55
- # of lines inside this frame
56
- def prefix
57
- raise NotImplementedError
58
- end
65
+ # Draws the "Close" line for this frame style
66
+ #
67
+ # ==== Attributes
68
+ #
69
+ # * +text+ - (required) the text/title to output in the frame
70
+ #
71
+ # ==== Options
72
+ #
73
+ # * +:color+ - (required) The color of the frame.
74
+ # * +:right_text+ - Text to print at the right of the line. Defaults to nil
75
+ #
76
+ sig { abstract.params(text: String, color: CLI::UI::Color, right_text: T.nilable(String)).returns(String) }
77
+ def close(text, color:, right_text: nil); end
59
78
 
60
- # Returns the printing width of the prefix
61
- def prefix_width
62
- CLI::UI::ANSI.printing_width(prefix)
63
- end
79
+ # Draws a "divider" line for the current frame style
80
+ #
81
+ # ==== Attributes
82
+ #
83
+ # * +text+ - (required) the text/title to output in the frame
84
+ #
85
+ # ==== Options
86
+ #
87
+ # * +:color+ - (required) The color of the frame.
88
+ #
89
+ sig { abstract.params(text: String, color: CLI::UI::Color).returns(String) }
90
+ def divider(text, color:); end
64
91
 
65
- # Draws the "Open" line for this frame style
66
- #
67
- # ==== Attributes
68
- #
69
- # * +text+ - (required) the text/title to output in the frame
70
- #
71
- # ==== Options
72
- #
73
- # * +:color+ - (required) The color of the frame.
74
- #
75
- def open(text, color:)
76
- raise NotImplementedError
77
- end
92
+ sig { params(x: Integer, str: String).returns(String) }
93
+ def print_at_x(x, str)
94
+ CLI::UI::ANSI.cursor_horizontal_absolute(1 + x) + str
95
+ end
78
96
 
79
- # Draws the "Close" line for this frame style
80
- #
81
- # ==== Attributes
82
- #
83
- # * +text+ - (required) the text/title to output in the frame
84
- #
85
- # ==== Options
86
- #
87
- # * +:color+ - (required) The color of the frame.
88
- # * +:right_text+ - Text to print at the right of the line. Defaults to nil
89
- #
90
- def close(text, color:, right_text: nil)
91
- raise NotImplementedError
92
- end
97
+ class InvalidFrameStyleName < ArgumentError
98
+ extend T::Sig
93
99
 
94
- # Draws a "divider" line for the current frame style
95
- #
96
- # ==== Attributes
97
- #
98
- # * +text+ - (required) the text/title to output in the frame
99
- #
100
- # ==== Options
101
- #
102
- # * +:color+ - (required) The color of the frame.
103
- #
104
- def divider(text, color: nil)
105
- raise NotImplementedError
100
+ sig { params(name: T.any(String, Symbol)).void }
101
+ def initialize(name)
102
+ super
103
+ @name = name
106
104
  end
107
105
 
108
- private
109
-
110
- def print_at_x(x, str)
111
- CLI::UI::ANSI.cursor_horizontal_absolute(1 + x) + str
106
+ sig { returns(String) }
107
+ def message
108
+ keys = FrameStyle::MAP.keys.map(&:inspect).join(', ')
109
+ "invalid frame style: #{@name.inspect}" \
110
+ ' -- must be one of CLI::UI::Frame::FrameStyle::MAP ' \
111
+ "(#{keys})"
112
112
  end
113
113
  end
114
114
  end
115
115
  end
116
116
  end
117
117
  end
118
-
119
- require 'cli/ui/frame/frame_style/box'
120
- require 'cli/ui/frame/frame_style/bracket'