clack 0.1.3 → 0.2.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 +4 -4
- data/CHANGELOG.md +27 -2
- data/README.md +108 -8
- data/examples/advanced_prompts.rb +63 -0
- data/examples/basic.rb +15 -0
- data/examples/create_app.rb +86 -0
- data/examples/date_demo.rb +40 -0
- data/examples/demo.rb +179 -0
- data/examples/full_demo.rb +84 -0
- data/examples/group_demo.rb +79 -0
- data/examples/images/confirm_example.rb +12 -0
- data/examples/images/multiselect_example.rb +15 -0
- data/examples/images/password_example.rb +10 -0
- data/examples/images/select_example.rb +15 -0
- data/examples/images/spinner_example.rb +11 -0
- data/examples/images/text_example.rb +11 -0
- data/examples/spinner_demo.rb +38 -0
- data/examples/tasks_demo.rb +59 -0
- data/examples/validation.rb +73 -0
- data/lib/clack/colors.rb +97 -3
- data/lib/clack/core/cursor.rb +1 -0
- data/lib/clack/core/key_reader.rb +5 -0
- data/lib/clack/core/prompt.rb +52 -8
- data/lib/clack/core/settings.rb +4 -0
- data/lib/clack/core/text_input_helper.rb +4 -0
- data/lib/clack/log.rb +51 -0
- data/lib/clack/note.rb +7 -0
- data/lib/clack/prompts/autocomplete.rb +29 -11
- data/lib/clack/prompts/autocomplete_multiselect.rb +5 -6
- data/lib/clack/prompts/date.rb +280 -0
- data/lib/clack/prompts/group_multiselect.rb +46 -18
- data/lib/clack/prompts/multiline_text.rb +8 -9
- data/lib/clack/prompts/multiselect.rb +3 -5
- data/lib/clack/prompts/password.rb +5 -10
- data/lib/clack/prompts/path.rb +2 -2
- data/lib/clack/prompts/progress.rb +2 -6
- data/lib/clack/prompts/select.rb +2 -6
- data/lib/clack/prompts/select_key.rb +5 -7
- data/lib/clack/prompts/spinner.rb +2 -6
- data/lib/clack/prompts/tasks.rb +50 -9
- data/lib/clack/prompts/text.rb +4 -3
- data/lib/clack/stream.rb +32 -3
- data/lib/clack/symbols.rb +25 -0
- data/lib/clack/task_log.rb +3 -5
- data/lib/clack/transformers.rb +8 -7
- data/lib/clack/validators.rb +33 -2
- data/lib/clack/version.rb +2 -1
- data/lib/clack.rb +72 -213
- metadata +18 -1
data/lib/clack/core/settings.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Clack
|
|
4
4
|
module Core
|
|
5
|
+
# Global configuration for key bindings, guide bar display, and input classification.
|
|
5
6
|
module Settings
|
|
6
7
|
# Navigation and control actions
|
|
7
8
|
ACTIONS = %i[up down left right space enter cancel].freeze
|
|
@@ -78,6 +79,9 @@ module Clack
|
|
|
78
79
|
@config_mutex.synchronize { @config[:with_guide] }
|
|
79
80
|
end
|
|
80
81
|
|
|
82
|
+
# Look up the action mapped to a key code.
|
|
83
|
+
# @param key [String] key code from {KeyReader}
|
|
84
|
+
# @return [Symbol, nil] the action (:up, :down, :enter, etc.) or nil
|
|
81
85
|
def action?(key)
|
|
82
86
|
aliases = @config_mutex.synchronize { @config[:aliases] }
|
|
83
87
|
aliases[key] if ACTIONS.include?(aliases[key])
|
|
@@ -25,10 +25,14 @@ module Clack
|
|
|
25
25
|
format_placeholder_with_cursor(text)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# @return [String, nil] the placeholder text, or nil if none set
|
|
28
29
|
def current_placeholder
|
|
29
30
|
defined?(@placeholder) ? @placeholder : nil
|
|
30
31
|
end
|
|
31
32
|
|
|
33
|
+
# Render placeholder text with an inverse cursor on the first character.
|
|
34
|
+
# @param text [String] placeholder text to format
|
|
35
|
+
# @return [String] formatted placeholder with cursor highlight
|
|
32
36
|
def format_placeholder_with_cursor(text)
|
|
33
37
|
chars = text.grapheme_clusters
|
|
34
38
|
first = chars.first || ""
|
data/lib/clack/log.rb
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Clack
|
|
4
|
+
# Styled console logging with consistent formatting.
|
|
5
|
+
#
|
|
6
|
+
# Each method prints a message prefixed with a colored symbol. Multi-line
|
|
7
|
+
# messages are automatically aligned with a continuation bar on subsequent lines.
|
|
8
|
+
#
|
|
9
|
+
# Accessed via +Clack.log+:
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# Clack.log.info("Starting build...")
|
|
13
|
+
# Clack.log.success("Build completed!")
|
|
14
|
+
# Clack.log.warn("Cache is stale")
|
|
15
|
+
# Clack.log.error("Build failed")
|
|
16
|
+
#
|
|
4
17
|
module Log
|
|
5
18
|
class << self
|
|
19
|
+
# Print a message with a custom or default symbol prefix.
|
|
20
|
+
#
|
|
21
|
+
# This is the base method used by all other log methods. Pass +symbol:+
|
|
22
|
+
# to customize the leading character (useful for extending with your own
|
|
23
|
+
# log levels).
|
|
24
|
+
#
|
|
25
|
+
# @param msg [String] the message to display
|
|
26
|
+
# @param symbol [String, nil] custom prefix symbol (default: gray bar)
|
|
27
|
+
# @param output [IO] output stream (default: $stdout)
|
|
28
|
+
# @return [void]
|
|
29
|
+
#
|
|
30
|
+
# @example Custom symbol
|
|
31
|
+
# Clack.log.message("Deploying...", symbol: "\u2708")
|
|
6
32
|
def message(msg = "", symbol: nil, output: $stdout)
|
|
7
33
|
symbol ||= Colors.gray(Symbols::S_BAR)
|
|
8
34
|
lines = msg.to_s.lines
|
|
@@ -17,23 +43,48 @@ module Clack
|
|
|
17
43
|
end
|
|
18
44
|
end
|
|
19
45
|
|
|
46
|
+
# Print an informational message (blue symbol).
|
|
47
|
+
#
|
|
48
|
+
# @param msg [String] the message to display
|
|
49
|
+
# @param output [IO] output stream (default: $stdout)
|
|
50
|
+
# @return [void]
|
|
20
51
|
def info(msg, output: $stdout)
|
|
21
52
|
message(msg, symbol: Colors.blue(Symbols::S_INFO), output:)
|
|
22
53
|
end
|
|
23
54
|
|
|
55
|
+
# Print a success message (green symbol).
|
|
56
|
+
#
|
|
57
|
+
# @param msg [String] the message to display
|
|
58
|
+
# @param output [IO] output stream (default: $stdout)
|
|
59
|
+
# @return [void]
|
|
24
60
|
def success(msg, output: $stdout)
|
|
25
61
|
message(msg, symbol: Colors.green(Symbols::S_SUCCESS), output:)
|
|
26
62
|
end
|
|
27
63
|
|
|
64
|
+
# Print a step completion message (green submit symbol).
|
|
65
|
+
#
|
|
66
|
+
# @param msg [String] the message to display
|
|
67
|
+
# @param output [IO] output stream (default: $stdout)
|
|
68
|
+
# @return [void]
|
|
28
69
|
def step(msg, output: $stdout)
|
|
29
70
|
message(msg, symbol: Colors.green(Symbols::S_STEP_SUBMIT), output:)
|
|
30
71
|
end
|
|
31
72
|
|
|
73
|
+
# Print a warning message (yellow symbol).
|
|
74
|
+
#
|
|
75
|
+
# @param msg [String] the message to display
|
|
76
|
+
# @param output [IO] output stream (default: $stdout)
|
|
77
|
+
# @return [void]
|
|
32
78
|
def warn(msg, output: $stdout)
|
|
33
79
|
message(msg, symbol: Colors.yellow(Symbols::S_WARN), output:)
|
|
34
80
|
end
|
|
35
81
|
alias_method :warning, :warn
|
|
36
82
|
|
|
83
|
+
# Print an error message (red symbol).
|
|
84
|
+
#
|
|
85
|
+
# @param msg [String] the message to display
|
|
86
|
+
# @param output [IO] output stream (default: $stdout)
|
|
87
|
+
# @return [void]
|
|
37
88
|
def error(msg, output: $stdout)
|
|
38
89
|
message(msg, symbol: Colors.red(Symbols::S_ERROR), output:)
|
|
39
90
|
end
|
data/lib/clack/note.rb
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Clack
|
|
4
|
+
# Renders a bordered note box with optional title in the terminal.
|
|
4
5
|
module Note
|
|
5
6
|
class << self
|
|
7
|
+
# Render a note box to the output stream.
|
|
8
|
+
#
|
|
9
|
+
# @param message [String] the note content
|
|
10
|
+
# @param title [String, nil] optional title displayed above the box
|
|
11
|
+
# @param output [IO] output stream (default: $stdout)
|
|
12
|
+
# @return [void]
|
|
6
13
|
def render(message = "", title: nil, output: $stdout)
|
|
7
14
|
lines = message.to_s.lines.map(&:chomp)
|
|
8
15
|
# Add empty lines at start and end like original
|
|
@@ -7,7 +7,9 @@ module Clack
|
|
|
7
7
|
# Combines text input with a filtered option list. Type to filter,
|
|
8
8
|
# use arrow keys to navigate matches, Enter to select.
|
|
9
9
|
#
|
|
10
|
-
#
|
|
10
|
+
# By default, filtering searches across value, label, and hint fields
|
|
11
|
+
# using a case-insensitive substring match. Supply a custom +filter+
|
|
12
|
+
# proc to override this behavior.
|
|
11
13
|
#
|
|
12
14
|
# @example Basic usage
|
|
13
15
|
# color = Clack.autocomplete(
|
|
@@ -23,6 +25,13 @@ module Clack
|
|
|
23
25
|
# max_items: 10
|
|
24
26
|
# )
|
|
25
27
|
#
|
|
28
|
+
# @example Custom filter
|
|
29
|
+
# Clack.autocomplete(
|
|
30
|
+
# message: "Select command",
|
|
31
|
+
# options: commands,
|
|
32
|
+
# filter: ->(opt, query) { opt[:label].start_with?(query) }
|
|
33
|
+
# )
|
|
34
|
+
#
|
|
26
35
|
class Autocomplete < Core::Prompt
|
|
27
36
|
include Core::OptionsHelper
|
|
28
37
|
include Core::TextInputHelper
|
|
@@ -31,12 +40,16 @@ module Clack
|
|
|
31
40
|
# @param options [Array<Hash, String>] list of options to filter
|
|
32
41
|
# @param max_items [Integer] max visible options (default: 5)
|
|
33
42
|
# @param placeholder [String, nil] placeholder text when empty
|
|
43
|
+
# @param filter [Proc, nil] custom filter proc receiving (option_hash, query_string)
|
|
44
|
+
# and returning true/false. When nil, the default case-insensitive substring
|
|
45
|
+
# match across label, value, and hint is used.
|
|
34
46
|
# @param opts [Hash] additional options passed to {Core::Prompt}
|
|
35
|
-
def initialize(message:, options:, max_items: 5, placeholder: nil, **opts)
|
|
47
|
+
def initialize(message:, options:, max_items: 5, placeholder: nil, filter: nil, **opts)
|
|
36
48
|
super(message:, **opts)
|
|
37
49
|
@all_options = normalize_options(options)
|
|
38
50
|
@max_items = max_items
|
|
39
51
|
@placeholder = placeholder
|
|
52
|
+
@filter = filter
|
|
40
53
|
@value = ""
|
|
41
54
|
@cursor = 0
|
|
42
55
|
@selected_index = 0
|
|
@@ -49,7 +62,6 @@ module Clack
|
|
|
49
62
|
def handle_key(key)
|
|
50
63
|
return if terminal_state?
|
|
51
64
|
|
|
52
|
-
@state = :active if @state == :error
|
|
53
65
|
action = Core::Settings.action?(key)
|
|
54
66
|
|
|
55
67
|
case action
|
|
@@ -97,9 +109,11 @@ module Clack
|
|
|
97
109
|
lines << "#{bar} #{option_display(opt, actual_idx == @selected_index)}\n"
|
|
98
110
|
end
|
|
99
111
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
112
|
+
if @state in :error | :warning
|
|
113
|
+
lines.concat(validation_message_lines)
|
|
114
|
+
else
|
|
115
|
+
lines << "#{bar_end}\n"
|
|
116
|
+
end
|
|
103
117
|
|
|
104
118
|
lines.join
|
|
105
119
|
end
|
|
@@ -119,11 +133,15 @@ module Clack
|
|
|
119
133
|
private
|
|
120
134
|
|
|
121
135
|
def update_filtered
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
@filtered = if @filter
|
|
137
|
+
@all_options.select { |opt| @filter.call(opt, @value) }
|
|
138
|
+
else
|
|
139
|
+
query = @value.downcase
|
|
140
|
+
@all_options.select do |opt|
|
|
141
|
+
opt[:label].downcase.include?(query) ||
|
|
142
|
+
opt[:value].to_s.downcase.include?(query) ||
|
|
143
|
+
opt[:hint]&.downcase&.include?(query)
|
|
144
|
+
end
|
|
127
145
|
end
|
|
128
146
|
end
|
|
129
147
|
|
|
@@ -57,7 +57,6 @@ module Clack
|
|
|
57
57
|
def handle_key(key)
|
|
58
58
|
return if terminal_state?
|
|
59
59
|
|
|
60
|
-
@state = :active if @state == :error
|
|
61
60
|
action = Core::Settings.action?(key)
|
|
62
61
|
|
|
63
62
|
case action
|
|
@@ -123,7 +122,7 @@ module Clack
|
|
|
123
122
|
|
|
124
123
|
def submit_selection
|
|
125
124
|
if @required && @selected_values.empty?
|
|
126
|
-
@error_message = "Please select at least one option"
|
|
125
|
+
@error_message = "Please select at least one option. Press #{Colors.cyan("space")} to select, #{Colors.cyan("enter")} to submit"
|
|
127
126
|
@state = :error
|
|
128
127
|
return
|
|
129
128
|
end
|
|
@@ -193,17 +192,17 @@ module Clack
|
|
|
193
192
|
|
|
194
193
|
# Override to work with @search_text instead of @value
|
|
195
194
|
def handle_text_input(key)
|
|
196
|
-
return false unless Core::Settings.printable?(key)
|
|
197
|
-
|
|
198
|
-
chars = @search_text.grapheme_clusters
|
|
199
|
-
|
|
200
195
|
if Core::Settings.backspace?(key)
|
|
201
196
|
return false if @cursor.zero?
|
|
202
197
|
|
|
198
|
+
chars = @search_text.grapheme_clusters
|
|
203
199
|
chars.delete_at(@cursor - 1)
|
|
204
200
|
@search_text = chars.join
|
|
205
201
|
@cursor -= 1
|
|
206
202
|
else
|
|
203
|
+
return false unless Core::Settings.printable?(key)
|
|
204
|
+
|
|
205
|
+
chars = @search_text.grapheme_clusters
|
|
207
206
|
chars.insert(@cursor, key)
|
|
208
207
|
@search_text = chars.join
|
|
209
208
|
@cursor += 1
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Clack
|
|
6
|
+
module Prompts
|
|
7
|
+
# Date picker prompt with inline segmented input.
|
|
8
|
+
#
|
|
9
|
+
# Features:
|
|
10
|
+
# - Three formats: :iso (YYYY-MM-DD), :us (MM/DD/YYYY), :eu (DD/MM/YYYY)
|
|
11
|
+
# - Arrow key navigation between segments
|
|
12
|
+
# - Up/down to increment/decrement values
|
|
13
|
+
# - Direct digit typing with auto-advance
|
|
14
|
+
# - Min/max date bounds validation
|
|
15
|
+
#
|
|
16
|
+
# @example Basic usage
|
|
17
|
+
# date = Clack.date(message: "Select a date")
|
|
18
|
+
#
|
|
19
|
+
# @example With bounds
|
|
20
|
+
# date = Clack.date(
|
|
21
|
+
# message: "When?",
|
|
22
|
+
# min: Date.today,
|
|
23
|
+
# max: Date.today + 365,
|
|
24
|
+
# format: :us
|
|
25
|
+
# )
|
|
26
|
+
#
|
|
27
|
+
class Date < Core::Prompt
|
|
28
|
+
# Supported date format configurations mapping format symbol to segment order and separator.
|
|
29
|
+
FORMATS = {
|
|
30
|
+
iso: {order: [:year, :month, :day], sep: "-"},
|
|
31
|
+
us: {order: [:month, :day, :year], sep: "/"},
|
|
32
|
+
eu: {order: [:day, :month, :year], sep: "/"}
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# Non-leap-year days per month (index 1-12; index 0 unused).
|
|
36
|
+
DAYS_IN_MONTH = [nil, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
|
|
37
|
+
KEY_SHIFT_TAB = "\e[Z" # ANSI escape sequence for Shift+Tab
|
|
38
|
+
|
|
39
|
+
# @param message [String] the prompt message
|
|
40
|
+
# @param format [Symbol] date format (:iso, :us, :eu)
|
|
41
|
+
# @param initial_value [Date, Time, String, nil] initial date value
|
|
42
|
+
# @param min [Date, nil] minimum allowed date
|
|
43
|
+
# @param max [Date, nil] maximum allowed date
|
|
44
|
+
# @param opts [Hash] additional options passed to {Core::Prompt}
|
|
45
|
+
def initialize(message:, format: :iso, initial_value: nil, min: nil, max: nil, **opts)
|
|
46
|
+
super(message:, **opts)
|
|
47
|
+
|
|
48
|
+
raise ArgumentError, "Unknown format: #{format}" unless FORMATS.key?(format)
|
|
49
|
+
raise ArgumentError, "min must be before or equal to max" if min && max && min > max
|
|
50
|
+
|
|
51
|
+
@format = format
|
|
52
|
+
@min = min
|
|
53
|
+
@max = max
|
|
54
|
+
@segment = 0
|
|
55
|
+
@input_buffer = ""
|
|
56
|
+
|
|
57
|
+
init_date(initial_value)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
protected
|
|
61
|
+
|
|
62
|
+
def handle_input(key, action)
|
|
63
|
+
case action
|
|
64
|
+
when :left then move_segment(-1)
|
|
65
|
+
when :right then move_segment(1)
|
|
66
|
+
when :up then adjust_segment(1)
|
|
67
|
+
when :down then adjust_segment(-1)
|
|
68
|
+
else
|
|
69
|
+
case key
|
|
70
|
+
when "\t" then move_segment(1)
|
|
71
|
+
when KEY_SHIFT_TAB then move_segment(-1)
|
|
72
|
+
when /\A\d\z/ then handle_digit(key)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def submit
|
|
78
|
+
@value = ::Date.new(@year, @month, @day)
|
|
79
|
+
super
|
|
80
|
+
rescue ArgumentError
|
|
81
|
+
@error_message = friendly_date_error
|
|
82
|
+
@state = :error
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_frame
|
|
86
|
+
lines = []
|
|
87
|
+
lines << "#{bar}\n"
|
|
88
|
+
lines << "#{symbol_for_state} #{@message}\n"
|
|
89
|
+
lines << help_line
|
|
90
|
+
lines << "#{active_bar} #{date_display}\n"
|
|
91
|
+
lines << "#{bar_end}\n" if %i[active initial].include?(@state)
|
|
92
|
+
|
|
93
|
+
validation_lines = validation_message_lines
|
|
94
|
+
if validation_lines.any?
|
|
95
|
+
lines[-1] = validation_lines.first
|
|
96
|
+
lines.concat(validation_lines[1..])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
lines.join
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_final_frame
|
|
103
|
+
lines = []
|
|
104
|
+
lines << "#{bar}\n"
|
|
105
|
+
lines << "#{symbol_for_state} #{@message}\n"
|
|
106
|
+
|
|
107
|
+
display_text = formatted_date
|
|
108
|
+
display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(display_text)) : Colors.dim(display_text)
|
|
109
|
+
lines << "#{bar} #{display}\n"
|
|
110
|
+
|
|
111
|
+
lines.join
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def init_date(initial)
|
|
117
|
+
date = parse_initial(initial)
|
|
118
|
+
date = clamp_to_bounds(date)
|
|
119
|
+
@year = date.year
|
|
120
|
+
@month = date.month
|
|
121
|
+
@day = date.day
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def parse_initial(initial)
|
|
125
|
+
case initial
|
|
126
|
+
when ::Date then initial
|
|
127
|
+
when ::Time then initial.to_date
|
|
128
|
+
when String then ::Date.parse(initial)
|
|
129
|
+
else ::Date.today
|
|
130
|
+
end
|
|
131
|
+
rescue ::Date::Error
|
|
132
|
+
::Date.today
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def clamp_to_bounds(date)
|
|
136
|
+
return @min if @min && date < @min
|
|
137
|
+
return @max if @max && date > @max
|
|
138
|
+
|
|
139
|
+
date
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Enforce min/max bounds on the current @year/@month/@day components.
|
|
143
|
+
# Constructs a temporary Date (after clamping day to valid range) and
|
|
144
|
+
# writes back if the date falls outside the allowed bounds.
|
|
145
|
+
def enforce_bounds
|
|
146
|
+
return unless @min || @max
|
|
147
|
+
|
|
148
|
+
clamp_day_to_month
|
|
149
|
+
date = ::Date.new(@year, @month, @day)
|
|
150
|
+
clamped = clamp_to_bounds(date)
|
|
151
|
+
return if clamped == date
|
|
152
|
+
|
|
153
|
+
@year = clamped.year
|
|
154
|
+
@month = clamped.month
|
|
155
|
+
@day = clamped.day
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def move_segment(delta)
|
|
159
|
+
commit_input_buffer
|
|
160
|
+
@segment = (@segment + delta) % 3
|
|
161
|
+
@input_buffer = ""
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def adjust_segment(delta)
|
|
165
|
+
commit_input_buffer
|
|
166
|
+
@input_buffer = ""
|
|
167
|
+
|
|
168
|
+
case current_segment_type
|
|
169
|
+
when :year
|
|
170
|
+
@year = (@year + delta).clamp(1, 9999)
|
|
171
|
+
clamp_day_to_month
|
|
172
|
+
when :month
|
|
173
|
+
@month += delta
|
|
174
|
+
@month = wrap_value(@month, 1, 12)
|
|
175
|
+
clamp_day_to_month
|
|
176
|
+
when :day
|
|
177
|
+
max_day = days_in_month(@year, @month)
|
|
178
|
+
@day = wrap_value(@day + delta, 1, max_day)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
enforce_bounds
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def wrap_value(val, min, max)
|
|
185
|
+
return min if val > max
|
|
186
|
+
return max if val < min
|
|
187
|
+
|
|
188
|
+
val
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def handle_digit(digit)
|
|
192
|
+
@input_buffer += digit
|
|
193
|
+
expected_length = (current_segment_type == :year) ? 4 : 2
|
|
194
|
+
|
|
195
|
+
if @input_buffer.length >= expected_length
|
|
196
|
+
commit_input_buffer
|
|
197
|
+
move_segment(1) unless @segment == 2
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def commit_input_buffer
|
|
202
|
+
return if @input_buffer.empty?
|
|
203
|
+
|
|
204
|
+
value = @input_buffer.to_i
|
|
205
|
+
@input_buffer = ""
|
|
206
|
+
|
|
207
|
+
case current_segment_type
|
|
208
|
+
when :year
|
|
209
|
+
@year = value.clamp(1, 9999)
|
|
210
|
+
clamp_day_to_month
|
|
211
|
+
when :month
|
|
212
|
+
@month = value.clamp(1, 12)
|
|
213
|
+
clamp_day_to_month
|
|
214
|
+
when :day
|
|
215
|
+
@day = value.clamp(1, days_in_month(@year, @month))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
enforce_bounds
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def current_segment_type = FORMATS[@format][:order][@segment]
|
|
222
|
+
|
|
223
|
+
def days_in_month(year, month)
|
|
224
|
+
return 29 if month == 2 && leap_year?(year)
|
|
225
|
+
|
|
226
|
+
DAYS_IN_MONTH[month]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def leap_year?(year) = ::Date.leap?(year)
|
|
230
|
+
|
|
231
|
+
def clamp_day_to_month
|
|
232
|
+
max_day = days_in_month(@year, @month)
|
|
233
|
+
@day = max_day if @day > max_day
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def formatted_date
|
|
237
|
+
fmt = FORMATS[@format]
|
|
238
|
+
fmt[:order].map { |type|
|
|
239
|
+
case type
|
|
240
|
+
when :year then @year.to_s.rjust(4, "0")
|
|
241
|
+
when :month then @month.to_s.rjust(2, "0")
|
|
242
|
+
when :day then @day.to_s.rjust(2, "0")
|
|
243
|
+
end
|
|
244
|
+
}.join(fmt[:sep])
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def date_display
|
|
248
|
+
fmt = FORMATS[@format]
|
|
249
|
+
fmt[:order].each_with_index.map { |type, idx|
|
|
250
|
+
text = segment_text_for(type)
|
|
251
|
+
(idx == @segment) ? Colors.inverse(text) : text
|
|
252
|
+
}.join(fmt[:sep])
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def segment_text_for(type)
|
|
256
|
+
showing_buffer = !@input_buffer.empty? && current_segment_type == type
|
|
257
|
+
case type
|
|
258
|
+
when :year
|
|
259
|
+
showing_buffer ? @input_buffer.ljust(4, "_") : @year.to_s.rjust(4, "0")
|
|
260
|
+
when :month
|
|
261
|
+
showing_buffer ? @input_buffer.ljust(2, "_") : @month.to_s.rjust(2, "0")
|
|
262
|
+
when :day
|
|
263
|
+
showing_buffer ? @input_buffer.ljust(2, "_") : @day.to_s.rjust(2, "0")
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def friendly_date_error
|
|
268
|
+
max_day = days_in_month(@year, @month)
|
|
269
|
+
month_name = ::Date::MONTHNAMES[@month]
|
|
270
|
+
|
|
271
|
+
if @day > max_day
|
|
272
|
+
leap_note = (@month == 2 && !leap_year?(@year)) ? " (not a leap year)" : ""
|
|
273
|
+
"#{month_name} #{@year} has #{max_day} days#{leap_note}"
|
|
274
|
+
else
|
|
275
|
+
"Invalid date"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
@@ -2,7 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
module Clack
|
|
4
4
|
module Prompts
|
|
5
|
+
# Multiple-selection prompt with options organized into named groups.
|
|
6
|
+
#
|
|
7
|
+
# Navigate with arrow keys or j/k. Toggle selection with Space.
|
|
8
|
+
# Groups can optionally be toggled as a whole when +selectable_groups+ is enabled.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# features = Clack.group_multiselect(
|
|
12
|
+
# message: "Select features",
|
|
13
|
+
# options: [
|
|
14
|
+
# { label: "Frontend", options: %w[hotwire stimulus] },
|
|
15
|
+
# { label: "Backend", options: %w[sidekiq solid_queue] }
|
|
16
|
+
# ]
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# @example With selectable groups and spacing
|
|
20
|
+
# features = Clack.group_multiselect(
|
|
21
|
+
# message: "Select features",
|
|
22
|
+
# options: [
|
|
23
|
+
# { label: "Frontend", options: [
|
|
24
|
+
# { value: "hotwire", label: "Hotwire" },
|
|
25
|
+
# { value: "stimulus", label: "Stimulus" }
|
|
26
|
+
# ]},
|
|
27
|
+
# { label: "Background", options: [
|
|
28
|
+
# { value: "sidekiq", label: "Sidekiq" },
|
|
29
|
+
# { value: "solid_queue", label: "Solid Queue" }
|
|
30
|
+
# ]}
|
|
31
|
+
# ],
|
|
32
|
+
# selectable_groups: true,
|
|
33
|
+
# group_spacing: 1
|
|
34
|
+
# )
|
|
35
|
+
#
|
|
5
36
|
class GroupMultiselect < Core::Prompt
|
|
37
|
+
# @param message [String] the prompt message
|
|
38
|
+
# @param options [Array<Hash>] groups, each with :label and :options (Array<Hash, String>)
|
|
39
|
+
# @param initial_values [Array] values to pre-select
|
|
40
|
+
# @param required [Boolean] require at least one selection (default: true)
|
|
41
|
+
# @param selectable_groups [Boolean] allow toggling all options in a group at once (default: false)
|
|
42
|
+
# @param group_spacing [Integer] number of blank lines between groups (default: 0)
|
|
43
|
+
# @param cursor_at [Object, nil] value to position cursor at initially
|
|
44
|
+
# @param opts [Hash] additional options passed to {Core::Prompt}
|
|
6
45
|
def initialize(
|
|
7
46
|
message:,
|
|
8
47
|
options:,
|
|
@@ -29,7 +68,6 @@ module Clack
|
|
|
29
68
|
def handle_key(key)
|
|
30
69
|
return if terminal_state?
|
|
31
70
|
|
|
32
|
-
@state = :active if @state == :error
|
|
33
71
|
action = Core::Settings.action?(key)
|
|
34
72
|
|
|
35
73
|
case action
|
|
@@ -48,7 +86,7 @@ module Clack
|
|
|
48
86
|
|
|
49
87
|
def submit
|
|
50
88
|
if @required && @selected.empty?
|
|
51
|
-
@error_message = "Please select at least one option."
|
|
89
|
+
@error_message = "Please select at least one option. Press #{Colors.cyan("space")} to select, #{Colors.cyan("enter")} to submit"
|
|
52
90
|
@state = :error
|
|
53
91
|
return
|
|
54
92
|
end
|
|
@@ -67,7 +105,7 @@ module Clack
|
|
|
67
105
|
is_last_in_group = item[:last_in_group]
|
|
68
106
|
|
|
69
107
|
# Add group spacing before groups (except first)
|
|
70
|
-
if is_group && prev_was_group
|
|
108
|
+
if is_group && !prev_was_group && idx.positive? && @group_spacing.positive?
|
|
71
109
|
@group_spacing.times { lines << "#{active_bar}\n" }
|
|
72
110
|
end
|
|
73
111
|
|
|
@@ -102,9 +140,7 @@ module Clack
|
|
|
102
140
|
|
|
103
141
|
private
|
|
104
142
|
|
|
105
|
-
def normalize_groups(options)
|
|
106
|
-
options.map { |group| normalize_group(group) }
|
|
107
|
-
end
|
|
143
|
+
def normalize_groups(options) = options.map { |group| normalize_group(group) }
|
|
108
144
|
|
|
109
145
|
def normalize_group(group)
|
|
110
146
|
{
|
|
@@ -122,9 +158,7 @@ module Clack
|
|
|
122
158
|
end
|
|
123
159
|
end
|
|
124
160
|
|
|
125
|
-
def build_flat_items
|
|
126
|
-
@groups.flat_map { |group| flatten_group(group) }
|
|
127
|
-
end
|
|
161
|
+
def build_flat_items = @groups.flat_map { |group| flatten_group(group) }
|
|
128
162
|
|
|
129
163
|
def flatten_group(group)
|
|
130
164
|
group_item = {type: :group, label: group[:label], options: group[:options]}
|
|
@@ -138,7 +172,7 @@ module Clack
|
|
|
138
172
|
last_in_group: idx == group[:options].length - 1
|
|
139
173
|
}
|
|
140
174
|
end
|
|
141
|
-
[group_item
|
|
175
|
+
[group_item, *option_items]
|
|
142
176
|
end
|
|
143
177
|
|
|
144
178
|
def selected_options
|
|
@@ -153,11 +187,7 @@ module Clack
|
|
|
153
187
|
return idx if idx
|
|
154
188
|
end
|
|
155
189
|
|
|
156
|
-
|
|
157
|
-
@flat_items.each_with_index do |item, idx|
|
|
158
|
-
return idx if can_select?(item)
|
|
159
|
-
end
|
|
160
|
-
0
|
|
190
|
+
@flat_items.find_index { |item| can_select?(item) } || 0
|
|
161
191
|
end
|
|
162
192
|
|
|
163
193
|
def can_select?(item)
|
|
@@ -211,9 +241,7 @@ module Clack
|
|
|
211
241
|
end
|
|
212
242
|
end
|
|
213
243
|
|
|
214
|
-
def update_value
|
|
215
|
-
@value = @selected.to_a
|
|
216
|
-
end
|
|
244
|
+
def update_value = @value = @selected.to_a
|
|
217
245
|
|
|
218
246
|
def group_display(item, active)
|
|
219
247
|
if @selectable_groups
|