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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "stringio"
5
+
6
+ module Clack
7
+ # Stream logging utility for iterables, enumerables, and IO streams
8
+ # Similar to Log but works with streaming data in real-time
9
+ module Stream
10
+ class << self
11
+ def info(source, output: $stdout, &block)
12
+ stream_with_symbol(source, Symbols::S_INFO, :cyan, output, &block)
13
+ end
14
+
15
+ def success(source, output: $stdout, &block)
16
+ stream_with_symbol(source, Symbols::S_SUCCESS, :green, output, &block)
17
+ end
18
+
19
+ def step(source, output: $stdout, &block)
20
+ stream_with_symbol(source, Symbols::S_STEP_SUBMIT, :green, output, &block)
21
+ end
22
+
23
+ def warn(source, output: $stdout, &block)
24
+ stream_with_symbol(source, Symbols::S_WARN, :yellow, output, &block)
25
+ end
26
+
27
+ def error(source, output: $stdout, &block)
28
+ stream_with_symbol(source, Symbols::S_ERROR, :red, output, &block)
29
+ end
30
+
31
+ def message(source, output: $stdout)
32
+ each_line(source) do |line|
33
+ output.puts "#{Colors.gray(Symbols::S_BAR)} #{line.chomp}"
34
+ output.flush
35
+ end
36
+ end
37
+
38
+ # Stream from a subprocess command
39
+ # Usage: Clack.stream.command("npm install", type: :info)
40
+ # Returns true on success, false on failure or if command cannot be executed
41
+ def command(cmd, type: :info, output: $stdout)
42
+ IO.popen(cmd, err: %i[child out]) do |io|
43
+ send(type, io, output: output)
44
+ end
45
+ $CHILD_STATUS.success?
46
+ rescue Errno::ENOENT
47
+ false
48
+ end
49
+
50
+ private
51
+
52
+ def stream_with_symbol(source, symbol, color, output)
53
+ first = true
54
+ each_line(source) do |line|
55
+ line = line.chomp
56
+ if first
57
+ output.puts "#{Colors.send(color, symbol)} #{line}"
58
+ first = false
59
+ else
60
+ output.puts "#{Colors.gray(Symbols::S_BAR)} #{line}"
61
+ end
62
+ output.flush
63
+ yield line if block_given?
64
+ end
65
+ end
66
+
67
+ def each_line(source, &block)
68
+ case source
69
+ when IO, StringIO
70
+ source.each_line(&block)
71
+ when String
72
+ source.each_line(&block)
73
+ else
74
+ # Enumerable (Array, etc.)
75
+ source.each do |item|
76
+ block.call(item.to_s)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Symbols
5
+ class << self
6
+ # Check if unicode output is enabled.
7
+ # CLACK_UNICODE=1 forces unicode, CLACK_UNICODE=0 forces ASCII.
8
+ # Otherwise auto-detects from TTY and TERM.
9
+ def unicode?
10
+ return @unicode if defined?(@unicode)
11
+
12
+ @unicode = compute_unicode_support
13
+ end
14
+
15
+ def reset!
16
+ remove_instance_variable(:@unicode) if defined?(@unicode)
17
+ end
18
+
19
+ private
20
+
21
+ def compute_unicode_support
22
+ # Explicit override
23
+ return ENV["CLACK_UNICODE"] == "1" if ENV["CLACK_UNICODE"]
24
+
25
+ # Default: TTY and not dumb terminal
26
+ $stdout.tty? && ENV["TERM"] != "dumb" && !ENV["NO_COLOR"]
27
+ end
28
+ end
29
+
30
+ # Step indicators
31
+ S_STEP_ACTIVE = unicode? ? "◆" : "*"
32
+ S_STEP_CANCEL = unicode? ? "■" : "x"
33
+ S_STEP_ERROR = unicode? ? "▲" : "x"
34
+ S_STEP_SUBMIT = unicode? ? "◇" : "o"
35
+
36
+ # Radio buttons
37
+ S_RADIO_ACTIVE = unicode? ? "●" : ">"
38
+ S_RADIO_INACTIVE = unicode? ? "○" : " "
39
+
40
+ # Checkboxes
41
+ S_CHECKBOX_ACTIVE = unicode? ? "◻" : "[•]"
42
+ S_CHECKBOX_SELECTED = unicode? ? "◼" : "[+]"
43
+ S_CHECKBOX_INACTIVE = unicode? ? "◻" : "[ ]"
44
+
45
+ # Password mask
46
+ S_PASSWORD_MASK = unicode? ? "▪" : "*"
47
+
48
+ # Bars and connectors
49
+ S_BAR = unicode? ? "│" : "|"
50
+ S_BAR_START = unicode? ? "┌" : "+"
51
+ S_BAR_END = unicode? ? "└" : "+"
52
+ S_BAR_H = unicode? ? "─" : "-"
53
+ S_CORNER_TOP_RIGHT = unicode? ? "╮" : "+"
54
+ S_CORNER_TOP_LEFT = unicode? ? "╭" : "+"
55
+ S_CORNER_BOTTOM_RIGHT = unicode? ? "╯" : "+"
56
+ S_CORNER_BOTTOM_LEFT = unicode? ? "╰" : "+"
57
+ S_CONNECT_LEFT = unicode? ? "├" : "+"
58
+
59
+ # Square corners (for box with rounded: false)
60
+ S_BAR_START_RIGHT = unicode? ? "┐" : "+"
61
+ S_BAR_END_RIGHT = unicode? ? "┘" : "+"
62
+
63
+ # Log symbols
64
+ S_INFO = unicode? ? "●" : "*"
65
+ S_SUCCESS = unicode? ? "◆" : "*"
66
+ S_WARN = unicode? ? "▲" : "!"
67
+ S_ERROR = unicode? ? "■" : "x"
68
+
69
+ # File system
70
+ S_FOLDER = unicode? ? "📁" : "[D]"
71
+ S_FILE = unicode? ? "📄" : "[F]"
72
+
73
+ # Spinner frames - quarter circle rotation pattern
74
+ SPINNER_FRAMES = unicode? ? %w[◒ ◐ ◓ ◑] : %w[• o O 0]
75
+ SPINNER_DELAY = unicode? ? 0.08 : 0.12
76
+
77
+ # Progress bar characters
78
+ S_PROGRESS_FILLED = unicode? ? "█" : "#"
79
+ S_PROGRESS_EMPTY = unicode? ? "░" : "-"
80
+
81
+ # Alternative progress bar (smoother gradient)
82
+ S_PROGRESS_BLOCKS = unicode? ? %w[░ ▒ ▓ █] : %w[- = # #]
83
+ end
84
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ # A streaming log that clears on success and remains on failure
5
+ # Useful for build output, npm install style streaming, etc.
6
+ #
7
+ # @example Basic usage
8
+ # tl = Clack.task_log(title: "Building...")
9
+ # tl.message("Compiling file 1...")
10
+ # tl.message("Compiling file 2...")
11
+ # tl.success("Build complete!") # Clears the log
12
+ # # or tl.error("Build failed!") # Keeps the log visible
13
+ #
14
+ class TaskLog
15
+ # @param title [String] Title displayed at the top
16
+ # @param limit [Integer, nil] Max lines to show (older lines scroll out)
17
+ # @param retain_log [Boolean] Keep full log history for display on error
18
+ # @param output [IO] Output stream
19
+ def initialize(title:, limit: nil, retain_log: false, output: $stdout)
20
+ @title = title
21
+ @limit = limit
22
+ @retain_log = retain_log
23
+ @output = output
24
+ @buffer = []
25
+ @full_buffer = []
26
+ @groups = []
27
+ @lines_written = 0
28
+ @tty = tty_output?(output)
29
+
30
+ render_title
31
+ end
32
+
33
+ # Add a message to the log
34
+ # @param msg [String] Message to display
35
+ # @param raw [Boolean] If true, don't add newline between messages
36
+ def message(msg, raw: false)
37
+ clear_buffer
38
+ @buffer << msg.to_s.gsub(/\e\[[\d;]*[ABCDEFGHfJKSTsu]/, "") # Strip cursor movement codes
39
+ apply_limit
40
+ render_buffer if @tty
41
+ end
42
+
43
+ # Create a named group for messages
44
+ # @param name [String] Group header name
45
+ # @return [TaskLogGroup] Group object with message/success/error methods
46
+ def group(name)
47
+ grp = TaskLogGroup.new(name, self)
48
+ @groups << grp
49
+ grp
50
+ end
51
+
52
+ # Complete with success - clears the log
53
+ # @param msg [String] Success message
54
+ # @param show_log [Boolean] If true, show the log even on success
55
+ def success(msg, show_log: false)
56
+ clear_all
57
+ @output.puts "#{Colors.green(Symbols::S_STEP_SUBMIT)} #{msg}"
58
+ render_full_buffer if show_log
59
+ reset_buffers
60
+ end
61
+
62
+ # Complete with error - keeps the log visible
63
+ # @param msg [String] Error message
64
+ # @param show_log [Boolean] If false, hide the log
65
+ def error(msg, show_log: true)
66
+ clear_all
67
+ @output.puts "#{Colors.red(Symbols::S_STEP_ERROR)} #{msg}"
68
+ render_full_buffer if show_log
69
+ reset_buffers
70
+ end
71
+
72
+ # @api private
73
+ def add_group_message(_group, msg)
74
+ clear_buffer
75
+ @buffer << msg.to_s
76
+ apply_limit
77
+ render_buffer if @tty
78
+ end
79
+
80
+ private
81
+
82
+ def render_title
83
+ @output.puts Colors.gray(Symbols::S_BAR)
84
+ @output.puts "#{Colors.green(Symbols::S_STEP_SUBMIT)} #{@title}"
85
+ @output.puts Colors.gray(Symbols::S_BAR)
86
+ @lines_written = 3
87
+ end
88
+
89
+ def clear_buffer
90
+ return unless @tty && @lines_written.positive?
91
+
92
+ # Move up and clear the buffer lines (not the title)
93
+ buffer_lines = @buffer.sum { |line| line.lines.count }
94
+ return unless buffer_lines.positive?
95
+
96
+ @output.print "\e[#{buffer_lines}A" # Move up
97
+ @output.print "\e[J" # Clear to end
98
+ end
99
+
100
+ def clear_all
101
+ return unless @tty && @lines_written.positive?
102
+
103
+ total_lines = @lines_written + @buffer.sum { |line| line.lines.count }
104
+ @output.print "\e[#{total_lines}A" # Move up
105
+ @output.print "\e[J" # Clear to end
106
+ end
107
+
108
+ def render_buffer
109
+ bar = Colors.gray(Symbols::S_BAR)
110
+ @buffer.each do |message|
111
+ print_message_lines(bar, message)
112
+ end
113
+ end
114
+
115
+ def render_full_buffer
116
+ bar = Colors.gray(Symbols::S_BAR)
117
+ lines = @retain_log ? (@full_buffer + @buffer) : @buffer
118
+ lines.each do |message|
119
+ print_message_lines(bar, message)
120
+ end
121
+ end
122
+
123
+ def print_message_lines(bar, message)
124
+ message.each_line do |line|
125
+ @output.puts "#{bar} #{Colors.dim(line.chomp)}"
126
+ end
127
+ end
128
+
129
+ def tty_output?(output)
130
+ output.tty?
131
+ rescue NoMethodError
132
+ false
133
+ end
134
+
135
+ def apply_limit
136
+ return unless @limit && @buffer.length > @limit
137
+
138
+ overflow = @buffer.shift(@buffer.length - @limit)
139
+ @full_buffer.concat(overflow) if @retain_log
140
+ end
141
+
142
+ def reset_buffers
143
+ @buffer.clear
144
+ @full_buffer.clear
145
+ @groups.clear
146
+ @lines_written = 0
147
+ end
148
+ end
149
+
150
+ # A group within a TaskLog
151
+ class TaskLogGroup
152
+ def initialize(name, parent)
153
+ @name = name
154
+ @parent = parent
155
+ @buffer = []
156
+ end
157
+
158
+ # Add a message to this group
159
+ def message(msg, raw: false)
160
+ @buffer << msg.to_s
161
+ @parent.add_group_message(self, msg)
162
+ end
163
+
164
+ # Complete group with success
165
+ def success(msg)
166
+ @parent.add_group_message(self, "#{Colors.green("✓")} #{msg}")
167
+ end
168
+
169
+ # Complete group with error
170
+ def error(msg)
171
+ @parent.add_group_message(self, "#{Colors.red("✗")} #{msg}")
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ # Utility functions for text manipulation and formatting
5
+ module Utils
6
+ class << self
7
+ # Strip ANSI escape sequences from text
8
+ # @param text [String] Text with ANSI codes
9
+ # @return [String] Text without ANSI codes
10
+ def strip_ansi(text)
11
+ text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
12
+ end
13
+
14
+ # Get visible length of text (excluding ANSI codes)
15
+ # @param text [String] Text potentially containing ANSI codes
16
+ # @return [Integer] Visible character count
17
+ def visible_length(text)
18
+ strip_ansi(text).length
19
+ end
20
+
21
+ # Wrap text to a specified width, preserving ANSI codes
22
+ # @param text [String] Text to wrap
23
+ # @param width [Integer] Maximum line width
24
+ # @return [String] Wrapped text
25
+ def wrap(text, width)
26
+ return text if width <= 0
27
+
28
+ lines = []
29
+ text.to_s.each_line do |line|
30
+ lines.concat(wrap_line(line.chomp, width))
31
+ end
32
+ lines.join("\n")
33
+ end
34
+
35
+ # Wrap text with a prefix on each line
36
+ # @param text [String] Text to wrap
37
+ # @param prefix [String] Prefix for each line (e.g., "│ ")
38
+ # @param width [Integer] Total width including prefix
39
+ # @return [String] Wrapped and prefixed text
40
+ def wrap_with_prefix(text, prefix, width)
41
+ prefix_len = visible_length(prefix)
42
+ content_width = width - prefix_len
43
+ return text if content_width <= 0
44
+
45
+ wrapped = wrap(text, content_width)
46
+ wrapped.lines.map { |line| "#{prefix}#{line.chomp}" }.join("\n")
47
+ end
48
+
49
+ # Truncate text to width with ellipsis
50
+ # @param text [String] Text to truncate
51
+ # @param width [Integer] Maximum width
52
+ # @param ellipsis [String] Ellipsis string (default: "...")
53
+ # @return [String] Truncated text
54
+ def truncate(text, width, ellipsis: "...")
55
+ return text if visible_length(text) <= width
56
+
57
+ target = width - ellipsis.length
58
+ return ellipsis if target <= 0
59
+
60
+ # Handle ANSI codes: we need to truncate visible chars while preserving codes
61
+ truncate_visible(text, target) + ellipsis
62
+ end
63
+
64
+ private
65
+
66
+ def wrap_line(line, width)
67
+ return [line] if visible_length(line) <= width
68
+
69
+ words = line.split(/(\s+)/)
70
+ lines = []
71
+ current = ""
72
+ current_len = 0
73
+
74
+ words.each do |word|
75
+ word_len = visible_length(word)
76
+
77
+ if current_len + word_len <= width
78
+ current += word
79
+ current_len += word_len
80
+ elsif word_len > width
81
+ # Word itself is too long, need to break it
82
+ unless current.empty?
83
+ lines << current.rstrip
84
+ current = ""
85
+ current_len = 0
86
+ end
87
+ lines.concat(break_long_word(word, width))
88
+ else
89
+ lines << current.rstrip unless current.empty?
90
+ current = word.lstrip
91
+ current_len = visible_length(current)
92
+ end
93
+ end
94
+
95
+ lines << current.rstrip unless current.empty?
96
+ lines
97
+ end
98
+
99
+ def break_long_word(word, width)
100
+ lines = []
101
+ stripped = strip_ansi(word)
102
+ position = 0
103
+
104
+ while position < stripped.length
105
+ lines << stripped[position, width]
106
+ position += width
107
+ end
108
+
109
+ lines
110
+ end
111
+
112
+ def truncate_visible(text, target_len)
113
+ result = ""
114
+ visible_count = 0
115
+ position = 0
116
+
117
+ while position < text.length && visible_count < target_len
118
+ if text[position] == "\e" && (match = text[position..].match(/\A\e\[[0-9;]*[a-zA-Z]/))
119
+ # ANSI sequence - include it but don't count
120
+ result += match[0]
121
+ position += match[0].length
122
+ else
123
+ result += text[position]
124
+ visible_count += 1
125
+ position += 1
126
+ end
127
+ end
128
+
129
+ # Add reset if we have unclosed ANSI codes
130
+ result += "\e[0m" if result.include?("\e[") && !result.end_with?("\e[0m")
131
+ result
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ # Built-in validators for common validation patterns.
5
+ # Use these with the `validate:` option on prompts.
6
+ #
7
+ # @example Using built-in validators
8
+ # Clack.text(message: "Name?", validate: Clack::Validators.required)
9
+ # Clack.text(message: "Email?", validate: Clack::Validators.format(/@/, "Must be an email"))
10
+ # Clack.password(message: "Password?", validate: Clack::Validators.min_length(8))
11
+ #
12
+ # @example Combining validators
13
+ # Clack.text(
14
+ # message: "Username?",
15
+ # validate: Clack::Validators.combine(
16
+ # Clack::Validators.required("Username is required"),
17
+ # Clack::Validators.min_length(3, "Must be at least 3 characters"),
18
+ # Clack::Validators.max_length(20, "Must be at most 20 characters"),
19
+ # Clack::Validators.format(/\A[a-z0-9_]+\z/i, "Only letters, numbers, and underscores")
20
+ # )
21
+ # )
22
+ #
23
+ module Validators
24
+ class << self
25
+ # Validates that the input is not empty.
26
+ #
27
+ # @param message [String] Custom error message
28
+ # @return [Proc] Validator proc
29
+ def required(message = "This field is required")
30
+ ->(value) { message if value.to_s.strip.empty? }
31
+ end
32
+
33
+ # Validates minimum length.
34
+ #
35
+ # @param length [Integer] Minimum length
36
+ # @param message [String, nil] Custom error message (supports %d placeholder)
37
+ # @return [Proc] Validator proc
38
+ def min_length(length, message = nil)
39
+ msg = message || "Must be at least #{length} characters"
40
+ ->(value) { msg if value.to_s.length < length }
41
+ end
42
+
43
+ # Validates maximum length.
44
+ #
45
+ # @param length [Integer] Maximum length
46
+ # @param message [String, nil] Custom error message
47
+ # @return [Proc] Validator proc
48
+ def max_length(length, message = nil)
49
+ msg = message || "Must be at most #{length} characters"
50
+ ->(value) { msg if value.to_s.length > length }
51
+ end
52
+
53
+ # Validates that input matches a regular expression.
54
+ #
55
+ # @param pattern [Regexp] Pattern to match
56
+ # @param message [String] Error message if pattern doesn't match
57
+ # @return [Proc] Validator proc
58
+ def format(pattern, message = "Invalid format")
59
+ ->(value) { message unless pattern.match?(value.to_s) }
60
+ end
61
+
62
+ # Validates that input is in a list of allowed values.
63
+ #
64
+ # @param allowed [Array] Allowed values
65
+ # @param message [String, nil] Custom error message
66
+ # @return [Proc] Validator proc
67
+ def one_of(allowed, message = nil)
68
+ msg = message || "Must be one of: #{allowed.join(", ")}"
69
+ ->(value) { msg unless allowed.include?(value) }
70
+ end
71
+
72
+ # Validates that input is a valid integer.
73
+ #
74
+ # @param message [String] Error message
75
+ # @return [Proc] Validator proc
76
+ def integer(message = "Must be a number")
77
+ ->(value) { message unless value.to_s.match?(/\A-?\d+\z/) }
78
+ end
79
+
80
+ # Validates that input is within a numeric range.
81
+ # Note: Parses value as integer for comparison.
82
+ #
83
+ # @param range [Range] Allowed range
84
+ # @param message [String, nil] Custom error message
85
+ # @return [Proc] Validator proc
86
+ def in_range(range, message = nil)
87
+ msg = message || "Must be between #{range.first} and #{range.last}"
88
+ lambda do |value|
89
+ int_val = value.to_s.to_i
90
+ msg unless range.cover?(int_val) && value.to_s.match?(/\A-?\d+\z/)
91
+ end
92
+ end
93
+
94
+ # Combines multiple validators. Returns the first error message, or nil if all pass.
95
+ #
96
+ # @param validators [Array<Proc>] Validators to combine
97
+ # @return [Proc] Combined validator proc
98
+ def combine(*validators)
99
+ ->(value) { first_failing_validation(validators, value) }
100
+ end
101
+
102
+ # Common email format validator.
103
+ #
104
+ # @param message [String] Error message
105
+ # @return [Proc] Validator proc
106
+ def email(message = "Must be a valid email address")
107
+ format(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/, message)
108
+ end
109
+
110
+ # Common URL format validator.
111
+ #
112
+ # @param message [String] Error message
113
+ # @return [Proc] Validator proc
114
+ def url(message = "Must be a valid URL")
115
+ format(%r{\Ahttps?://\S+\z}, message)
116
+ end
117
+
118
+ # Validates file path exists.
119
+ #
120
+ # @param message [String] Error message
121
+ # @return [Proc] Validator proc
122
+ def path_exists(message = "Path does not exist")
123
+ ->(value) { message unless File.exist?(value.to_s) }
124
+ end
125
+
126
+ # Validates directory path exists.
127
+ #
128
+ # @param message [String] Error message
129
+ # @return [Proc] Validator proc
130
+ def directory_exists(message = "Directory does not exist")
131
+ ->(value) { message unless File.directory?(value.to_s) }
132
+ end
133
+
134
+ private
135
+
136
+ def first_failing_validation(validators, value)
137
+ validators.each do |validator|
138
+ result = validator.call(value)
139
+ return result if result
140
+ end
141
+ nil
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ VERSION = "0.1.0"
5
+ end