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,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module Clack
6
+ # Environment detection utilities for cross-platform compatibility
7
+ # and CI/terminal environment awareness.
8
+ module Environment
9
+ class << self
10
+ # Check if running on Windows
11
+ # @return [Boolean]
12
+ def windows?
13
+ return @windows if defined?(@windows)
14
+
15
+ @windows = !!(RUBY_PLATFORM =~ /mswin|mingw|cygwin|bccwin/i)
16
+ end
17
+
18
+ # Check if running in a CI environment
19
+ # Common CI env vars: CI, CONTINUOUS_INTEGRATION, BUILD_NUMBER, GITHUB_ACTIONS, etc.
20
+ # @return [Boolean]
21
+ def ci?
22
+ return @ci if defined?(@ci)
23
+
24
+ @ci = ENV["CI"] == "true" ||
25
+ ENV["CONTINUOUS_INTEGRATION"] == "true" ||
26
+ ENV.key?("BUILD_NUMBER") ||
27
+ ENV.key?("GITHUB_ACTIONS") ||
28
+ ENV.key?("GITLAB_CI") ||
29
+ ENV.key?("CIRCLECI") ||
30
+ ENV.key?("TRAVIS") ||
31
+ ENV.key?("JENKINS_URL") ||
32
+ ENV.key?("TEAMCITY_VERSION") ||
33
+ ENV.key?("BUILDKITE")
34
+ end
35
+
36
+ # Check if stdout is a TTY (interactive terminal)
37
+ # @param output [IO] Output stream to check (default: $stdout)
38
+ # @return [Boolean]
39
+ def tty?(output = $stdout)
40
+ output.respond_to?(:tty?) && output.tty?
41
+ rescue IOError
42
+ false
43
+ end
44
+
45
+ # Check if running in Windows Terminal (modern)
46
+ # @return [Boolean]
47
+ def windows_terminal?
48
+ windows? && ENV.key?("WT_SESSION")
49
+ end
50
+
51
+ # Check if running in a dumb terminal (no ANSI support)
52
+ # @return [Boolean]
53
+ def dumb_terminal?
54
+ ENV["TERM"] == "dumb"
55
+ end
56
+
57
+ # Check if ANSI colors are supported
58
+ # @param output [IO] Output stream to check
59
+ # @return [Boolean]
60
+ def colors_supported?(output = $stdout)
61
+ return false if ENV["NO_COLOR"]
62
+ return true if ENV["FORCE_COLOR"]
63
+ return false unless tty?(output)
64
+ return false if dumb_terminal?
65
+
66
+ # Windows: Modern Windows Terminal, ConEmu, ANSICON, or Windows 10 1511+
67
+ # all support ANSI. We optimistically assume modern systems support it.
68
+ true
69
+ end
70
+
71
+ # Get terminal columns (width)
72
+ # @param output [IO] Output stream (default: $stdout)
73
+ # @param default [Integer] Default if detection fails
74
+ # @return [Integer]
75
+ def columns(output = $stdout, default: 80)
76
+ return default unless tty?(output)
77
+
78
+ if output.respond_to?(:winsize)
79
+ _, cols = output.winsize
80
+ (cols > 0) ? cols : default
81
+ else
82
+ default
83
+ end
84
+ rescue IOError, SystemCallError
85
+ default
86
+ end
87
+
88
+ # Get terminal rows (height)
89
+ # @param output [IO] Output stream (default: $stdout)
90
+ # @param default [Integer] Default if detection fails
91
+ # @return [Integer]
92
+ def rows(output = $stdout, default: 24)
93
+ return default unless tty?(output)
94
+
95
+ if output.respond_to?(:winsize)
96
+ rows, = output.winsize
97
+ (rows > 0) ? rows : default
98
+ else
99
+ default
100
+ end
101
+ rescue IOError, SystemCallError
102
+ default
103
+ end
104
+
105
+ # Get terminal dimensions as [rows, columns]
106
+ # @param output [IO] Output stream
107
+ # @return [Array<Integer>] [rows, columns]
108
+ def dimensions(output = $stdout)
109
+ [rows(output), columns(output)]
110
+ end
111
+
112
+ # Check if raw mode is supported for input
113
+ # @param input [IO] Input stream (default: $stdin)
114
+ # @return [Boolean]
115
+ def raw_mode_supported?(input = $stdin)
116
+ return false unless input.respond_to?(:raw)
117
+
118
+ # On Windows without proper console, raw mode may fail
119
+ if windows? && !windows_terminal?
120
+ begin
121
+ IO.console&.respond_to?(:raw)
122
+ rescue
123
+ false
124
+ end
125
+ else
126
+ true
127
+ end
128
+ end
129
+
130
+ # Reset cached environment checks (useful for testing)
131
+ def reset!
132
+ remove_instance_variable(:@windows) if defined?(@windows)
133
+ remove_instance_variable(:@ci) if defined?(@ci)
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ # Collects results from multiple prompts and handles cancellation gracefully.
5
+ #
6
+ # @example Basic usage
7
+ # result = Clack.group do |g|
8
+ # g.prompt(:name) { Clack.text(message: "Your name?") }
9
+ # g.prompt(:age) { Clack.text(message: "Your age?") }
10
+ # end
11
+ # # => { name: "Alice", age: "30" } or Clack::CANCEL
12
+ #
13
+ # @example With previous results
14
+ # result = Clack.group do |g|
15
+ # g.prompt(:name) { Clack.text(message: "Your name?") }
16
+ # g.prompt(:greeting) { |r| Clack.text(message: "Hello #{r[:name]}!") }
17
+ # end
18
+ #
19
+ class Group
20
+ # @return [Hash] The collected results
21
+ attr_reader :results
22
+
23
+ def initialize(on_cancel: nil)
24
+ @results = {}
25
+ @prompts = []
26
+ @on_cancel = on_cancel
27
+ end
28
+
29
+ # Define a prompt in the group.
30
+ #
31
+ # @param name [Symbol, String] The key for this result
32
+ # @yield [results] Block that returns a prompt result
33
+ # @yieldparam results [Hash] Previous results collected so far
34
+ # @return [void]
35
+ def prompt(name, &block)
36
+ raise ArgumentError, "Block required for prompt :#{name}" unless block_given?
37
+
38
+ @prompts << {name: name.to_sym, block: block}
39
+ end
40
+
41
+ # Run all prompts and collect results.
42
+ #
43
+ # @return [Hash, Clack::CANCEL] Results hash or CANCEL if user cancelled
44
+ def run
45
+ @prompts.each do |prompt_def|
46
+ name = prompt_def[:name]
47
+ block = prompt_def[:block]
48
+
49
+ # Pass previous results to the block if it accepts an argument
50
+ result = if block.arity.zero?
51
+ block.call
52
+ else
53
+ block.call(@results.dup.freeze)
54
+ end
55
+
56
+ if Clack.cancel?(result)
57
+ @results[name] = :cancelled
58
+ @on_cancel&.call(@results.dup.freeze)
59
+ return CANCEL
60
+ end
61
+
62
+ @results[name] = result
63
+ end
64
+
65
+ @results
66
+ end
67
+ end
68
+
69
+ class << self
70
+ # Run a group of prompts and collect their results.
71
+ #
72
+ # If any prompt is cancelled, the entire group returns Clack::CANCEL.
73
+ # The on_cancel callback receives partial results collected so far.
74
+ #
75
+ # @param on_cancel [Proc, nil] Callback when a prompt is cancelled
76
+ # @yield [group] Block to define prompts
77
+ # @yieldparam group [Clack::Group] The group builder
78
+ # @return [Hash, Clack::CANCEL] Results hash or CANCEL
79
+ #
80
+ # @example
81
+ # result = Clack.group do |g|
82
+ # g.prompt(:name) { Clack.text(message: "Name?") }
83
+ # g.prompt(:confirm) { Clack.confirm(message: "Continue?") }
84
+ # end
85
+ #
86
+ # if Clack.cancel?(result)
87
+ # Clack.cancel("Cancelled")
88
+ # else
89
+ # puts "Name: #{result[:name]}"
90
+ # end
91
+ #
92
+ def group(on_cancel: nil, &block)
93
+ raise ArgumentError, "Block required for Clack.group" unless block_given?
94
+
95
+ group = Group.new(on_cancel: on_cancel)
96
+ block.call(group)
97
+ group.run
98
+ end
99
+ end
100
+ end
data/lib/clack/log.rb ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Log
5
+ class << self
6
+ def message(msg = "", symbol: nil, output: $stdout)
7
+ symbol ||= Colors.gray(Symbols::S_BAR)
8
+ lines = msg.to_s.lines
9
+
10
+ if lines.empty?
11
+ output.puts symbol
12
+ else
13
+ lines.each_with_index do |line, idx|
14
+ prefix = idx.zero? ? symbol : Colors.gray(Symbols::S_BAR)
15
+ output.puts "#{prefix} #{line.chomp}"
16
+ end
17
+ end
18
+ end
19
+
20
+ def info(msg, output: $stdout)
21
+ message(msg, symbol: Colors.blue(Symbols::S_INFO), output:)
22
+ end
23
+
24
+ def success(msg, output: $stdout)
25
+ message(msg, symbol: Colors.green(Symbols::S_SUCCESS), output:)
26
+ end
27
+
28
+ def step(msg, output: $stdout)
29
+ message(msg, symbol: Colors.green(Symbols::S_STEP_SUBMIT), output:)
30
+ end
31
+
32
+ def warn(msg, output: $stdout)
33
+ message(msg, symbol: Colors.yellow(Symbols::S_WARN), output:)
34
+ end
35
+ alias_method :warning, :warn
36
+
37
+ def error(msg, output: $stdout)
38
+ message(msg, symbol: Colors.red(Symbols::S_ERROR), output:)
39
+ end
40
+ end
41
+ end
42
+ end
data/lib/clack/note.rb ADDED
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Note
5
+ class << self
6
+ def render(message = "", title: nil, output: $stdout)
7
+ lines = message.to_s.lines.map(&:chomp)
8
+ # Add empty lines at start and end like original
9
+ lines = ["", *lines, ""]
10
+ title_len = title&.length || 0
11
+ width = calculate_width(lines, title_len)
12
+
13
+ output.puts Colors.gray(Symbols::S_BAR)
14
+ output.puts build_top_border(title, title_len, width)
15
+
16
+ lines.each do |line|
17
+ padded = line.ljust(width)
18
+ output.puts "#{Colors.gray(Symbols::S_BAR)} #{Colors.dim(padded)}#{Colors.gray(Symbols::S_BAR)}"
19
+ end
20
+
21
+ output.puts build_bottom_border(width)
22
+ end
23
+
24
+ private
25
+
26
+ def calculate_width(lines, title_len)
27
+ max_line = lines.map(&:length).max || 0
28
+ [max_line, title_len].max + 2
29
+ end
30
+
31
+ def build_top_border(title, title_len, width)
32
+ if title
33
+ # Format: ◇ title ───────╮
34
+ right_len = [width - title_len - 1, 1].max
35
+ right = "#{Symbols::S_BAR_H * right_len}#{Symbols::S_CORNER_TOP_RIGHT}"
36
+ "#{Colors.green(Symbols::S_STEP_SUBMIT)} #{title} #{Colors.gray(right)}"
37
+ else
38
+ border = Symbols::S_BAR_H * (width + 2)
39
+ "#{Colors.gray(Symbols::S_CORNER_TOP_LEFT)}#{Colors.gray(border)}#{Colors.gray(Symbols::S_CORNER_TOP_RIGHT)}"
40
+ end
41
+ end
42
+
43
+ def build_bottom_border(width)
44
+ border = Symbols::S_BAR_H * (width + 2)
45
+ "#{Colors.gray(Symbols::S_CONNECT_LEFT)}#{Colors.gray(border)}#{Colors.gray(Symbols::S_CORNER_BOTTOM_RIGHT)}"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Prompts
5
+ # Type-to-filter autocomplete prompt.
6
+ #
7
+ # Combines text input with a filtered option list. Type to filter,
8
+ # use arrow keys to navigate matches, Enter to select.
9
+ #
10
+ # Filtering searches across value, label, and hint fields.
11
+ #
12
+ # @example Basic usage
13
+ # color = Clack.autocomplete(
14
+ # message: "Pick a color",
15
+ # options: %w[red orange yellow green blue indigo violet]
16
+ # )
17
+ #
18
+ # @example With placeholder
19
+ # city = Clack.autocomplete(
20
+ # message: "Select city",
21
+ # options: cities,
22
+ # placeholder: "Type to search...",
23
+ # max_items: 10
24
+ # )
25
+ #
26
+ class Autocomplete < Core::Prompt
27
+ include Core::OptionsHelper
28
+ include Core::TextInputHelper
29
+
30
+ # @param message [String] the prompt message
31
+ # @param options [Array<Hash, String>] list of options to filter
32
+ # @param max_items [Integer] max visible options (default: 5)
33
+ # @param placeholder [String, nil] placeholder text when empty
34
+ # @param opts [Hash] additional options passed to {Core::Prompt}
35
+ def initialize(message:, options:, max_items: 5, placeholder: nil, **opts)
36
+ super(message:, **opts)
37
+ @all_options = normalize_options(options)
38
+ @max_items = max_items
39
+ @placeholder = placeholder
40
+ @value = ""
41
+ @cursor = 0
42
+ @selected_index = 0
43
+ @scroll_offset = 0
44
+ update_filtered
45
+ end
46
+
47
+ protected
48
+
49
+ def handle_key(key)
50
+ return if terminal_state?
51
+
52
+ @state = :active if @state == :error
53
+ action = Core::Settings.action?(key)
54
+
55
+ case action
56
+ when :cancel
57
+ @state = :cancel
58
+ when :enter
59
+ submit_selection
60
+ when :up
61
+ move_selection(-1)
62
+ when :down
63
+ move_selection(1)
64
+ else
65
+ handle_text_input(key)
66
+ end
67
+ end
68
+
69
+ def handle_text_input(key)
70
+ return unless super
71
+
72
+ @selected_index = 0
73
+ @scroll_offset = 0
74
+ update_filtered
75
+ end
76
+
77
+ def submit_selection
78
+ if @filtered.empty?
79
+ @error_message = "No matching option"
80
+ @state = :error
81
+ return
82
+ end
83
+
84
+ @value = @filtered[@selected_index][:value]
85
+ submit
86
+ end
87
+
88
+ def build_frame
89
+ lines = []
90
+ lines << "#{bar}\n"
91
+ lines << "#{symbol_for_state} #{@message}\n"
92
+ lines << "#{active_bar} #{input_display}\n"
93
+
94
+ visible_options.each_with_index do |opt, idx|
95
+ actual_idx = @scroll_offset + idx
96
+ lines << "#{bar} #{option_display(opt, actual_idx == @selected_index)}\n"
97
+ end
98
+
99
+ lines << "#{bar_end}\n"
100
+
101
+ lines[-1] = "#{Colors.yellow(Symbols::S_BAR_END)} #{Colors.yellow(@error_message)}\n" if @state == :error
102
+
103
+ lines.join
104
+ end
105
+
106
+ def build_final_frame
107
+ lines = []
108
+ lines << "#{bar}\n"
109
+ lines << "#{symbol_for_state} #{@message}\n"
110
+
111
+ display_value = @filtered[@selected_index]&.[](:label) || @value
112
+ display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(display_value)) : Colors.dim(display_value)
113
+ lines << "#{bar} #{display}\n"
114
+
115
+ lines.join
116
+ end
117
+
118
+ private
119
+
120
+ def update_filtered
121
+ query = @value.downcase
122
+ @filtered = @all_options.select do |opt|
123
+ opt[:label].downcase.include?(query) ||
124
+ opt[:value].to_s.downcase.include?(query) ||
125
+ opt[:hint]&.downcase&.include?(query)
126
+ end
127
+ end
128
+
129
+ def visible_options
130
+ return @filtered if @filtered.length <= @max_items
131
+
132
+ @filtered[@scroll_offset, @max_items]
133
+ end
134
+
135
+ def move_selection(delta)
136
+ return if @filtered.empty?
137
+
138
+ @selected_index = (@selected_index + delta) % @filtered.length
139
+ update_scroll
140
+ end
141
+
142
+ def update_scroll
143
+ return unless @filtered.length > @max_items
144
+
145
+ if @selected_index < @scroll_offset
146
+ @scroll_offset = @selected_index
147
+ elsif @selected_index >= @scroll_offset + @max_items
148
+ @scroll_offset = @selected_index - @max_items + 1
149
+ end
150
+ end
151
+
152
+ def option_display(opt, active)
153
+ hint = (opt[:hint] && active) ? Colors.dim(" (#{opt[:hint]})") : ""
154
+ if active
155
+ "#{Colors.green(Symbols::S_RADIO_ACTIVE)} #{opt[:label]}#{hint}"
156
+ else
157
+ "#{Colors.dim(Symbols::S_RADIO_INACTIVE)} #{Colors.dim(opt[:label])}"
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end