tuile 0.16.0 → 0.17.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 +108 -0
- data/README.md +21 -12
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +5 -4
- data/book/05-focus.md +12 -9
- data/book/06-theming.md +55 -17
- data/book/07-components.md +188 -40
- data/book/08-testing.md +115 -15
- data/book/10-locale.md +1 -1
- data/book/README.md +5 -5
- data/examples/file_commander.rb +38 -27
- data/examples/hello_world.rb +1 -1
- data/examples/sampler.rb +225 -169
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +81 -80
- data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +19 -11
- data/lib/tuile/component/checkbox.rb +12 -10
- data/lib/tuile/component/checkbox_group.rb +11 -13
- data/lib/tuile/component/combo_box.rb +30 -40
- data/lib/tuile/component/confirm_window.rb +27 -22
- data/lib/tuile/component/date_field.rb +27 -20
- data/lib/tuile/component/date_time_field.rb +75 -31
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +98 -27
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +5 -12
- data/lib/tuile/component/has_validation.rb +39 -13
- data/lib/tuile/component/has_value.rb +70 -16
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -63
- data/lib/tuile/component/layout.rb +124 -10
- data/lib/tuile/component/list.rb +197 -94
- data/lib/tuile/component/list_dropdown.rb +148 -88
- data/lib/tuile/component/menu_bar/cascade.rb +97 -27
- data/lib/tuile/component/menu_bar.rb +84 -64
- data/lib/tuile/component/notification.rb +44 -31
- data/lib/tuile/component/overlay.rb +210 -52
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +15 -10
- data/lib/tuile/component/popup.rb +13 -24
- data/lib/tuile/component/progress_bar.rb +7 -7
- data/lib/tuile/component/radio_group.rb +10 -12
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +15 -31
- data/lib/tuile/component/slot.rb +1 -2
- data/lib/tuile/component/tab_sheet.rb +21 -28
- data/lib/tuile/component/tabs.rb +39 -24
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +21 -19
- data/lib/tuile/component/text_field.rb +55 -39
- data/lib/tuile/component/text_view.rb +143 -79
- data/lib/tuile/component/time_field.rb +26 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +481 -259
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +14 -0
- data/lib/tuile/fake_screen.rb +41 -10
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +51 -35
- data/lib/tuile/mouse.rb +96 -29
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +419 -84
- data/lib/tuile/screen_pane.rb +144 -31
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +139 -9
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +310 -36
- data/lib/tuile/theme.rb +170 -19
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +4951 -1158
- metadata +16 -2
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/lib/tuile/buffer.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Tuile
|
|
4
4
|
# An in-memory grid of styled cells mirroring the terminal screen. This is
|
|
5
5
|
# the back buffer behind flicker-free rendering: components paint into it
|
|
6
|
-
#
|
|
6
|
+
# through a {Canvas}, whose {Canvas::Backend} it is, instead of writing escape
|
|
7
7
|
# sequences straight to the terminal, and {#flush} emits the minimal escape
|
|
8
8
|
# string needed to bring a terminal — one that already matches the buffer's
|
|
9
9
|
# state as of the previous flush — up to date. Only cells that actually
|
|
@@ -34,6 +34,10 @@ module Tuile
|
|
|
34
34
|
# Overwriting either half of a wide glyph blanks the orphaned half, so the
|
|
35
35
|
# grid never holds a dangling continuation or a headless one.
|
|
36
36
|
class Buffer
|
|
37
|
+
# The three primitives below are exactly {Canvas::Backend}'s, so a canvas
|
|
38
|
+
# paints into a buffer with no adapter between them.
|
|
39
|
+
include Canvas::Backend
|
|
40
|
+
|
|
37
41
|
# One screen cell: a single grapheme cluster, the {StyledString::Style} it's
|
|
38
42
|
# drawn in, and a dirty flag. Mutable by design (see {Buffer} "Dirty
|
|
39
43
|
# tracking") — the grid rewrites cells in place. A continuation cell (right
|
|
@@ -317,6 +321,13 @@ module Tuile
|
|
|
317
321
|
region_cells(rect).map { |row| row.map(&:grapheme).join }
|
|
318
322
|
end
|
|
319
323
|
|
|
324
|
+
# {#region_text} over the whole buffer, for one {Testing.paint} returned:
|
|
325
|
+
#
|
|
326
|
+
# Testing.paint(window).text # => ["┌Caption─────┐", "│ alpha │", …]
|
|
327
|
+
#
|
|
328
|
+
# @return [Array<String>] the plain text of every row, top to bottom.
|
|
329
|
+
def text = region_text(Rect.new(0, 0, @width, @height))
|
|
330
|
+
|
|
320
331
|
# @param rect [Rect]
|
|
321
332
|
# @return [Array<String>] each row within `rect` rendered to ANSI, top to
|
|
322
333
|
# bottom — byte-identical to what a component's per-row `set_text` over
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Canvas
|
|
5
|
+
# Where a {Canvas}'s cells land: three primitives taking a fully resolved
|
|
6
|
+
# style. Include it to *be* one — {Tuile::Buffer} does, and needs no adapter:
|
|
7
|
+
#
|
|
8
|
+
# Canvas.new(screen.buffer)
|
|
9
|
+
#
|
|
10
|
+
# A backend is dumb by contract: no theme, no component, no background
|
|
11
|
+
# chain, no translation, and no default arguments — the canvas resolves all
|
|
12
|
+
# of that and hands down a whole style at a coordinate in the backend's own
|
|
13
|
+
# grid. What varies here is only *where* the cells go;
|
|
14
|
+
# how a write is transformed on the way there belongs to the {Canvas}, which
|
|
15
|
+
# is final. See `D_canvas`.
|
|
16
|
+
#
|
|
17
|
+
# **`include`, never `prepend`.** A class's own methods win over an included
|
|
18
|
+
# module's, which is what lets {Tuile::Buffer} carry this while defining all
|
|
19
|
+
# three itself; prepended, these bodies would sit *ahead* of the class and
|
|
20
|
+
# every write would raise.
|
|
21
|
+
#
|
|
22
|
+
# Out-of-range writes are **dropped, not raised** — the terminal-edge clip
|
|
23
|
+
# belongs here, which is what lets a component paint a row the terminal is
|
|
24
|
+
# too narrow for without checking first.
|
|
25
|
+
module Backend
|
|
26
|
+
# @param x [Integer] starting column, in this backend's own grid —
|
|
27
|
+
# the canvas has already applied its {Canvas#origin}.
|
|
28
|
+
# @param y [Integer] row, likewise.
|
|
29
|
+
# @param styled [StyledString] the text of one row; newlines are not handled.
|
|
30
|
+
# @return [void]
|
|
31
|
+
def set_text(x, y, styled) = raise(NotImplementedError, "#{self.class}#set_text")
|
|
32
|
+
|
|
33
|
+
# @param x [Integer] column, in this backend's own grid.
|
|
34
|
+
# @param y [Integer] row, likewise.
|
|
35
|
+
# @param grapheme [String] one grapheme cluster.
|
|
36
|
+
# @param style [StyledString::Style] fully resolved.
|
|
37
|
+
# @return [void]
|
|
38
|
+
def set_char(x, y, grapheme, style) = raise(NotImplementedError, "#{self.class}#set_char")
|
|
39
|
+
|
|
40
|
+
# @param rect [Rect] in this backend's own grid.
|
|
41
|
+
# @param style [StyledString::Style] fully resolved; only its `bg` shows.
|
|
42
|
+
# @return [void]
|
|
43
|
+
def fill(rect, style) = raise(NotImplementedError, "#{self.class}#fill")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/tuile/canvas.rb
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# The paint context a component draws through: a {Backend} that says where
|
|
5
|
+
# cells land, plus the state every write needs — the {#origin} that puts
|
|
6
|
+
# `(0, 0)` at the component's own top-left, and the background to fill in
|
|
7
|
+
# behind content that carries none.
|
|
8
|
+
#
|
|
9
|
+
# A component never builds one. It paints onto the canvas its
|
|
10
|
+
# {Component#repaint} was handed, already loaded with that component's
|
|
11
|
+
# {ComponentBackground#effective} and positioned at its {Component#rect}, and
|
|
12
|
+
# derives a second for the cells that are not its own ink:
|
|
13
|
+
#
|
|
14
|
+
# def repaint(canvas)
|
|
15
|
+
# canvas.set_text(0, 0, label) # my well
|
|
16
|
+
# canvas.with(bg_color: bg.ambient) { _1.fill(tail) } # not my ink
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# Three methods, in **paint coordinates**: `(0, 0)` is the component's own
|
|
20
|
+
# top-left, not the screen's, and {Component#local_rect} is its whole rect
|
|
21
|
+
# over here. Passing {Component#rect} instead lands the write at twice the
|
|
22
|
+
# offset, silently. Everything outside painting stays screen-space — `rect`,
|
|
23
|
+
# {Mouse::Event}, {Component#cursor_position} — which `D_canvas` argues.
|
|
24
|
+
#
|
|
25
|
+
# A `nil` {#bg_color} is the terminal default, never "inherit" — inheritance
|
|
26
|
+
# is resolved before a canvas is built ({Screen#canvas_for}).
|
|
27
|
+
#
|
|
28
|
+
# == Implementation details
|
|
29
|
+
#
|
|
30
|
+
# Frozen, and final by convention: {#with} yields a *derived* canvas and never
|
|
31
|
+
# mutates, so there are no save/restore pairs and no state to leave dangling
|
|
32
|
+
# for whatever paints next. Subclassing is not the extension point — {Backend}
|
|
33
|
+
# is, and it is the half that varies. See `D_canvas`.
|
|
34
|
+
#
|
|
35
|
+
# UI-thread-confined.
|
|
36
|
+
class Canvas
|
|
37
|
+
# @return [Backend] where the cells land.
|
|
38
|
+
attr_reader :backend
|
|
39
|
+
|
|
40
|
+
# @return [Color, nil] the background painted behind content that states
|
|
41
|
+
# none; `nil` leaves the terminal default showing.
|
|
42
|
+
attr_reader :bg_color
|
|
43
|
+
|
|
44
|
+
# Where this canvas's `(0, 0)` sits in {#backend} coordinates — the
|
|
45
|
+
# component's {Component#rect}`.top_left`, as {Screen#canvas_for} built it.
|
|
46
|
+
# A component never reads it: adding the offset back by hand is the one
|
|
47
|
+
# thing it exists to make unnecessary.
|
|
48
|
+
# @return [Point]
|
|
49
|
+
attr_reader :origin
|
|
50
|
+
|
|
51
|
+
# The region of the {#backend}'s grid this canvas may write to —
|
|
52
|
+
# {Screen#clip_for}'s answer moved into backend coordinates, or `nil` for a
|
|
53
|
+
# canvas nobody bounded, which costs one test per write and nothing else.
|
|
54
|
+
# Every canvas {Screen#canvas_for} builds carries one; {Screen#canvas}, the
|
|
55
|
+
# root, is the `nil` case.
|
|
56
|
+
#
|
|
57
|
+
# In **backend** coordinates, like {#origin} and unlike every argument the
|
|
58
|
+
# three paint methods take: a canvas's *state* says where it sits in the
|
|
59
|
+
# world, its arguments are in paint coordinates (`D_clip`).
|
|
60
|
+
#
|
|
61
|
+
# An {Rect#empty? empty} clip is not `nil`: it means *paint nothing*, and it
|
|
62
|
+
# is what a component that can show nothing gets — collapsed, or scrolled
|
|
63
|
+
# clean out of its viewport. Every write is judged against the clip on its
|
|
64
|
+
# own terms, so this needs no special case; it is simply the case where they
|
|
65
|
+
# all fail.
|
|
66
|
+
#
|
|
67
|
+
# The one cell it does not protect: {Buffer#put_char} blanks the head of a
|
|
68
|
+
# wide glyph whose continuation half a clipped write overwrites, one column
|
|
69
|
+
# outside. That is the terminal's physical truth, and better than the
|
|
70
|
+
# dangling half-glyph the alternative leaves.
|
|
71
|
+
# @return [Rect, nil]
|
|
72
|
+
attr_reader :clip
|
|
73
|
+
|
|
74
|
+
# @param backend [Backend]
|
|
75
|
+
# @param bg_color [Color, nil] already resolved — a canvas consults no
|
|
76
|
+
# component and no theme.
|
|
77
|
+
# @param origin [Point] the {#origin}; the default paints in backend
|
|
78
|
+
# coordinates, which is what {Screen#canvas} is.
|
|
79
|
+
# @param clip [Rect, nil] the {#clip}, in backend coordinates —
|
|
80
|
+
# {Screen#canvas_for} converts.
|
|
81
|
+
# @raise [Error] if `backend` does not include {Backend}, which is worth
|
|
82
|
+
# catching here rather than mid-paint.
|
|
83
|
+
def initialize(backend, bg_color: nil, origin: Point::ZERO, clip: nil)
|
|
84
|
+
raise Error, "#{backend.class} must include Tuile::Canvas::Backend" unless backend.is_a?(Backend)
|
|
85
|
+
|
|
86
|
+
@backend = backend
|
|
87
|
+
@bg_color = bg_color
|
|
88
|
+
@origin = origin
|
|
89
|
+
@clip = clip
|
|
90
|
+
@blank_style = bg_color ? StyledString::Style.new(bg: bg_color) : StyledString::Style::DEFAULT
|
|
91
|
+
freeze
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Yields a canvas onto the same backend with a different background, for the
|
|
95
|
+
# span of the block — the *only* way to change it.
|
|
96
|
+
#
|
|
97
|
+
# canvas.with(bg_color: bg.ambient) do |c|
|
|
98
|
+
# c.fill(right)
|
|
99
|
+
# c.fill(below)
|
|
100
|
+
# end
|
|
101
|
+
#
|
|
102
|
+
# The receiver is untouched, so nothing has to be restored afterwards and
|
|
103
|
+
# the block cannot leave the wrong background on for whatever paints next.
|
|
104
|
+
# The {#origin} and the {#clip} ride along, so a derived canvas paints in
|
|
105
|
+
# the same coordinates and is bounded the same way.
|
|
106
|
+
# @param bg_color [Color, nil] the background inside the block.
|
|
107
|
+
# @return [Object] the block's value.
|
|
108
|
+
# @raise [Error] if no block is given — a derived canvas nobody scoped is
|
|
109
|
+
# the dangling state this shape exists to make impossible.
|
|
110
|
+
def with(bg_color:)
|
|
111
|
+
raise Error, "Canvas#with needs a block: with(bg_color:) { |canvas| … }" unless block_given?
|
|
112
|
+
return yield self if bg_color == @bg_color
|
|
113
|
+
|
|
114
|
+
yield Canvas.new(@backend, bg_color:, origin: @origin, clip: @clip)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Writes a {StyledString}, filling {#bg_color} behind any span that states
|
|
118
|
+
# no background of its own — so an inherited tint, or an invalid field's
|
|
119
|
+
# error well, shows through content the component did not colour.
|
|
120
|
+
# @param x [Integer] starting column, relative to {#origin}.
|
|
121
|
+
# @param y [Integer] row, relative to {#origin}.
|
|
122
|
+
# @param styled [StyledString] the text of one row; newlines are not handled.
|
|
123
|
+
# @return [void]
|
|
124
|
+
def set_text(x, y, styled)
|
|
125
|
+
col = x + @origin.x
|
|
126
|
+
row = y + @origin.y
|
|
127
|
+
return @backend.set_text(col, row, styled.under_bg(@bg_color)) if @clip.nil?
|
|
128
|
+
return unless clipped_row?(row)
|
|
129
|
+
|
|
130
|
+
write_clipped_text(col, row, styled)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# {#set_text}'s single-grapheme counterpart.
|
|
134
|
+
# @param x [Integer] column, relative to {#origin}.
|
|
135
|
+
# @param y [Integer] row, relative to {#origin}.
|
|
136
|
+
# @param grapheme [String] one grapheme cluster.
|
|
137
|
+
# @param style [StyledString::Style] its `bg`, when set, wins over {#bg_color}.
|
|
138
|
+
# @return [void]
|
|
139
|
+
def set_char(x, y, grapheme, style = StyledString::Style::DEFAULT)
|
|
140
|
+
style = style.merge(bg: @bg_color) if @bg_color && style.bg.nil?
|
|
141
|
+
col = x + @origin.x
|
|
142
|
+
row = y + @origin.y
|
|
143
|
+
return @backend.set_char(col, row, grapheme, style) if @clip.nil?
|
|
144
|
+
return unless clipped_row?(row)
|
|
145
|
+
|
|
146
|
+
# A zero-width cluster still lands in one cell, and that cell is what the
|
|
147
|
+
# clip judges.
|
|
148
|
+
width = [Buffer.display_width(grapheme), 1].max
|
|
149
|
+
from = [col, @clip.left].max
|
|
150
|
+
to = [col + width, @clip.left + @clip.width].min
|
|
151
|
+
return if to <= from
|
|
152
|
+
|
|
153
|
+
# Half of a wide glyph is unrenderable, so the columns the clip keeps are
|
|
154
|
+
# blanked instead — {Buffer#put_char}'s own policy at the terminal's edge.
|
|
155
|
+
return @backend.fill(Rect.new(from, row, to - from, 1), @blank_style) if to - from < width
|
|
156
|
+
|
|
157
|
+
@backend.set_char(col, row, grapheme, style)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Blanks `area` to {#bg_color}.
|
|
161
|
+
#
|
|
162
|
+
# Only for cells nothing is about to paint over: {Buffer::Cell#set} dirties
|
|
163
|
+
# on a real change, so blanking a cell that is then redrawn re-emits it.
|
|
164
|
+
# @param area [Rect] relative to {#origin} — {Component#local_rect} for the
|
|
165
|
+
# whole of a component, never its {Component#rect}.
|
|
166
|
+
# @return [void]
|
|
167
|
+
def fill(area)
|
|
168
|
+
# The early-out {#set_text} gets from `clipped_row?`: an empty clip keeps
|
|
169
|
+
# no cell at all, so neither rectangle below is worth building.
|
|
170
|
+
return if @clip && @clip.empty?
|
|
171
|
+
|
|
172
|
+
area = area.moved_by(@origin)
|
|
173
|
+
@backend.fill(@clip.nil? ? area : area.intersect(@clip), @blank_style)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
# @param row [Integer] a row in backend coordinates.
|
|
179
|
+
# @return [Boolean] whether {#clip} keeps it. Callers have already checked
|
|
180
|
+
# that there *is* a clip.
|
|
181
|
+
def clipped_row?(row) = row >= @clip.top && row < @clip.top + @clip.height
|
|
182
|
+
|
|
183
|
+
# {#set_text} for the case that has to think: cut `styled` to the clip's
|
|
184
|
+
# columns and write what survived where it really belongs.
|
|
185
|
+
#
|
|
186
|
+
# {StyledString#slice} **drops** a cluster the boundary falls inside rather
|
|
187
|
+
# than splitting one, at the start as readily as at the end — so the kept
|
|
188
|
+
# text can begin a column later than the cut asked for, and the write
|
|
189
|
+
# position is derived from it rather than assumed. The column a dropped
|
|
190
|
+
# cluster half-covered is blanked (`D_clip`).
|
|
191
|
+
# @param col [Integer] starting column, in backend coordinates.
|
|
192
|
+
# @param row [Integer] row, likewise; the caller has checked the clip keeps it.
|
|
193
|
+
# @param styled [StyledString] the text of one row, uncoloured as handed in.
|
|
194
|
+
# @return [void]
|
|
195
|
+
def write_clipped_text(col, row, styled)
|
|
196
|
+
right = @clip.left + @clip.width
|
|
197
|
+
width = styled.display_width
|
|
198
|
+
from = [col, @clip.left].max
|
|
199
|
+
to = [col + width, right].min
|
|
200
|
+
return if to <= from
|
|
201
|
+
|
|
202
|
+
kept = col < @clip.left ? styled.slice(@clip.left - col, width) : styled
|
|
203
|
+
start = col + width - kept.display_width
|
|
204
|
+
kept = kept.slice(0, right - start) if start + kept.display_width > right
|
|
205
|
+
stop = start + kept.display_width
|
|
206
|
+
|
|
207
|
+
@backend.fill(Rect.new(from, row, start - from, 1), @blank_style) if start > from
|
|
208
|
+
@backend.fill(Rect.new(stop, row, to - stop, 1), @blank_style) if to > stop
|
|
209
|
+
@backend.set_text(start, row, kept.under_bg(@bg_color)) unless kept.empty?
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
data/lib/tuile/color.rb
CHANGED
|
@@ -122,6 +122,27 @@ module Tuile
|
|
|
122
122
|
# @return [Symbol, Integer, Array<Integer>]
|
|
123
123
|
attr_reader :value
|
|
124
124
|
|
|
125
|
+
# This color's red, green and blue, for an app doing color math — a
|
|
126
|
+
# contrast check, a tint derived from {Screen#background_color}:
|
|
127
|
+
#
|
|
128
|
+
# Color.hex("#ff6400").rgb # => [255, 100, 0]
|
|
129
|
+
# Color::DEEP_SKY_BLUE1.rgb # => [0, 175, 255], palette cell 39
|
|
130
|
+
# Color::MAGENTA.rgb # => nil, the terminal's scheme decides
|
|
131
|
+
# Color.palette(5).rgb # => nil, likewise
|
|
132
|
+
#
|
|
133
|
+
# `nil` for the 16 named colors, in either spelling, because the scheme
|
|
134
|
+
# remaps them and any answer would be a guess. Indices 16..255 answer
|
|
135
|
+
# xterm's cube and grey ramp: a terminal *may* redefine those too (OSC 4),
|
|
136
|
+
# but in practice none does.
|
|
137
|
+
#
|
|
138
|
+
# @return [Array<Integer>, nil] frozen, each channel 0..255.
|
|
139
|
+
def rgb
|
|
140
|
+
case @value
|
|
141
|
+
when Array then @value
|
|
142
|
+
when Integer then PALETTE_RGB[@value]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
125
146
|
# SGR parameter codes for emitting this color as either a foreground
|
|
126
147
|
# (`target: :fg`) or background (`target: :bg`). Returned as an array so
|
|
127
148
|
# callers can splice them into a multi-attribute SGR (e.g. bold + color).
|
|
@@ -214,17 +235,12 @@ module Tuile
|
|
|
214
235
|
|
|
215
236
|
private
|
|
216
237
|
|
|
217
|
-
#
|
|
218
|
-
#
|
|
219
|
-
#
|
|
238
|
+
# {#rgb}, except an index 0..15 answers xterm's default for it — the guess
|
|
239
|
+
# `:ansi16` quantization needs something to match against, and {#rgb}
|
|
240
|
+
# must not hand out. Only ever asked of a non-Symbol value.
|
|
220
241
|
# @return [Array<Integer>] red, green and blue, each 0..255.
|
|
221
242
|
def rgb_triple
|
|
222
|
-
|
|
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]]
|
|
243
|
+
rgb || ANSI16_RGB[@value]
|
|
228
244
|
end
|
|
229
245
|
|
|
230
246
|
# Written flat — destructured rather than splatted, `x * x` rather than
|
|
@@ -293,6 +309,19 @@ module Tuile
|
|
|
293
309
|
CUBE_INDEX = Array.new(256) { |c| (0...6).min_by { |i| (CUBE_LEVELS[i] - c).abs } }.freeze
|
|
294
310
|
private_constant :CUBE_INDEX
|
|
295
311
|
|
|
312
|
+
# Palette index 0..255 → its RGB, frozen, or nil for 0..15 — what {#rgb}
|
|
313
|
+
# answers for an index, precomputed so it allocates nothing.
|
|
314
|
+
# @return [Array<Array<Integer>, nil>]
|
|
315
|
+
PALETTE_RGB = Array.new(256) do |index|
|
|
316
|
+
if index < 16 then nil
|
|
317
|
+
elsif index >= 232 then ([8 + (10 * (index - 232))] * 3).freeze
|
|
318
|
+
else
|
|
319
|
+
cube = index - 16
|
|
320
|
+
[CUBE_LEVELS[cube / 36], CUBE_LEVELS[(cube / 6) % 6], CUBE_LEVELS[cube % 6]].freeze
|
|
321
|
+
end
|
|
322
|
+
end.freeze
|
|
323
|
+
private_constant :PALETTE_RGB
|
|
324
|
+
|
|
296
325
|
# xterm's default RGB for each of the 16 named colors, in {COLOR_SYMBOLS}
|
|
297
326
|
# order — what `:ansi16` quantization matches against. A terminal scheme
|
|
298
327
|
# may redefine these; see {#quantize}.
|
|
@@ -9,9 +9,9 @@ module Tuile
|
|
|
9
9
|
# drag this String-typed `text`/`value` seam onto its face alongside the
|
|
10
10
|
# real typed one.
|
|
11
11
|
#
|
|
12
|
-
# Holds the shared state — a mutable {#text} buffer, a {#caret} index,
|
|
13
|
-
# {#
|
|
14
|
-
#
|
|
12
|
+
# Holds the shared state — a mutable {#text} buffer, a {#caret} index, the
|
|
13
|
+
# {#on_escape} slot — and the keyboard machinery that single-line and
|
|
14
|
+
# multi-line inputs both need: ESC handling,
|
|
15
15
|
# LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, CTRL+W
|
|
16
16
|
# word-delete, and the `tab_stop?` flag (`focusable?` comes from
|
|
17
17
|
# {HasValue}).
|
|
@@ -21,14 +21,14 @@ module Tuile
|
|
|
21
21
|
# forward onto the enclosing cluster's end, and every edit steps by a whole
|
|
22
22
|
# cluster:
|
|
23
23
|
#
|
|
24
|
-
# f.
|
|
24
|
+
# f.value = "e\u{0301}x" # a decomposed e-acute then "x": 3 chars, 2 glyphs
|
|
25
25
|
# f.caret = 1 # into the middle of the e-acute …
|
|
26
26
|
# f.caret # => 2, its end — where the caret already drew
|
|
27
27
|
# f.handle_key?(Keys::BACKSPACE)
|
|
28
28
|
# f.text # => "x": the whole glyph went, not its accent
|
|
29
29
|
#
|
|
30
30
|
# Insertion stays character-native, so `String#insert` merges a typed
|
|
31
|
-
# combining mark into its base; {#
|
|
31
|
+
# combining mark into its base; {#set_value}'s snap covers the case where that
|
|
32
32
|
# re-segments the text around the caret.
|
|
33
33
|
#
|
|
34
34
|
# Subclasses implement the layout-specific pieces ({#cursor_position},
|
|
@@ -66,13 +66,15 @@ module Tuile
|
|
|
66
66
|
# share one slot, and a filter written on a *key* callback let the same
|
|
67
67
|
# characters in through a paste (`D_input_filters`, book ch7).
|
|
68
68
|
#
|
|
69
|
-
# The mutation pipeline is a template method: {#
|
|
70
|
-
# detect no-ops, mutate state, fire {#
|
|
69
|
+
# The mutation pipeline is a template method: {#set_value} and {#caret=}
|
|
70
|
+
# detect no-ops, mutate state, fire {HasValue#on_value_change}, and invalidate.
|
|
71
|
+
# Typing, a paste and the deleting keys write with `from_user: true`, every
|
|
72
|
+
# other write with `false` ({HasValue::ValueChangeEvent#from_user?}).
|
|
71
73
|
# Subclasses inject their own behavior via four protected hooks:
|
|
72
74
|
#
|
|
73
75
|
# - {#insert_text} — **the one filter seam**: every insertion runs through
|
|
74
76
|
# it, typed or pasted, so what the buffer may hold is decided here.
|
|
75
|
-
# - {#preprocess_text} — filter for a whole assignment to {#
|
|
77
|
+
# - {#preprocess_text} — filter for a whole assignment to {#set_value},
|
|
76
78
|
# which insertion does *not* pass through.
|
|
77
79
|
# - {#preprocess_paste} — sanitizer for {#handle_paste}, run before the
|
|
78
80
|
# clipboard reaches {#insert_text} ({TextField} keeps its first line).
|
|
@@ -86,24 +88,41 @@ module Tuile
|
|
|
86
88
|
super
|
|
87
89
|
@text = +""
|
|
88
90
|
@caret = 0
|
|
89
|
-
@
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
@escape_clears_focus = true
|
|
92
|
+
# Unconditional on purpose: a field used as the face of a composed one
|
|
93
|
+
# ({ComboBox}, {IntegerField} …) is *told* to drop its well with
|
|
94
|
+
# {ComponentBackground::INHERIT} — a second well would make the
|
|
95
|
+
# composer's own bg_color inert over the very cells this field paints.
|
|
96
|
+
bg.default_color = ComponentBackground::INPUT_WELL
|
|
92
97
|
end
|
|
93
98
|
|
|
94
99
|
# @return [String] current text contents.
|
|
95
100
|
attr_reader :text
|
|
96
101
|
|
|
97
|
-
# A text component's value *is* its text: {#value}
|
|
98
|
-
#
|
|
99
|
-
# drive it alongside typed fields. `text` stays the text-native name.
|
|
102
|
+
# A text component's value *is* its text: {#value} reads the same buffer
|
|
103
|
+
# as {#text}, and {#set_value} is its one writer.
|
|
100
104
|
# @return [String]
|
|
101
105
|
def value = text
|
|
102
106
|
|
|
107
|
+
# Replaces the text. Runs {#preprocess_text} first, then clamps the caret
|
|
108
|
+
# to the new text and snaps it onto a cluster boundary of it. Fires
|
|
109
|
+
# {HasValue#on_value_change} only on a real change — never on {#caret=}.
|
|
110
|
+
#
|
|
111
|
+
# field.value = "admin"
|
|
112
|
+
# field.caret = field.text.length # the caret is clamped, never moved to the end
|
|
113
|
+
#
|
|
103
114
|
# @param new_value [String, #to_s]
|
|
115
|
+
# @param from_user [Boolean] see {HasValue#set_value}.
|
|
104
116
|
# @return [void]
|
|
105
|
-
def
|
|
106
|
-
|
|
117
|
+
def set_value(new_value, from_user:)
|
|
118
|
+
new_value = preprocess_text(new_value)
|
|
119
|
+
return if @text == new_value
|
|
120
|
+
|
|
121
|
+
@text = +new_value
|
|
122
|
+
@caret = snap_to_cluster(@caret.clamp(0, @text.length))
|
|
123
|
+
handle_text_mutated
|
|
124
|
+
invalidate
|
|
125
|
+
on_value_change.fire(HasValue::ValueChangeEvent.new(source: self, value: @text, from_user:))
|
|
107
126
|
end
|
|
108
127
|
|
|
109
128
|
# `""` (not `nil`): a text field is empty when its buffer is blank.
|
|
@@ -114,39 +133,37 @@ module Tuile
|
|
|
114
133
|
# and always on a grapheme-cluster boundary (see the class doc).
|
|
115
134
|
attr_reader :caret
|
|
116
135
|
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
|
|
136
|
+
# What {#on_escape} fires.
|
|
137
|
+
#
|
|
138
|
+
# @!attribute [r] source
|
|
139
|
+
# @return [AbstractStringField] the field ESC reached.
|
|
140
|
+
EscapeEvent = Data.define(:source) { include Tuile::Event }
|
|
141
|
+
|
|
142
|
+
# @!method on_escape
|
|
143
|
+
# Fired with an {EscapeEvent} when ESC is pressed, after
|
|
144
|
+
# {#escape_clears_focus} has had its say. Append to react as well as
|
|
145
|
+
# blur; turn that flag off first to react *instead*:
|
|
146
|
+
#
|
|
147
|
+
# field.escape_clears_focus = false
|
|
148
|
+
# field.on_escape << method(:close_search)
|
|
149
|
+
#
|
|
150
|
+
# **Empty means the field declines ESC** — but only while
|
|
151
|
+
# {#escape_clears_focus} is off too, since a field that blurs has
|
|
152
|
+
# handled the key. With both, ESC bubbles to the parent, and on to the
|
|
153
|
+
# screen's ESC-to-quit.
|
|
154
|
+
# @return [Listeners]
|
|
155
|
+
listener :on_escape
|
|
156
|
+
|
|
157
|
+
# Whether ESC clears focus (`true` by default), so text entry visibly
|
|
158
|
+
# cancels instead of quitting the app. It runs before {#on_escape} fires,
|
|
159
|
+
# and it — not a listener an app must remove by identity — is the whole
|
|
160
|
+
# representation of the default, so turning it off is the one way to give
|
|
161
|
+
# ESC another meaning. See `D_escape_opt_out`.
|
|
162
|
+
# @return [Boolean]
|
|
163
|
+
attr_accessor :escape_clears_focus
|
|
130
164
|
|
|
131
165
|
def tab_stop? = true
|
|
132
166
|
|
|
133
|
-
# Sets the text. Runs {#preprocess_text} first (subclasses may filter or
|
|
134
|
-
# truncate). Caret is clamped to the new text length, then snapped back
|
|
135
|
-
# onto a cluster boundary of the *new* text. Fires {#on_change} only on a
|
|
136
|
-
# real change.
|
|
137
|
-
# @param new_text [String]
|
|
138
|
-
def text=(new_text)
|
|
139
|
-
new_text = preprocess_text(new_text)
|
|
140
|
-
return if @text == new_text
|
|
141
|
-
|
|
142
|
-
@text = +new_text
|
|
143
|
-
@caret = snap_to_cluster(@caret.clamp(0, @text.length))
|
|
144
|
-
handle_text_mutated
|
|
145
|
-
invalidate
|
|
146
|
-
@on_change&.call(@text)
|
|
147
|
-
on_value_change&.call(@text)
|
|
148
|
-
end
|
|
149
|
-
|
|
150
167
|
# Clamps to `0..text.length`, then snaps forward onto a grapheme-cluster
|
|
151
168
|
# boundary, so an index that fell inside a cluster reads back as that
|
|
152
169
|
# cluster's end. Fires the {#handle_caret_mutated} hook for subclasses (e.g.
|
|
@@ -169,8 +186,9 @@ module Tuile
|
|
|
169
186
|
# @return [Boolean]
|
|
170
187
|
def handle_key?(key) = handle_text_input_key?(key)
|
|
171
188
|
|
|
172
|
-
# Inserts pasted text at the caret as **one** mutation, so
|
|
173
|
-
# fires once for the whole paste rather than
|
|
189
|
+
# Inserts pasted text at the caret as **one** mutation, so
|
|
190
|
+
# {HasValue#on_value_change} fires once for the whole paste rather than
|
|
191
|
+
# once per character.
|
|
174
192
|
# {#preprocess_paste} filters it first.
|
|
175
193
|
# @param text [String]
|
|
176
194
|
# @return [void]
|
|
@@ -210,8 +228,10 @@ module Tuile
|
|
|
210
228
|
# (a date — `"2020-13-45"` is well-formed at every character) reports bad
|
|
211
229
|
# input rather than filtering it (`D_input_filters`, book ch7).
|
|
212
230
|
#
|
|
213
|
-
# {#
|
|
214
|
-
# programmatic
|
|
231
|
+
# {#set_value} does *not* pass through here: only user input is filtered,
|
|
232
|
+
# so a programmatic write may still hold what no key types. The same line
|
|
233
|
+
# decides the origin: an insertion is the user's, so it writes with
|
|
234
|
+
# `from_user: true`.
|
|
215
235
|
# @param str [String]
|
|
216
236
|
# @return [Boolean] true if the text changed.
|
|
217
237
|
def insert_text(str)
|
|
@@ -219,25 +239,11 @@ module Tuile
|
|
|
219
239
|
|
|
220
240
|
new_text = @text.dup.insert(@caret, str)
|
|
221
241
|
@caret += str.length
|
|
222
|
-
|
|
242
|
+
set_value(new_text, from_user: true)
|
|
223
243
|
true
|
|
224
244
|
end
|
|
225
245
|
|
|
226
|
-
#
|
|
227
|
-
# at paint time: {Theme#active_bg_color} while this input is on the active
|
|
228
|
-
# (focus) chain, {Theme#input_bg_color} otherwise — visibly a field either
|
|
229
|
-
# way, distinctly highlighted when focused. An app overrides the pair by
|
|
230
|
-
# setting {Component#bg_color}, which wins over this.
|
|
231
|
-
#
|
|
232
|
-
# Unconditional on purpose. A field used as the face of a composed one
|
|
233
|
-
# ({Component::ComboBox}, {Component::IntegerField} …) is *told* to drop
|
|
234
|
-
# its well — that widget assigns {Component::BG_INHERIT} at construction,
|
|
235
|
-
# since it owns the surface and a second well would make its own
|
|
236
|
-
# {Component#bg_color} inert over the very cells this field paints.
|
|
237
|
-
# @return [Color]
|
|
238
|
-
def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
|
|
239
|
-
|
|
240
|
-
# Input filter for a whole assignment to {#text=}. Nothing overrides it
|
|
246
|
+
# Input filter for a whole assignment to {#set_value}. Nothing overrides it
|
|
241
247
|
# today; a subclass that does is filtering the *programmatic* setter, not
|
|
242
248
|
# user input — that is {#insert_text}.
|
|
243
249
|
# @param new_text [String]
|
|
@@ -254,8 +260,8 @@ module Tuile
|
|
|
254
260
|
def columns_of(str) = str.each_grapheme_cluster.sum { |g| Buffer.display_width(g) }
|
|
255
261
|
|
|
256
262
|
# Hook called after {#text} has been mutated, before invalidation /
|
|
257
|
-
# {#
|
|
258
|
-
# ({TextArea}'s wrap cache) and update derived state.
|
|
263
|
+
# {HasValue#on_value_change}. Default no-op. Subclasses use this to
|
|
264
|
+
# invalidate caches ({TextArea}'s wrap cache) and update derived state.
|
|
259
265
|
# @return [void]
|
|
260
266
|
def handle_text_mutated; end
|
|
261
267
|
|
|
@@ -283,9 +289,10 @@ module Tuile
|
|
|
283
289
|
when Keys::CTRL_RIGHT_ARROW then self.caret = word_right
|
|
284
290
|
when Keys::CTRL_W then delete_back_to(word_left)
|
|
285
291
|
when Keys::ESC
|
|
286
|
-
return false if
|
|
292
|
+
return false if !@escape_clears_focus && on_escape.empty?
|
|
287
293
|
|
|
288
|
-
@
|
|
294
|
+
screen.focused = nil if @escape_clears_focus
|
|
295
|
+
on_escape.fire(EscapeEvent.new(source: self))
|
|
289
296
|
else
|
|
290
297
|
return false
|
|
291
298
|
end
|
|
@@ -299,7 +306,8 @@ module Tuile
|
|
|
299
306
|
def delete_before_caret = delete_back_to(cluster_boundary_before(@caret))
|
|
300
307
|
|
|
301
308
|
# Removes the text between `index` and the caret, leaving the caret at
|
|
302
|
-
# `index` — one mutation, so {#
|
|
309
|
+
# `index` — one mutation, so {HasValue#on_value_change} fires once, as the
|
|
310
|
+
# user's: every caller is a deleting key.
|
|
303
311
|
#
|
|
304
312
|
# `index` is snapped forward onto a grapheme-cluster boundary, so a
|
|
305
313
|
# caller may compute it by counting characters.
|
|
@@ -312,7 +320,7 @@ module Tuile
|
|
|
312
320
|
new_text = @text.dup
|
|
313
321
|
new_text.slice!(start...@caret)
|
|
314
322
|
@caret = start
|
|
315
|
-
|
|
323
|
+
set_value(new_text, from_user: true)
|
|
316
324
|
end
|
|
317
325
|
|
|
318
326
|
# Removes the whole grapheme cluster at the caret.
|
|
@@ -322,7 +330,7 @@ module Tuile
|
|
|
322
330
|
|
|
323
331
|
new_text = @text.dup
|
|
324
332
|
new_text.slice!(@caret...cluster_boundary_after(@caret))
|
|
325
|
-
|
|
333
|
+
set_value(new_text, from_user: true)
|
|
326
334
|
end
|
|
327
335
|
|
|
328
336
|
private
|
|
@@ -366,13 +374,6 @@ module Tuile
|
|
|
366
374
|
offset
|
|
367
375
|
end
|
|
368
376
|
|
|
369
|
-
# Default {#on_escape} action: clear focus. Component deactivates; user
|
|
370
|
-
# can re-focus by clicking or tabbing back in.
|
|
371
|
-
# @return [void]
|
|
372
|
-
def default_on_escape
|
|
373
|
-
screen.focused = nil
|
|
374
|
-
end
|
|
375
|
-
|
|
376
377
|
# Caret target for ctrl+left: skip whitespace going left, then a run of
|
|
377
378
|
# non-whitespace. Lands at the beginning of the current word, or the
|
|
378
379
|
# beginning of the previous word if already there.
|