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,74 @@
1
+ require 'cli/ui'
2
+
3
+ module CLI
4
+ module UI
5
+ class Glyph
6
+ class InvalidGlyphHandle < ArgumentError
7
+ def initialize(handle)
8
+ @handle = handle
9
+ end
10
+
11
+ def message
12
+ keys = Glyph.available.join(',')
13
+ "invalid glyph handle: #{@handle} " \
14
+ "-- must be one of CLI::UI::Glyph.available (#{keys})"
15
+ end
16
+ end
17
+
18
+ attr_reader :handle, :codepoint, :color, :char, :to_s, :fmt
19
+
20
+ # Creates a new glyph
21
+ #
22
+ # ==== Attributes
23
+ #
24
+ # * +handle+ - The handle in the +MAP+ constant
25
+ # * +codepoint+ - The codepoint used to create the glyph (e.g. +0x2717+ for a ballot X)
26
+ # * +color+ - What color to output the glyph. Check +CLI::UI::Color+ for options.
27
+ #
28
+ def initialize(handle, codepoint, color)
29
+ @handle = handle
30
+ @codepoint = codepoint
31
+ @color = color
32
+ @char = [codepoint].pack('U')
33
+ @to_s = color.code + char + Color::RESET.code
34
+ @fmt = "{{#{color.name}:#{char}}}"
35
+
36
+ MAP[handle] = self
37
+ end
38
+
39
+ # Mapping of glyphs to terminal output
40
+ MAP = {}
41
+ # YELLOw SMALL STAR (⭑)
42
+ STAR = new('*', 0x2b51, Color::YELLOW)
43
+ # BLUE MATHEMATICAL SCRIPT SMALL i (𝒾)
44
+ INFO = new('i', 0x1d4be, Color::BLUE)
45
+ # BLUE QUESTION MARK (?)
46
+ QUESTION = new('?', 0x003f, Color::BLUE)
47
+ # GREEN CHECK MARK (✓)
48
+ CHECK = new('v', 0x2713, Color::GREEN)
49
+ # RED BALLOT X (✗)
50
+ X = new('x', 0x2717, Color::RED)
51
+
52
+ # Looks up a glyph by name
53
+ #
54
+ # ==== Raises
55
+ # Raises a InvalidGlyphHandle if the glyph is not available
56
+ # You likely need to create it with +.new+ or you made a typo
57
+ #
58
+ # ==== Returns
59
+ # Returns a terminal output-capable string
60
+ #
61
+ def self.lookup(name)
62
+ MAP.fetch(name.to_s)
63
+ rescue KeyError
64
+ raise InvalidGlyphHandle, name
65
+ end
66
+
67
+ # All available glyphs by name
68
+ #
69
+ def self.available
70
+ MAP.keys
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,90 @@
1
+ require 'cli/ui'
2
+
3
+ module CLI
4
+ module UI
5
+ class Progress
6
+ # A Cyan filled block
7
+ FILLED_BAR = "\e[46m"
8
+ # A bright white block
9
+ UNFILLED_BAR = "\e[1;47m"
10
+
11
+ # Add a progress bar to the terminal output
12
+ #
13
+ # https://user-images.githubusercontent.com/3074765/33799794-cc4c940e-dd00-11e7-9bdc-90f77ec9167c.gif
14
+ #
15
+ # ==== Example Usage:
16
+ #
17
+ # Set the percent to X
18
+ # CLI::UI::Progress.progress do |bar|
19
+ # bar.tick(set_percent: percent)
20
+ # end
21
+ #
22
+ # Increase the percent by 1 percent
23
+ # CLI::UI::Progress.progress do |bar|
24
+ # bar.tick
25
+ # end
26
+ #
27
+ # Increase the percent by X
28
+ # CLI::UI::Progress.progress do |bar|
29
+ # bar.tick(percent: 5)
30
+ # end
31
+ def self.progress(width: Terminal.width)
32
+ bar = Progress.new(width: width)
33
+ print CLI::UI::ANSI.hide_cursor
34
+ yield(bar)
35
+ ensure
36
+ puts bar.to_s
37
+ CLI::UI.raw do
38
+ print(ANSI.show_cursor)
39
+ puts(ANSI.previous_line + ANSI.end_of_line)
40
+ end
41
+ end
42
+
43
+ # Initialize a progress bar. Typically used in a +Progress.progress+ block
44
+ #
45
+ # ==== Options
46
+ # One of the follow can be used, but not both together
47
+ #
48
+ # * +:width+ - The width of the terminal
49
+ #
50
+ def initialize(width: Terminal.width)
51
+ @percent_done = 0
52
+ @max_width = width
53
+ end
54
+
55
+ # Set the progress of the bar. Typically used in a +Progress.progress+ block
56
+ #
57
+ # ==== Options
58
+ # One of the follow can be used, but not both together
59
+ #
60
+ # * +:percent+ - Increment progress by a specific percent amount
61
+ # * +:set_percent+ - Set progress to a specific percent
62
+ #
63
+ def tick(percent: 0.01, set_percent: nil)
64
+ raise ArgumentError, 'percent and set_percent cannot both be specified' if percent != 0.01 && set_percent
65
+ @percent_done += percent
66
+ @percent_done = set_percent if set_percent
67
+ @percent_done = [@percent_done, 1.0].min # Make sure we can't go above 1.0
68
+
69
+ print to_s
70
+ print CLI::UI::ANSI.previous_line
71
+ print CLI::UI::ANSI.end_of_line + "\n"
72
+ end
73
+
74
+ # Format the progress bar to be printed to terminal
75
+ #
76
+ def to_s
77
+ suffix = " #{(@percent_done * 100).round(2)}%"
78
+ workable_width = @max_width - Frame.prefix_width - suffix.size
79
+ filled = (@percent_done * workable_width.to_f).ceil
80
+ unfilled = workable_width - filled
81
+
82
+ CLI::UI.resolve_text [
83
+ FILLED_BAR + ' ' * filled,
84
+ UNFILLED_BAR + ' ' * unfilled,
85
+ CLI::UI::Color::RESET.code + suffix
86
+ ].join
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,156 @@
1
+ require 'cli/ui'
2
+ require 'readline'
3
+
4
+ module CLI
5
+ module UI
6
+ module Prompt
7
+ autoload :InteractiveOptions, 'cli/ui/prompt/interactive_options'
8
+ private_constant :InteractiveOptions
9
+
10
+ class << self
11
+ # Ask a user a question with either free form answer or a set of answers
12
+ # Do not use this method for yes/no questions. Use +confirm+
13
+ # Can use arrows, y/n, numbers, and vim bindings to control
14
+ #
15
+ # * Handles free form answers (options are nil)
16
+ # * Handles default answers for free form text
17
+ # * Handles file auto completion for file input
18
+ # * Handles interactively choosing answers using +InteractiveOptions+
19
+ #
20
+ # https://user-images.githubusercontent.com/3074765/33799822-47f23302-dd01-11e7-82f3-9072a5a5f611.png
21
+ #
22
+ # ==== Attributes
23
+ #
24
+ # * +question+ - (required) The question to ask the user
25
+ #
26
+ # ==== Options
27
+ #
28
+ # * +:options+ - Options to ask the user. Will use +InteractiveOptions+ to do so
29
+ # * +:default+ - The default answer to the question (e.g. they just press enter and don't input anything)
30
+ # * +:is_file+ - Tells the input to use file auto-completion (tab completion)
31
+ # * +:allow_empty+ - Allows the answer to be empty
32
+ #
33
+ # Note:
34
+ # * +:options+ conflicts with +:default+ and +:is_file+, you cannot set options with either of these keywords
35
+ # * +:default+ conflicts with +:allow_empty:, you cannot set these together
36
+ #
37
+ # ==== Example Usage:
38
+ #
39
+ # Free form question
40
+ # CLI::UI::Prompt.ask('What color is the sky?')
41
+ #
42
+ # Free form question with a file answer
43
+ # CLI::UI::Prompt.ask('Where is your Gemfile located?', is_file: true)
44
+ #
45
+ # Free form question with a default answer
46
+ # CLI::UI::Prompt.ask('What color is the sky?', default: 'blue')
47
+ #
48
+ # Free form question when the answer can be empty
49
+ # CLI::UI::Prompt.ask('What is your opinion on this question?', allow_empty: true)
50
+ #
51
+ # Question with answers
52
+ # CLI::UI::Prompt.ask('What kind of project is this?', options: %w(rails go ruby python))
53
+ #
54
+ #
55
+ def ask(question, options: nil, default: nil, is_file: nil, allow_empty: true)
56
+ if (default && !allow_empty) || (options && (default || is_file))
57
+ raise(ArgumentError, 'conflicting arguments')
58
+ end
59
+
60
+ if default
61
+ puts_question("#{question} (empty = #{default})")
62
+ elsif options
63
+ puts_question("#{question} {{yellow:(choose with ↑ ↓ ⏎)}}")
64
+ else
65
+ puts_question(question)
66
+ end
67
+
68
+ # Present the user with options
69
+ if options
70
+ resp = InteractiveOptions.call(options)
71
+
72
+ # Clear the line, and reset the question to include the answer
73
+ print(ANSI.previous_line + ANSI.end_of_line + ' ')
74
+ print(ANSI.cursor_save)
75
+ print(' ' * CLI::UI::Terminal.width)
76
+ print(ANSI.cursor_restore)
77
+ puts_question("#{question} (You chose: {{italic:#{resp}}})")
78
+
79
+ return resp
80
+ end
81
+
82
+ # Ask a free form question
83
+ loop do
84
+ line = readline(is_file: is_file)
85
+
86
+ if line.empty? && default
87
+ write_default_over_empty_input(default)
88
+ return default
89
+ end
90
+
91
+ if !line.empty? || allow_empty
92
+ return line
93
+ end
94
+ end
95
+ end
96
+
97
+ # Asks the user a yes/no question.
98
+ # Can use arrows, y/n, numbers (1/2), and vim bindings to control
99
+ #
100
+ # ==== Example Usage:
101
+ #
102
+ # Confirmation question
103
+ # CLI::UI::Prompt.confirm('Is the sky blue?')
104
+ #
105
+ def confirm(question)
106
+ ask(question, options: %w(yes no)) == 'yes'
107
+ end
108
+
109
+ private
110
+
111
+ def write_default_over_empty_input(default)
112
+ CLI::UI.raw do
113
+ STDERR.puts(
114
+ CLI::UI::ANSI.cursor_up(1) +
115
+ "\r" +
116
+ CLI::UI::ANSI.cursor_forward(4) + # TODO: width
117
+ default +
118
+ CLI::UI::Color::RESET.code
119
+ )
120
+ end
121
+ end
122
+
123
+ def puts_question(str)
124
+ CLI::UI.with_frame_color(:blue) do
125
+ STDOUT.puts(CLI::UI.fmt('{{?}} ' + str))
126
+ end
127
+ end
128
+
129
+ def readline(is_file: false)
130
+ if is_file
131
+ Readline.completion_proc = Readline::FILENAME_COMPLETION_PROC
132
+ Readline.completion_append_character = ""
133
+ else
134
+ Readline.completion_proc = proc { |*| nil }
135
+ Readline.completion_append_character = " "
136
+ end
137
+
138
+ # because Readline is a C library, CLI::UI's hooks into $stdout don't
139
+ # work. We could work around this by having CLI::UI use a pipe and a
140
+ # thread to manage output, but the current strategy feels like a
141
+ # better tradeoff.
142
+ prefix = CLI::UI.with_frame_color(:blue) { CLI::UI::Frame.prefix }
143
+ prompt = prefix + CLI::UI.fmt('{{blue:> }}{{yellow:')
144
+
145
+ begin
146
+ line = Readline.readline(prompt, true)
147
+ line.to_s.chomp
148
+ rescue Interrupt
149
+ CLI::UI.raw { STDERR.puts('^C' + CLI::UI::Color::RESET.code) }
150
+ raise
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,171 @@
1
+ require 'io/console'
2
+
3
+ module CLI
4
+ module UI
5
+ module Prompt
6
+ class InteractiveOptions
7
+ # Prompts the user with options
8
+ # Uses an interactive session to allow the user to pick an answer
9
+ # Can use arrows, y/n, numbers (1/2), and vim bindings to control
10
+ #
11
+ # https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif
12
+ #
13
+ # ==== Example Usage:
14
+ #
15
+ # Ask an interactive question
16
+ # CLI::UI::Prompt::InteractiveOptions.call(%w(rails go python))
17
+ #
18
+ def self.call(options)
19
+ list = new(options)
20
+ options[list.call - 1]
21
+ end
22
+
23
+ # Initializes a new +InteractiveOptions+
24
+ # Usually called from +self.call+
25
+ #
26
+ # ==== Example Usage:
27
+ #
28
+ # CLI::UI::Prompt::InteractiveOptions.new(%w(rails go python))
29
+ #
30
+ def initialize(options)
31
+ @options = options
32
+ @active = 1
33
+ @marker = '>'
34
+ @answer = nil
35
+ @state = :root
36
+ end
37
+
38
+ # Calls the +InteractiveOptions+ and asks the question
39
+ # Usually used from +self.call+
40
+ #
41
+ def call
42
+ CLI::UI.raw { print(ANSI.hide_cursor) }
43
+ while @answer.nil?
44
+ render_options
45
+ wait_for_user_input
46
+ reset_position
47
+ end
48
+ clear_output
49
+ @answer
50
+ ensure
51
+ CLI::UI.raw do
52
+ print(ANSI.show_cursor)
53
+ puts(ANSI.previous_line + ANSI.end_of_line)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def reset_position
60
+ # This will put us back at the beginning of the options
61
+ # When we redraw the options, they will be overwritten
62
+ CLI::UI.raw do
63
+ num_lines.times { print(ANSI.previous_line) }
64
+ print(ANSI.previous_line + ANSI.end_of_line + "\n")
65
+ end
66
+ end
67
+
68
+ def clear_output
69
+ CLI::UI.raw do
70
+ # Write over all lines with whitespace
71
+ num_lines.times { puts(' ' * CLI::UI::Terminal.width) }
72
+ end
73
+ reset_position
74
+ end
75
+
76
+ def num_lines
77
+ # @options will be an array of questions but each option can be multi-line
78
+ # so to get the # of lines, you need to join then split
79
+ joined_options = @options.join("\n")
80
+ joined_options.split("\n").reject(&:empty?).size
81
+ end
82
+
83
+ ESC = "\e"
84
+
85
+ def up
86
+ @active = @active - 1 >= 1 ? @active - 1 : @options.length
87
+ end
88
+
89
+ def down
90
+ @active = @active + 1 <= @options.length ? @active + 1 : 1
91
+ end
92
+
93
+ def select_n(n)
94
+ @active = n
95
+ @answer = n
96
+ end
97
+
98
+ def select_bool(char)
99
+ return unless (@options - %w(yes no)).empty?
100
+ opt = @options.detect { |o| o.start_with?(char) }
101
+ @active = @options.index(opt) + 1
102
+ @answer = @options.index(opt) + 1
103
+ end
104
+
105
+ # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon
106
+ def wait_for_user_input
107
+ char = read_char
108
+ case @state
109
+ when :root
110
+ case char
111
+ when ESC ; @state = :esc
112
+ when 'k' ; up
113
+ when 'j' ; down
114
+ when ('1'..@options.size.to_s) ; select_n(char.to_i)
115
+ when 'y', 'n' ; select_bool(char)
116
+ when " ", "\r", "\n" ; @answer = @active # <enter>
117
+ when "\u0003" ; raise Interrupt # Ctrl-c
118
+ end
119
+ when :esc
120
+ case char
121
+ when '[' ; @state = :esc_bracket
122
+ else ; raise Interrupt # unhandled escape sequence.
123
+ end
124
+ when :esc_bracket
125
+ @state = :root
126
+ case char
127
+ when 'A' ; up
128
+ when 'B' ; down
129
+ else ; raise Interrupt # unhandled escape sequence.
130
+ end
131
+ end
132
+ end
133
+ # rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon
134
+
135
+ def read_char
136
+ raw_tty! { $stdin.getc.chr }
137
+ rescue IOError
138
+ "\e"
139
+ end
140
+
141
+ def raw_tty!
142
+ if ENV['TEST'] || !$stdin.tty?
143
+ yield
144
+ else
145
+ $stdin.raw { yield }
146
+ end
147
+ end
148
+
149
+ def render_options
150
+ max_num_length = (@options.size + 1).to_s.length
151
+ @options.each_with_index do |choice, index|
152
+ num = index + 1
153
+ padding = ' ' * (max_num_length - num.to_s.length)
154
+ message = " #{num}.#{padding}"
155
+ message += choice.split("\n").map { |l| " {{bold:#{l}}}" }.join("\n")
156
+
157
+ if num == @active
158
+ message = message.split("\n").map.with_index do |l, idx|
159
+ idx == 0 ? "{{blue:> #{l.strip}}}" : "{{blue:>#{l.strip}}}"
160
+ end.join("\n")
161
+ end
162
+
163
+ CLI::UI.with_frame_color(:blue) do
164
+ puts CLI::UI.fmt(message)
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end