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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/README.md +184 -10
  4. data/examples/advanced_prompts.rb +63 -0
  5. data/examples/basic.rb +15 -0
  6. data/examples/create_app.rb +86 -0
  7. data/examples/date_demo.rb +40 -0
  8. data/examples/demo.rb +179 -0
  9. data/examples/full_demo.rb +84 -0
  10. data/examples/group_demo.rb +79 -0
  11. data/examples/images/confirm_example.rb +12 -0
  12. data/examples/images/multiselect_example.rb +15 -0
  13. data/examples/images/password_example.rb +10 -0
  14. data/examples/images/select_example.rb +15 -0
  15. data/examples/images/spinner_example.rb +11 -0
  16. data/examples/images/text_example.rb +11 -0
  17. data/examples/spinner_demo.rb +38 -0
  18. data/examples/tasks_demo.rb +59 -0
  19. data/examples/validation.rb +73 -0
  20. data/lib/clack/colors.rb +97 -3
  21. data/lib/clack/core/ci_mode.rb +35 -0
  22. data/lib/clack/core/cursor.rb +1 -0
  23. data/lib/clack/core/fuzzy_matcher.rb +121 -0
  24. data/lib/clack/core/key_reader.rb +5 -0
  25. data/lib/clack/core/prompt.rb +98 -20
  26. data/lib/clack/core/scroll_helper.rb +54 -0
  27. data/lib/clack/core/settings.rb +11 -3
  28. data/lib/clack/core/text_input_helper.rb +28 -8
  29. data/lib/clack/environment.rb +1 -1
  30. data/lib/clack/log.rb +51 -0
  31. data/lib/clack/note.rb +7 -0
  32. data/lib/clack/prompts/autocomplete.rb +27 -34
  33. data/lib/clack/prompts/autocomplete_multiselect.rb +23 -66
  34. data/lib/clack/prompts/date.rb +280 -0
  35. data/lib/clack/prompts/group_multiselect.rb +46 -18
  36. data/lib/clack/prompts/multiline_text.rb +8 -9
  37. data/lib/clack/prompts/multiselect.rb +3 -5
  38. data/lib/clack/prompts/password.rb +5 -10
  39. data/lib/clack/prompts/path.rb +24 -27
  40. data/lib/clack/prompts/progress.rb +2 -6
  41. data/lib/clack/prompts/range.rb +112 -0
  42. data/lib/clack/prompts/select.rb +2 -6
  43. data/lib/clack/prompts/select_key.rb +5 -8
  44. data/lib/clack/prompts/spinner.rb +12 -10
  45. data/lib/clack/prompts/tasks.rb +47 -62
  46. data/lib/clack/prompts/text.rb +61 -5
  47. data/lib/clack/stream.rb +32 -3
  48. data/lib/clack/symbols.rb +25 -0
  49. data/lib/clack/task_log.rb +3 -5
  50. data/lib/clack/testing.rb +171 -0
  51. data/lib/clack/transformers.rb +8 -7
  52. data/lib/clack/validators.rb +33 -2
  53. data/lib/clack/version.rb +2 -1
  54. data/lib/clack.rb +123 -215
  55. metadata +23 -1
@@ -119,7 +119,7 @@ module Clack
119
119
  if windows? && !windows_terminal?
120
120
  begin
121
121
  IO.console&.respond_to?(:raw)
122
- rescue
122
+ rescue IOError, SystemCallError
123
123
  false
124
124
  end
125
125
  else
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
- # Filtering searches across value, label, and hint fields.
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
- visible_options.each_with_index do |opt, idx|
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
- lines << "#{bar_end}\n"
101
-
102
- lines[-1] = "#{Colors.yellow(Symbols::S_BAR_END)} #{Colors.yellow(@error_message)}\n" if @state == :error
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
- query = @value.downcase
123
- @filtered = @all_options.select do |opt|
124
- opt[:label].downcase.include?(query) ||
125
- opt[:value].to_s.downcase.include?(query) ||
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 visible_options
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:")} #{search_input_display}#{match_count}\n"
144
+ lines << "#{active_bar} #{Colors.dim("Search:")} #{input_display}#{match_count}\n"
141
145
 
142
- visible_options.each_with_index do |opt, idx|
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
- if @state == :error
153
- lines[-2] = "#{Colors.yellow(Symbols::S_BAR)} #{Colors.yellow(@error_message)}\n"
154
- lines[-1] = "#{Colors.yellow(Symbols::S_BAR_END)}\n"
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
- # Override TextInputHelper methods to use @search_text instead of @value
178
- def search_input_display
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
- search_value_with_cursor
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 false unless Core::Settings.printable?(key)
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
- query = @search_text.downcase
236
- @filtered = @all_options.select do |opt|
237
- opt[:label].downcase.include?(query) ||
238
- opt[:value].to_s.downcase.include?(query) ||
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 visible_options
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