r2ui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +79 -0
  4. data/lib/r2ui/app.rb +311 -34
  5. data/lib/r2ui/canvas.rb +135 -14
  6. data/lib/r2ui/cli/builder.rb +72 -0
  7. data/lib/r2ui/cli/context.rb +45 -0
  8. data/lib/r2ui/cli/definition.rb +174 -0
  9. data/lib/r2ui/cli/ext/ask.rb +133 -0
  10. data/lib/r2ui/cli/ext/box.rb +112 -0
  11. data/lib/r2ui/cli/ext/choose.rb +162 -0
  12. data/lib/r2ui/cli/ext/choose_many.rb +241 -0
  13. data/lib/r2ui/cli/ext/color_option.rb +64 -0
  14. data/lib/r2ui/cli/ext/completion.rb +520 -0
  15. data/lib/r2ui/cli/ext/config.rb +179 -0
  16. data/lib/r2ui/cli/ext/confirm.rb +110 -0
  17. data/lib/r2ui/cli/ext/dashboard.rb +105 -0
  18. data/lib/r2ui/cli/ext/diff.rb +195 -0
  19. data/lib/r2ui/cli/ext/editor.rb +94 -0
  20. data/lib/r2ui/cli/ext/env.rb +108 -0
  21. data/lib/r2ui/cli/ext/examples.rb +62 -0
  22. data/lib/r2ui/cli/ext/filter.rb +269 -0
  23. data/lib/r2ui/cli/ext/format.rb +104 -0
  24. data/lib/r2ui/cli/ext/list.rb +117 -0
  25. data/lib/r2ui/cli/ext/log.rb +89 -0
  26. data/lib/r2ui/cli/ext/pager.rb +97 -0
  27. data/lib/r2ui/cli/ext/pairs.rb +62 -0
  28. data/lib/r2ui/cli/ext/parallel.rb +274 -0
  29. data/lib/r2ui/cli/ext/password.rb +158 -0
  30. data/lib/r2ui/cli/ext/progress.rb +330 -0
  31. data/lib/r2ui/cli/ext/reference.rb +129 -0
  32. data/lib/r2ui/cli/ext/spinner.rb +165 -0
  33. data/lib/r2ui/cli/ext/summary.rb +63 -0
  34. data/lib/r2ui/cli/ext/table.rb +141 -0
  35. data/lib/r2ui/cli/ext/tasks.rb +216 -0
  36. data/lib/r2ui/cli/ext/trace.rb +64 -0
  37. data/lib/r2ui/cli/ext/tree.rb +114 -0
  38. data/lib/r2ui/cli/ext/version.rb +67 -0
  39. data/lib/r2ui/cli/extension.rb +129 -0
  40. data/lib/r2ui/cli/help.rb +115 -0
  41. data/lib/r2ui/cli/helpers.rb +20 -0
  42. data/lib/r2ui/cli/live.rb +150 -0
  43. data/lib/r2ui/cli/parser.rb +165 -0
  44. data/lib/r2ui/cli/prompt.rb +133 -0
  45. data/lib/r2ui/cli/runner.rb +86 -0
  46. data/lib/r2ui/cli/shell.rb +185 -0
  47. data/lib/r2ui/cli/testing.rb +49 -0
  48. data/lib/r2ui/cli.rb +129 -0
  49. data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
  50. data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
  51. data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
  52. data/lib/r2ui/compat/bubbletea/input.rb +269 -0
  53. data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
  54. data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
  55. data/lib/r2ui/compat/bubbletea/model.rb +65 -0
  56. data/lib/r2ui/compat/bubbletea/native.rb +254 -0
  57. data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
  58. data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
  59. data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
  60. data/lib/r2ui/compat/bubbletea/version.rb +8 -0
  61. data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
  62. data/lib/r2ui/compat/bubbletea.rb +16 -0
  63. data/lib/r2ui/compat/lipgloss/border.rb +17 -0
  64. data/lib/r2ui/compat/lipgloss/color.rb +78 -0
  65. data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
  66. data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
  67. data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
  68. data/lib/r2ui/compat/lipgloss/list.rb +85 -0
  69. data/lib/r2ui/compat/lipgloss/position.rb +30 -0
  70. data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
  71. data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
  72. data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
  73. data/lib/r2ui/compat/lipgloss/table.rb +185 -0
  74. data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
  75. data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
  76. data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
  77. data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
  78. data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
  79. data/lib/r2ui/compat/lipgloss/version.rb +15 -0
  80. data/lib/r2ui/compat/lipgloss.rb +64 -0
  81. data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
  82. data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
  83. data/lib/r2ui/component.rb +67 -0
  84. data/lib/r2ui/context.rb +87 -0
  85. data/lib/r2ui/drop_in.rb +16 -0
  86. data/lib/r2ui/dsl/column.rb +11 -3
  87. data/lib/r2ui/dsl/dashboard.rb +45 -9
  88. data/lib/r2ui/dsl/resource.rb +12 -0
  89. data/lib/r2ui/ext/after.rb +54 -0
  90. data/lib/r2ui/ext/async.rb +81 -0
  91. data/lib/r2ui/ext/batch_sequence.rb +54 -0
  92. data/lib/r2ui/ext/countdown.rb +52 -0
  93. data/lib/r2ui/ext/data_table.rb +71 -0
  94. data/lib/r2ui/ext/every.rb +66 -0
  95. data/lib/r2ui/ext/exec.rb +116 -0
  96. data/lib/r2ui/ext/file_picker.rb +78 -0
  97. data/lib/r2ui/ext/focus_report.rb +69 -0
  98. data/lib/r2ui/ext/help.rb +62 -0
  99. data/lib/r2ui/ext/keys.rb +64 -0
  100. data/lib/r2ui/ext/list.rb +145 -0
  101. data/lib/r2ui/ext/messages.rb +87 -0
  102. data/lib/r2ui/ext/mouse.rb +134 -0
  103. data/lib/r2ui/ext/paginator.rb +86 -0
  104. data/lib/r2ui/ext/paste.rb +86 -0
  105. data/lib/r2ui/ext/println.rb +21 -0
  106. data/lib/r2ui/ext/progress.rb +85 -0
  107. data/lib/r2ui/ext/refresh.rb +56 -0
  108. data/lib/r2ui/ext/resize.rb +62 -0
  109. data/lib/r2ui/ext/screen.rb +47 -0
  110. data/lib/r2ui/ext/screen_mode.rb +63 -0
  111. data/lib/r2ui/ext/spinner.rb +59 -0
  112. data/lib/r2ui/ext/state.rb +45 -0
  113. data/lib/r2ui/ext/stopwatch.rb +67 -0
  114. data/lib/r2ui/ext/suspend.rb +56 -0
  115. data/lib/r2ui/ext/text_area.rb +73 -0
  116. data/lib/r2ui/ext/text_input.rb +83 -0
  117. data/lib/r2ui/ext/theme.rb +112 -0
  118. data/lib/r2ui/ext/view.rb +37 -0
  119. data/lib/r2ui/ext/viewport.rb +92 -0
  120. data/lib/r2ui/ext/window_title.rb +51 -0
  121. data/lib/r2ui/extension.rb +199 -0
  122. data/lib/r2ui/history.rb +4 -1
  123. data/lib/r2ui/keys.rb +35 -8
  124. data/lib/r2ui/motion.rb +169 -0
  125. data/lib/r2ui/panel_state.rb +46 -2
  126. data/lib/r2ui/renderer.rb +87 -22
  127. data/lib/r2ui/version.rb +1 -1
  128. data/lib/r2ui/widgets/box.rb +6 -3
  129. data/lib/r2ui/widgets/glyphs.rb +149 -0
  130. data/lib/r2ui/widgets/table.rb +109 -25
  131. data/lib/r2ui.rb +12 -1
  132. metadata +119 -4
  133. data/lib/r2ui/terminal.rb +0 -53
