tuile 0.13.0 → 0.14.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +1095 -195
  5. data/README.md +19 -19
  6. data/TERMINOLOGY.md +6 -5
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +4 -1
  9. data/book/06-theming.md +98 -0
  10. data/book/07-components.md +169 -19
  11. data/book/08-testing.md +16 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/examples/file_commander.rb +1 -1
  14. data/examples/sampler.rb +143 -43
  15. data/ideas/arrow-key-navigation.md +2 -2
  16. data/ideas/modal-backdrop.md +24 -0
  17. data/ideas/new-components.md +24 -24
  18. data/lib/tuile/buffer.rb +51 -3
  19. data/lib/tuile/color.rb +143 -0
  20. data/lib/tuile/color_depth.rb +80 -0
  21. data/lib/tuile/component/button.rb +3 -3
  22. data/lib/tuile/component/checkbox.rb +3 -3
  23. data/lib/tuile/component/combo_box.rb +9 -2
  24. data/lib/tuile/component/confirm_window.rb +442 -0
  25. data/lib/tuile/component/has_content.rb +22 -9
  26. data/lib/tuile/component/has_value.rb +1 -1
  27. data/lib/tuile/component/info_window.rb +64 -16
  28. data/lib/tuile/component/layout.rb +0 -10
  29. data/lib/tuile/component/list_dropdown.rb +18 -10
  30. data/lib/tuile/component/log_text_view.rb +71 -0
  31. data/lib/tuile/component/log_window.rb +13 -48
  32. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  33. data/lib/tuile/component/menu_bar.rb +5 -5
  34. data/lib/tuile/component/notification.rb +16 -34
  35. data/lib/tuile/component/overlay.rb +192 -0
  36. data/lib/tuile/component/popup.rb +59 -187
  37. data/lib/tuile/component/progress_bar.rb +1 -1
  38. data/lib/tuile/component/select.rb +14 -6
  39. data/lib/tuile/component/slot.rb +54 -0
  40. data/lib/tuile/component/tab_sheet.rb +0 -11
  41. data/lib/tuile/component/tabs.rb +5 -5
  42. data/lib/tuile/component/window.rb +22 -46
  43. data/lib/tuile/component.rb +149 -19
  44. data/lib/tuile/event_queue.rb +21 -1
  45. data/lib/tuile/fake_screen.rb +26 -2
  46. data/lib/tuile/keys.rb +7 -0
  47. data/lib/tuile/screen.rb +120 -38
  48. data/lib/tuile/screen_pane.rb +37 -35
  49. data/lib/tuile/styled_string.rb +40 -7
  50. data/lib/tuile/terminal_background.rb +74 -16
  51. data/lib/tuile/version.rb +1 -1
  52. data/sig/tuile.rbs +1157 -368
  53. metadata +8 -1
data/lib/tuile/color.rb CHANGED
@@ -144,6 +144,47 @@ module Tuile
144
144
  end
145
145
  end
146
146
 
147
+ # This color as the nearest one `depth` can actually show — the
148
+ # degradation {Buffer#flush} applies to every color on its way to the wire:
149
+ #
150
+ # Color.rgb(100, 100, 100).quantize(:palette256) # => Color.palette(241)
151
+ # Color.rgb(255, 0, 0).quantize(:ansi16) # => Color::BRIGHT_RED
152
+ # Color.rgb(255, 0, 0).quantize(:truecolor) # => itself, unchanged
153
+ #
154
+ # Returns **the same instance** whenever `depth` shows this color as-is —
155
+ # every named color at every depth, a palette index anywhere but
156
+ # `:ansi16`, RGB at `:truecolor` — so `color.quantize(depth).equal?(color)`
157
+ # *is* the "needs no translating" predicate, and the common path
158
+ # allocates nothing.
159
+ #
160
+ # == Implementation details
161
+ #
162
+ # RGB picks whichever is nearer in squared-RGB distance: the 6×6×6 cube
163
+ # (16..231, its per-channel nearest levels being the nearest cell outright
164
+ # — the axes are independent) or the 24-step grey ramp (232..255, whose
165
+ # nearest step is the one nearest the channel mean).
166
+ #
167
+ # Under `:ansi16` a color goes *direct* to the nearest of the 16, never
168
+ # via the 256-palette — two steps would compound the rounding — and the
169
+ # result is a *named* color, which keeps respecting the user's terminal
170
+ # scheme. Matching is against xterm's default RGBs for the 16, which that
171
+ # scheme may itself redefine: the one mapping here that can be honestly
172
+ # wrong.
173
+ #
174
+ # @param depth [Symbol] one of {ColorDepth::DEPTHS}.
175
+ # @return [Color]
176
+ # @raise [ArgumentError] when `depth` is not a known depth.
177
+ def quantize(depth)
178
+ case depth
179
+ when :truecolor then self
180
+ when :palette256
181
+ @value.is_a?(Array) ? PALETTE_COLORS[nearest_palette(@value)] : self
182
+ when :ansi16
183
+ @value.is_a?(Symbol) ? self : ANSI16_COLORS[nearest_ansi16(rgb_triple)]
184
+ else raise ArgumentError, "invalid color depth: #{depth.inspect}"
185
+ end
186
+ end
187
+
147
188
  # Full SGR escape sequence for this color (e.g. `"\e[31m"`). Useful for
