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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Core
5
+ # ANSI escape sequences for cursor control.
6
+ # See: https://en.wikipedia.org/wiki/ANSI_escape_code
7
+ module Cursor
8
+ class << self
9
+ # Override enabled state for testing or special cases
10
+ attr_writer :enabled
11
+
12
+ def enabled?
13
+ return @enabled unless @enabled.nil?
14
+
15
+ # Default: check if output supports ANSI escape sequences
16
+ $stdout.tty? && ENV["TERM"] != "dumb" && !ENV["NO_COLOR"]
17
+ end
18
+
19
+ # Visibility
20
+ # DECTCEM: Hide cursor
21
+ def hide = enabled? ? "\e[?25l" : ""
22
+ # DECTCEM: Show cursor
23
+ def show = enabled? ? "\e[?25h" : ""
24
+
25
+ # Movement (CSI sequences)
26
+ # CUU: Cursor Up
27
+ def up(n = 1) = enabled? ? "\e[#{n}A" : ""
28
+ # CUD: Cursor Down
29
+ def down(n = 1) = enabled? ? "\e[#{n}B" : ""
30
+ # CUF: Cursor Forward
31
+ def forward(n = 1) = enabled? ? "\e[#{n}C" : ""
32
+ # CUB: Cursor Back
33
+ def back(n = 1) = enabled? ? "\e[#{n}D" : ""
34
+
35
+ # Absolute positioning
36
+ # CUP: Cursor Position
37
+ def to(x, y) = enabled? ? "\e[#{y};#{x}H" : ""
38
+ # CHA: Cursor Horizontal Absolute
39
+ def column(n) = enabled? ? "\e[#{n}G" : ""
40
+ # CUP: Home position (1,1)
41
+ def home = enabled? ? "\e[H" : ""
42
+
43
+ # Save/restore
44
+ # DECSC: Save Cursor Position
45
+ def save = enabled? ? "\e7" : ""
46
+ # DECRC: Restore Cursor Position
47
+ def restore = enabled? ? "\e8" : ""
48
+
49
+ # Erasing
50
+ # EL: Erase entire line
51
+ def clear_line = enabled? ? "\e[2K" : ""
52
+ # EL: Erase to end of line
53
+ def clear_to_end = enabled? ? "\e[K" : ""
54
+ # ED: Erase below cursor
55
+ def clear_down = enabled? ? "\e[J" : ""
56
+ # ED: Erase entire screen
57
+ def clear_screen = enabled? ? "\e[2J" : ""
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module Clack
6
+ module Core
7
+ # Reads single keystrokes from the terminal in raw mode.
8
+ # Handles escape sequences for arrow keys and other special keys.
9
+ module KeyReader
10
+ # Timeout for detecting if Escape is part of a sequence (50ms).
11
+ # If no follow-up character arrives, treat Escape as a standalone key.
12
+ ESCAPE_TIMEOUT = 0.05
13
+
14
+ # Timeout for reading additional characters in a CSI sequence (10ms).
15
+ # Short because subsequent bytes in a sequence arrive almost instantly.
16
+ SEQUENCE_TIMEOUT = 0.01
17
+
18
+ class << self
19
+ def read
20
+ console = IO.console
21
+ raise IOError, "No console available (not a TTY?)" unless console
22
+
23
+ console.raw do |io|
24
+ char = io.getc
25
+ return char if char.nil? # EOF
26
+ return char unless char == "\e"
27
+
28
+ # Check for escape sequence - wait briefly for follow-up
29
+ return char unless IO.select([io], nil, nil, ESCAPE_TIMEOUT)
30
+
31
+ seq = io.getc.to_s
32
+ return "\e#{seq}" unless seq == "["
33
+
34
+ # Read CSI sequence until no more characters arrive
35
+ seq += io.getc.to_s while IO.select([io], nil, nil, SEQUENCE_TIMEOUT)
36
+ "\e[#{seq[1..]}"
37
+ end
38
+ rescue Errno::EIO, Errno::EBADF, IOError
39
+ # Terminal disconnected or closed - treat as cancel
40
+ "\u0003" # Ctrl+C
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Core
5
+ # Shared functionality for option-based prompts (Select, Multiselect).
6
+ # Handles option normalization, cursor navigation, and scrolling.
7
+ module OptionsHelper
8
+ # Normalize options to a consistent hash format.
9
+ # Accepts strings, symbols, or hashes with value/label/hint/disabled keys.
10
+ #
11
+ # @param options [Array] Raw options in various formats
12
+ # @return [Array<Hash>] Normalized option hashes
13
+ # @raise [ArgumentError] if options is empty
14
+ def normalize_options(options)
15
+ raise ArgumentError, "options cannot be empty" if options.nil? || options.empty?
16
+
17
+ options.map do |opt|
18
+ case opt
19
+ when Hash
20
+ {
21
+ value: opt[:value],
22
+ label: opt[:label] || opt[:value].to_s,
23
+ hint: opt[:hint],
24
+ disabled: opt[:disabled] || false
25
+ }
26
+ else
27
+ {value: opt, label: opt.to_s, hint: nil, disabled: false}
28
+ end
29
+ end
30
+ end
31
+
32
+ # Find the next enabled option in the given direction.
33
+ # Wraps around the list if necessary.
34
+ #
35
+ # @param from [Integer] Starting index
36
+ # @param delta [Integer] Direction (+1 for forward, -1 for backward)
37
+ # @return [Integer] Index of next enabled option, or from if all disabled
38
+ def find_next_enabled(from, delta)
39
+ max = @options.length
40
+ idx = (from + delta) % max
41
+
42
+ max.times do
43
+ return idx unless @options[idx][:disabled]
44
+
45
+ idx = (idx + delta) % max
46
+ end
47
+
48
+ from
49
+ end
50
+
51
+ # Move cursor in the given direction, skipping disabled options.
52
+ #
53
+ # @param delta [Integer] Direction (+1 for down/right, -1 for up/left)
54
+ def move_cursor(delta)
55
+ @cursor = find_next_enabled(@cursor, delta)
56
+ update_scroll
57
+ end
58
+
59
+ # Get the currently visible options based on scroll offset and max_items.
60
+ #
61
+ # @return [Array<Hash>] Visible options
62
+ def visible_options
63
+ return @options unless @max_items && @options.length > @max_items
64
+
65
+ @options[@scroll_offset, @max_items]
66
+ end
67
+
68
+ # Update scroll offset to keep cursor visible within the window.
69
+ def update_scroll
70
+ return unless @max_items && @options.length > @max_items
71
+
72
+ if @cursor < @scroll_offset
73
+ @scroll_offset = @cursor
74
+ elsif @cursor >= @scroll_offset + @max_items
75
+ @scroll_offset = @cursor - @max_items + 1
76
+ end
77
+ end
78
+
79
+ # Find initial cursor position based on initial value or first enabled option.
80
+ #
81
+ # @param initial_value [Object, nil] Initial value to select
82
+ # @return [Integer] Cursor position
83
+ def find_initial_cursor(initial_value)
84
+ return 0 if @options.empty?
85
+
86
+ if initial_value.nil?
87
+ # Start at first enabled option
88
+ return @options[0][:disabled] ? find_next_enabled(-1, 1) : 0
89
+ end
90
+
91
+ idx = @options.find_index { |o| o[:value] == initial_value }
92
+ (idx && !@options[idx][:disabled]) ? idx : find_next_enabled(-1, 1)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module Clack
6
+ module Core
7
+ # Base class for all interactive prompts.
8
+ #
9
+ # Implements a state machine with states: :initial, :active, :error, :submit, :cancel.
10
+ # Subclasses override {#handle_input}, {#build_frame}, and {#build_final_frame}
11
+ # to customize behavior and rendering.
12
+ #
13
+ # The prompt loop:
14
+ # 1. Renders the initial frame
15
+ # 2. Reads keyboard input via {KeyReader}
16
+ # 3. Handles input and transitions state
17
+ # 4. Re-renders the frame
18
+ # 5. Repeats until a terminal state (:submit or :cancel)
19
+ #
20
+ # @abstract Subclass and override {#build_frame} to implement a prompt.
21
+ #
22
+ # @example Creating a custom prompt
23
+ # class MyPrompt < Clack::Core::Prompt
24
+ # def build_frame
25
+ # "#{bar}\n#{symbol_for_state} #{@message}\n"
26
+ # end
27
+ # end
28
+ #
29
+ class Prompt
30
+ # @return [Symbol] current state (:initial, :active, :error, :submit, :cancel)
31
+ attr_reader :state
32
+ # @return [Object] the current/final value
33
+ attr_reader :value
34
+ # @return [String, nil] validation error message, if any
35
+ attr_reader :error_message
36
+
37
+ # @param message [String] the prompt message to display
38
+ # @param validate [Proc, nil] optional validation proc; returns error string or nil
39
+ # @param input [IO] input stream (default: $stdin)
40
+ # @param output [IO] output stream (default: $stdout)
41
+ def initialize(message:, validate: nil, input: $stdin, output: $stdout)
42
+ @message = message
43
+ @validate = validate
44
+ @input = input
45
+ @output = output
46
+ @state = :initial
47
+ @value = nil
48
+ @error_message = nil
49
+ @prev_frame = nil
50
+ @cursor = 0
51
+ end
52
+
53
+ # Run the prompt interaction loop.
54
+ #
55
+ # Sets up the terminal, renders frames, and processes input until the user
56
+ # submits or cancels. Returns the final value or {Clack::CANCEL}.
57
+ #
58
+ # @return [Object, Clack::CANCEL] the submitted value or CANCEL sentinel
59
+ def run
60
+ setup_terminal
61
+ render
62
+ @state = :active
63
+
64
+ loop do
65
+ key = KeyReader.read
66
+ handle_key(key)
67
+ render
68
+
69
+ break if terminal_state?
70
+ end
71
+
72
+ finalize
73
+ (terminal_state? && @state == :cancel) ? CANCEL : @value
74
+ ensure
75
+ cleanup_terminal
76
+ end
77
+
78
+ protected
79
+
80
+ # Process a keypress and update state accordingly.
81
+ # Delegates to {#handle_input} for prompt-specific behavior.
82
+ #
83
+ # @param key [String] the key code from {KeyReader}
84
+ def handle_key(key)
85
+ return if terminal_state?
86
+
87
+ @state = :active if @state == :error
88
+
89
+ action = Settings.action?(key)
90
+
91
+ case action
92
+ when :cancel
93
+ @state = :cancel
94
+ when :enter
95
+ submit
96
+ else
97
+ handle_input(key, action)
98
+ end
99
+ end
100
+
101
+ # Handle prompt-specific input. Override in subclasses.
102
+ #
103
+ # @param key [String] the raw key code
104
+ # @param action [Symbol, nil] the mapped action (:up, :down, etc.) or nil
105
+ def handle_input(key, action)
106
+ # Override in subclasses
107
+ end
108
+
109
+ # Validate and submit the current value.
110
+ # Sets state to :error if validation fails, :submit otherwise.
111
+ def submit
112
+ if @validate
113
+ result = @validate.call(@value)
114
+ if result
115
+ @error_message = result.is_a?(Exception) ? result.message : result.to_s
116
+ @state = :error
117
+ return
118
+ end
119
+ end
120
+ @state = :submit
121
+ end
122
+
123
+ # Render the current frame using differential rendering.
124
+ # Only redraws if the frame content has changed.
125
+ def render
126
+ frame = build_frame
127
+ return if frame == @prev_frame
128
+
129
+ if @state == :initial
130
+ @output.print Cursor.hide
131
+ else
132
+ restore_cursor
133
+ end
134
+
135
+ @output.print Cursor.clear_down
136
+ @output.print frame
137
+ @prev_frame = frame
138
+ end
139
+
140
+ # Build the frame string for the current state.
141
+ # Override in subclasses to customize display.
142
+ #
143
+ # @return [String] the frame content to render
144
+ def build_frame
145
+ # Override in subclasses
146
+ ""
147
+ end
148
+
149
+ # Render the final frame after submit/cancel.
150
+ def finalize
151
+ restore_cursor
152
+ @output.print Cursor.clear_down
153
+ @output.print build_final_frame
154
+ end
155
+
156
+ # Build the final frame shown after interaction ends.
157
+ # Override to show a different view for completed prompts.
158
+ #
159
+ # @return [String] the final frame content
160
+ def build_final_frame
161
+ build_frame
162
+ end
163
+
164
+ # Check if prompt has reached a terminal state.
165
+ #
166
+ # @return [Boolean] true if state is :submit or :cancel
167
+ def terminal_state?
168
+ %i[submit cancel].include?(@state)
169
+ end
170
+
171
+ private
172
+
173
+ def setup_terminal
174
+ @output.print Cursor.hide
175
+ end
176
+
177
+ def cleanup_terminal
178
+ @output.print Cursor.show
179
+ end
180
+
181
+ def restore_cursor
182
+ return unless @prev_frame
183
+
184
+ lines = @prev_frame.count("\n")
185
+ @output.print Cursor.up(lines) if lines.positive?
186
+ @output.print Cursor.column(1)
187
+ end
188
+
189
+ def bar
190
+ Colors.gray(Symbols::S_BAR)
191
+ end
192
+
193
+ def active_bar
194
+ (@state == :error) ? Colors.yellow(Symbols::S_BAR) : bar
195
+ end
196
+
197
+ def bar_end
198
+ (@state == :error) ? Colors.yellow(Symbols::S_BAR_END) : Colors.gray(Symbols::S_BAR_END)
199
+ end
200
+
201
+ def cursor_block
202
+ Colors.inverse(" ")
203
+ end
204
+
205
+ def symbol_for_state
206
+ case @state
207
+ when :initial, :active then Colors.cyan(Symbols::S_STEP_ACTIVE)
208
+ when :submit then Colors.green(Symbols::S_STEP_SUBMIT)
209
+ when :cancel then Colors.red(Symbols::S_STEP_CANCEL)
210
+ when :error then Colors.yellow(Symbols::S_STEP_ERROR)
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Core
5
+ module Settings
6
+ # Navigation and control actions
7
+ ACTIONS = %i[up down left right space enter cancel].freeze
8
+
9
+ # Key code constants
10
+ KEY_BACKSPACE = "\b" # ASCII 8: Backspace
11
+ KEY_DELETE = "\u007F" # ASCII 127: Delete (often sent by backspace key)
12
+ KEY_CTRL_C = "\u0003" # ASCII 3: Ctrl+C (interrupt)
13
+ KEY_ESCAPE = "\e" # ASCII 27: Escape
14
+ KEY_ENTER = "\r" # ASCII 13: Carriage return
15
+ KEY_NEWLINE = "\n" # ASCII 10: Line feed
16
+ KEY_SPACE = " " # ASCII 32: Space
17
+
18
+ # First printable ASCII character (space)
19
+ PRINTABLE_CHAR_MIN = 32
20
+
21
+ # Key to action mappings
22
+ ALIASES = {
23
+ "k" => :up,
24
+ "j" => :down,
25
+ "h" => :left,
26
+ "l" => :right,
27
+ "\e[A" => :up,
28
+ "\e[B" => :down,
29
+ "\e[C" => :right,
30
+ "\e[D" => :left,
31
+ KEY_ENTER => :enter,
32
+ KEY_NEWLINE => :enter,
33
+ KEY_SPACE => :space,
34
+ KEY_ESCAPE => :cancel,
35
+ KEY_CTRL_C => :cancel
36
+ }.freeze
37
+
38
+ # Global configuration (mutable)
39
+ @config = {
40
+ aliases: ALIASES.dup,
41
+ with_guide: true
42
+ }
43
+ @config_mutex = Mutex.new
44
+
45
+ class << self
46
+ # Get a copy of the current global config
47
+ # @return [Hash] Current configuration
48
+ def config
49
+ @config_mutex.synchronize { @config.dup }
50
+ end
51
+
52
+ # Update global settings
53
+ # @param aliases [Hash, nil] Custom key to action mappings (merged with defaults)
54
+ # @param with_guide [Boolean, nil] Whether to show guide bars
55
+ # @return [Hash] Updated configuration
56
+ def update(aliases: nil, with_guide: nil)
57
+ @config_mutex.synchronize do
58
+ @config[:aliases] = ALIASES.merge(aliases) if aliases
59
+ @config[:with_guide] = with_guide unless with_guide.nil?
60
+ @config.dup
61
+ end
62
+ end
63
+
64
+ # Reset settings to defaults
65
+ def reset!
66
+ @config_mutex.synchronize do
67
+ @config = {
68
+ aliases: ALIASES.dup,
69
+ with_guide: true
70
+ }
71
+ end
72
+ end
73
+
74
+ # Check if guide bars should be shown
75
+ # @return [Boolean]
76
+ def with_guide?
77
+ @config_mutex.synchronize { @config[:with_guide] }
78
+ end
79
+
80
+ def action?(key)
81
+ aliases = @config_mutex.synchronize { @config[:aliases] }
82
+ aliases[key] if ACTIONS.include?(aliases[key])
83
+ end
84
+
85
+ # Check if a key is a printable character
86
+ def printable?(key)
87
+ key && key.length == 1 && key.ord >= PRINTABLE_CHAR_MIN
88
+ end
89
+
90
+ # Check if a key is a backspace/delete
91
+ def backspace?(key)
92
+ [KEY_BACKSPACE, KEY_DELETE].include?(key)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Core
5
+ # Shared functionality for text input prompts (Text, Autocomplete, Path).
6
+ # Handles cursor display, placeholder rendering, and text manipulation.
7
+ module TextInputHelper
8
+ # Display the input field with cursor or placeholder.
9
+ #
10
+ # @return [String] Formatted input display
11
+ def input_display
12
+ return placeholder_display if @value.empty?
13
+
14
+ value_with_cursor
15
+ end
16
+
17
+ # Display placeholder with cursor on first character.
18
+ # Override @placeholder in including class to customize.
19
+ #
20
+ # @return [String] Formatted placeholder
21
+ def placeholder_display
22
+ text = current_placeholder
23
+ return cursor_block if text.nil? || text.empty?
24
+
25
+ format_placeholder_with_cursor(text)
26
+ end
27
+
28
+ def current_placeholder
29
+ defined?(@placeholder) ? @placeholder : nil
30
+ end
31
+
32
+ def format_placeholder_with_cursor(text)
33
+ chars = text.grapheme_clusters
34
+ first = chars.first || ""
35
+ rest = chars[1..].join
36
+ "#{Colors.inverse(first)}#{Colors.dim(rest)}"
37
+ end
38
+
39
+ # Display value with inverse cursor at current position.
40
+ # Uses grapheme clusters for proper Unicode handling (e.g., emoji).
41
+ #
42
+ # @return [String] Value with cursor
43
+ def value_with_cursor
44
+ chars = @value.grapheme_clusters
45
+ return "#{@value}#{cursor_block}" if @cursor >= chars.length
46
+
47
+ before = chars[0...@cursor].join
48
+ current = Colors.inverse(chars[@cursor])
49
+ after = chars[(@cursor + 1)..].join
50
+ "#{before}#{current}#{after}"
51
+ end
52
+
53
+ # Handle text input key (backspace/delete or printable character).
54
+ # Requires @value and @cursor instance variables.
55
+ # Uses grapheme clusters for proper Unicode handling.
56
+ #
57
+ # @param key [String] The key pressed
58
+ # @return [Boolean] true if input was handled
59
+ def handle_text_input(key)
60
+ return handle_backspace if Core::Settings.backspace?(key)
61
+ return false unless Core::Settings.printable?(key)
62
+
63
+ chars = @value.grapheme_clusters
64
+ chars.insert(@cursor, key)
65
+ @value = chars.join
66
+ @cursor += 1
67
+ true
68
+ end
69
+
70
+ private
71
+
72
+ def handle_backspace
73
+ return false if @cursor.zero?
74
+
75
+ chars = @value.grapheme_clusters
76
+ chars.delete_at(@cursor - 1)
77
+ @value = chars.join
78
+ @cursor -= 1
79
+ true
80
+ end
81
+ end
82
+ end
83
+ end