@@ -0,0 +1,585 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ module Compat
5
+ module Gloss
6
+ # Port of the parts of lucasb-eyer/go-colorful v1.2.0 that lipgloss and termenv use: hex parsing
7
+ # and formatting, RGB/Luv/HCL blending, HSL, and the HSLuv distance termenv uses to pick the
8
+ # nearest ANSI/ANSI256 color.
9
+ #
10
+ # c = Colorful.hex("#ff8000") # => Colorful::Color, or nil where Go returns an error
11
+ # c.blend_luv(other, 0.5).hex # => "#rrggbb"
12
+ #
13
+ # Float fidelity: the gem ships Go code compiled for arm64, where the Go compiler fuses `a + x*y`,
14
+ # `a - x*y` and `x*y - a` into single-rounding FMA instructions, and evaluates constant
15
+ # expressions exactly. The formulas below spell out each fused operation (GoMath.madd/msub/nmsub)
16
+ # and use exactly folded constants, so blends land on the same side of rounding ties as the gem.
17
+ module Colorful
18
+ # Float helpers reproducing how Go computes on arm64.
19
+ module GoMath
20
+ module_function
21
+
22
+ # Correctly rounded x*y + z (one rounding, like the FMADD instruction).
23
+ def fma(x, y, z)
24
+ return x * y + z unless x.finite? && y.finite? && z.finite?
25
+ return x * y + z if x.zero? || y.zero?
26
+
27
+ mx, ex = split(x)
28
+ my, ey = split(y)
29
+ mz, ez = split(z)
30
+ mp = mx * my
31
+ ep = ex + ey
32
+ return round_scaled(mp, ep) if mz.zero?
33
+
34
+ e = ep < ez ? ep : ez
35
+ round_scaled((mp << (ep - e)) + (mz << (ez - e)), e)
36
+ end
37
+
38
+ # a + x*y, fused.
39
+ def madd(a, x, y)
40
+ fma(x, y, a)
41
+ end
42
+
43
+ # a - x*y, fused.
44
+ def msub(a, x, y)
45
+ fma(-x, y, a)
46
+ end
47
+
48
+ # x*y - a, fused.
49
+ def nmsub(x, y, a)
50
+ fma(x, y, -a)
51
+ end
52
+
53
+ # Exact integer mantissa and exponent: f == m * 2**e.
54
+ def split(f)
55
+ return [0, 0] if f.zero?
56
+
57
+ frac, exp = Math.frexp(f)
58
+ [Math.ldexp(frac, 53).to_i, exp - 53]
59
+ end
60
+
61
+ # Round m * 2**e to the nearest float, ties to even.
62
+ def round_scaled(m, e)
63
+ return 0.0 if m.zero?
64
+
65
+ sign = m.negative? ? -1.0 : 1.0
66
+ m = m.abs
67
+ shift = m.bit_length - 53
68
+ if shift.positive?
69
+ q = m >> shift
70
+ rem = m & ((1 << shift) - 1)
71
+ half = 1 << (shift - 1)
72
+ q += 1 if rem > half || (rem == half && q.odd?)
73
+ sign * Math.ldexp(q.to_f, e + shift)
74
+ else
75
+ sign * Math.ldexp(m.to_f, e)
76
+ end
77
+ end
78
+
79
+ # math.Mod: like C fmod, exact, and the result takes the sign of x.
80
+ def mod(x, y)
81
+ r = x.abs % y.abs
82
+ x.negative? ? -r : r
83
+ end
84
+
85
+ # math.Pow as Go implements it: x**frac via Exp(frac*Log(x)), then the integer part by
86
+ # repeated squaring of the Frexp mantissa (each product rounded), finally Ldexp.
87
+ def pow(x, y)
88
+ return 1.0 if y.zero? || x == 1.0
89
+ return x if y == 1.0
90
+ return Float::NAN if x.nan? || y.nan?
91
+ return x**y if x.zero? || x.infinite? || y.infinite?
92
+ return Math.sqrt(x) if y == 0.5
93
+ return 1 / Math.sqrt(x) if y == -0.5
94
+
95
+ yi = y.abs.floor.to_f
96
+ yf = y.abs - yi
97
+ return Float::NAN if yf != 0 && x.negative?
98
+
99
+ a1 = 1.0
100
+ ae = 0
101
+ if yf != 0
102
+ if yf > 0.5
103
+ yf -= 1
104
+ yi += 1
105
+ end
106
+ a1 = Math.exp(yf * Math.log(x))
107
+ end
108
+ x1, xe = Math.frexp(x)
109
+ i = yi.to_i
110
+ while i != 0
111
+ if xe < -(1 << 12) || (1 << 12) < xe
112
+ ae += xe
113
+ break
114
+ end
115
+ if i.odd?
116
+ a1 *= x1
117
+ ae += xe
118
+ end
119
+ x1 *= x1
120
+ xe <<= 1
121
+ if x1 < 0.5
122
+ x1 += x1
123
+ xe -= 1
124
+ end
125
+ i >>= 1
126
+ end
127
+ if y.negative?
128
+ a1 = 1 / a1
129
+ ae = -ae
130
+ end
131
+ Math.ldexp(a1, ae)
132
+ end
133
+
134
+ CBRT_B1 = 715_094_163
135
+ CBRT_B2 = 696_219_795
136
+ CBRT_C = 5.42857142857142815906e-01
137
+ CBRT_D = -7.05306122448979611050e-01
138
+ CBRT_E = 1.41428571428571436819e+00
139
+ CBRT_F = 1.60714285714285720630e+00
140
+ CBRT_G = 3.57142857142857150787e-01
141
+ SMALLEST_NORMAL = 2.22507385850720138309e-308
142
+
143
+ # math.Cbrt (pure Go, FreeBSD s_cbrt.c), with arm64's fused multiply-adds.
144
+ def cbrt(x)
145
+ return x if x.zero? || x.nan? || x.infinite?
146
+
147
+ sign = x.negative?
148
+ x = -x if sign
149
+ t = from_bits(bits(x) / 3 + (CBRT_B1 << 32))
150
+ if x < SMALLEST_NORMAL
151
+ t = (1 << 54).to_f * x
152
+ t = from_bits(bits(t) / 3 + (CBRT_B2 << 32))
153
+ end
154
+ r = t * t / x
155
+ s = madd(CBRT_C, r, t)
156
+ t *= CBRT_G + CBRT_F / (s + CBRT_E + CBRT_D / s)
157
+ t = from_bits((bits(t) & (0xFFFFFFFFC << 28)) + (1 << 30))
158
+ s = t * t
159
+ r = x / s
160
+ w = t + t
161
+ r = (r - t) / (w + r)
162
+ t = madd(t, t, r)
163
+ sign ? -t : t
164
+ end
165
+
166
+ def bits(f)
167
+ [f].pack("G").unpack1("Q>")
168
+ end
169
+
170
+ def from_bits(i)
171
+ [i & 0xFFFF_FFFF_FFFF_FFFF].pack("Q>").unpack1("G")
172
+ end
173
+ end
174
+
175
+ extend GoMath
176
+
177
+ D65 = [0.95047, 1.00000, 1.08883].freeze
178
+ HSLUV_D65 = [0.95045592705167, 1.0, 1.089057750759878].freeze
179
+
180
+ # Go constant expressions are folded exactly, then rounded once.
181
+ LAB_EPS = Rational(216, 24_389).to_f # 6.0/29.0*6.0/29.0*6.0/29.0
182
+ LAB_6_29 = Rational(6, 29).to_f # 6.0/29.0
183
+ LAB_4_29 = Rational(4, 29).to_f # 4.0/29.0
184
+ LAB_FINV_K = Rational(108, 841).to_f # 3.0*6.0/29.0*6.0/29.0
185
+ LUV_KAPPA = Rational(24_389, 27).to_f # 29.0/3.0*29.0/3.0*29.0/3.0
186
+ ONE_THIRD = Rational(1, 3).to_f
187
+ TWO_THIRDS = Rational(2, 3).to_f
188
+ INV_GAMMA = Rational(5, 12).to_f # 1.0/2.4
189
+ RAD2DEG = 57.29577951308232087721
190
+ DEG2RAD = 0.01745329251994329576
191
+
192
+ # Go's fmt scanning treats these as space (fmt/scan.go `space` table).
193
+ SPACE_RANGES = [
194
+ [0x0009, 0x000d], [0x0020, 0x0020], [0x0085, 0x0085], [0x00a0, 0x00a0], [0x1680, 0x1680],
195
+ [0x2000, 0x200a], [0x2028, 0x2029], [0x202f, 0x202f], [0x205f, 0x205f], [0x3000, 0x3000]
196
+ ].freeze
197
+ HEX_SCAN_DIGITS = "0123456789aAbBcCdDeEfF_"
198
+
199
+ module_function
200
+
201
+ # colorful.Hex: parses "#rrggbb", or "#rgb" when the string is 4 bytes long. Mirrors
202
+ # fmt.Sscanf(scol, "#%02x%02x%02x") exactly, including its quirks: spaces before a number are
203
+ # skipped, a short final component is accepted ("#12345"), and trailing input is ignored
204
+ # ("#ffffffzz"). Returns nil where Go returns an error.
205
+ def hex(scol)
206
+ scol = scol.to_s
207
+ width = 2
208
+ factor = 1.0 / 255.0
209
+ if scol.bytesize == 4
210
+ width = 1
211
+ factor = 1.0 / 15.0
212
+ end
213
+
214
+ runes = go_runes(scol)
215
+ return nil unless runes[0] == 0x23 # '#'
216
+
217
+ pos = 1
218
+ values = []
219
+ 3.times do
220
+ value, pos = scan_hex_uint8(runes, pos, width)
221
+ return nil if value.nil?
222
+
223
+ values << value
224
+ end
225
+
226
+ Color.new(values[0] * factor, values[1] * factor, values[2] * factor)
227
+ end
228
+
229
+ # One "%0Nx" verb into a uint8, as fmt's doScanf/scanUint do it: SkipSpace (not counted in the
230
+ # width), notEOF, then up to `width` runes of hex digits or '_', parsed with strconv.ParseUint.
231
+ def scan_hex_uint8(runes, pos, width)
232
+ while pos < runes.size
233
+ r = runes[pos]
234
+ if r == 0x0d && runes[pos + 1] == 0x0a
235
+ pos += 1
236
+ next
237
+ end
238
+ return [nil, pos] if r == 0x0a # "unexpected newline"
239
+ break unless go_space?(r)
240
+
241
+ pos += 1
242
+ end
243
+ return [nil, pos] if pos >= runes.size # notEOF
244
+
245
+ limit = pos + width
246
+ start = pos
247
+ pos += 1 while pos < limit && pos < runes.size && runes[pos] < 0x80 && HEX_SCAN_DIGITS.include?(runes[pos].chr)
248
+ return [nil, pos] if pos == start # "expected integer"
249
+
250
+ token = runes[start...pos].pack("U*")
251
+ return [nil, pos] if token.include?("_") # ParseUint with base 16 rejects underscores
252
+
253
+ [token.to_i(16), pos]
254
+ end
255
+
256
+ def go_space?(r)
257
+ SPACE_RANGES.any? { |lo, hi| r >= lo && r <= hi }
258
+ end
259
+
260
+ # Go decodes strings as UTF-8, turning each invalid byte into U+FFFD.
261
+ def go_runes(str)
262
+ str.b.force_encoding(Encoding::UTF_8).scrub("�").codepoints
263
+ end
264
+
265
+ # math.Max(0, math.Min(v, 1)); NaN stays NaN as in Go.
266
+ def clamp01(v)
267
+ return v if v.nan?
268
+
269
+ v = 1.0 if v > 1.0
270
+ v < 0.0 ? 0.0 : v
271
+ end
272
+
273
+ # Go's uint8(float64) as compiled on arm64 (the gem's platform): a saturating conversion to
274
+ # int32 (FCVTZS, NaN -> 0), then the low 8 bits. So -2.0 * 255 + 0.5 wraps to 3, while huge
275
+ # values and +Inf give 0xff. Observed from the real gem.
276
+ def go_uint8(f)
277
+ return 0 if f.nan?
278
+ return 0xff if f >= 2_147_483_647
279
+ return 0 if f <= -2_147_483_648
280
+
281
+ f.to_i & 0xff
282
+ end
283
+
284
+ def interp_angle(a0, a1, t)
285
+ delta = mod(mod(a1 - a0, 360.0) + 540, 360.0) - 180.0
286
+ mod(madd(a0, t, delta) + 360.0, 360.0)
287
+ end
288
+
289
+ def linearize(v)
290
+ return v / 12.92 if v <= 0.04045
291
+
292
+ pow((v + 0.055) / 1.055, 2.4)
293
+ end
294
+
295
+ def delinearize(v)
296
+ return 12.92 * v if v <= 0.0031308
297
+
298
+ nmsub(1.055, pow(v, INV_GAMMA), 0.055)
299
+ end
300
+
301
+ def linear_rgb(r, g, b)
302
+ Color.new(delinearize(r), delinearize(g), delinearize(b))
303
+ end
304
+
305
+ def xyz_to_linear_rgb(x, y, z)
306
+ [
307
+ msub(msub(3.2409699419045214 * x, 1.5373831775700935, y), 0.49861076029300328, z),
308
+ madd(madd(-0.96924363628087983 * x, 1.8759675015077207, y), 0.041555057407175613, z),
309
+ madd(msub(0.055630079696993609 * x, 0.20397695888897657, y), 1.0569715142428786, z)
310
+ ]
311
+ end
312
+
313
+ def linear_rgb_to_xyz(r, g, b)
314
+ [
315
+ madd(madd(0.41239079926595948 * r, 0.35758433938387796, g), 0.18048078840183429, b),
316
+ madd(madd(0.21263900587151036 * r, 0.71516867876775593, g), 0.072192315360733715, b),
317
+ madd(madd(0.019330818715591851 * r, 0.11919477979462599, g), 0.95053215224966058, b)
318
+ ]
319
+ end
320
+
321
+ def xyz(x, y, z)
322
+ linear_rgb(*xyz_to_linear_rgb(x, y, z))
323
+ end
324
+
325
+ def lab_f(t)
326
+ return cbrt(t) if t > LAB_EPS
327
+
328
+ t / 3.0 * 29.0 / 6.0 * 29.0 / 6.0 + LAB_4_29
329
+ end
330
+
331
+ def lab_finv(t)
332
+ return t * t * t if t > LAB_6_29
333
+
334
+ LAB_FINV_K * (t - LAB_4_29)
335
+ end
336
+
337
+ def xyz_to_lab_white_ref(x, y, z, wref)
338
+ fy = lab_f(y / wref[1])
339
+ [nmsub(1.16, fy, 0.16), 5.0 * (lab_f(x / wref[0]) - fy), 2.0 * (fy - lab_f(z / wref[2]))]
340
+ end
341
+
342
+ def lab_to_xyz_white_ref(l, a, b, wref)
343
+ l2 = (l + 0.16) / 1.16
344
+ [wref[0] * lab_finv(l2 + a / 5.0), wref[1] * lab_finv(l2), wref[2] * lab_finv(l2 - b / 2.0)]
345
+ end
346
+
347
+ def lab_white_ref(l, a, b, wref)
348
+ xyz(*lab_to_xyz_white_ref(l, a, b, wref))
349
+ end
350
+
351
+ def xyz_to_uv(x, y, z)
352
+ denom = madd(madd(x, 15.0, y), 3.0, z)
353
+ return [0.0, 0.0] if denom == 0.0
354
+
355
+ [4.0 * x / denom, 9.0 * y / denom]
356
+ end
357
+
358
+ def xyz_to_luv_white_ref(x, y, z, wref)
359
+ l = if y / wref[1] <= LAB_EPS
360
+ y / wref[1] * LUV_KAPPA / 100.0
361
+ else
362
+ nmsub(1.16, cbrt(y / wref[1]), 0.16)
363
+ end
364
+ ubis, vbis = xyz_to_uv(x, y, z)
365
+ un, vn = xyz_to_uv(wref[0], wref[1], wref[2])
366
+ [l, 13.0 * l * (ubis - un), 13.0 * l * (vbis - vn)]
367
+ end
368
+
369
+ def luv_to_xyz_white_ref(l, u, v, wref)
370
+ y = if l <= 0.08
371
+ wref[1] * l * 100.0 * 3.0 / 29.0 * 3.0 / 29.0 * 3.0 / 29.0
372
+ else
373
+ q = (l + 0.16) / 1.16
374
+ wref[1] * (q * q * q)
375
+ end
376
+ un, vn = xyz_to_uv(wref[0], wref[1], wref[2])
377
+ if l != 0.0
378
+ ubis = u / (13.0 * l) + un
379
+ vbis = v / (13.0 * l) + vn
380
+ x = y * 9.0 * ubis / (4.0 * vbis)
381
+ z = y * msub(msub(12.0, 3.0, ubis), 20.0, vbis) / (4.0 * vbis)
382
+ else
383
+ x = 0.0
384
+ y = 0.0
385
+ z = 0.0
386
+ end
387
+ [x, y, z]
388
+ end
389
+
390
+ def luv_white_ref(l, u, v, wref)
391
+ xyz(*luv_to_xyz_white_ref(l, u, v, wref))
392
+ end
393
+
394
+ def luv(l, u, v)
395
+ luv_white_ref(l, u, v, D65)
396
+ end
397
+
398
+ def lab_to_hcl(l, a, b)
399
+ h = if (b - a).abs > 1e-4 && a.abs > 1e-4
400
+ mod(madd(360.0, RAD2DEG, Math.atan2(b, a)), 360.0)
401
+ else
402
+ 0.0
403
+ end
404
+ [h, Math.sqrt(madd(a * a, b, b)), l]
405
+ end
406
+
407
+ def hcl_to_lab(h, c, l)
408
+ rad = DEG2RAD * h
409
+ [l, c * Math.cos(rad), c * Math.sin(rad)]
410
+ end
411
+
412
+ def hcl(h, c, l)
413
+ lab_white_ref(*hcl_to_lab(h, c, l), D65)
414
+ end
415
+
416
+ def luv_to_luv_lch(l, u, v)
417
+ h = if (v - u).abs > 1e-4 && u.abs > 1e-4
418
+ mod(madd(360.0, RAD2DEG, Math.atan2(v, u)), 360.0)
419
+ else
420
+ 0.0
421
+ end
422
+ [l, Math.sqrt(madd(u * u, v, v)), h]
423
+ end
424
+
425
+ # colorful.Hsl: hue in [0..360], saturation and lightness in [0..1].
426
+ def hsl(h, s, l)
427
+ return Color.new(l, l, l) if s == 0
428
+
429
+ t1 = l < 0.5 ? l * (1.0 + s) : msub(l + s, l, s)
430
+ t2 = 2 * l - t1
431
+ h /= 360
432
+ channel = lambda do |t|
433
+ t += 1 if t < 0
434
+ t -= 1 if t > 1
435
+ if 6 * t < 1 then madd(t2, (t1 - t2) * 6, t)
436
+ elsif 2 * t < 1 then t1
437
+ elsif 3 * t < 2 then madd(t2, (t1 - t2) * (TWO_THIRDS - t), 6.0)
438
+ else t2
439
+ end
440
+ end
441
+ Color.new(channel.call(h + ONE_THIRD), channel.call(h), channel.call(h - ONE_THIRD))
442
+ end
443
+
444
+ # --- HSLuv (hsluv.go) ---
445
+
446
+ M = [
447
+ [3.2409699419045214, -1.5373831775700935, -0.49861076029300328],
448
+ [-0.96924363628087983, 1.8759675015077207, 0.041555057407175613],
449
+ [0.055630079696993609, -0.20397695888897657, 1.0569715142428786]
450
+ ].freeze
451
+ KAPPA = 903.2962962962963
452
+ EPSILON = 0.0088564516790356308
453
+
454
+ def luv_lch_to_hsluv(l, c, h)
455
+ c *= 100.0
456
+ l *= 100.0
457
+ s = if l > 99.9999999 || l < 0.00000001
458
+ 0.0
459
+ else
460
+ c / max_chroma_for_lh(l, h) * 100.0
461
+ end
462
+ [h, clamp01(s / 100.0), clamp01(l / 100.0)]
463
+ end
464
+
465
+ def max_chroma_for_lh(l, h)
466
+ h_rad = h / 360.0 * Math::PI * 2.0
467
+ sin = Math.sin(h_rad)
468
+ cos = Math.cos(h_rad)
469
+ min_length = Float::MAX
470
+ get_bounds(l).each do |x, y|
471
+ length = y / msub(sin, x, cos)
472
+ min_length = length if length > 0.0 && length < min_length
473
+ end
474
+ min_length
475
+ end
476
+
477
+ def get_bounds(l)
478
+ sub1 = pow(l + 16.0, 3.0) / 1_560_896.0
479
+ sub2 = sub1 > EPSILON ? sub1 : l / KAPPA
480
+ M.flat_map do |m|
481
+ [0.0, 1.0].map do |k|
482
+ top1 = msub(284_517.0 * m[0], 94_839.0, m[2]) * sub2
483
+ sum = madd(madd(838_422.0 * m[2], 769_860.0, m[1]), 731_718.0, m[0])
484
+ top2 = msub(sum * l * sub2, 769_860.0 * k, l)
485
+ bottom = madd(msub(632_260.0 * m[2], 126_452.0, m[1]) * sub2, 126_452.0, k)
486
+ [top1 / bottom, top2 / bottom]
487
+ end
488
+ end
489
+ end
490
+
491
+ # A color in sRGB, each channel nominally in [0..1] (blends may leave the gamut).
492
+ Color = Struct.new(:r, :g, :b) do
493
+ include GoMath
494
+
495
+ # Color.Hex: "#rrggbb" with Go's +0.5 rounding and uint8 conversion.
496
+ def hex
497
+ format("#%02x%02x%02x", *rgb255)
498
+ end
499
+
500
+ def rgb255
501
+ [Colorful.go_uint8(madd(0.5, r, 255.0)), Colorful.go_uint8(madd(0.5, g, 255.0)),
502
+ Colorful.go_uint8(madd(0.5, b, 255.0))]
503
+ end
504
+
505
+ def clamped
506
+ Color.new(Colorful.clamp01(r), Colorful.clamp01(g), Colorful.clamp01(b))
507
+ end
508
+
509
+ def linear_rgb
510
+ [Colorful.linearize(r), Colorful.linearize(g), Colorful.linearize(b)]
511
+ end
512
+
513
+ def xyz
514
+ Colorful.linear_rgb_to_xyz(*linear_rgb)
515
+ end
516
+
517
+ def lab
518
+ Colorful.xyz_to_lab_white_ref(*xyz, D65)
519
+ end
520
+
521
+ def luv_white_ref(wref)
522
+ Colorful.xyz_to_luv_white_ref(*xyz, wref)
523
+ end
524
+
525
+ def luv
526
+ luv_white_ref(D65)
527
+ end
528
+
529
+ def hcl
530
+ Colorful.lab_to_hcl(*lab)
531
+ end
532
+
533
+ def hsl
534
+ min = [[r, g].min, b].min
535
+ max = [[r, g].max, b].max
536
+ l = (max + min) / 2
537
+ return [0.0, 0.0, l] if min == max
538
+
539
+ s = l < 0.5 ? (max - min) / (max + min) : (max - min) / (2.0 - max - min)
540
+ h = if max == r then (g - b) / (max - min)
541
+ elsif max == g then 2.0 + (b - r) / (max - min)
542
+ else 4.0 + (r - g) / (max - min)
543
+ end
544
+ h *= 60
545
+ h += 360 if h < 0
546
+ [h, s, l]
547
+ end
548
+
549
+ def hsluv
550
+ Colorful.luv_lch_to_hsluv(*Colorful.luv_to_luv_lch(*luv_white_ref(HSLUV_D65)))
551
+ end
552
+
553
+ def distance_hsluv(other)
554
+ h1, s1, l1 = hsluv
555
+ h2, s2, l2 = other.hsluv
556
+ dh = (h1 - h2) / 100.0
557
+ Math.sqrt(madd(madd(dh * dh, s1 - s2, s1 - s2), l1 - l2, l1 - l2))
558
+ end
559
+
560
+ def distance_luv(other)
561
+ l1, u1, v1 = luv
562
+ l2, u2, v2 = other.luv
563
+ Math.sqrt(madd(madd((l1 - l2) * (l1 - l2), u1 - u2, u1 - u2), v1 - v2, v1 - v2))
564
+ end
565
+
566
+ def blend_rgb(other, t)
567
+ Color.new(madd(r, t, other.r - r), madd(g, t, other.g - g), madd(b, t, other.b - b))
568
+ end
569
+
570
+ def blend_luv(other, t)
571
+ l1, u1, v1 = luv
572
+ l2, u2, v2 = other.luv
573
+ Colorful.luv(madd(l1, t, l2 - l1), madd(u1, t, u2 - u1), madd(v1, t, v2 - v1))
574
+ end
575
+
576
+ def blend_hcl(other, t)
577
+ h1, c1, l1 = hcl
578
+ h2, c2, l2 = other.hcl
579
+ Colorful.hcl(Colorful.interp_angle(h1, h2, t), madd(c1, t, c2 - c1), madd(l1, t, l2 - l1)).clamped
580
+ end
581
+ end
582
+ end
583
+ end
584
+ end
585
+ end