148
189
  # `print`-style direct emission; for composing with other attributes use
149
190
  # {#sgr_codes} instead.
@@ -171,10 +212,112 @@ module Tuile
171
212
  "#<#{self.class.name} #{@value.inspect}>"
172
213
  end
173
214
 
215
+ private
216
+
217
+ # This color's RGB — the palette cell's own coordinates when the value is
218
+ # an index. Only ever asked of a non-Symbol value; a named color has no
219
+ # RGB of its own, since the terminal's scheme decides what it looks like.
220
+ # @return [Array<Integer>] red, green and blue, each 0..255.
221
+ def rgb_triple
222
+ return @value if @value.is_a?(Array)
223
+ return ANSI16_RGB[@value] if @value < 16
224
+ return [8 + (10 * (@value - 232))] * 3 if @value >= 232
225
+
226
+ cube = @value - 16
227
+ [CUBE_LEVELS[cube / 36], CUBE_LEVELS[(cube / 6) % 6], CUBE_LEVELS[cube % 6]]
228
+ end
229
+
230
+ # Written flat — destructured rather than splatted, `x * x` rather than
231
+ # `x**2`, no distance helper — because it runs per style transition in
232
+ # {Buffer#flush}, and the tidy shape measures ~2x slower (see
233
+ # `benchmark/quantize.rb`).
234
+ #
235
+ # @param rgb [Array<Integer>] red, green and blue, each 0..255.
236
+ # @return [Integer] palette index, 16..255 — the nearer of this color's
237
+ # cube cell and its grey-ramp step. A tie goes to the cube, which spans
238
+ # the whole space where the ramp only covers the diagonal.
239
+ def nearest_palette(rgb)
240
+ red, green, blue = rgb
241
+ ri = CUBE_INDEX[red]
242
+ gi = CUBE_INDEX[green]
243
+ bi = CUBE_INDEX[blue]
244
+ dr = CUBE_LEVELS[ri] - red
245
+ dg = CUBE_LEVELS[gi] - green
246
+ db = CUBE_LEVELS[bi] - blue
247
+ cube = (dr * dr) + (dg * dg) + (db * db)
248
+ # The grey minimizing the distance sits at the channel mean, so the best
249
+ # ramp step is the one nearest it; floor division rounds it half-up.
250
+ step = ((((red + green + blue) / 3) - 3) / 10).clamp(0, 23)
251
+ level = 8 + (10 * step)
252
+ gr = level - red
253
+ gg = level - green
254
+ gb = level - blue
255
+ grey = (gr * gr) + (gg * gg) + (gb * gb)
256
+ cube <= grey ? 16 + (36 * ri) + (6 * gi) + bi : 232 + step
257
+ end
258
+
259
+ # @param rgb [Array<Integer>] red, green and blue, each 0..255.
260
+ # @return [Integer] index into {COLOR_SYMBOLS} of the nearest of the 16.
261
+ def nearest_ansi16(rgb)
262
+ red, green, blue = rgb
263
+ best = 0
264
+ best_distance = nil
265
+ index = 0
266
+ while index < 16
267
+ candidate = ANSI16_RGB[index]
268
+ dr = candidate[0] - red
269
+ dg = candidate[1] - green
270
+ db = candidate[2] - blue
271
+ distance = (dr * dr) + (dg * dg) + (db * db)
272
+ if best_distance.nil? || distance < best_distance
273
+ best = index
274
+ best_distance = distance
275
+ end
276
+ index += 1
277
+ end
278
+ best
279
+ end
280
+
174
281
  COLOR_SYMBOLS.each do |sym|
175
282
  const_set(sym.upcase, new(sym))
176
283
  end
177
284
 
