cli-ui 1.5.1 → 2.1.0

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