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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +159 -49
- data/README.md +53 -17
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +152 -22
- data/book/06-theming.md +105 -25
- data/book/07-components.md +531 -50
- data/book/08-testing.md +100 -20
- data/book/10-locale.md +216 -0
- data/book/README.md +19 -9
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +654 -40
- data/lib/tuile/component/abstract_string_field.rb +114 -68
- data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +38 -21
- data/lib/tuile/component/combo_box.rb +102 -59
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +347 -0
- data/lib/tuile/component/date_time_field.rb +275 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +32 -13
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +28 -1
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +7 -39
- data/lib/tuile/component/layout/box.rb +90 -19
- data/lib/tuile/component/layout.rb +15 -5
- data/lib/tuile/component/list.rb +53 -38
- data/lib/tuile/component/list_dropdown.rb +7 -3
- data/lib/tuile/component/menu_bar/cascade.rb +5 -5
- data/lib/tuile/component/menu_bar.rb +18 -18
- data/lib/tuile/component/notification.rb +32 -18
- data/lib/tuile/component/overlay.rb +26 -8
- data/lib/tuile/component/picker_window.rb +27 -8
- data/lib/tuile/component/popup.rb +2 -2
- data/lib/tuile/component/progress_bar.rb +10 -4
- data/lib/tuile/component/radio_group.rb +41 -23
- data/lib/tuile/component/select.rb +23 -16
- data/lib/tuile/component/slot.rb +3 -3
- data/lib/tuile/component/tab_sheet.rb +6 -6
- data/lib/tuile/component/tabs.rb +11 -11
- data/lib/tuile/component/text_area.rb +26 -18
- data/lib/tuile/component/text_field.rb +55 -26
- data/lib/tuile/component/text_view.rb +40 -19
- data/lib/tuile/component/time_field.rb +479 -0
- data/lib/tuile/component/window.rb +26 -13
- data/lib/tuile/component.rb +635 -131
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +95 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +219 -68
- data/lib/tuile/screen_pane.rb +51 -42
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +110 -32
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +4595 -825
- metadata +14 -9
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -5422
- data/TERMINOLOGY.md +0 -71
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -124
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
data/lib/tuile/locale.rb
ADDED
|
@@ -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
|