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,768 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Terminal colors and SGR styling: a port of muesli/termenv v0.16.0 (profiles, colors, Style, and the
4
+ # Unix color-profile / background detection) plus lipgloss v1.1.0 color.go and renderer.go.
5
+ #
6
+ # API used by Lipgloss::Style (mirrors termenv / lipgloss Go names):
7
+ #
8
+ # R2UI::Compat::Gloss::Termenv
9
+ # Profile::TRUE_COLOR / ANSI256 / ANSI / ASCII termenv.Profile values (frozen Profile objects)
10
+ # Profile.resolve(:true_color | :ansi256 | :ansi | :ascii | Profile) -> Profile
11
+ # Profile#color(str) -> termenv color or nil (Profile.Color: "#hex", "0".."255", "" -> nil)
12
+ # Profile#convert(c) -> color or nil (Profile.Convert: degrade to the profile)
13
+ # Profile#string -> Style (Profile.String(): a Style for this profile)
14
+ # NoColor.new / ANSIColor.new(n) / ANSI256Color.new(n) / RGBColor.new("#hex")
15
+ # #sequence(bg = false) -> "31", "38;5;123", "48;2;1;2;3", "" (Color.Sequence)
16
+ # Termenv.convert_to_rgb(c) -> Colorful::Color (ConvertToRGB)
17
+ # Style.new(profile = Profile::TRUE_COLOR) (termenv.Style; Style{} is TrueColor in Go)
18
+ # #bold #faint #italic #underline #overline #blink #reverse #cross_out -> new Style
19
+ # #foreground(color_or_nil) #background(color_or_nil) -> new Style (nil: no-op)
20
+ # #styled(str) -> "\e[<seq>m" + str + "\e[0m", or str for ASCII / no styles / empty sequence
21
+ # Output.new(io: STDOUT, env: ENV) termenv.Output: color_profile, env_color_profile,
22
+ # env_no_color?, tty?, background_color,
23
+ # has_dark_background?
24
+ #
25
+ # R2UI::Compat::Gloss::Renderer lipgloss.Renderer; the default one is global
26
+ # Renderer.default the renderer Lipgloss uses
27
+ # Renderer.color_profile -> Termenv::Profile detected lazily, once (EnvColorProfile on stdout)
28
+ # Renderer.has_dark_background? -> bool detected lazily, once (OSC 11 query on a TTY)
29
+ # Renderer.terminal_color(value, adaptive: true, complete: true, strict: true)
30
+ # Ruby color value -> TerminalColor, validated like
31
+ # the C extension (raises the same TypeErrors)
32
+ # Renderer.color(value) -> termenv color or nil terminal_color(value).color(Renderer.default)
33
+ # Test hooks (r2ui only): Renderer.color_profile = :true_color | :ansi256 | :ansi | :ascii | nil,
34
+ # Renderer.has_dark_background = true | false | nil, Renderer.reset! (nil re-enables detection)
35
+ #
36
+ # R2UI::Compat::Gloss::TerminalColor lipgloss TerminalColor implementations
37
+ # Color.new(str), NoColor.new, AdaptiveColor.new(light, dark),
38
+ # CompleteColor.new(true_color, ansi256, ansi), CompleteAdaptiveColor.new(light_cc, dark_cc)
39
+ # #color(renderer = Renderer.default) -> termenv color or nil; #rgba -> [r, g, b, a] (16-bit)
40
+ #
41
+ # Typical render (lipgloss style.go):
42
+ # r = Renderer.default
43
+ # te = r.color_profile.string.bold.foreground(fg.color(r)).background(bg.color(r))
44
+ # te.styled("text")
45
+ module R2UI
46
+ module Compat
47
+ module Gloss
48
+ module Termenv
49
+ ESC = "\e"
50
+ BEL = "\a"
51
+ CSI = "#{ESC}[".freeze
52
+ OSC = "#{ESC}]".freeze
53
+ ST = "#{ESC}\\".freeze
54
+
55
+ RESET_SEQ = "0"
56
+ BOLD_SEQ = "1"
57
+ FAINT_SEQ = "2"
58
+ ITALIC_SEQ = "3"
59
+ UNDERLINE_SEQ = "4"
60
+ BLINK_SEQ = "5"
61
+ REVERSE_SEQ = "7"
62
+ CROSS_OUT_SEQ = "9"
63
+ OVERLINE_SEQ = "53"
64
+
65
+ FOREGROUND = "38"
66
+ BACKGROUND = "48"
67
+
68
+ # termenv ansiHex: the 16 ANSI colors, the 6x6x6 cube and the 24 grays.
69
+ ANSI_HEX = begin
70
+ base = %w[#000000 #800000 #008000 #808000 #000080 #800080 #008080 #c0c0c0
71
+ #808080 #ff0000 #00ff00 #ffff00 #0000ff #ff00ff #00ffff #ffffff]
72
+ levels = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]
73
+ cube = (0...216).map { |i| format("#%02x%02x%02x", levels[i / 36], levels[(i / 6) % 6], levels[i % 6]) }
74
+ grays = (0...24).map { |i| format("#%02x%02x%02x", 8 + 10 * i, 8 + 10 * i, 8 + 10 * i) }
75
+ (base + cube + grays).map(&:freeze).freeze
76
+ end
77
+
78
+ class Error < StandardError; end
79
+
80
+ # termenv.NoColor: no sequence at all (but still appended to a Style as "").
81
+ class NoColor
82
+ def sequence(_bg = false)
83
+ ""
84
+ end
85
+
86
+ def to_s
87
+ ""
88
+ end
89
+
90
+ def ==(other)
91
+ other.is_a?(NoColor)
92
+ end
93
+ alias eql? ==
94
+
95
+ def hash
96
+ NoColor.hash
97
+ end
98
+ end
99
+
100
+ # termenv.ANSIColor (0-15; other values still produce a sequence, as in Go).
101
+ ANSIColor = Struct.new(:value) do
102
+ def sequence(bg = false)
103
+ col = value
104
+ bg_mod = ->(c) { bg ? c + 10 : c }
105
+ return (bg_mod.call(col) + 30).to_s if col < 8
106
+
107
+ (bg_mod.call(col - 8) + 90).to_s
108
+ end
109
+
110
+ def to_s
111
+ ANSI_HEX.fetch(value, "")
112
+ end
113
+ end
114
+
115
+ # termenv.ANSI256Color (16-255).
116
+ ANSI256Color = Struct.new(:value) do
117
+ def sequence(bg = false)
118
+ "#{bg ? BACKGROUND : FOREGROUND};5;#{value}"
119
+ end
120
+
121
+ def to_s
122
+ ANSI_HEX.fetch(value, "")
123
+ end
124
+ end
125
+
126
+ # termenv.RGBColor: a "#rrggbb" (or "#rgb") string.
127
+ RGBColor = Struct.new(:value) do
128
+ def sequence(bg = false)
129
+ f = Colorful.hex(value)
130
+ return "" if f.nil?
131
+
132
+ channels = [f.r, f.g, f.b].map { |v| Colorful.go_uint8(v * 255) }
133
+ "#{bg ? BACKGROUND : FOREGROUND};2;#{channels.join(';')}"
134
+ end
135
+
136
+ def to_s
137
+ value
138
+ end
139
+ end
140
+
141
+ module_function
142
+
143
+ # termenv.ConvertToRGB. Unknown colors (and Go's out-of-range palette indexes, which panic in
144
+ # Go) come back as black, like colorful.Hex("") does.
145
+ def convert_to_rgb(color)
146
+ hex = case color
147
+ when RGBColor then color.value
148
+ when ANSIColor, ANSI256Color
149
+ color.value.between?(0, 255) ? ANSI_HEX[color.value] : ""
150
+ else ""
151
+ end
152
+ Colorful.hex(hex) || Colorful::Color.new(0.0, 0.0, 0.0)
153
+ end
154
+
155
+ def ansi256_to_ansi_color(color)
156
+ h = Colorful.hex(ANSI_HEX.fetch(color.value)) # Go panics on an out-of-range index
157
+ best = 0
158
+ md = Float::MAX
159
+ 16.times do |i|
160
+ d = h.distance_hsluv(Colorful.hex(ANSI_HEX[i]))
161
+ if d < md
162
+ md = d
163
+ best = i
164
+ end
165
+ end
166
+ ANSIColor.new(best)
167
+ end
168
+
169
+ def hex_to_ansi256_color(c)
170
+ v2ci = lambda do |v|
171
+ if v < 48 then 0
172
+ elsif v < 115 then 1
173
+ else ((v - 35) / 40).to_i
174
+ end
175
+ end
176
+ r = v2ci.call(c.r * 255.0)
177
+ g = v2ci.call(c.g * 255.0)
178
+ b = v2ci.call(c.b * 255.0)
179
+ ci = 36 * r + 6 * g + b
180
+ i2cv = [0, 0x5f, 0x87, 0xaf, 0xd7, 0xff]
181
+ cr = i2cv[r]
182
+ cg = i2cv[g]
183
+ cb = i2cv[b]
184
+
185
+ average = (r + g + b) / 3
186
+ gray_idx = if average > 238
187
+ 23
188
+ else
189
+ (average - 3).fdiv(10).truncate # Go integer division truncates toward zero
190
+ end
191
+ gv = 8 + 10 * gray_idx
192
+
193
+ c2 = Colorful::Color.new(cr / 255.0, cg / 255.0, cb / 255.0)
194
+ g2 = Colorful::Color.new(gv / 255.0, gv / 255.0, gv / 255.0)
195
+ return ANSI256Color.new(16 + ci) if c.distance_hsluv(c2) <= c.distance_hsluv(g2)
196
+
197
+ ANSI256Color.new(232 + gray_idx)
198
+ end
199
+
200
+ # strconv.Atoi: optional sign, decimal digits, must fit in int64.
201
+ def atoi(str)
202
+ return nil unless str.match?(/\A[+-]?[0-9]+\z/)
203
+
204
+ i = str.to_i
205
+ i.between?(-2**63, 2**63 - 1) ? i : nil
206
+ end
207
+
208
+ # termenv.Profile.
209
+ class Profile
210
+ attr_reader :value, :name
211
+
212
+ CACHE_LIMIT = 4096
213
+
214
+ def initialize(value, name, sym)
215
+ @value = value
216
+ @name = name
217
+ @sym = sym
218
+ @cache = {} # Profile#color results; degrading to ANSI costs ~2ms per color
219
+ freeze
220
+ end
221
+
222
+ def to_sym
223
+ @sym
224
+ end
225
+
226
+ def inspect
227
+ "#<Termenv::Profile #{@name}>"
228
+ end
229
+
230
+ TRUE_COLOR = new(0, "TrueColor", :true_color)
231
+ ANSI256 = new(1, "ANSI256", :ansi256)
232
+ ANSI = new(2, "ANSI", :ansi)
233
+ ASCII = new(3, "Ascii", :ascii)
234
+ ALL = [TRUE_COLOR, ANSI256, ANSI, ASCII].freeze
235
+
236
+ def self.resolve(profile)
237
+ return profile if profile.is_a?(Profile)
238
+
239
+ ALL.find { |p| p.to_sym == profile } ||
240
+ raise(ArgumentError, "unknown color profile #{profile.inspect} (use :true_color, :ansi256, :ansi or :ascii)")
241
+ end
242
+
243
+ # Profile.String: a new Style for this profile.
244
+ def string
245
+ Style.new(self)
246
+ end
247
+
248
+ # Profile.Convert. Returns nil for an unparsable RGB color, as Go does.
249
+ def convert(color)
250
+ return NoColor.new if equal?(ASCII)
251
+
252
+ case color
253
+ when ANSIColor then color
254
+ when ANSI256Color
255
+ equal?(ANSI) ? Termenv.ansi256_to_ansi_color(color) : color
256
+ when RGBColor
257
+ h = Colorful.hex(color.value)
258
+ return nil if h.nil?
259
+ return color if equal?(TRUE_COLOR)
260
+
261
+ ac = Termenv.hex_to_ansi256_color(h)
262
+ equal?(ANSI) ? Termenv.ansi256_to_ansi_color(ac) : ac
263
+ else color
264
+ end
265
+ end
266
+
267
+ # Profile.Color: "#..." is RGB, an integer below 16 is ANSI, any other integer is ANSI256,
268
+ # anything else (including "") is nil.
269
+ def color(str)
270
+ str = str.to_s
271
+ return nil if str.empty?
272
+ return @cache[str] if @cache.key?(str)
273
+
274
+ c = if str.start_with?("#")
275
+ RGBColor.new(str.dup.freeze)
276
+ else
277
+ i = Termenv.atoi(str)
278
+ i.nil? ? nil : (i < 16 ? ANSIColor.new(i) : ANSI256Color.new(i))
279
+ end
280
+ c = convert(c)&.freeze unless c.nil?
281
+ @cache.clear if @cache.size >= CACHE_LIMIT
282
+ @cache[str.dup.freeze] = c
283
+ end
284
+ end
285
+
286
+ # termenv.Style: an immutable list of SGR parameters for one profile.
287
+ class Style
288
+ attr_reader :profile, :styles
289
+
290
+ def initialize(profile = Profile::TRUE_COLOR, styles = [])
291
+ @profile = Profile.resolve(profile)
292
+ @styles = styles.dup.freeze
293
+ freeze
294
+ end
295
+
296
+ def styled(str)
297
+ return str if @profile.equal?(Profile::ASCII)
298
+ return str if @styles.empty?
299
+
300
+ seq = @styles.join(";")
301
+ return str if seq.empty?
302
+
303
+ "#{CSI}#{seq}m#{str}#{CSI}#{RESET_SEQ}m"
304
+ end
305
+
306
+ def foreground(color)
307
+ color.nil? ? self : with(color.sequence(false))
308
+ end
309
+
310
+ def background(color)
311
+ color.nil? ? self : with(color.sequence(true))
312
+ end
313
+
314
+ def bold = with(BOLD_SEQ)
315
+ def faint = with(FAINT_SEQ)
316
+ def italic = with(ITALIC_SEQ)
317
+ def underline = with(UNDERLINE_SEQ)
318
+ def overline = with(OVERLINE_SEQ)
319
+ def blink = with(BLINK_SEQ)
320
+ def reverse = with(REVERSE_SEQ)
321
+ def cross_out = with(CROSS_OUT_SEQ)
322
+
323
+ private
324
+
325
+ def with(seq)
326
+ Style.new(@profile, @styles + [seq])
327
+ end
328
+ end
329
+
330
+ # termenv.Output for a file (stdout by default): color profile and background detection as
331
+ # termenv_unix.go does it.
332
+ class Output
333
+ OSC_TIMEOUT = 5.0 # seconds per byte, as termenv's OSCTimeout
334
+
335
+ # TIOCGPGRP, for termenv's isForeground check.
336
+ TIOCGPGRP = case RUBY_PLATFORM
337
+ when /darwin|bsd/ then 0x40047477
338
+ when /linux/ then 0x540F
339
+ end
340
+
341
+ attr_reader :io, :env
342
+
343
+ # io: the terminal output (termenv uses os.Stdout).
344
+ # env: anything with #[] (ENV by default).
345
+ # assume_tty: true forces the TTY checks to pass (termenv WithTTY), nil asks the io.
346
+ # osc_timeout: seconds to wait for each byte of a terminal reply.
347
+ def initialize(io: STDOUT, env: ENV, assume_tty: nil, osc_timeout: OSC_TIMEOUT)
348
+ @io = io
349
+ @env = env
350
+ @assume_tty = assume_tty
351
+ @osc_timeout = osc_timeout
352
+ end
353
+
354
+ def getenv(key)
355
+ @env[key].to_s
356
+ end
357
+
358
+ def tty?
359
+ return true if @assume_tty
360
+ return false unless getenv("CI").empty?
361
+
362
+ @io.respond_to?(:tty?) && @io.tty?
363
+ rescue IOError
364
+ false
365
+ end
366
+
367
+ def env_no_color?
368
+ !getenv("NO_COLOR").empty? || (getenv("CLICOLOR") == "0" && !cli_color_forced?)
369
+ end
370
+
371
+ def env_color_profile
372
+ return Profile::ASCII if env_no_color?
373
+
374
+ p = color_profile
375
+ return Profile::ANSI if cli_color_forced? && p.equal?(Profile::ASCII)
376
+
377
+ p
378
+ end
379
+
380
+ def cli_color_forced?
381
+ forced = getenv("CLICOLOR_FORCE")
382
+ !forced.empty? && forced != "0"
383
+ end
384
+
385
+ def color_profile
386
+ return Profile::ASCII unless tty?
387
+ return Profile::TRUE_COLOR if getenv("GOOGLE_CLOUD_SHELL") == "true"
388
+
389
+ term = getenv("TERM")
390
+ case getenv("COLORTERM").downcase
391
+ when "24bit", "truecolor"
392
+ return Profile::ANSI256 if term.start_with?("screen") && getenv("TERM_PROGRAM") != "tmux"
393
+
394
+ return Profile::TRUE_COLOR
395
+ when "yes", "true"
396
+ return Profile::ANSI256
397
+ end
398
+
399
+ case term
400
+ when "alacritty", "contour", "rio", "wezterm", "xterm-ghostty", "xterm-kitty"
401
+ return Profile::TRUE_COLOR
402
+ when "linux", "xterm"
403
+ return Profile::ANSI
404
+ end
405
+ return Profile::ANSI256 if term.include?("256color")
406
+ return Profile::ANSI if term.include?("color") || term.include?("ansi")
407
+
408
+ Profile::ASCII
409
+ end
410
+
411
+ # Output.BackgroundColor (uncached; lipgloss's Renderer caches the dark/light answer).
412
+ def background_color
413
+ return NoColor.new unless tty?
414
+
415
+ begin
416
+ s = term_status_report(11)
417
+ c = xterm_color(s)
418
+ return c if c
419
+ rescue Error, SystemCallError, IOError
420
+ nil
421
+ end
422
+
423
+ colorfgbg = getenv("COLORFGBG")
424
+ if colorfgbg.include?(";")
425
+ i = Termenv.atoi(colorfgbg.split(";", -1).last)
426
+ return ANSIColor.new(i) if i
427
+ end
428
+
429
+ ANSIColor.new(0)
430
+ end
431
+
432
+ def has_dark_background?
433
+ _h, _s, l = Termenv.convert_to_rgb(background_color).hsl
434
+ l < 0.5
435
+ end
436
+
437
+ # termenv xTermColor: "\e]11;rgb:rrrr/gggg/bbbb" + BEL / ESC / ST -> RGBColor, or nil.
438
+ def xterm_color(str)
439
+ return nil if str.bytesize < 24 || str.bytesize > 25
440
+
441
+ if str.end_with?(BEL) then str = str.delete_suffix(BEL)
442
+ elsif str.end_with?(ESC) then str = str.delete_suffix(ESC)
443
+ elsif str.end_with?(ST) then str = str.delete_suffix(ST)
444
+ else return nil
445
+ end
446
+ str = str.byteslice(4..)
447
+ return nil unless str.start_with?(";rgb:")
448
+
449
+ parts = str.delete_prefix(";rgb:").split("/", -1)
450
+ return nil if parts.size < 3 || parts[0, 3].any? { |p| p.bytesize < 2 } # Go would panic
451
+
452
+ RGBColor.new("##{parts[0].byteslice(0, 2)}#{parts[1].byteslice(0, 2)}#{parts[2].byteslice(0, 2)}")
453
+ end
454
+
455
+ # termStatusReport: query the terminal with OSC <sequence>;? followed by a cursor position
456
+ # request (which every terminal answers), with echo and canonical mode off. The terminal
457
+ # mode is restored on every exit path.
458
+ def term_status_report(sequence)
459
+ term = getenv("TERM")
460
+ raise Error, "unable to retrieve status report" if term.start_with?("screen", "tmux", "dumb")
461
+
462
+ tty = tty_io
463
+ raise Error, "unable to retrieve status report" if tty.nil?
464
+ raise Error, "unable to retrieve status report" unless foreground?(tty)
465
+
466
+ require "io/console"
467
+ saved = tty.console_mode
468
+ begin
469
+ mode = saved.dup
470
+ mode.raw!(intr: true)
471
+ tty.console_mode = mode
472
+
473
+ tty.syswrite("#{OSC}#{sequence};?#{ST}")
474
+ tty.syswrite("#{CSI}6n")
475
+
476
+ res, is_osc = read_next_response(tty)
477
+ raise Error, "unable to retrieve status report" unless is_osc
478
+
479
+ read_next_response(tty) # the cursor position reply, discarded
480
+ res
481
+ ensure
482
+ tty.console_mode = saved
483
+ end
484
+ end
485
+
486
+ private
487
+
488
+ # A read/write handle on the output's file descriptor (Go reads replies from os.Stdout).
489
+ def tty_io
490
+ return @io if @io.respond_to?(:sysread) && !@io.equal?(STDOUT) && !@io.equal?($stdout)
491
+ return nil unless @io.respond_to?(:fileno)
492
+
493
+ @tty_io ||= IO.new(@io.fileno, "r+", autoclose: false)
494
+ rescue SystemCallError, IOError, ArgumentError
495
+ nil
496
+ end
497
+
498
+ def foreground?(tty)
499
+ return false if TIOCGPGRP.nil?
500
+
501
+ buf = [0].pack("l")
502
+ tty.ioctl(TIOCGPGRP, buf)
503
+ buf.unpack1("l") == Process.getpgrp
504
+ rescue SystemCallError, IOError, NotImplementedError
505
+ false
506
+ end
507
+
508
+ def read_next_byte(tty)
509
+ raise Error, "timeout" if IO.select([tty], nil, nil, @osc_timeout).nil?
510
+
511
+ tty.sysread(1)
512
+ rescue EOFError
513
+ raise Error, "EOF"
514
+ end
515
+
516
+ # readNextResponse: an OSC reply (ends in BEL or ESC) or a cursor position reply (ends in R).
517
+ def read_next_response(tty)
518
+ start = read_next_byte(tty)
519
+ start = read_next_byte(tty) while start != ESC
520
+ response = +start
521
+
522
+ tpe = read_next_byte(tty)
523
+ response << tpe
524
+ osc = case tpe
525
+ when "[" then false
526
+ when "]" then true
527
+ else raise Error, "unable to retrieve status report"
528
+ end
529
+
530
+ loop do
531
+ b = read_next_byte(tty)
532
+ response << b
533
+ if osc
534
+ return [response.force_encoding(Encoding::BINARY), true] if b == BEL || response.end_with?(ESC)
535
+ elsif b == "R"
536
+ return [response.force_encoding(Encoding::BINARY), false]
537
+ end
538
+ break if response.bytesize > 25
539
+ end
540
+ raise Error, "unable to retrieve status report"
541
+ end
542
+ end
543
+ end
544
+
545
+ # lipgloss TerminalColor implementations (color.go). Each resolves against a Renderer at render
546
+ # time, like Go's TerminalColor.color(*Renderer).
547
+ module TerminalColor
548
+ # lipgloss.NoColor
549
+ class NoColor
550
+ def color(_renderer = Renderer.default)
551
+ Termenv::NoColor.new
552
+ end
553
+
554
+ def rgba
555
+ [0x0, 0x0, 0x0, 0xFFFF]
556
+ end
557
+
558
+ def ==(other)
559
+ other.is_a?(NoColor)
560
+ end
561
+ end
562
+
563
+ module RGBA
564
+ def rgba(renderer = Renderer.default)
565
+ c = Termenv.convert_to_rgb(color(renderer))
566
+ conv = ->(v) { [[(v * 65_535.0 + 0.5).to_i, 0].max, 0xFFFF_FFFF].min }
567
+ [conv.call(c.r), conv.call(c.g), conv.call(c.b), 0xFFFF]
568
+ end
569
+ end
570
+
571
+ # lipgloss.Color: hex ("#ff8000", "#f80") or ANSI ("0".."255"); "" means no color.
572
+ Color = Struct.new(:value) do
573
+ include RGBA
574
+
575
+ def color(renderer = Renderer.default)
576
+ renderer.color_profile.color(value)
577
+ end
578
+ end
579
+
580
+ # lipgloss.AdaptiveColor
581
+ AdaptiveColor = Struct.new(:light, :dark) do
582
+ include RGBA
583
+
584
+ def color(renderer = Renderer.default)
585
+ Color.new(renderer.has_dark_background? ? dark : light).color(renderer)
586
+ end
587
+ end
588
+
589
+ # lipgloss.CompleteColor: exact values per profile, no degradation.
590
+ CompleteColor = Struct.new(:true_color, :ansi256, :ansi) do
591
+ include RGBA
592
+
593
+ def color(renderer = Renderer.default)
594
+ p = renderer.color_profile
595
+ case p
596
+ when Termenv::Profile::TRUE_COLOR then p.color(true_color)
597
+ when Termenv::Profile::ANSI256 then p.color(ansi256)
598
+ when Termenv::Profile::ANSI then p.color(ansi)
599
+ else Termenv::NoColor.new
600
+ end
601
+ end
602
+ end
603
+
604
+ # lipgloss.CompleteAdaptiveColor
605
+ CompleteAdaptiveColor = Struct.new(:light, :dark) do
606
+ include RGBA
607
+
608
+ def color(renderer = Renderer.default)
609
+ (renderer.has_dark_background? ? dark : light).color(renderer)
610
+ end
611
+ end
612
+ end
613
+
614
+ # lipgloss.Renderer: caches the color profile and the dark-background answer, each detected
615
+ # once on first use (Go's sync.Once), unless set explicitly.
616
+ class Renderer
617
+ attr_reader :output
618
+
619
+ def initialize(output = Termenv::Output.new)
620
+ @output = output
621
+ @mutex = Mutex.new
622
+ reset!
623
+ end
624
+
625
+ def color_profile
626
+ @mutex.synchronize do
627
+ @color_profile ||= @explicit_profile || @output.env_color_profile
628
+ end
629
+ end
630
+
631
+ # nil re-enables detection (r2ui test hook; Go's SetColorProfile has no reset).
632
+ def color_profile=(profile)
633
+ @mutex.synchronize do
634
+ @explicit_profile = profile.nil? ? nil : Termenv::Profile.resolve(profile)
635
+ @color_profile = @explicit_profile
636
+ end
637
+ end
638
+
639
+ # Detection queries the terminal (up to seconds), so it runs outside the lock: other threads
640
+ # asking for the profile or background aren't blocked. The first answer stored wins; one
641
+ # started before a setter or reset! is used by its caller but not stored.
642
+ def has_dark_background?
643
+ generation = @mutex.synchronize do
644
+ return @explicit_dark unless @explicit_dark.nil?
645
+ return @dark unless @dark.nil?
646
+
647
+ @generation
648
+ end
649
+ detected = @output.has_dark_background?
650
+ @mutex.synchronize do
651
+ return @explicit_dark unless @explicit_dark.nil?
652
+
653
+ @dark = detected if @dark.nil? && @generation == generation
654
+ @dark.nil? ? detected : @dark
655
+ end
656
+ end
657
+
658
+ # nil re-enables detection (r2ui test hook).
659
+ def has_dark_background=(value)
660
+ @mutex.synchronize do
661
+ @explicit_dark = value.nil? ? nil : (value ? true : false)
662
+ @dark = nil
663
+ @generation += 1
664
+ end
665
+ end
666
+
667
+ def reset!
668
+ @mutex.synchronize do
669
+ @explicit_profile = nil
670
+ @color_profile = nil
671
+ @explicit_dark = nil
672
+ @dark = nil
673
+ @generation = (@generation || 0) + 1
674
+ end
675
+ end
676
+
677
+ class << self
678
+ def default
679
+ @default ||= new
680
+ end
681
+
682
+ attr_writer :default
683
+
684
+ def color_profile = default.color_profile
685
+ def color_profile=(profile)
686
+ default.color_profile = profile
687
+ end
688
+
689
+ def has_dark_background? = default.has_dark_background?
690
+ def has_dark_background=(value)
691
+ default.has_dark_background = value
692
+ end
693
+
694
+ def reset! = default.reset!
695
+
696
+ # The Ruby color value -> TerminalColor conversion the C extension does (style.c
697
+ # style_foreground / style_background):
698
+ # * responds to #light and #dark: if both answers respond to #true_color, #ansi256 and
699
+ # #ansi it is a CompleteAdaptiveColor, otherwise an AdaptiveColor of two strings;
700
+ # * responds to #true_color, #ansi256 and #ansi: CompleteColor;
701
+ # * otherwise it must be a String (Check_Type).
702
+ # Strings inside composite colors go through StringValueCStr (implicit #to_str, no NUL).
703
+ # Other setters accept less: border colors are adaptive: true, complete: false; margin
704
+ # background is adaptive: false, complete: false; place's whitespace foreground is
705
+ # complete: false, strict: false (StringValueCStr instead of Check_Type).
706
+ def terminal_color(value, adaptive: true, complete: true, strict: true)
707
+ if adaptive && adaptive_color?(value)
708
+ light = value.light
709
+ dark = value.dark
710
+ if complete && complete_color?(light) && complete_color?(dark)
711
+ return TerminalColor::CompleteAdaptiveColor.new(complete_from(light), complete_from(dark))
712
+ end
713
+
714
+ return TerminalColor::AdaptiveColor.new(c_string(light), c_string(dark))
715
+ end
716
+ return complete_from(value) if complete && complete_color?(value)
717
+
718
+ check_string(value) if strict
719
+ TerminalColor::Color.new(c_string(value))
720
+ end
721
+
722
+ def color(value, renderer = default)
723
+ terminal_color(value).color(renderer)
724
+ end
725
+
726
+ def adaptive_color?(value)
727
+ value.respond_to?(:light) && value.respond_to?(:dark)
728
+ end
729
+
730
+ def complete_color?(value)
731
+ value.respond_to?(:true_color) && value.respond_to?(:ansi256) && value.respond_to?(:ansi)
732
+ end
733
+
734
+ private
735
+
736
+ def complete_from(value)
737
+ true_color = value.true_color
738
+ ansi256 = value.ansi256
739
+ ansi = value.ansi
740
+ TerminalColor::CompleteColor.new(c_string(true_color), c_string(ansi256), c_string(ansi))
741
+ end
742
+
743
+ def check_string(value)
744
+ return if value.is_a?(String)
745
+
746
+ raise TypeError, "wrong argument type #{type_name(value)} (expected String)"
747
+ end
748
+
749
+ # StringValueCStr: implicit #to_str conversion, and no NUL bytes.
750
+ def c_string(value)
751
+ str = String.try_convert(value)
752
+ raise TypeError, "no implicit conversion of #{type_name(value)} into String" if str.nil?
753
+ raise ArgumentError, "string contains null byte" if str.include?("\0")
754
+
755
+ str.dup.freeze
756
+ end
757
+
758
+ def type_name(value)
759
+ case value
760
+ when nil, true, false then value.inspect
761
+ else value.class.to_s
762
+ end
763
+ end
764
+ end
765
+ end
766
+ end
767
+ end
768
+ end