tuile 0.14.0 → 0.16.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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -49
  3. data/README.md +53 -17
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +152 -22
  6. data/book/06-theming.md +105 -25
  7. data/book/07-components.md +531 -50
  8. data/book/08-testing.md +100 -20
  9. data/book/10-locale.md +216 -0
  10. data/book/README.md +19 -9
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +654 -40
  14. data/lib/tuile/component/abstract_string_field.rb +114 -68
  15. data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
  16. data/lib/tuile/component/big_decimal_field.rb +52 -79
  17. data/lib/tuile/component/button.rb +8 -8
  18. data/lib/tuile/component/checkbox.rb +9 -9
  19. data/lib/tuile/component/checkbox_group.rb +38 -21
  20. data/lib/tuile/component/combo_box.rb +102 -59
  21. data/lib/tuile/component/confirm_window.rb +7 -5
  22. data/lib/tuile/component/date_field.rb +347 -0
  23. data/lib/tuile/component/date_time_field.rb +275 -0
  24. data/lib/tuile/component/float_field.rb +57 -82
  25. data/lib/tuile/component/has_bad_input.rb +88 -0
  26. data/lib/tuile/component/has_caption.rb +8 -0
  27. data/lib/tuile/component/has_content.rb +32 -13
  28. data/lib/tuile/component/has_placeholder.rb +62 -0
  29. data/lib/tuile/component/has_validation.rb +115 -0
  30. data/lib/tuile/component/has_value.rb +28 -1
  31. data/lib/tuile/component/integer_field.rb +51 -78
  32. data/lib/tuile/component/label.rb +7 -39
  33. data/lib/tuile/component/layout/box.rb +90 -19
  34. data/lib/tuile/component/layout.rb +15 -5
  35. data/lib/tuile/component/list.rb +53 -38
  36. data/lib/tuile/component/list_dropdown.rb +7 -3
  37. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  38. data/lib/tuile/component/menu_bar.rb +18 -18
  39. data/lib/tuile/component/notification.rb +32 -18
  40. data/lib/tuile/component/overlay.rb +26 -8
  41. data/lib/tuile/component/picker_window.rb +27 -8
  42. data/lib/tuile/component/popup.rb +2 -2
  43. data/lib/tuile/component/progress_bar.rb +10 -4
  44. data/lib/tuile/component/radio_group.rb +41 -23
  45. data/lib/tuile/component/select.rb +23 -16
  46. data/lib/tuile/component/slot.rb +3 -3
  47. data/lib/tuile/component/tab_sheet.rb +6 -6
  48. data/lib/tuile/component/tabs.rb +11 -11
  49. data/lib/tuile/component/text_area.rb +26 -18
  50. data/lib/tuile/component/text_field.rb +55 -26
  51. data/lib/tuile/component/text_view.rb +40 -19
  52. data/lib/tuile/component/time_field.rb +479 -0
  53. data/lib/tuile/component/window.rb +26 -13
  54. data/lib/tuile/component.rb +635 -131
  55. data/lib/tuile/event_queue.rb +4 -4
  56. data/lib/tuile/fake_event_queue.rb +1 -1
  57. data/lib/tuile/fake_screen.rb +95 -3
  58. data/lib/tuile/final.rb +75 -0
  59. data/lib/tuile/locale.rb +851 -0
  60. data/lib/tuile/mouse/router.rb +217 -0
  61. data/lib/tuile/mouse.rb +177 -0
  62. data/lib/tuile/screen.rb +219 -68
  63. data/lib/tuile/screen_pane.rb +51 -42
  64. data/lib/tuile/styled_string.rb +5 -5
  65. data/lib/tuile/testing.rb +198 -0
  66. data/lib/tuile/theme.rb +110 -32
  67. data/lib/tuile/version.rb +1 -1
  68. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  69. data/lib/tuile.rb +1 -0
  70. data/sig/tuile.rbs +4595 -825
  71. metadata +14 -9
  72. data/COMPARISON.md +0 -101
  73. data/DECISIONS.md +0 -5422
  74. data/TERMINOLOGY.md +0 -71
  75. data/ideas/arrow-key-navigation.md +0 -221
  76. data/ideas/modal-backdrop.md +0 -24
  77. data/ideas/new-components.md +0 -124
  78. data/ideas/per-component-buffers.md +0 -55
  79. data/lib/tuile/mouse_event.rb +0 -68