285
+ # The channel values the 6×6×6 cube (palette 16..231) samples.
286
+ # @return [Array<Integer>]
287
+ CUBE_LEVELS = [0, 95, 135, 175, 215, 255].freeze
288
+ private_constant :CUBE_LEVELS
289
+
290
+ # Channel value 0..255 → index into {CUBE_LEVELS} of the nearest level,
291
+ # so quantizing a channel is one array read rather than six compares.
292
+ # @return [Array<Integer>]
293
+ CUBE_INDEX = Array.new(256) { |c| (0...6).min_by { |i| (CUBE_LEVELS[i] - c).abs } }.freeze
294
+ private_constant :CUBE_INDEX
295
+
296
+ # xterm's default RGB for each of the 16 named colors, in {COLOR_SYMBOLS}
297
+ # order — what `:ansi16` quantization matches against. A terminal scheme
298
+ # may redefine these; see {#quantize}.
299
+ # @return [Array<Array<Integer>>]
300
+ ANSI16_RGB = [
301
+ [0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
302
+ [0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192],
303
+ [128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0],
304
+ [0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255]
305
+ ].freeze
306
+ private_constant :ANSI16_RGB
307
+
308
+ # Every named color, in {COLOR_SYMBOLS} order — the shared instances
309
+ # `:ansi16` quantization returns.
310
+ # @return [Array<Color>]
311
+ ANSI16_COLORS = COLOR_SYMBOLS.map { |sym| const_get(sym.upcase) }.freeze
312
+ private_constant :ANSI16_COLORS
313
+
314
+ # Every palette cell 0..255 as a {Color}, so quantizing to the palette
315
+ # allocates nothing and lands on a shared instance. Distinct from the
316
+ # {PALETTE_NAMES} constants, which cover only the *named* cells.
317
+ # @return [Array<Color>]
318
+ PALETTE_COLORS = Array.new(256) { |index| new(index) }.freeze
319
+ private_constant :PALETTE_COLORS
320
+
178
321
  # Names for the 256-color palette indices 16..255, from the standard
179
322
  # xterm chart (<https://www.ditig.com/256-colors-cheat-sheet>). A constant
180
323
  # per entry is pre-defined, an exact palette cell — no quantization:
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # How many colors the terminal on the other end can actually show — the
5
+ # depth {Color#quantize} degrades a color to:
6
+ #
7
+ # ColorDepth.detect # => :truecolor
8
+ # ColorDepth.detect(env: { "TERM" => "xterm-256color" }) # => :palette256
9
+ # ColorDepth.detect(env: {}) # => :ansi16
10
+ #
11
+ # Env-only: no terminal round-trip, so unlike {TerminalBackground.detect}
12
+ # there is no stdin timing to respect, and the answer cannot go stale
13
+ # mid-session the way a background color can.
14
+ #
15
+ # Terminals lie in both directions — `COLORTERM` frequently doesn't survive
16
+ # ssh (it isn't in the default `SendEnv` set) or tmux — so {OVERRIDE_ENV}
17
+ # beats every other signal, the escape hatch for a terminal detected wrong.
18
+ # Misdetection otherwise lands *conservatively*: a truecolor tmux
19
+ # advertising only `tmux-256color` reads as `:palette256`, which renders
20
+ # coarser but never mangled.
21
+ #
22
+ # == Implementation details
23
+ #
24
+ # Terminfo is deliberately not consulted — its `RGB` boolean and
25
+ # `colors#0x1000000` would mean shelling out to `tput`/`infocmp` at every
26
+ # startup, and the env ladder plus the override already covers the real
27
+ # terminal matrix.
28
+ module ColorDepth
29
+ # The depths, most capable first: 24-bit RGB, the 256-color palette, and
30
+ # the 16 named ANSI colors.
31
+ # @return [Array<Symbol>]
32
+ DEPTHS = %i[truecolor palette256 ansi16].freeze
33
+
34
+ # Environment variable that overrides detection outright; holds one of
35
+ # {DEPTHS}. Empty counts as unset.
36
+ # @return [String]
37
+ OVERRIDE_ENV = "TUILE_COLOR_DEPTH"
38
+
39
+ # `COLORTERM` values that promise 24-bit color.
40
+ # @return [Array<String>]
41
+ TRUECOLOR_COLORTERM = %w[truecolor 24bit].freeze
42
+
43
+ class << self
44
+ # The terminal's color depth, from {OVERRIDE_ENV}, else `COLORTERM`,
45
+ # else `TERM` (a `-direct` entry means 24-bit, a `256color` one the
46
+ # palette), else the 16-color floor.
47
+ #
48
+ # @param env [Hash{String => String}] environment to read; defaults to
49
+ # `ENV` (which duck-types the `[]` lookup).
50
+ # @return [Symbol] one of {DEPTHS}.
51
+ # @raise [ArgumentError] when {OVERRIDE_ENV} holds an unknown value. It
52
+ # is only ever set deliberately, so a typo in it is worth failing at
53
+ # startup over — ignoring it silently means a whole session of
54
+ # debugging the wrong colors.
55
+ def detect(env: ENV)
56
+ override = env[OVERRIDE_ENV].to_s
57
+ return parse_override(override) unless override.empty?
58
+
59
+ term = env["TERM"].to_s
60
+ return :truecolor if TRUECOLOR_COLORTERM.include?(env["COLORTERM"].to_s.downcase) ||
61
+ term.include?("-direct")
62
+ return :palette256 if term.include?("256color")
63
+
64
+ :ansi16
65
+ end
66
+
67
+ private
68
+
69
+ # @param value [String] the raw {OVERRIDE_ENV} value.
70
+ # @return [Symbol]
71
+ def parse_override(value)
72
+ depth = value.strip.downcase.to_sym
73
+ return depth if DEPTHS.include?(depth)
74
+
75
+ raise ArgumentError,
76
+ "invalid #{OVERRIDE_ENV}: #{value.inspect} (expected one of #{DEPTHS.join(", ")})"
77
+ end
78
+ end
79
+ end
80
+ end
@@ -55,8 +55,8 @@ module Tuile
55
55
  # It still *focuses*: {Component#handle_mouse}'s click-to-focus is ungated
56
56
  # by geometry. Same rule as {Checkbox#extent}, which documents the two
57
57
  # traps behind it.
58
- # @return [Rect]
59
- def extent = Rect.new(rect.left, rect.top, [caption.display_width + 4, rect.width].min, 1)
58
+ # @return [Size]
59
+ def extent = Size.new([caption.display_width + 4, rect.width].min, 1)
60
60
 
61
61
  # Fires {#on_click} on a left click within {#extent}; `super` runs first, so
62
62
  # a click anywhere in {#rect} still focuses.
@@ -64,7 +64,7 @@ module Tuile
64
64
  # @return [void]
65
65
  def handle_mouse(event)
66
66
  super
67
- return unless event.button == :left && extent.contains?(event.point)
67
+ return unless event.button == :left && extent_rect.contains?(event.point)
68
68
 
69
69
  @on_click&.call
70
70
  end
@@ -95,8 +95,8 @@ module Tuile
95
95
  # The extent ignores {Component#bg_color}: an inherited tint paints the dead
96
96
  # tail, but a hit test that silently widened with a background would be a
97
97
  # mode switch invisible in the code and untestable by inspection.
98
- # @return [Rect]
99
- def extent = Rect.new(rect.left, rect.top, [caption.display_width + 4, rect.width].min, 1)
98
+ # @return [Size]
99
+ def extent = Size.new([caption.display_width + 4, rect.width].min, 1)
100
100
 
101
101
  # Toggles on Space or Enter. Every other key is left unhandled so it bubbles
102
102
  # to an ancestor.
@@ -115,7 +115,7 @@ module Tuile
115
115
  # @return [void]
116
116
  def handle_mouse(event)
117
117
  super
118
- return unless event.button == :left && extent.contains?(event.point)
118
+ return unless event.button == :left && extent_rect.contains?(event.point)
119
119
 
120
120
  toggle
121
121
  end
@@ -14,7 +14,7 @@ module Tuile
14
14
  # combo.value = some_user # selects it; field shows its label
15
15
  #
16
16
  # It's the assembly you'd otherwise wire by hand — a {TextField} plus a
17
- # non-modal {Popup} over a {List} — promoted to one component. Give it a
17
+ # an {Overlay} over a {List} — promoted to one component. Give it a
18
18
  # single-row {#rect}; it paints the field across that row with a `▾` in the
19
19
  # last column and floats the dropdown above or below.
20
20
  #
@@ -147,6 +147,13 @@ module Tuile
147
147
  draw_char(rect.left + rect.width - 1, rect.top, "▾", StyledString::Style::DEFAULT.with(bg: well))
148
148
  end
149
149
 
150
+ # The one row this combo paints — the full width, at the top of {#rect}.
151
+ # A single-slot container hands its content the whole inner rect, so a
152
+ # ComboBox is routinely assigned more height than it uses; the dropdown
153
+ # hangs under this rather than under the unused space below it.
154
+ # @return [Size]
155
+ def extent = Size.new(rect.width, 1)
156
+
150
157
  protected
151
158
 
152
159
  # Field spans the row bar the last column, which the `▾` occupies
@@ -262,7 +269,7 @@ module Tuile
262
269
  # labels, which ellipsize a column earlier once the list scrolls. That is
263
270
  # the trade a measuring driver ({Select}) makes the other way.
264
271
  # @return [void]
265
- def anchor = @overlay.anchor_to(rect, rows: @filtered.size)
272
+ def anchor = @overlay.anchor_to(extent_rect, rows: @filtered.size)
266
273
  end
267
274
  end
268
275
  end