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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +81 -37
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +1095 -195
- data/README.md +19 -19
- data/TERMINOLOGY.md +6 -5
- data/book/03-layout.md +17 -10
- data/book/05-focus.md +4 -1
- data/book/06-theming.md +98 -0
- data/book/07-components.md +169 -19
- data/book/08-testing.md +16 -0
- data/book/09-styled-text.md +3 -3
- data/examples/file_commander.rb +1 -1
- data/examples/sampler.rb +143 -43
- data/ideas/arrow-key-navigation.md +2 -2
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +24 -24
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +9 -2
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/has_content.rb +22 -9
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/layout.rb +0 -10
- data/lib/tuile/component/list_dropdown.rb +18 -10
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +3 -3
- data/lib/tuile/component/menu_bar.rb +5 -5
- data/lib/tuile/component/notification.rb +16 -34
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/popup.rb +59 -187
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +14 -6
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +0 -11
- data/lib/tuile/component/tabs.rb +5 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +149 -19
- data/lib/tuile/event_queue.rb +21 -1
- data/lib/tuile/fake_screen.rb +26 -2
- data/lib/tuile/keys.rb +7 -0
- data/lib/tuile/screen.rb +120 -38
- data/lib/tuile/screen_pane.rb +37 -35
- data/lib/tuile/styled_string.rb +40 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1157 -368
- 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 [
|
|
59
|
-
def extent =
|
|
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 &&
|
|
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 [
|
|
99
|
-
def extent =
|
|
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 &&
|
|
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
|
-
#
|
|
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(
|
|
272
|
+
def anchor = @overlay.anchor_to(extent_rect, rows: @filtered.size)
|
|
266
273
|
end
|
|
267
274
|
end
|
|
268
275
|
end
|