clack 0.1.4 → 0.4.1
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 +76 -1
- data/README.md +184 -10
- 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/ci_mode.rb +35 -0
- data/lib/clack/core/cursor.rb +1 -0
- data/lib/clack/core/fuzzy_matcher.rb +121 -0
- data/lib/clack/core/key_reader.rb +5 -0
- data/lib/clack/core/prompt.rb +98 -20
- data/lib/clack/core/scroll_helper.rb +54 -0
- data/lib/clack/core/settings.rb +11 -3
- data/lib/clack/core/text_input_helper.rb +28 -8
- data/lib/clack/environment.rb +1 -1
- data/lib/clack/log.rb +51 -0
- data/lib/clack/note.rb +7 -0
- data/lib/clack/prompts/autocomplete.rb +27 -34
- data/lib/clack/prompts/autocomplete_multiselect.rb +23 -66
- 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 +24 -27
- data/lib/clack/prompts/progress.rb +2 -6
- data/lib/clack/prompts/range.rb +112 -0
- data/lib/clack/prompts/select.rb +2 -6
- data/lib/clack/prompts/select_key.rb +5 -8
- data/lib/clack/prompts/spinner.rb +12 -10
- data/lib/clack/prompts/tasks.rb +47 -62
- data/lib/clack/prompts/text.rb +61 -5
- 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/testing.rb +171 -0
- 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 +123 -215
- metadata +23 -1
data/lib/clack/environment.rb
CHANGED
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 uses fuzzy matching across value, label, and
|
|
11
|
+
# hint fields, sorted by relevance score. Supply a custom +filter+
|
|
12
|
+
# proc to override this behavior.
|
|
11
13
|
#
|
|
12
14
|
# @example Basic usage
|
|
13
15
|
# color = Clack.autocomplete(
|
|
@@ -23,20 +25,32 @@ 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
|
|
38
|
+
include Core::ScrollHelper
|
|
29
39
|
|
|
30
40
|
# @param message [String] the prompt message
|
|
31
41
|
# @param options [Array<Hash, String>] list of options to filter
|
|
32
42
|
# @param max_items [Integer] max visible options (default: 5)
|
|
33
43
|
# @param placeholder [String, nil] placeholder text when empty
|
|
44
|
+
# @param filter [Proc, nil] custom filter proc receiving (option_hash, query_string)
|
|
45
|
+
# and returning true/false. When nil, the default fuzzy matching
|
|
46
|
+
# across label, value, and hint is used.
|
|
34
47
|
# @param opts [Hash] additional options passed to {Core::Prompt}
|
|
35
|
-
def initialize(message:, options:, max_items: 5, placeholder: nil, **opts)
|
|
48
|
+
def initialize(message:, options:, max_items: 5, placeholder: nil, filter: nil, **opts)
|
|
36
49
|
super(message:, **opts)
|
|
37
50
|
@all_options = normalize_options(options)
|
|
38
51
|
@max_items = max_items
|
|
39
52
|
@placeholder = placeholder
|
|
53
|
+
@filter = filter
|
|
40
54
|
@value = ""
|
|
41
55
|
@cursor = 0
|
|
42
56
|
@selected_index = 0
|
|
@@ -49,7 +63,6 @@ module Clack
|
|
|
49
63
|
def handle_key(key)
|
|
50
64
|
return if terminal_state?
|
|
51
65
|
|
|
52
|
-
@state = :active if @state == :error
|
|
53
66
|
action = Core::Settings.action?(key)
|
|
54
67
|
|
|
55
68
|
case action
|
|
@@ -92,14 +105,16 @@ module Clack
|
|
|
92
105
|
lines << help_line
|
|
93
106
|
lines << "#{active_bar} #{input_display}\n"
|
|
94
107
|
|
|
95
|
-
|
|
108
|
+
visible_items.each_with_index do |opt, idx|
|
|
96
109
|
actual_idx = @scroll_offset + idx
|
|
97
110
|
lines << "#{bar} #{option_display(opt, actual_idx == @selected_index)}\n"
|
|
98
111
|
end
|
|
99
112
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
113
|
+
if @state in :error | :warning
|
|
114
|
+
lines.concat(validation_message_lines)
|
|
115
|
+
else
|
|
116
|
+
lines << "#{bar_end}\n"
|
|
117
|
+
end
|
|
103
118
|
|
|
104
119
|
lines.join
|
|
105
120
|
end
|
|
@@ -119,36 +134,14 @@ module Clack
|
|
|
119
134
|
private
|
|
120
135
|
|
|
121
136
|
def update_filtered
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
opt[:hint]&.downcase&.include?(query)
|
|
137
|
+
@filtered = if @filter
|
|
138
|
+
@all_options.select { |opt| @filter.call(opt, @value) }
|
|
139
|
+
else
|
|
140
|
+
Core::FuzzyMatcher.filter(@all_options, @value)
|
|
127
141
|
end
|
|
128
142
|
end
|
|
129
143
|
|
|
130
|
-
def
|
|
131
|
-
return @filtered if @filtered.length <= @max_items
|
|
132
|
-
|
|
133
|
-
@filtered[@scroll_offset, @max_items]
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def move_selection(delta)
|
|
137
|
-
return if @filtered.empty?
|
|
138
|
-
|
|
139
|
-
@selected_index = (@selected_index + delta) % @filtered.length
|
|
140
|
-
update_scroll
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def update_scroll
|
|
144
|
-
return unless @filtered.length > @max_items
|
|
145
|
-
|
|
146
|
-
if @selected_index < @scroll_offset
|
|
147
|
-
@scroll_offset = @selected_index
|
|
148
|
-
elsif @selected_index >= @scroll_offset + @max_items
|
|
149
|
-
@scroll_offset = @selected_index - @max_items + 1
|
|
150
|
-
end
|
|
151
|
-
end
|
|
144
|
+
def scroll_items = @filtered
|
|
152
145
|
|
|
153
146
|
def option_display(opt, active)
|
|
154
147
|
hint = (opt[:hint] && active) ? Colors.dim(" (#{opt[:hint]})") : ""
|
|
@@ -30,6 +30,7 @@ module Clack
|
|
|
30
30
|
class AutocompleteMultiselect < Core::Prompt
|
|
31
31
|
include Core::OptionsHelper
|
|
32
32
|
include Core::TextInputHelper
|
|
33
|
+
include Core::ScrollHelper
|
|
33
34
|
|
|
34
35
|
# @param message [String] the prompt message
|
|
35
36
|
# @param options [Array<Hash, String>] list of options to filter
|
|
@@ -37,13 +38,17 @@ module Clack
|
|
|
37
38
|
# @param placeholder [String, nil] placeholder text when empty
|
|
38
39
|
# @param required [Boolean] require at least one selection (default: true)
|
|
39
40
|
# @param initial_values [Array, nil] values to pre-select
|
|
41
|
+
# @param filter [Proc, nil] custom filter proc receiving (option_hash, query_string)
|
|
42
|
+
# and returning true/false. When nil, the default fuzzy matching
|
|
43
|
+
# across label, value, and hint is used.
|
|
40
44
|
# @param opts [Hash] additional options passed to {Core::Prompt}
|
|
41
|
-
def initialize(message:, options:, max_items: 5, placeholder: nil, required: true, initial_values: nil, **opts)
|
|
45
|
+
def initialize(message:, options:, max_items: 5, placeholder: nil, required: true, initial_values: nil, filter: nil, **opts)
|
|
42
46
|
super(message:, **opts)
|
|
43
47
|
@all_options = normalize_options(options)
|
|
44
48
|
@max_items = max_items
|
|
45
49
|
@placeholder = placeholder
|
|
46
50
|
@required = required
|
|
51
|
+
@filter = filter
|
|
47
52
|
@search_text = ""
|
|
48
53
|
@cursor = 0
|
|
49
54
|
@selected_index = 0
|
|
@@ -57,7 +62,6 @@ module Clack
|
|
|
57
62
|
def handle_key(key)
|
|
58
63
|
return if terminal_state?
|
|
59
64
|
|
|
60
|
-
@state = :active if @state == :error
|
|
61
65
|
action = Core::Settings.action?(key)
|
|
62
66
|
|
|
63
67
|
case action
|
|
@@ -123,7 +127,7 @@ module Clack
|
|
|
123
127
|
|
|
124
128
|
def submit_selection
|
|
125
129
|
if @required && @selected_values.empty?
|
|
126
|
-
@error_message = "Please select at least one option"
|
|
130
|
+
@error_message = "Please select at least one option. Press #{Colors.cyan("space")} to select, #{Colors.cyan("enter")} to submit"
|
|
127
131
|
@state = :error
|
|
128
132
|
return
|
|
129
133
|
end
|
|
@@ -137,9 +141,9 @@ module Clack
|
|
|
137
141
|
lines << "#{bar}\n"
|
|
138
142
|
lines << "#{symbol_for_state} #{@message}\n"
|
|
139
143
|
lines << help_line
|
|
140
|
-
lines << "#{active_bar} #{Colors.dim("Search:")} #{
|
|
144
|
+
lines << "#{active_bar} #{Colors.dim("Search:")} #{input_display}#{match_count}\n"
|
|
141
145
|
|
|
142
|
-
|
|
146
|
+
visible_items.each_with_index do |opt, idx|
|
|
143
147
|
actual_idx = @scroll_offset + idx
|
|
144
148
|
lines << "#{active_bar} #{option_display(opt, actual_idx == @selected_index)}\n"
|
|
145
149
|
end
|
|
@@ -149,9 +153,10 @@ module Clack
|
|
|
149
153
|
lines << "#{active_bar} #{instructions}\n"
|
|
150
154
|
lines << "#{bar_end}\n"
|
|
151
155
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
lines[-1] =
|
|
156
|
+
validation_lines = validation_message_lines
|
|
157
|
+
if validation_lines.any?
|
|
158
|
+
lines[-1] = validation_lines.first
|
|
159
|
+
lines.concat(validation_lines[1..])
|
|
155
160
|
end
|
|
156
161
|
|
|
157
162
|
lines.join
|
|
@@ -174,45 +179,19 @@ module Clack
|
|
|
174
179
|
|
|
175
180
|
private
|
|
176
181
|
|
|
177
|
-
#
|
|
178
|
-
def
|
|
179
|
-
return placeholder_display if @search_text.empty?
|
|
182
|
+
# Use @search_text as text input backing store
|
|
183
|
+
def text_value = @search_text
|
|
180
184
|
|
|
181
|
-
|
|
185
|
+
def text_value=(val)
|
|
186
|
+
@search_text = val
|
|
182
187
|
end
|
|
183
188
|
|
|
184
|
-
def search_value_with_cursor
|
|
185
|
-
chars = @search_text.grapheme_clusters
|
|
186
|
-
return "#{@search_text}#{cursor_block}" if @cursor >= chars.length
|
|
187
|
-
|
|
188
|
-
before = chars[0...@cursor].join
|
|
189
|
-
current = Colors.inverse(chars[@cursor])
|
|
190
|
-
after = chars[(@cursor + 1)..].join
|
|
191
|
-
"#{before}#{current}#{after}"
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
# Override to work with @search_text instead of @value
|
|
195
189
|
def handle_text_input(key)
|
|
196
|
-
return
|
|
197
|
-
|
|
198
|
-
chars = @search_text.grapheme_clusters
|
|
199
|
-
|
|
200
|
-
if Core::Settings.backspace?(key)
|
|
201
|
-
return false if @cursor.zero?
|
|
202
|
-
|
|
203
|
-
chars.delete_at(@cursor - 1)
|
|
204
|
-
@search_text = chars.join
|
|
205
|
-
@cursor -= 1
|
|
206
|
-
else
|
|
207
|
-
chars.insert(@cursor, key)
|
|
208
|
-
@search_text = chars.join
|
|
209
|
-
@cursor += 1
|
|
210
|
-
end
|
|
190
|
+
return unless super
|
|
211
191
|
|
|
212
192
|
@selected_index = 0
|
|
213
193
|
@scroll_offset = 0
|
|
214
194
|
update_filtered
|
|
215
|
-
true
|
|
216
195
|
end
|
|
217
196
|
|
|
218
197
|
def match_count
|
|
@@ -232,36 +211,14 @@ module Clack
|
|
|
232
211
|
end
|
|
233
212
|
|
|
234
213
|
def update_filtered
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
opt[:hint]&.downcase&.include?(query)
|
|
214
|
+
@filtered = if @filter
|
|
215
|
+
@all_options.select { |opt| @filter.call(opt, @search_text) }
|
|
216
|
+
else
|
|
217
|
+
Core::FuzzyMatcher.filter(@all_options, @search_text)
|
|
240
218
|
end
|
|
241
219
|
end
|
|
242
220
|
|
|
243
|
-
def
|
|
244
|
-
return @filtered if @filtered.length <= @max_items
|
|
245
|
-
|
|
246
|
-
@filtered[@scroll_offset, @max_items]
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
def move_selection(delta)
|
|
250
|
-
return if @filtered.empty?
|
|
251
|
-
|
|
252
|
-
@selected_index = (@selected_index + delta) % @filtered.length
|
|
253
|
-
update_scroll
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
def update_scroll
|
|
257
|
-
return unless @filtered.length > @max_items
|
|
258
|
-
|
|
259
|
-
if @selected_index < @scroll_offset
|
|
260
|
-
@scroll_offset = @selected_index
|
|
261
|
-
elsif @selected_index >= @scroll_offset + @max_items
|
|
262
|
-
@scroll_offset = @selected_index - @max_items + 1
|
|
263
|
-
end
|
|
264
|
-
end
|
|
221
|
+
def scroll_items = @filtered
|
|
265
222
|
|
|
266
223
|
def option_display(opt, active)
|
|
267
224
|
is_selected = @selected_values.include?(opt[:value])
|
|
@@ -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
|