@@ -0,0 +1,851 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # The formatting conventions of the person on the other end — a frozen value
5
+ # type held by {Screen#locale}, seeded at construction from {Locale.system}:
6
+ #
7
+ # Screen.instance.locale.date_formats # => ["%Y-%m-%d"]
8
+ # Screen.instance.locale = Locale::ISO.with(decimal_separator: ",")
9
+ #
10
+ # **It holds formatting conventions — how a value is rendered and parsed. It
11
+ # never holds prose.** That is the rule a ninth member has to pass: no
12
+ # message catalogue, no lookup, no pluralization. Book ch10 argues it;
13
+ # `D_locale` records what it excludes.
14
+ #
15
+ # Index a name table with the `Date` accessor that selects it — which is why
16
+ # the two have different shapes:
17
+ #
18
+ # locale.month_names[date.month] # Hash keyed 1..12, since Date#month is 1-based
19
+ # locale.day_names[date.wday] # Array 0..6, since Date#wday is 0-based
20
+ #
21
+ # A 0-based month array would answer `month_names[9] # => "October"`: a
22
+ # plausible wrong answer, silently, which is why the shapes differ rather than
23
+ # matching.
24
+ #
25
+ # {ISO} is the only constant; build your own from it, and every member is
26
+ # validated — by the constructor and again by `Data#with`, so an invalid
27
+ # {Locale} is unreachable:
28
+ #
29
+ # Locale::ISO.with(date_formats: ["%d.%m.%Y", "%Y-%m-%d"])
30
+ #
31
+ # == Implementation details
32
+ # Read it at *use* time and never cache it in an ivar — {Screen#locale=} can
33
+ # replace it mid-session, exactly as {Screen#theme=} can. A {Component} reads
34
+ # it through the protected `Component#locale`, which answers {ISO} when no
35
+ # screen exists at all, so a detached tree still works.
36
+ #
37
+ # @!attribute [r] date_formats
38
+ # The strftime patterns a date field accepts, primary first: it is what
39
+ # {Component::DateField} writes and canonicalizes into, and only it must
40
+ # survive a round-trip. Frozen.
41
+ # @return [Array<String>]
42
+ # @!attribute [r] time_formats
43
+ # The strftime patterns a time field accepts, primary first — carrying the
44
+ # locale's spelling at **full detected precision**, seconds included,
45
+ # because dropping them is a *form field's* policy rather than a
46
+ # convention: {Component::TimeField} strips them per its
47
+ # {Component::TimeField#step}, and a clock display wants them kept. Frozen.
48
+ # @return [Array<String>]
49
+ # @!attribute [r] calendar_start
50
+ # When the Gregorian calendar takes over from the Julian one, as a Julian
51
+ # Day Number — `Date::GREGORIAN` (proleptic) here, *not* Ruby's
52
+ # `Date::ITALY`. See {Component::DateField#calendar_start}.
53
+ # @return [Numeric]
54
+ # @!attribute [r] first_weekday
55
+ # The day a calendar week starts on, in `Date#wday` numbering: 0 = Sunday,
56
+ # 1 = Monday. **Not** glibc's `first_weekday`, which is a 1-based index
57
+ # into a Sunday-first list; {Locale.system} converts.
58
+ # @return [Integer] 0..6.
59
+ # @!attribute [r] month_names
60
+ # Full month names, keyed `1..12` by `Date#month`. Frozen.
61
+ # @return [Hash{Integer => String}]
62
+ # @!attribute [r] abbr_month_names
63
+ # Abbreviated month names, keyed `1..12`. Frozen.
64
+ # @return [Hash{Integer => String}]
65
+ # @!attribute [r] day_names
66
+ # Full weekday names, indexed 0..6 by `Date#wday` (Sunday first). Frozen.
67
+ # @return [Array<String>]
68
+ # @!attribute [r] abbr_day_names
69
+ # Abbreviated weekday names, indexed 0..6. Frozen.
70
+ # @return [Array<String>]
71
+ # @!attribute [r] decimal_separator
72
+ # What separates a number's integer and fractional parts — one grapheme
73
+ # cluster, one column wide.
74
+ # @return [String]
75
+ class Locale < Data.define(:date_formats, :time_formats, :calendar_start, :first_weekday,
76
+ :month_names, :abbr_month_names,
77
+ :day_names, :abbr_day_names, :decimal_separator)
78
+ # The strftime *lexer*, shared by every format validator here — one
79
+ # tokenizer, so {DateFormats} and {TimeFormats} cannot drift on what a
80
+ # directive is:
81
+ #
82
+ # Formats.each_directive("%d.%m.%Y") { |t| p t } # "%d", ".", "%m", ".", "%Y"
83
+ #
84
+ # Each validator over it supplies the rest — a reference value to round-trip
85
+ # against, a hint table, its own by-name rejections. See {DateFormats} and
86
+ # {TimeFormats}.
87
+ module Formats
88
+ # *Any* strftime directive, known or not — flags, width, the `E`/`O`
89
+ # modifiers and the `%::z` colons included. Matching the ones a caller
90
+ # cannot translate is the point: they must reach a hint table's miss
91
+ # rather than falling through as literal text, or `"%Y-%j"` would
92
+ # humanize to the lying hint `"yyyy-%j"`.
93
+ # @return [Regexp]
94
+ DIRECTIVE = /%[-_0^#]*\d*[EO]?:{0,2}[A-Za-z%]/
95
+
96
+ # They *look* like a locale channel and are not: Ruby's `%x` is a fixed
97
+ # `"09/04/26"` under every locale, and it round-trips — so it would pass
98
+ # validation while silently meaning "American". Rejected by name by
99
+ # every validator here.
100
+ # @return [Array<String>]
101
+ LOCALE_LOOKALIKES = %w[%x %X %c].freeze
102
+
103
+ module_function
104
+
105
+ # Yields each strftime directive in `format`, and each character between
106
+ # them one at a time — so a caller can tell `"%%"` (one directive, a
107
+ # literal percent) from a bare `"%"` that is merely text.
108
+ # @param format [String]
109
+ # @yieldparam token [String] a whole directive, or a single character.
110
+ # @return [void]
111
+ def each_directive(format)
112
+ scanner = StringScanner.new(format)
113
+ until scanner.eos?
114
+ directive = scanner.scan(DIRECTIVE)
115
+ yield(directive || scanner.getch)
116
+ end
117
+ end
118
+
119
+ # @param format [String]
120
+ # @return [String, nil] the first locale lookalike in `format`, or `nil`.
121
+ def lookalike(format) = LOCALE_LOOKALIKES.find { format.include?(_1) }
122
+
123
+ # Translates a format through `hints`, or `nil` when it holds any
124
+ # directive the table does not cover — never a half-translated hint.
125
+ #
126
+ # Formats.humanize("%d.%m.%Y", DateFormats::HINTS) # => "dd.mm.yyyy"
127
+ #
128
+ # @param format [String]
129
+ # @param hints [Hash{String => String}]
130
+ # @return [String, nil] frozen.
131
+ def humanize(format, hints)
132
+ hint = +""
133
+ each_directive(format) do |directive|
134
+ next hint << directive if directive.length == 1
135
+
136
+ translated = hints[directive]
137
+ return nil if translated.nil?
138
+
139
+ hint << translated
140
+ end
141
+ hint.freeze
142
+ end
143
+ end
144
+
145
+ # The two rules a strftime *date* format list obeys: what may be *in* one
146
+ # ({validate}) and what one looks like to a human ({humanize}). Both answer
147
+ # at assignment, so a bad format raises there rather than at the first
148
+ # keystroke. {TimeFormats} is its sibling over the same {Formats} lexer.
149
+ module DateFormats
150
+ # The date every format is round-tripped against. Every property is
151
+ # load-bearing: *pre-1969* so `%y` fails (it cannot carry a century),
152
+ # *post-1582-10-15* so the Gregorian reform fails no innocent format,
153
+ # and *month ≠ day* so a `%m`/`%d` swap is not masked. A canary rather
154
+ # than a proof — but a century-lossy directive is lossy in both
155
+ # directions, so one pre-window date catches the class that ships.
156
+ # @return [Date]
157
+ REF = Date.new(1962, 9, 4)
158
+
159
+ # The directives {DateFormats.humanize} can turn into a placeholder.
160
+ # There is deliberately no `%b`/`%B`: a month *name* would need an
161
+ # invented `mmm`, and an app typing month names sets its own hint.
162
+ # @return [Hash{String => String}]
163
+ HINTS = { "%Y" => "yyyy", "%m" => "mm", "%d" => "dd", "%%" => "%" }.freeze
164
+
165
+ module_function
166
+
167
+ # Normalizes one format or a list of them into a frozen `Array` of frozen
168
+ # `String`s, validating each.
169
+ #
170
+ # DateFormats.validate("%d.%m.%Y") # => ["%d.%m.%Y"]
171
+ #
172
+ # **The primary is held to a stricter rule than the rest.** `formats.first`
173
+ # is what a field *writes*, so it must survive a `strftime`/`strptime`
174
+ # round-trip; every later entry only ever *parses*, so it need only be a
175
+ # usable strptime pattern — which is how a lenient list carries a
176
+ # two-digit-year pattern behind its widened one.
177
+ #
178
+ # @param list [String, Array<String>]
179
+ # @return [Array<String>] frozen, as are its elements.
180
+ # @raise [TypeError] on anything but a String or an Array of Strings.
181
+ # @raise [ArgumentError] on an empty list, a locale lookalike, a primary
182
+ # that does not round-trip, or any entry `strptime` cannot use.
183
+ def validate(list)
184
+ formats = list.instance_of?(String) ? [list] : list
185
+ raise TypeError, "expected a String or an Array of Strings, got #{list.inspect}" unless formats.is_a?(Array)
186
+ raise ArgumentError, "expected at least one format" if formats.empty?
187
+
188
+ formats.each_with_index.map { |format, index| validate_one(format, primary: index.zero?) }.freeze
189
+ end
190
+
191
+ # Translates a format into a typing hint, or `nil` when it holds any
192
+ # directive {HINTS} does not cover.
193
+ #
194
+ # DateFormats.humanize("%d.%m.%Y") # => "dd.mm.yyyy"
195
+ # DateFormats.humanize("%Y-%j") # => nil, rather than "yyyy-%j"
196
+ #
197
+ # @param format [String]
198
+ # @return [String, nil] frozen.
199
+ def humanize(format) = Formats.humanize(format, HINTS)
200
+
201
+ # Rewrites every `%y` in `format` as `%Y`, leaving the rest alone.
202
+ #
203
+ # DateFormats.widen("%d/%m/%y") # => "%d/%m/%Y"
204
+ # DateFormats.widen("100%%y") # => "100%%y" — that is a literal %
205
+ #
206
+ # For {validate}'s benefit: a two-digit year cannot round-trip, since
207
+ # `Date.new(1962, 9, 4)` renders `"04/09/62"` and reparses as **2062**
208
+ # under Ruby's fixed POSIX window. So {Locale.system} widens a detected
209
+ # `d_fmt` here rather than losing it, where an app assigning the same
210
+ # pattern gets the rejection instead (`D_locale`).
211
+ #
212
+ # @param format [String]
213
+ # @return [String] frozen.
214
+ def widen(format)
215
+ widened = +""
216
+ Formats.each_directive(format) do |directive|
217
+ widened << (directive.end_with?("y") && directive.length > 1 ? "#{directive[0..-2]}Y" : directive)
218
+ end
219
+ widened.freeze
220
+ end
221
+
222
+ # @param format [String]
223
+ # @param primary [Boolean] whether this is `formats.first`, which is
224
+ # written as well as read and so must round-trip.
225
+ # @return [String] a frozen copy.
226
+ # @raise [TypeError] unless `format` is a String.
227
+ # @raise [ArgumentError] on a locale lookalike or a failed check.
228
+ def validate_one(format, primary: true)
229
+ raise TypeError, "expected a String format, got #{format.inspect}" unless format.instance_of?(String)
230
+
231
+ lookalike = Formats.lookalike(format)
232
+ raise ArgumentError, "#{lookalike} is not locale-aware in Ruby (it is a fixed American format)" if lookalike
233
+
234
+ usable = primary ? round_trips?(format) : parses?(format)
235
+ raise ArgumentError, rejection(format, primary: primary) unless usable
236
+
237
+ format.dup.freeze
238
+ end
239
+
240
+ # @param format [String]
241
+ # @return [Boolean] true iff formatting {REF} and parsing the result back
242
+ # yields {REF} again.
243
+ def round_trips?(format)
244
+ Date.strptime(REF.strftime(format), format) == REF
245
+ rescue ArgumentError # Date::Error is one; so is an unparseable format
246
+ false
247
+ end
248
+
249
+ # @param format [String]
250
+ # @return [Boolean] true iff `strptime` consumes its own `strftime`
251
+ # output whole. Weaker than {round_trips?} on purpose: `"%d/%m/%y"`
252
+ # parses fine, it just parses to the wrong century.
253
+ def parses?(format)
254
+ parsed = Date._strptime(REF.strftime(format), format)
255
+ !parsed.nil? && parsed[:leftover].to_s.empty?
256
+ rescue ArgumentError
257
+ false
258
+ end
259
+
260
+ # @param format [String]
261
+ # @param primary [Boolean]
262
+ # @return [String] why the check failed, in the terms most likely to be
263
+ # the caller's actual mistake.
264
+ def rejection(format, primary: true)
265
+ return "#{format.inspect} is not a usable strptime pattern: #{malformed}" unless primary
266
+
267
+ reason =
268
+ if format.include?("%y")
269
+ "%y cannot carry a century (Ruby reads 69 as 1969 and 26 as 2026), so write %Y — " \
270
+ "it may still appear later in the list, where it only ever parses"
271
+ else
272
+ malformed
273
+ end
274
+ "#{format.inspect} does not survive a strftime/strptime round-trip: #{reason}"
275
+ end
276
+
277
+ # @return [String]
278
+ def malformed
279
+ "it is incomplete, is write-only (strptime takes no `-` flag), " \
280
+ "or is not the directive you meant"
281
+ end
282
+ end
283
+
284
+ # {DateFormats}' sibling for *times of day*, over the same {Formats} lexer.
285
+ # Same two rules — what may be in a list ({validate}), what one looks like
286
+ # to a human ({humanize}) — plus the two operations a time format needs and
287
+ # a date one does not:
288
+ #
289
+ # TimeFormats.expand("%r") # => "%I:%M:%S %p" libc's shorthand
290
+ # TimeFormats.strip_seconds("%H.%M.%S") # => "%H.%M" spelling kept, precision dropped
291
+ #
292
+ # The tables are kept separate from {DateFormats}' on purpose: `%m` → `mm`
293
+ # (month) and `%M` → `mm` (minute) are each right in their own table, and
294
+ # merging them is a question only a date-*and*-time field would have to ask.
295
+ module TimeFormats
296
+ # The time every format is round-tripped against, on
297
+ # {Component::TimeField::MIDNIGHT}'s date. Every property is
298
+ # load-bearing: *hour ≥ 13* so a 12-hour directive with no `%p` fails
299
+ # (`"%I:%M"` writes `"01:45"` and reads back 1 o'clock), *minute ≠ hour*
300
+ # so an `%H`/`%M` swap is not masked, and *second = 0* so a
301
+ # minute-precision primary is legal — which the shipped default is.
302
+ #
303
+ # What it deliberately does not catch is a **precision truncation**:
304
+ # `"%H:%M"` round-trips itself perfectly, and how precise a field is, is
305
+ # {Component::TimeField#step}'s business.
306
+ # @return [Time]
307
+ REF = Time.utc(2000, 1, 1, 13, 45, 0)
308
+
309
+ # The directives {TimeFormats.humanize} can turn into a placeholder.
310
+ # `%p` earns a place where {DateFormats}' `%b` did not: `mmm` would be an
311
+ # invented token, while `AM` is literally what the field prints, and a
312
+ # placeholder is a typing *sample*.
313
+ # @return [Hash{String => String}]
314
+ HINTS = {
315
+ "%H" => "hh", "%I" => "hh", "%k" => "hh", "%l" => "hh",
316
+ "%M" => "mm", "%S" => "ss", "%p" => "AM", "%P" => "am", "%%" => "%"
317
+ }.freeze
318
+
319
+ # libc's compound time formats, expanded at the detection boundary so
320
+ # every later consumer sees one vocabulary. A representation change and
321
+ # nothing more — which is why it belongs here and the *seconds* strip
322
+ # does not (see {strip_seconds}).
323
+ # @return [Hash{String => String}]
324
+ EXPANSIONS = { "%T" => "%H:%M:%S", "%R" => "%H:%M", "%r" => "%I:%M:%S %p" }.freeze
325
+
326
+ # Rejected by name because they **round-trip cleanly and lose information
327
+ # anyway**: `"%H:%M:%S%z"` writes `+0000` and reads it back, while
328
+ # `Date._strptime("13:45:00+0200", "%H:%M:%S%z")` hands back an offset a
329
+ # field with no zone drops on the floor — so a user typing one would see
330
+ # it silently reinterpreted as a local wall time.
331
+ # @return [Array<String>]
332
+ ZONE_DIRECTIVES = ["%z", "%Z", "%:z", "%::z", "%s"].freeze
333
+
334
+ # Rejected by name for the same reason: `"%H:%M:%S.%L"` round-trips (the
335
+ # reference's fraction is 0) while `"13:45:00.500"` parses to a
336
+ # `sec_fraction` a time of day cannot hold.
337
+ # @return [Array<String>]
338
+ SUBSECOND_DIRECTIVES = ["%L", "%N"].freeze
339
+
340
+ module_function
341
+
342
+ # Normalizes one format or a list of them into a frozen `Array` of frozen
343
+ # `String`s, validating each — the primary by round-trip, the rest by
344
+ # whether `strptime` can use them at all ({DateFormats.validate} has the
345
+ # argument; it is the same split).
346
+ # @param list [String, Array<String>]
347
+ # @return [Array<String>] frozen, as are its elements.
348
+ # @raise [TypeError] on anything but a String or an Array of Strings.
349
+ # @raise [ArgumentError] on an empty list, a locale lookalike, a zone or
350
+ # sub-second directive, a primary that does not round-trip, or any
351
+ # entry `strptime` cannot use.
352
+ def validate(list)
353
+ formats = list.instance_of?(String) ? [list] : list
354
+ raise TypeError, "expected a String or an Array of Strings, got #{list.inspect}" unless formats.is_a?(Array)
355
+ raise ArgumentError, "expected at least one format" if formats.empty?
356
+
357
+ formats.each_with_index.map { |format, index| validate_one(format, primary: index.zero?) }.freeze
358
+ end
359
+
360
+ # @param format [String]
361
+ # @return [String, nil] frozen; `nil` when `format` holds a directive
362
+ # {HINTS} does not cover.
363
+ def humanize(format) = Formats.humanize(format, HINTS)
364
+
365
+ # Rewrites libc's compound directives ({EXPANSIONS}) as their components,
366
+ # leaving everything else alone.
367
+ #
368
+ # TimeFormats.expand("%T") # => "%H:%M:%S"
369
+ #
370
+ # @param format [String]
371
+ # @return [String] frozen.
372
+ def expand(format)
373
+ expanded = +""
374
+ Formats.each_directive(format) { expanded << (EXPANSIONS[_1] || _1) }
375
+ expanded.freeze
376
+ end
377
+
378
+ # Drops `%S` and the literal run immediately before it, so a format keeps
379
+ # its *spelling* and loses only its *precision*.
380
+ #
381
+ # TimeFormats.strip_seconds("%I:%M:%S %p") # => "%I:%M %p"
382
+ # TimeFormats.strip_seconds("%H%M%S") # => "%H%M"
383
+ # TimeFormats.strip_seconds("%H:%M") # => "%H:%M" — nothing to drop
384
+ #
385
+ # **Policy, not normalization**, which is why {Locale} carries the
386
+ # full-precision spelling and the *field* applies this: a clock display
387
+ # legitimately wants the seconds `t_fmt` gave it (`D_time_field`).
388
+ # @param format [String]
389
+ # @return [String] frozen.
390
+ def strip_seconds(format)
391
+ tokens = []
392
+ Formats.each_directive(format) { tokens << _1 }
393
+ kept = tokens.each_with_object([]) do |token, out|
394
+ next out << token unless token == "%S"
395
+
396
+ out.pop while out.last&.length == 1 # the separator run in front of it
397
+ end
398
+ kept.join.freeze
399
+ end
400
+
401
+ # Whether `format` writes at least one whole second.
402
+ # @param format [String]
403
+ # @return [Boolean]
404
+ def seconds?(format) = format.include?("%S")
405
+
406
+ # Parses `text` as a time of day, on `epoch`'s date in UTC.
407
+ #
408
+ # Three gates, because `Time` **normalizes where `Date` raised**:
409
+ # `Date._strptime("24:00", "%H:%M")` yields `hour: 24` and
410
+ # `Time.utc(…, 24, 0, 0)` is silently *the next day*, while `"13:45:60"`
411
+ # rolls over to `13:46:00` — both wrong values that save cleanly, and the
412
+ # rollover lands on a different date from every other value the field
413
+ # produces, so comparison and sorting quietly break.
414
+ #
415
+ # @param text [String]
416
+ # @param format [String]
417
+ # @param epoch [Time] whose date the result sits on.
418
+ # @return [Time, nil] `nil` unless `format` consumes `text` whole *and*
419
+ # the fields it yields are a real time of day.
420
+ def parse(text, format, epoch)
421
+ parsed = Date._strptime(text, format)
422
+ return nil if parsed.nil? || !parsed[:leftover].to_s.empty?
423
+
424
+ hour, min, sec = parsed.values_at(:hour, :min, :sec).map { _1 || 0 }
425
+ return nil unless in_range?(hour, min, sec)
426
+
427
+ Time.utc(epoch.year, epoch.month, epoch.day, hour, min, sec)
428
+ rescue ArgumentError
429
+ nil
430
+ end
431
+
432
+ # The one range gate, shared by {parse} and
433
+ # {Component::TimeField.time_of_day} so
434
+ # a parsed time and a constructed one cannot disagree on what is legal.
435
+ # `24:00` is rejected: a legal ISO 8601 end-of-day that `Time` cannot
436
+ # hold.
437
+ # @param hour [Integer]
438
+ # @param min [Integer]
439
+ # @param sec [Integer]
440
+ # @return [Boolean]
441
+ def in_range?(hour, min, sec) = (0..23).cover?(hour) && (0..59).cover?(min) && (0..59).cover?(sec)
442
+
443
+ # @param format [String]
444
+ # @param primary [Boolean]
445
+ # @return [String] a frozen copy.
446
+ # @raise [TypeError] unless `format` is a String.
447
+ # @raise [ArgumentError] on a rejected directive or a failed check.
448
+ def validate_one(format, primary: true)
449
+ raise TypeError, "expected a String format, got #{format.inspect}" unless format.instance_of?(String)
450
+
451
+ reject_by_name(format)
452
+ usable = primary ? round_trips?(format) : parses?(format)
453
+ raise ArgumentError, rejection(format, primary: primary) unless usable
454
+
455
+ format.dup.freeze
456
+ end
457
+
458
+ # @param format [String]
459
+ # @return [void]
460
+ # @raise [ArgumentError] naming the directive and why a time of day
461
+ # cannot carry it.
462
+ def reject_by_name(format)
463
+ lookalike = Formats.lookalike(format)
464
+ raise ArgumentError, "#{lookalike} is not locale-aware in Ruby (it is a fixed American format)" if lookalike
465
+
466
+ zone = ZONE_DIRECTIVES.find { format.include?(_1) }
467
+ raise ArgumentError, "#{zone} carries a time zone, which this field has none of — the offset would be dropped" \
468
+ if zone
469
+
470
+ fraction = SUBSECOND_DIRECTIVES.find { format.include?(_1) }
471
+ raise ArgumentError, "#{fraction} carries a fraction of a second, which a time of day does not hold" if fraction
472
+ end
473
+
474
+ # @param format [String]
475
+ # @return [Boolean] true iff formatting {REF} and parsing the result back
476
+ # yields {REF} again.
477
+ def round_trips?(format) = parse(REF.strftime(format), format, REF) == REF
478
+
479
+ # @param format [String]
480
+ # @return [Boolean] true iff `strptime` consumes its own `strftime`
481
+ # output whole. Weaker than {round_trips?} on purpose: a later entry
482
+ # only ever parses.
483
+ def parses?(format)
484
+ parsed = Date._strptime(REF.strftime(format), format)
485
+ !parsed.nil? && parsed[:leftover].to_s.empty?
486
+ rescue ArgumentError
487
+ false
488
+ end
489
+
490
+ # @param format [String]
491
+ # @param primary [Boolean]
492
+ # @return [String] why the check failed, in the terms most likely to be
493
+ # the caller's actual mistake.
494
+ def rejection(format, primary: true)
495
+ return "#{format.inspect} is not a usable strptime pattern: #{malformed}" unless primary
496
+
497
+ reason =
498
+ if format.match?(/%[Il]/) && !format.match?(/%[pP]/)
499
+ "a 12-hour hour reads back as the morning without a %p or %P beside it"
500
+ else
501
+ malformed
502
+ end
503
+ "#{format.inspect} does not survive a strftime/strptime round-trip: #{reason}"
504
+ end
505
+
506
+ # @return [String]
507
+ def malformed
508
+ "it is incomplete, is write-only (strptime takes no `-` flag), " \
509
+ "or does not write a whole hour and minute"
510
+ end
511
+ end
512
+
513
+ # The month numbers a month table is keyed by — `Date#month`'s range.
514
+ # @return [Array<Integer>]
515
+ MONTHS = (1..12).to_a.freeze
516
+
517
+ # The weekday numbers a day table is indexed by — `Date#wday`'s range,
518
+ # Sunday first.
519
+ # @return [Array<Integer>]
520
+ WEEKDAYS = (0..6).to_a.freeze
521
+
522
+ # The `locale(1)` keywords {.system} asks for, spanning both categories it
523
+ # reads: `LC_TIME` for the date conventions, `LC_NUMERIC` for the numeric
524
+ # one. libc resolves each in its own category, so one call is enough.
525
+ # @return [Array<String>]
526
+ # `t_fmt_ampm` is deliberately absent beside `t_fmt`: en_GB's is
527
+ # `%l:%M:%S %P %Z`, which carries a zone name and a blank-padded 12-hour
528
+ # hour — two directives {TimeFormats} rejects.
529
+ KEYWORDS = %w[d_fmt t_fmt first_weekday mon abmon day abday decimal_point].freeze
530
+
531
+ # Locale names that mean "the user said nothing" — the C/POSIX default,
532
+ # whose conventions are American. Compared against the name with any
533
+ # codeset suffix removed, so `C.UTF-8` counts too.
534
+ # @return [Array<String>]
535
+ SILENT_LOCALES = %w[C POSIX].freeze
536
+
537
+ # The program {.system} asks. POSIX, so present on Linux and macOS; absent
538
+ # on Windows and in some musl containers, where {.system} yields {ISO}.
539
+ # @return [String]
540
+ PROGRAM = "locale"
541
+
542
+ class << self
543
+ # This system's conventions, or {ISO} when it has none to offer — the
544
+ # seed for every new {Screen}.
545
+ #
546
+ # # under en_GB, whose d_fmt is the un-round-trippable "%d/%m/%y":
547
+ # Locale.system.date_formats # => ["%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"]
548
+ # # widened as detected fallback
549
+ #
550
+ # **This shells out** (`locale -k`, ~1 ms) — Ruby exposes no locale data
551
+ # at all — and it is not memoized, so it costs that once per {Screen}.
552
+ #
553
+ # Two contracts a caller depends on:
554
+ #
555
+ # - **Each half is kept only if its own POSIX chain speaks**: `LC_ALL` /
556
+ # `LC_TIME` / `LANG` for the date conventions, `LC_ALL` / `LC_NUMERIC` /
557
+ # `LANG` for the numeric ones, with unset, `C` and `POSIX` all counting
558
+ # as silence. Silence yields the {ISO} member, *not* what `locale(1)`
559
+ # would answer — which is American. Book ch10 has the argument.
560
+ # - **Nothing here fails loudly.** A value that does not validate falls
561
+ # back to its {ISO} member on its own, and a missing binary or any other
562
+ # error yields {ISO} whole. `locale(1)`'s exit status is meaningless in
563
+ # both directions and is ignored.
564
+ #
565
+ # @param env [Hash{String => String}] environment to read the gates from;
566
+ # defaults to `ENV`. The subprocess always inherits the real one.
567
+ # @return [Locale]
568
+ def system(env: ENV)
569
+ return ISO unless speaks?(env, "LC_TIME") || speaks?(env, "LC_NUMERIC")
570
+
571
+ from_keywords(probe, env: env)
572
+ rescue StandardError
573
+ ISO
574
+ end
575
+
576
+ # Builds a {Locale} from `locale -k` keyword values, applying the same
577
+ # per-category gates and per-member fallbacks {.system} does. Public so a
578
+ # spec can drive the conversion with canned answers rather than the
579
+ # machine's own.
580
+ # @api private
581
+ # @param keywords [Hash{String => String}] as parsed from `locale -k`.
582
+ # @param env [Hash{String => String}]
583
+ # @return [Locale]
584
+ def from_keywords(keywords, env: ENV)
585
+ locale = ISO
586
+ if speaks?(env, "LC_TIME")
587
+ locale = merge(locale, :date_formats, date_formats_from(keywords["d_fmt"]))
588
+ locale = merge(locale, :time_formats, time_formats_from(keywords["t_fmt"]))
589
+ locale = merge(locale, :first_weekday, first_weekday_from(keywords["first_weekday"]))
590
+ locale = merge(locale, :month_names, month_table_from(keywords["mon"]))
591
+ locale = merge(locale, :abbr_month_names, month_table_from(keywords["abmon"]))
592
+ locale = merge(locale, :day_names, day_table_from(keywords["day"]))
593
+ locale = merge(locale, :abbr_day_names, day_table_from(keywords["abday"]))
594
+ end
595
+ locale = merge(locale, :decimal_separator, keywords["decimal_point"]) if speaks?(env, "LC_NUMERIC")
596
+ locale
597
+ end
598
+
599
+ # Whether the POSIX chain for one category names a locale at all.
600
+ # @api private
601
+ # @param env [Hash{String => String}]
602
+ # @param category [String] e.g. `"LC_TIME"`.
603
+ # @return [Boolean]
604
+ def speaks?(env, category)
605
+ name = [env["LC_ALL"], env[category], env["LANG"]].map(&:to_s).find { !_1.empty? }
606
+ return false if name.nil?
607
+
608
+ !SILENT_LOCALES.include?(name.split(".").first.to_s.upcase)
609
+ end
610
+
611
+ # @param value [Numeric]
612
+ # @return [Numeric]
613
+ # @raise [TypeError]
614
+ def validate_calendar_start(value)
615
+ raise TypeError, "calendar_start must be Numeric, got #{value.inspect}" unless value.is_a?(Numeric)
616
+
617
+ value
618
+ end
619
+
620
+ # @param value [Integer]
621
+ # @return [Integer]
622
+ # @raise [TypeError]
623
+ # @raise [ArgumentError] outside `Date#wday`'s 0..6.
624
+ def validate_first_weekday(value)
625
+ raise TypeError, "first_weekday must be an Integer, got #{value.inspect}" unless value.is_a?(Integer)
626
+ unless WEEKDAYS.include?(value)
627
+ raise ArgumentError, "first_weekday must be 0..6 in Date#wday numbering (0 = Sunday), got #{value.inspect}"
628
+ end
629
+
630
+ value
631
+ end
632
+
633
+ # @param value [Hash{Integer => String}]
634
+ # @param member [Symbol] for the message.
635
+ # @return [Hash{Integer => String}] frozen, as are its values.
636
+ # @raise [TypeError] on anything but a Hash — an Array especially, which
637
+ # is the mistake this keying exists to prevent.
638
+ # @raise [ArgumentError] unless keyed exactly 1..12 with non-empty names.
639
+ def validate_month_table(value, member)
640
+ unless value.is_a?(Hash)
641
+ raise TypeError,
642
+ "#{member} must be a Hash keyed 1..12 (Date#month is 1-based), got #{value.inspect}"
643
+ end
644
+ raise ArgumentError, "#{member} must be keyed exactly 1..12, got #{value.keys.inspect}" \
645
+ unless value.keys.sort == MONTHS
646
+
647
+ value.to_h { |month, name| [month, validate_name(name, member)] }.freeze
648
+ end
649
+
650
+ # @param value [Array<String>]
651
+ # @param member [Symbol] for the message.
652
+ # @return [Array<String>] frozen, as are its elements.
653
+ # @raise [TypeError] on anything but an Array.
654
+ # @raise [ArgumentError] unless it holds exactly 7 non-empty names.
655
+ def validate_day_table(value, member)
656
+ unless value.is_a?(Array)
657
+ raise TypeError,
658
+ "#{member} must be an Array indexed 0..6 (Date#wday is 0-based), got #{value.inspect}"
659
+ end
660
+ raise ArgumentError, "#{member} must hold exactly 7 names, got #{value.size}" unless value.size == WEEKDAYS.size
661
+
662
+ value.map { validate_name(_1, member) }.freeze
663
+ end
664
+
665
+ # @param value [String]
666
+ # @return [String] frozen.
667
+ # @raise [TypeError]
668
+ # @raise [ArgumentError] unless it is one grapheme cluster one column
669
+ # wide — a painted glyph, held to the same rule as every other glyph
670
+ # knob in Tuile.
671
+ def validate_separator(value)
672
+ raise TypeError, "decimal_separator must be a String, got #{value.inspect}" unless value.instance_of?(String)
673
+
674
+ clusters = value.grapheme_clusters
675
+ unless clusters.size == 1 && Buffer.display_width(value) == 1
676
+ raise ArgumentError,
677
+ "decimal_separator must be one single-column grapheme cluster, got #{value.inspect}"
678
+ end
679
+
680
+ value.dup.freeze
681
+ end
682
+
683
+ private
684
+
685
+ # @param name [Object]
686
+ # @param member [Symbol]
687
+ # @return [String] frozen.
688
+ def validate_name(name, member)
689
+ raise TypeError, "#{member} must hold Strings, got #{name.inspect}" unless name.instance_of?(String)
690
+ raise ArgumentError, "#{member} must hold non-empty names" if name.empty?
691
+
692
+ name.dup.freeze
693
+ end
694
+
695
+ # Applies one detected member, keeping what {ISO} had whenever the value
696
+ # is absent or does not validate. Per-member rather than all-or-nothing,
697
+ # and it reuses the real validator rather than restating its shape rules.
698
+ # @param locale [Locale]
699
+ # @param member [Symbol]
700
+ # @param value [Object, nil]
701
+ # @return [Locale]
702
+ def merge(locale, member, value)
703
+ return locale if value.nil?
704
+
705
+ locale.with(member => value)
706
+ rescue StandardError
707
+ locale
708
+ end
709
+
710
+ # Runs `locale -k` and parses its `key=value` lines.
711
+ # @return [Hash{String => String}] empty when the program is missing or
712
+ # says nothing.
713
+ def probe
714
+ output = IO.popen([PROGRAM, "-k", *KEYWORDS], err: File::NULL, &:read)
715
+ parse_keywords(output.to_s)
716
+ rescue SystemCallError, IOError
717
+ {}
718
+ end
719
+
720
+ # @param output [String]
721
+ # @return [Hash{String => String}]
722
+ def parse_keywords(output)
723
+ output.each_line.filter_map do |line|
724
+ key, separator, value = line.chomp.partition("=")
725
+ next if separator.empty?
726
+
727
+ [key, unquote(value)]
728
+ end.to_h
729
+ end
730
+
731
+ # `locale -k` quotes string values and leaves numeric ones bare.
732
+ # @param value [String]
733
+ # @return [String]
734
+ def unquote(value)
735
+ quoted = value.length >= 2 && value.start_with?('"') && value.end_with?('"')
736
+ quoted ? value[1..-2] : value
737
+ end
738
+
739
+ # The detected list: the widened pattern as primary, the raw one behind
740
+ # it so what the user types is still understood, and ISO last as a
741
+ # universal fallback. Lenient in, strict out.
742
+ # @param raw [String, nil] the `d_fmt` value.
743
+ # @return [Array<String>, nil]
744
+ def date_formats_from(raw)
745
+ return nil if raw.to_s.empty?
746
+
747
+ [DateFormats.widen(raw), raw, ISO.date_formats.first].uniq
748
+ end
749
+
750
+ # The detected list: libc's shorthand expanded, ISO behind it as a
751
+ # universal fallback. **Seconds are kept** — `t_fmt` is a clock-display
752
+ # format, and reducing it to minutes is
753
+ # {Component::TimeField#step}'s policy, applied where a clock display can
754
+ # still opt out of it (`D_time_field`).
755
+ #
756
+ # No *raw* entry, unlike {date_formats_from}: there is no widening here,
757
+ # so the expansion parses everything the shorthand did.
758
+ # @param raw [String, nil] the `t_fmt` value.
759
+ # @return [Array<String>, nil]
760
+ def time_formats_from(raw)
761
+ return nil if raw.to_s.empty?
762
+
763
+ [TimeFormats.expand(raw), ISO.time_formats.first].uniq
764
+ end
765
+
766
+ # glibc's `first_weekday` is a **1-based index into `day`, which starts
767
+ # at Sunday** — so its Monday is 2. Converted here, at the boundary,
768
+ # never at the consumer.
769
+ # @param raw [String, nil]
770
+ # @return [Integer, nil]
771
+ def first_weekday_from(raw)
772
+ return nil if raw.to_s.empty?
773
+
774
+ Integer(raw, 10) - 1
775
+ rescue ArgumentError, TypeError
776
+ nil
777
+ end
778
+
779
+ # @param raw [String, nil] a `;`-separated `mon` / `abmon` value.
780
+ # @return [Hash{Integer => String}, nil]
781
+ def month_table_from(raw)
782
+ names = split_list(raw)
783
+ names.size == MONTHS.size ? MONTHS.zip(names).to_h : nil
784
+ end
785
+
786
+ # @param raw [String, nil] a `;`-separated `day` / `abday` value.
787
+ # @return [Array<String>, nil]
788
+ def day_table_from(raw)
789
+ names = split_list(raw)
790
+ names.size == WEEKDAYS.size ? names : nil
791
+ end
792
+
793
+ # @param raw [String, nil]
794
+ # @return [Array<String>]
795
+ def split_list(raw) = raw.to_s.split(";", -1)
796
+ end
797
+
798
+ # @param date_formats [Array<String>, String]
799
+ # @param time_formats [Array<String>, String]
800
+ # @param calendar_start [Numeric]
801
+ # @param first_weekday [Integer]
802
+ # @param month_names [Hash{Integer => String}]
803
+ # @param abbr_month_names [Hash{Integer => String}]
804
+ # @param day_names [Array<String>]
805
+ # @param abbr_day_names [Array<String>]
806
+ # @param decimal_separator [String]
807
+ # @raise [TypeError] on a member of the wrong type.
808
+ # @raise [ArgumentError] on a member of the wrong shape — a format list
809
+ # that does not validate, a month table not keyed `1..12`, a day table
810
+ # that is not 7 long, a `first_weekday` outside 0..6, or a decimal
811
+ # separator that is not one single-column grapheme cluster.
812
+ def initialize(date_formats:, time_formats:, calendar_start:, first_weekday:, month_names:,
813
+ abbr_month_names:, day_names:, abbr_day_names:, decimal_separator:)
814
+ super(
815
+ date_formats: DateFormats.validate(date_formats),
816
+ time_formats: TimeFormats.validate(time_formats),
817
+ calendar_start: Locale.validate_calendar_start(calendar_start),
818
+ first_weekday: Locale.validate_first_weekday(first_weekday),
819
+ month_names: Locale.validate_month_table(month_names, :month_names),
820
+ abbr_month_names: Locale.validate_month_table(abbr_month_names, :abbr_month_names),
821
+ day_names: Locale.validate_day_table(day_names, :day_names),
822
+ abbr_day_names: Locale.validate_day_table(abbr_day_names, :abbr_day_names),
823
+ decimal_separator: Locale.validate_separator(decimal_separator)
824
+ )
825
+ end
826
+
827
+ # The ISO 8601 floor, and the only constant this file ships: it is what a
828
+ # Windows box, a musl container, a `LANG=C` runner and a failed probe all
829
+ # get. Three of its members cite the same standard — ISO 8601 dates, an ISO
830
+ # 8601 Monday week start, and the proleptic Gregorian calendar ISO 8601
831
+ # mandates — which is what makes it a coherent floor rather than a bag of
832
+ # defaults.
833
+ #
834
+ # The names are Ruby's own frozen English tables, re-keyed but not
835
+ # authored, so Tuile still ships zero locale data of its own. The decimal
836
+ # separator is `"."` because `Float#to_s` and `BigDecimal#to_s` write one,
837
+ # and a field's `value=` goes through them.
838
+ # @return [Locale]
839
+ ISO = new(
840
+ date_formats: ["%Y-%m-%d"],
841
+ time_formats: ["%H:%M:%S"],
842
+ calendar_start: Date::GREGORIAN,
843
+ first_weekday: 1,
844
+ month_names: MONTHS.zip(Date::MONTHNAMES[1..]).to_h,
845
+ abbr_month_names: MONTHS.zip(Date::ABBR_MONTHNAMES[1..]).to_h,
846
+ day_names: Date::DAYNAMES,
847
+ abbr_day_names: Date::ABBR_DAYNAMES,
848
+ decimal_separator: "."
849
+ )
850
+ end
851
+ end