cli-ui 0.1.2

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
+ require 'cli/ui'
2
+
3
+ module CLI
4
+ module UI
5
+ module ANSI
6
+ ESC = "\x1b"
7
+
8
+ # ANSI escape sequences (like \x1b[31m) have zero width.
9
+ # when calculating the padding width, we must exclude them.
10
+ # This also implements a basic version of utf8 character width calculation like
11
+ # we could get for real from something like utf8proc.
12
+ #
13
+ def self.printing_width(str)
14
+ zwj = false
15
+ strip_codes(str).codepoints.reduce(0) do |acc, cp|
16
+ if zwj
17
+ zwj = false
18
+ next acc
19
+ end
20
+ case cp
21
+ when 0x200d # zero-width joiner
22
+ zwj = true
23
+ acc
24
+ else
25
+ acc + 1
26
+ end
27
+ end
28
+ end
29
+
30
+ # Strips ANSI codes from a str
31
+ #
32
+ # ==== Attributes
33
+ #
34
+ # - +str+ - The string from which to strip codes
35
+ #
36
+ def self.strip_codes(str)
37
+ str.gsub(/\x1b\[[\d;]+[A-z]|\r/, '')
38
+ end
39
+
40
+ # Returns an ANSI control sequence
41
+ #
42
+ # ==== Attributes
43
+ #
44
+ # - +args+ - Argument to pass to the ANSI control sequence
45
+ # - +cmd+ - ANSI control sequence Command
46
+ #
47
+ def self.control(args, cmd)
48
+ ESC + "[" + args + cmd
49
+ end
50
+
51
+ # https://en.wikipedia.org/wiki/ANSI_escape_code#graphics
52
+ def self.sgr(params)
53
+ control(params.to_s, 'm')
54
+ end
55
+
56
+ # Cursor Movement
57
+
58
+ # Move the cursor up n lines
59
+ #
60
+ # ==== Attributes
61
+ #
62
+ # * +n+ - number of lines by which to move the cursor up
63
+ #
64
+ def self.cursor_up(n = 1)
65
+ return '' if n.zero?
66
+ control(n.to_s, 'A')
67
+ end
68
+
69
+ # Move the cursor down n lines
70
+ #
71
+ # ==== Attributes
72
+ #
73
+ # * +n+ - number of lines by which to move the cursor down
74
+ #
75
+ def self.cursor_down(n = 1)
76
+ return '' if n.zero?
77
+ control(n.to_s, 'B')
78
+ end
79
+
80
+ # Move the cursor forward n columns
81
+ #
82
+ # ==== Attributes
83
+ #
84
+ # * +n+ - number of columns by which to move the cursor forward
85
+ #
86
+ def self.cursor_forward(n = 1)
87
+ return '' if n.zero?
88
+ control(n.to_s, 'C')
89
+ end
90
+
91
+ # Move the cursor back n columns
92
+ #
93
+ # ==== Attributes
94
+ #
95
+ # * +n+ - number of columns by which to move the cursor back
96
+ #
97
+ def self.cursor_back(n = 1)
98
+ return '' if n.zero?
99
+ control(n.to_s, 'D')
100
+ end
101
+
102
+ # Move the cursor to a specific column
103
+ #
104
+ # ==== Attributes
105
+ #
106
+ # * +n+ - The column to move to
107
+ #
108
+ def self.cursor_horizontal_absolute(n = 1)
109
+ control(n.to_s, 'G')
110
+ end
111
+
112
+ # Show the cursor
113
+ #
114
+ def self.show_cursor
115
+ control('', "?25h")
116
+ end
117
+
118
+ # Hide the cursor
119
+ #
120
+ def self.hide_cursor
121
+ control('', "?25l")
122
+ end
123
+
124
+ # Save the cursor position
125
+ #
126
+ def self.cursor_save
127
+ control('', 's')
128
+ end
129
+
130
+ # Restore the saved cursor position
131
+ #
132
+ def self.cursor_restore
133
+ control('', 'u')
134
+ end
135
+
136
+ # Move to the next line
137
+ #
138
+ def self.next_line
139
+ cursor_down + control('1', 'G')
140
+ end
141
+
142
+ # Move to the previous line
143
+ #
144
+ def self.previous_line
145
+ cursor_up + control('1', 'G')
146
+ end
147
+
148
+ # Move to the end of the line
149
+ #
150
+ def self.end_of_line
151
+ control("\033[", 'C')
152
+ end
153
+ end
154
+ end
155
+ end
data/lib/cli/ui/box.rb ADDED
@@ -0,0 +1,15 @@
1
+ require 'cli/ui'
2
+
3
+ module CLI
4
+ module UI
5
+ module Box
6
+ module Heavy
7
+ VERT = '┃'
8
+ HORZ = '━'
9
+ DIV = "┣"
10
+ TL = '┏'
11
+ BL = '┗'
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,79 @@
1
+ require 'cli/ui'
2
+
3
+ module CLI
4
+ module UI
5
+ class Color
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
+ #
17
+ def initialize(sgr, name)
18
+ @sgr = sgr
19
+ @code = CLI::UI::ANSI.sgr(sgr)
20
+ @name = name
21
+ end
22
+
23
+ RED = new('31', :red)
24
+ GREEN = new('32', :green)
25
+ YELLOW = new('33', :yellow)
26
+ # default blue is low-contrast against black in some default terminal color scheme
27
+ BLUE = new('94', :blue) # 9x = high-intensity fg color x
28
+ MAGENTA = new('35', :magenta)
29
+ CYAN = new('36', :cyan)
30
+ RESET = new('0', :reset)
31
+ BOLD = new('1', :bold)
32
+ WHITE = new('97', :white)
33
+
34
+ MAP = {
35
+ red: RED,
36
+ green: GREEN,
37
+ yellow: YELLOW,
38
+ blue: BLUE,
39
+ magenta: MAGENTA,
40
+ cyan: CYAN,
41
+ reset: RESET,
42
+ bold: BOLD,
43
+ }.freeze
44
+
45
+ class InvalidColorName < ArgumentError
46
+ def initialize(name)
47
+ @name = name
48
+ end
49
+
50
+ def message
51
+ keys = Color.available.map(&:inspect).join(',')
52
+ "invalid color: #{@name.inspect} " \
53
+ "-- must be one of CLI::UI::Color.available (#{keys})"
54
+ end
55
+ end
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
+ #
66
+ def self.lookup(name)
67
+ MAP.fetch(name)
68
+ rescue KeyError
69
+ raise InvalidColorName, name
70
+ end
71
+
72
+ # All available colors by name
73
+ #
74
+ def self.available
75
+ MAP.keys
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cli/ui'
4
+ require 'strscan'
5
+
6
+ module CLI
7
+ module UI
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
+ #
14
+ SGR_MAP = {
15
+ # presentational
16
+ 'red' => '31',
17
+ 'green' => '32',
18
+ 'yellow' => '33',
19
+ 'blue' => '34',
20
+ 'magenta' => '35',
21
+ 'cyan' => '36',
22
+ 'bold' => '1',
23
+ 'italic' => '3',
24
+ 'underline' => '4',
25
+ 'reset' => '0',
26
+
27
+ # semantic
28
+ 'error' => '31', # red
29
+ 'success' => '32', # success
30
+ 'warning' => '33', # yellow
31
+ 'info' => '34', # blue
32
+ 'command' => '36', # cyan
33
+ }.freeze
34
+
35
+ BEGIN_EXPR = '{{'
36
+ END_EXPR = '}}'
37
+
38
+ SCAN_FUNCNAME = /\w+:/
39
+ SCAN_GLYPH = /.}}/
40
+ SCAN_BODY = /
41
+ .*?
42
+ (
43
+ #{BEGIN_EXPR} |
44
+ #{END_EXPR} |
45
+ \z
46
+ )
47
+ /mx
48
+
49
+ DISCARD_BRACES = 0..-3
50
+
51
+ LITERAL_BRACES = :__literal_braces__
52
+
53
+ class FormatError < StandardError
54
+ attr_accessor :input, :index
55
+
56
+ def initialize(message = nil, input = nil, index = nil)
57
+ super(message)
58
+ @input = input
59
+ @index = index
60
+ end
61
+ end
62
+
63
+ # Initialize a formatter with text.
64
+ #
65
+ # ===== Attributes
66
+ #
67
+ # * +text+ - the text to format
68
+ #
69
+ def initialize(text)
70
+ @text = text
71
+ end
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
+ #
83
+ def format(sgr_map = SGR_MAP, enable_color: true)
84
+ @nodes = []
85
+ stack = parse_body(StringScanner.new(@text))
86
+ prev_fmt = nil
87
+ content = @nodes.each_with_object(String.new) do |(text, fmt), str|
88
+ if prev_fmt != fmt && enable_color
89
+ text = apply_format(text, fmt, sgr_map)
90
+ end
91
+ str << text
92
+ prev_fmt = fmt
93
+ end
94
+
95
+ stack.reject! { |e| e == LITERAL_BRACES }
96
+
97
+ return content unless enable_color
98
+ return content if stack == prev_fmt
99
+
100
+ unless stack.empty? && (@nodes.size.zero? || @nodes.last[1].empty?)
101
+ content << apply_format('', stack, sgr_map)
102
+ end
103
+ content
104
+ end
105
+
106
+ private
107
+
108
+ def apply_format(text, fmt, sgr_map)
109
+ sgr = fmt.each_with_object(String.new('0')) do |name, str|
110
+ next if name == LITERAL_BRACES
111
+ begin
112
+ str << ';' << sgr_map.fetch(name)
113
+ rescue KeyError
114
+ raise FormatError.new(
115
+ "invalid format specifier: #{name}",
116
+ @text,
117
+ -1
118
+ )
119
+ end
120
+ end
121
+ CLI::UI::ANSI.sgr(sgr) + text
122
+ end
123
+
124
+ def parse_expr(sc, stack)
125
+ if match = sc.scan(SCAN_GLYPH)
126
+ glyph_handle = match[0]
127
+ begin
128
+ glyph = Glyph.lookup(glyph_handle)
129
+ emit(glyph.char, [glyph.color.name.to_s])
130
+ rescue Glyph::InvalidGlyphHandle
131
+ index = sc.pos - 2 # rewind past '}}'
132
+ raise FormatError.new(
133
+ "invalid glyph handle at index #{index}: '#{glyph_handle}'",
134
+ @text,
135
+ index
136
+ )
137
+ end
138
+ elsif match = sc.scan(SCAN_FUNCNAME)
139
+ funcname = match.chop
140
+ stack.push(funcname)
141
+ else
142
+ # We read a {{ but it's not apparently Formatter syntax.
143
+ # We could error, but it's nicer to just pass through as text.
144
+ # We do kind of assume that the text will probably have balanced
145
+ # pairs of {{ }} at least.
146
+ emit('{{', stack)
147
+ stack.push(LITERAL_BRACES)
148
+ end
149
+ parse_body(sc, stack)
150
+ stack
151
+ end
152
+
153
+ def parse_body(sc, stack = [])
154
+ match = sc.scan(SCAN_BODY)
155
+ if match && match.end_with?(BEGIN_EXPR)
156
+ emit(match[DISCARD_BRACES], stack)
157
+ parse_expr(sc, stack)
158
+ elsif match && match.end_with?(END_EXPR)
159
+ emit(match[DISCARD_BRACES], stack)
160
+ if stack.pop == LITERAL_BRACES
161
+ emit('}}', stack)
162
+ end
163
+ parse_body(sc, stack)
164
+ elsif match
165
+ emit(match, stack)
166
+ else
167
+ emit(sc.rest, stack)
168
+ end
169
+ stack
170
+ end
171
+
172
+ def emit(text, stack)
173
+ return if text.nil? || text.empty?
174
+ @nodes << [text, stack.reject { |n| n == LITERAL_BRACES }]
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,310 @@
1
+ require 'cli/ui'
2
+
3
+ module CLI
4
+ module UI
5
+ module Frame
6
+ class UnnestedFrameException < StandardError; end
7
+ class << self
8
+ DEFAULT_FRAME_COLOR = CLI::UI.resolve_color(:cyan)
9
+
10
+ # Opens a new frame. Can be nested
11
+ # Can be invoked in two ways: block and blockless
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 +CLI::UI::StdoutRouter.enable+ has been called)
34
+ #
35
+ # CLI::UI::Frame.open('Open') { puts 'hi' }
36
+ #
37
+ # Output:
38
+ # ┏━━ Open ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
39
+ # ┃ hi
40
+ # ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ (0.0s) ━━
41
+ #
42
+ # ===== Blockless Form
43
+ #
44
+ # CLI::UI::Frame.open('Open')
45
+ #
46
+ # Output:
47
+ # ┏━━ Open ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48
+ #
49
+ #
50
+ def open(
51
+ text,
52
+ color: DEFAULT_FRAME_COLOR,
53
+ failure_text: nil,
54
+ success_text: nil,
55
+ timing: nil
56
+ )
57
+ color = CLI::UI.resolve_color(color)
58
+
59
+ unless block_given?
60
+ if failure_text
61
+ raise ArgumentError, "failure_text is not compatible with blockless invocation"
62
+ elsif success_text
63
+ raise ArgumentError, "success_text is not compatible with blockless invocation"
64
+ elsif !timing.nil?
65
+ raise ArgumentError, "timing is not compatible with blockless invocation"
66
+ end
67
+ end
68
+
69
+ timing = true if timing.nil?
70
+
71
+ t_start = Time.now.to_f
72
+ CLI::UI.raw do
73
+ puts edge(text, color: color, first: CLI::UI::Box::Heavy::TL)
74
+ end
75
+ FrameStack.push(color)
76
+
77
+ return unless block_given?
78
+
79
+ closed = false
80
+ begin
81
+ success = false
82
+ success = yield
83
+ rescue
84
+ closed = true
85
+ t_diff = timing ? (Time.now.to_f - t_start) : nil
86
+ close(failure_text, color: :red, elapsed: t_diff)
87
+ raise
88
+ else
89
+ success
90
+ ensure
91
+ unless closed
92
+ t_diff = timing ? (Time.now.to_f - t_start) : nil
93
+ if success != false
94
+ close(success_text, color: color, elapsed: t_diff)
95
+ else
96
+ close(failure_text, color: :red, elapsed: t_diff)
97
+ end
98
+ end
99
+ end
100
+ end
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
+ # CLI::UI::Frame.close('Close')
117
+ #
118
+ # Output:
119
+ # ┗━━ Close ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
120
+ #
121
+ #
122
+ def close(text, color: DEFAULT_FRAME_COLOR, elapsed: nil)
123
+ color = CLI::UI.resolve_color(color)
124
+
125
+ FrameStack.pop
126
+ kwargs = {}
127
+ if elapsed
128
+ kwargs[:right_text] = "(#{elapsed.round(2)}s)"
129
+ end
130
+ CLI::UI.raw do
131
+ puts edge(text, color: color, first: CLI::UI::Box::Heavy::BL, **kwargs)
132
+ end
133
+ end
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
+ # CLI::UI::Frame.open('Open') { CLI::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
+ #
159
+ def divider(text, color: nil)
160
+ fs_item = FrameStack.pop
161
+ raise UnnestedFrameException, "no frame nesting to unnest" unless fs_item
162
+ color = CLI::UI.resolve_color(color)
163
+ item = CLI::UI.resolve_color(fs_item)
164
+
165
+ CLI::UI.raw do
166
+ puts edge(text, color: (color || item), first: CLI::UI::Box::Heavy::DIV)
167
+ end
168
+ FrameStack.push(item)
169
+ end
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[:cliui_frame_color_override]+ or nil
176
+ #
177
+ def prefix(color: nil)
178
+ pfx = String.new
179
+ items = FrameStack.items
180
+ items[0..-2].each do |item|
181
+ pfx << CLI::UI.resolve_color(item).code << CLI::UI::Box::Heavy::VERT
182
+ end
183
+ if item = items.last
184
+ c = Thread.current[:cliui_frame_color_override] || color || item
185
+ pfx << CLI::UI.resolve_color(c).code \
186
+ << CLI::UI::Box::Heavy::VERT << ' ' << CLI::UI::Color::RESET.code
187
+ end
188
+ pfx
189
+ end
190
+
191
+ # Override a color for a given thread.
192
+ #
193
+ # ==== Attributes
194
+ #
195
+ # * +color+ - The color to override to
196
+ #
197
+ def with_frame_color_override(color)
198
+ prev = Thread.current[:cliui_frame_color_override]
199
+ Thread.current[:cliui_frame_color_override] = color
200
+ yield
201
+ ensure
202
+ Thread.current[:cliui_frame_color_override] = prev
203
+ end
204
+
205
+ # The width of a prefix given the number of Frames in the stack
206
+ #
207
+ def prefix_width
208
+ w = FrameStack.items.size
209
+ w.zero? ? 0 : w + 1
210
+ end
211
+
212
+ private
213
+
214
+ def edge(text, color: raise, first: raise, right_text: nil)
215
+ color = CLI::UI.resolve_color(color)
216
+ text = CLI::UI.resolve_text("{{#{color.name}:#{text}}}")
217
+
218
+ prefix = String.new
219
+ FrameStack.items.each do |item|
220
+ prefix << CLI::UI.resolve_color(item).code << CLI::UI::Box::Heavy::VERT
221
+ end
222
+ prefix << color.code << first << (CLI::UI::Box::Heavy::HORZ * 2)
223
+ text ||= ''
224
+ unless text.empty?
225
+ prefix << ' ' << text << ' '
226
+ end
227
+
228
+ termwidth = CLI::UI::Terminal.width
229
+
230
+ suffix = String.new
231
+ if right_text
232
+ suffix << ' ' << right_text << ' '
233
+ end
234
+
235
+ suffix_width = CLI::UI::ANSI.printing_width(suffix)
236
+ suffix_end = termwidth - 2
237
+ suffix_start = suffix_end - suffix_width
238
+
239
+ prefix_width = CLI::UI::ANSI.printing_width(prefix)
240
+ prefix_start = 0
241
+ prefix_end = prefix_start + prefix_width
242
+
243
+ if prefix_end > suffix_start
244
+ suffix = ''
245
+ # if prefix_end > termwidth
246
+ # we *could* truncate it, but let's just let it overflow to the
247
+ # next line and call it poor usage of this API.
248
+ end
249
+
250
+ o = String.new
251
+
252
+ is_ci = ![0, '', nil].include?(ENV['CI'])
253
+
254
+ # Jumping around the line can cause some unwanted flashes
255
+ o << CLI::UI::ANSI.hide_cursor
256
+
257
+ o << 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
+ CLI::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
+ "\r"
265
+ end
266
+
267
+ o << color.code
268
+ o << CLI::UI::Box::Heavy::HORZ * termwidth # draw a full line
269
+ o << print_at_x(prefix_start, prefix, is_ci)
270
+ o << color.code
271
+ o << print_at_x(suffix_start, suffix, is_ci)
272
+ o << CLI::UI::Color::RESET.code
273
+ o << CLI::UI::ANSI.show_cursor
274
+ o << "\n"
275
+
276
+ o
277
+ end
278
+
279
+ def print_at_x(x, str, is_ci)
280
+ if is_ci
281
+ CLI::UI::ANSI.cursor_restore + CLI::UI::ANSI.cursor_forward(x) + str
282
+ else
283
+ CLI::UI::ANSI.cursor_horizontal_absolute(1 + x) + str
284
+ end
285
+ end
286
+
287
+ module FrameStack
288
+ ENVVAR = 'CLI_FRAME_STACK'
289
+
290
+ def self.items
291
+ ENV.fetch(ENVVAR, '').split(':').map(&:to_sym)
292
+ end
293
+
294
+ def self.push(item)
295
+ curr = items
296
+ curr << item.name
297
+ ENV[ENVVAR] = curr.join(':')
298
+ end
299
+
300
+ def self.pop
301
+ curr = items
302
+ ret = curr.pop
303
+ ENV[ENVVAR] = curr.join(':')
304
+ ret.nil? ? nil : ret.to_sym
305
+ end
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end