clack 0.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.
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Prompts
5
+ # Single-selection prompt from a list of options.
6
+ #
7
+ # Navigate with arrow keys or j/k (vim-style). Press Enter to confirm.
8
+ # Supports disabled options, hints, and scrolling for long lists.
9
+ #
10
+ # Options can be:
11
+ # - Strings: `["a", "b", "c"]` (value and label are the same)
12
+ # - Hashes: `[{value: "a", label: "Option A", hint: "details", disabled: false}]`
13
+ #
14
+ # @example Basic usage
15
+ # choice = Clack.select(
16
+ # message: "Pick a color",
17
+ # options: %w[red green blue]
18
+ # )
19
+ #
20
+ # @example With rich options
21
+ # db = Clack.select(
22
+ # message: "Choose database",
23
+ # options: [
24
+ # { value: "pg", label: "PostgreSQL", hint: "recommended" },
25
+ # { value: "mysql", label: "MySQL" },
26
+ # { value: "sqlite", label: "SQLite", disabled: true }
27
+ # ],
28
+ # initial_value: "pg",
29
+ # max_items: 5
30
+ # )
31
+ #
32
+ class Select < Core::Prompt
33
+ include Core::OptionsHelper
34
+
35
+ # @param message [String] the prompt message
36
+ # @param options [Array<Hash, String>] list of options (see class docs)
37
+ # @param initial_value [Object, nil] value of initially selected option
38
+ # @param max_items [Integer, nil] max visible items (enables scrolling)
39
+ # @param opts [Hash] additional options passed to {Core::Prompt}
40
+ def initialize(message:, options:, initial_value: nil, max_items: nil, **opts)
41
+ super(message:, **opts)
42
+ @options = normalize_options(options)
43
+ @cursor = find_initial_cursor(initial_value)
44
+ @max_items = max_items
45
+ @scroll_offset = 0
46
+ update_value
47
+ end
48
+
49
+ protected
50
+
51
+ def handle_key(key)
52
+ return if terminal_state?
53
+
54
+ action = Core::Settings.action?(key)
55
+
56
+ case action
57
+ when :cancel
58
+ @state = :cancel
59
+ when :enter
60
+ submit unless current_option[:disabled]
61
+ when :up, :left
62
+ move_cursor(-1)
63
+ when :down, :right
64
+ move_cursor(1)
65
+ end
66
+ end
67
+
68
+ def build_frame
69
+ lines = []
70
+ lines << "#{bar}\n"
71
+ lines << "#{symbol_for_state} #{@message}\n"
72
+
73
+ visible_options.each_with_index do |opt, idx|
74
+ actual_idx = @scroll_offset + idx
75
+ lines << "#{bar} #{option_display(opt, actual_idx == @cursor)}\n"
76
+ end
77
+
78
+ lines << "#{Colors.gray(Symbols::S_BAR_END)}\n"
79
+ lines.join
80
+ end
81
+
82
+ def build_final_frame
83
+ lines = []
84
+ lines << "#{bar}\n"
85
+ lines << "#{symbol_for_state} #{@message}\n"
86
+
87
+ label = current_option[:label]
88
+ display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(label)) : Colors.dim(label)
89
+ lines << "#{bar} #{display}\n"
90
+
91
+ lines.join
92
+ end
93
+
94
+ private
95
+
96
+ def move_cursor(delta)
97
+ super
98
+ update_value
99
+ end
100
+
101
+ def update_value
102
+ @value = current_option[:value]
103
+ end
104
+
105
+ def current_option
106
+ @options[@cursor]
107
+ end
108
+
109
+ def option_display(opt, active)
110
+ return disabled_option_display(opt) if opt[:disabled]
111
+ return active_option_display(opt) if active
112
+
113
+ inactive_option_display(opt)
114
+ end
115
+
116
+ def disabled_option_display(opt)
117
+ symbol = Colors.dim(Symbols::S_RADIO_INACTIVE)
118
+ label = Colors.strikethrough(Colors.dim(opt[:label]))
119
+ "#{symbol} #{label}"
120
+ end
121
+
122
+ def active_option_display(opt)
123
+ symbol = Colors.green(Symbols::S_RADIO_ACTIVE)
124
+ hint = opt[:hint] ? " #{Colors.dim("(#{opt[:hint]})")}" : ""
125
+ "#{symbol} #{opt[:label]}#{hint}"
126
+ end
127
+
128
+ def inactive_option_display(opt)
129
+ symbol = Colors.dim(Symbols::S_RADIO_INACTIVE)
130
+ "#{symbol} #{Colors.dim(opt[:label])}"
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Prompts
5
+ # Quick selection via keyboard shortcuts.
6
+ #
7
+ # Each option has an associated key. Pressing that key immediately
8
+ # selects the option and submits.
9
+ #
10
+ # Options format:
11
+ # - `{ value: "x", label: "Do X", key: "x" }` - explicit key
12
+ # - `{ value: "create", label: "Create" }` - key defaults to first char
13
+ #
14
+ # @example Basic usage
15
+ # action = Clack.select_key(
16
+ # message: "What to do?",
17
+ # options: [
18
+ # { value: "create", label: "Create new", key: "c" },
19
+ # { value: "open", label: "Open existing", key: "o" },
20
+ # { value: "quit", label: "Quit", key: "q" }
21
+ # ]
22
+ # )
23
+ #
24
+ class SelectKey < Core::Prompt
25
+ # @param message [String] the prompt message
26
+ # @param options [Array<Hash>] options with :value, :label, and optionally :key, :hint
27
+ # @param opts [Hash] additional options passed to {Core::Prompt}
28
+ def initialize(message:, options:, **opts)
29
+ super(message:, **opts)
30
+ @options = normalize_options(options)
31
+ @value = nil
32
+ end
33
+
34
+ protected
35
+
36
+ def handle_key(key)
37
+ return if terminal_state?
38
+
39
+ action = Core::Settings.action?(key)
40
+
41
+ case action
42
+ when :cancel
43
+ @state = :cancel
44
+ else
45
+ # Check if key matches any option
46
+ opt = @options.find { |o| o[:key]&.downcase == key&.downcase }
47
+ if opt
48
+ @value = opt[:value]
49
+ @state = :submit
50
+ end
51
+ end
52
+ end
53
+
54
+ def build_frame
55
+ lines = []
56
+ lines << "#{bar}\n"
57
+ lines << "#{symbol_for_state} #{@message}\n"
58
+
59
+ @options.each do |opt|
60
+ lines << "#{bar} #{option_display(opt)}\n"
61
+ end
62
+
63
+ lines << "#{Colors.gray(Symbols::S_BAR_END)}\n"
64
+ lines.join
65
+ end
66
+
67
+ def build_final_frame
68
+ lines = []
69
+ lines << "#{bar}\n"
70
+ lines << "#{symbol_for_state} #{@message}\n"
71
+
72
+ selected = @options.find { |o| o[:value] == @value }
73
+ label = selected ? selected[:label] : ""
74
+ display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(label)) : Colors.dim(label)
75
+ lines << "#{bar} #{display}\n"
76
+
77
+ lines.join
78
+ end
79
+
80
+ private
81
+
82
+ def normalize_options(options)
83
+ options.map do |opt|
84
+ {
85
+ value: opt[:value],
86
+ label: opt[:label] || opt[:value].to_s,
87
+ key: opt[:key] || opt[:value].to_s[0],
88
+ hint: opt[:hint]
89
+ }
90
+ end
91
+ end
92
+
93
+ def option_display(opt)
94
+ key_display = Colors.cyan("[#{opt[:key]}]")
95
+ hint = opt[:hint] ? " #{Colors.dim("(#{opt[:hint]})")}" : ""
96
+ "#{key_display} #{opt[:label]}#{hint}"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Prompts
5
+ # Animated spinner for async operations.
6
+ #
7
+ # Runs animation in a background thread. Call {#start} to begin,
8
+ # {#stop}/{#error}/{#cancel} to finish. Thread-safe message updates.
9
+ #
10
+ # Indicator modes:
11
+ # - `:dots` - animating dots after message (default)
12
+ # - `:timer` - elapsed time display [Xs] or [Xm Ys]
13
+ #
14
+ # @example Basic usage
15
+ # s = Clack.spinner
16
+ # s.start("Installing...")
17
+ # # ... do work ...
18
+ # s.stop("Done!")
19
+ #
20
+ # @example With timer
21
+ # s = Clack.spinner(indicator: :timer)
22
+ # s.start("Building")
23
+ # build_project
24
+ # s.stop("Build complete") # => "Build complete [12s]"
25
+ #
26
+ # @example Updating message mid-spin
27
+ # s = Clack.spinner
28
+ # s.start("Step 1...")
29
+ # do_step_1
30
+ # s.message("Step 2...")
31
+ # do_step_2
32
+ # s.stop("All done!")
33
+ #
34
+ class Spinner
35
+ # @param indicator [:dots, :timer] animation style (default: :dots)
36
+ # @param frames [Array<String>, nil] custom spinner frames
37
+ # @param delay [Float, nil] delay between frames in seconds
38
+ # @param style_frame [Proc, nil] proc to style each frame character
39
+ # @param output [IO] output stream (default: $stdout)
40
+ def initialize(
41
+ indicator: :dots,
42
+ frames: nil,
43
+ delay: nil,
44
+ style_frame: nil,
45
+ output: $stdout
46
+ )
47
+ @output = output
48
+ @indicator = indicator
49
+ @frames = frames || Symbols::SPINNER_FRAMES
50
+ @delay = delay || Symbols::SPINNER_DELAY
51
+ @style_frame = style_frame || ->(frame) { Colors.magenta(frame) }
52
+ @running = false
53
+ @cancelled = false
54
+ @message = ""
55
+ @thread = nil
56
+ @frame_idx = 0
57
+ @dot_idx = 0
58
+ @prev_frame = nil
59
+ @start_time = nil
60
+ @mutex = Mutex.new
61
+ end
62
+
63
+ # Start the spinner animation.
64
+ #
65
+ # @param message [String, nil] initial message to display
66
+ # @return [self] for method chaining
67
+ def start(message = nil)
68
+ @mutex.synchronize do
69
+ return if @running
70
+
71
+ @message = remove_trailing_dots(message || "")
72
+ @running = true
73
+ @cancelled = false
74
+ @prev_frame = nil
75
+ @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
76
+ @dot_idx = 0
77
+ end
78
+
79
+ @output.print Core::Cursor.hide
80
+ @output.print "#{Colors.gray(Symbols::S_BAR)}\n"
81
+
82
+ @thread = Thread.new { spin_loop }
83
+ self
84
+ end
85
+
86
+ # Stop with success state.
87
+ #
88
+ # @param message [String, nil] final message (uses current if nil)
89
+ def stop(message = nil)
90
+ finish(:success, message)
91
+ end
92
+
93
+ # Stop with error state.
94
+ #
95
+ # @param message [String, nil] error message (uses current if nil)
96
+ def error(message = nil)
97
+ finish(:error, message)
98
+ end
99
+
100
+ # Stop with cancelled state.
101
+ #
102
+ # @param message [String, nil] cancellation message
103
+ def cancel(message = nil)
104
+ finish(:cancel, message)
105
+ end
106
+
107
+ # Update the spinner message while running.
108
+ #
109
+ # @param msg [String] new message to display
110
+ def message(msg)
111
+ @mutex.synchronize { @message = remove_trailing_dots(msg) }
112
+ end
113
+
114
+ # Clear the spinner without showing a final message.
115
+ def clear
116
+ @mutex.synchronize do
117
+ @running = false
118
+ end
119
+ @thread&.join
120
+ restore_cursor
121
+ @output.print Core::Cursor.clear_down
122
+ @output.print Core::Cursor.show
123
+ end
124
+
125
+ def cancelled?
126
+ @cancelled
127
+ end
128
+
129
+ private
130
+
131
+ def remove_trailing_dots(msg)
132
+ msg.to_s.sub(/\.+$/, "")
133
+ end
134
+
135
+ def format_timer
136
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
137
+ min = (elapsed / 60).to_i
138
+ secs = (elapsed % 60).to_i
139
+ min.positive? ? "[#{min}m #{secs}s]" : "[#{secs}s]"
140
+ end
141
+
142
+ def spin_loop
143
+ frame_count = 0
144
+ while @mutex.synchronize { @running }
145
+ frame = @style_frame.call(@frames[@frame_idx])
146
+ msg = @mutex.synchronize { @message }
147
+ render_frame(frame, msg, frame_count)
148
+
149
+ @frame_idx = (@frame_idx + 1) % @frames.length
150
+ frame_count += 1
151
+ sleep @delay
152
+ end
153
+ end
154
+
155
+ def render_frame(frame, msg, frame_count)
156
+ suffix = case @indicator
157
+ when :timer
158
+ " #{format_timer}"
159
+ when :dots
160
+ # Animate dots: cycles every 8 frames (0-3 dots)
161
+ dot_count = (frame_count / 2) % 4
162
+ "." * dot_count
163
+ else
164
+ ""
165
+ end
166
+
167
+ line = "#{frame} #{msg}#{suffix}"
168
+ @mutex.synchronize do
169
+ return if line == @prev_frame
170
+
171
+ @output.print "\r#{Core::Cursor.clear_to_end}#{line}"
172
+ @prev_frame = line
173
+ end
174
+ end
175
+
176
+ def finish(state, message)
177
+ msg, timer_suffix = @mutex.synchronize do
178
+ @running = false
179
+ suffix = (@indicator == :timer) ? " #{format_timer}" : ""
180
+ [message || @message, suffix]
181
+ end
182
+
183
+ @thread&.join
184
+
185
+ @output.print "\r#{Core::Cursor.clear_to_end}"
186
+
187
+ symbol = case state
188
+ when :success then Colors.green(Symbols::S_STEP_SUBMIT)
189
+ when :error then Colors.red(Symbols::S_STEP_ERROR)
190
+ when :cancel
191
+ @cancelled = true
192
+ Colors.red(Symbols::S_STEP_CANCEL)
193
+ end
194
+
195
+ @output.print "#{symbol} #{msg}#{timer_suffix}\n"
196
+ @output.print Core::Cursor.show
197
+ end
198
+
199
+ def restore_cursor
200
+ return unless @prev_frame
201
+
202
+ @output.print "\r"
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Prompts
5
+ # Sequential task runner with spinner animation.
6
+ #
7
+ # Runs tasks in order, showing a spinner while each runs.
8
+ # Displays success/error status after each task completes.
9
+ #
10
+ # Each task is a hash with:
11
+ # - `:title` - display title
12
+ # - `:task` - Proc to execute (exceptions are caught)
13
+ #
14
+ # @example Basic usage
15
+ # results = Clack.tasks(tasks: [
16
+ # { title: "Checking dependencies", task: -> { check_deps } },
17
+ # { title: "Building project", task: -> { build } },
18
+ # { title: "Running tests", task: -> { run_tests } }
19
+ # ])
20
+ #
21
+ # @example Checking results
22
+ # results.each do |r|
23
+ # if r.status == :error
24
+ # puts "#{r.title} failed: #{r.error}"
25
+ # end
26
+ # end
27
+ #
28
+ class Tasks
29
+ # @!attribute [r] title
30
+ # @return [String] the task title
31
+ # @!attribute [r] task
32
+ # @return [Proc] the task to execute
33
+ Task = Struct.new(:title, :task, keyword_init: true)
34
+
35
+ # @!attribute [r] title
36
+ # @return [String] the task title
37
+ # @!attribute [r] status
38
+ # @return [Symbol] :success or :error
39
+ # @!attribute [r] error
40
+ # @return [String, nil] error message if failed
41
+ TaskResult = Struct.new(:title, :status, :error, keyword_init: true)
42
+
43
+ # @param tasks [Array<Hash>] tasks with :title and :task keys
44
+ # @param output [IO] output stream (default: $stdout)
45
+ def initialize(tasks:, output: $stdout)
46
+ @tasks = tasks.map { |task_data| Task.new(title: task_data[:title], task: task_data[:task]) }
47
+ @output = output
48
+ @results = []
49
+ @current_index = 0
50
+ @frame_index = 0
51
+ @spinning = false
52
+ @mutex = Mutex.new
53
+ end
54
+
55
+ # Run all tasks sequentially.
56
+ #
57
+ # @return [Array<TaskResult>] results for each task
58
+ def run
59
+ @output.print Core::Cursor.hide
60
+ @tasks.each_with_index do |task, idx|
61
+ @current_index = idx
62
+ run_task(task)
63
+ end
64
+ @output.print Core::Cursor.show
65
+ @results
66
+ end
67
+
68
+ private
69
+
70
+ def run_task(task)
71
+ render_pending(task.title)
72
+
73
+ begin
74
+ task.task.call
75
+ @results << TaskResult.new(title: task.title, status: :success, error: nil)
76
+ render_success(task.title)
77
+ rescue => exception
78
+ @results << TaskResult.new(title: task.title, status: :error, error: exception.message)
79
+ render_error(task.title, exception.message)
80
+ end
81
+ end
82
+
83
+ def render_pending(title)
84
+ @output.print "\r#{Core::Cursor.clear_to_end}"
85
+ @output.print "#{Colors.magenta(spinner_frame)} #{title}"
86
+ @spinner_thread = start_spinner(title)
87
+ end
88
+
89
+ def render_success(title)
90
+ stop_spinner
91
+ @output.print "\r#{Core::Cursor.clear_to_end}"
92
+ @output.puts "#{Colors.green(Symbols::S_STEP_SUBMIT)} #{title}"
93
+ end
94
+
95
+ def render_error(title, message)
96
+ stop_spinner
97
+ @output.print "\r#{Core::Cursor.clear_to_end}"
98
+ @output.puts "#{Colors.red(Symbols::S_STEP_CANCEL)} #{title}"
99
+ @output.puts "#{Colors.gray(Symbols::S_BAR)} #{Colors.red(message)}"
100
+ end
101
+
102
+ def start_spinner(title)
103
+ @mutex.synchronize do
104
+ @spinning = true
105
+ @frame_index = 0
106
+ end
107
+ Thread.new do
108
+ while @mutex.synchronize { @spinning }
109
+ frame = @mutex.synchronize do
110
+ current_frame = Symbols::SPINNER_FRAMES[@frame_index]
111
+ @frame_index = (@frame_index + 1) % Symbols::SPINNER_FRAMES.length
112
+ current_frame
113
+ end
114
+ @output.print "\r#{Core::Cursor.clear_to_end}"
115
+ @output.print "#{Colors.magenta(frame)} #{title}"
116
+ sleep Symbols::SPINNER_DELAY
117
+ end
118
+ end
119
+ end
120
+
121
+ def stop_spinner
122
+ @mutex.synchronize { @spinning = false }
123
+ @spinner_thread&.join
124
+ end
125
+
126
+ def spinner_frame
127
+ @mutex.synchronize { Symbols::SPINNER_FRAMES[@frame_index] }
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Prompts
5
+ # Single-line text input prompt with cursor navigation.
6
+ #
7
+ # Features:
8
+ # - Arrow key cursor movement (left/right)
9
+ # - Backspace/delete support
10
+ # - Placeholder text (shown when empty)
11
+ # - Default value (used if submitted empty)
12
+ # - Initial value (pre-filled, editable)
13
+ # - Validation support
14
+ #
15
+ # @example Basic usage
16
+ # name = Clack.text(message: "What is your name?")
17
+ #
18
+ # @example With all options
19
+ # name = Clack.text(
20
+ # message: "Project name?",
21
+ # placeholder: "my-project",
22
+ # default_value: "untitled",
23
+ # initial_value: "hello",
24
+ # validate: ->(v) { "Required!" if v.empty? }
25
+ # )
26
+ #
27
+ class Text < Core::Prompt
28
+ include Core::TextInputHelper
29
+
30
+ # @param message [String] the prompt message
31
+ # @param placeholder [String, nil] dim text shown when input is empty
32
+ # @param default_value [String, nil] value used if submitted empty
33
+ # @param initial_value [String, nil] pre-filled editable text
34
+ # @param validate [Proc, nil] validation proc returning error string or nil
35
+ # @param opts [Hash] additional options passed to {Core::Prompt}
36
+ def initialize(message:, placeholder: nil, default_value: nil, initial_value: nil, **opts)
37
+ super(message:, **opts)
38
+ @placeholder = placeholder
39
+ @default_value = default_value
40
+ @value = initial_value || ""
41
+ @cursor = @value.grapheme_clusters.length
42
+ end
43
+
44
+ protected
45
+
46
+ def handle_input(key, action)
47
+ # Only use arrow key actions for actual arrow keys, not vim h/l keys
48
+ # which should be treated as text input
49
+ if key&.start_with?("\e[")
50
+ max_cursor = @value.grapheme_clusters.length
51
+ case action
52
+ when :left
53
+ @cursor = [@cursor - 1, 0].max
54
+ return
55
+ when :right
56
+ @cursor = [@cursor + 1, max_cursor].min
57
+ return
58
+ end
59
+ end
60
+
61
+ handle_text_input(key)
62
+ end
63
+
64
+ def submit
65
+ @value = @default_value if @value.empty? && @default_value
66
+ super
67
+ end
68
+
69
+ def build_frame
70
+ lines = []
71
+ lines << "#{bar}\n"
72
+ lines << "#{symbol_for_state} #{@message}\n"
73
+ lines << "#{active_bar} #{input_display}\n"
74
+ lines << "#{bar_end}\n" if @state == :active || @state == :initial
75
+
76
+ lines[-1] = "#{Colors.yellow(Symbols::S_BAR_END)} #{Colors.yellow(@error_message)}\n" if @state == :error
77
+
78
+ lines.join
79
+ end
80
+
81
+ def build_final_frame
82
+ lines = []
83
+ lines << "#{bar}\n"
84
+ lines << "#{symbol_for_state} #{@message}\n"
85
+
86
+ display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(@value)) : Colors.dim(@value)
87
+ lines << "#{bar} #{display}\n"
88
+
89
+ lines.join
90
+ end
91
+ end
92
+ end
93
+ end