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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE +24 -0
- data/README.md +424 -0
- data/exe/clack-demo +9 -0
- data/lib/clack/box.rb +120 -0
- data/lib/clack/colors.rb +55 -0
- data/lib/clack/core/cursor.rb +61 -0
- data/lib/clack/core/key_reader.rb +45 -0
- data/lib/clack/core/options_helper.rb +96 -0
- data/lib/clack/core/prompt.rb +215 -0
- data/lib/clack/core/settings.rb +97 -0
- data/lib/clack/core/text_input_helper.rb +83 -0
- data/lib/clack/environment.rb +137 -0
- data/lib/clack/group.rb +100 -0
- data/lib/clack/log.rb +42 -0
- data/lib/clack/note.rb +49 -0
- data/lib/clack/prompts/autocomplete.rb +162 -0
- data/lib/clack/prompts/autocomplete_multiselect.rb +280 -0
- data/lib/clack/prompts/confirm.rb +100 -0
- data/lib/clack/prompts/group_multiselect.rb +250 -0
- data/lib/clack/prompts/multiselect.rb +185 -0
- data/lib/clack/prompts/password.rb +77 -0
- data/lib/clack/prompts/path.rb +226 -0
- data/lib/clack/prompts/progress.rb +145 -0
- data/lib/clack/prompts/select.rb +134 -0
- data/lib/clack/prompts/select_key.rb +100 -0
- data/lib/clack/prompts/spinner.rb +206 -0
- data/lib/clack/prompts/tasks.rb +131 -0
- data/lib/clack/prompts/text.rb +93 -0
- data/lib/clack/stream.rb +82 -0
- data/lib/clack/symbols.rb +84 -0
- data/lib/clack/task_log.rb +174 -0
- data/lib/clack/utils.rb +135 -0
- data/lib/clack/validators.rb +145 -0
- data/lib/clack/version.rb +5 -0
- data/lib/clack.rb +576 -0
- metadata +83 -0
|
@@ -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
|