Class: Tuile::Canvas
- Inherits:
-
Object
- Object
- Tuile::Canvas
- Defined in:
- lib/tuile/canvas.rb,
lib/tuile/canvas/backend.rb,
sig/tuile.rbs
Overview
The paint context a component draws through: a Backend that says where
cells land, plus the state every write needs — the #origin that puts
(0, 0) at the component's own top-left, and the background to fill in
behind content that carries none.
A component never builds one. It paints onto the canvas its Tuile::Component#repaint was handed, already loaded with that component's Tuile::ComponentBackground#effective and positioned at its Tuile::Component#rect, and derives a second for the cells that are not its own ink:
def repaint(canvas)
canvas.set_text(0, 0, label) # my well
canvas.with(bg_color: bg.ambient) { _1.fill(tail) } # not my ink
end
Three methods, in paint coordinates: (0, 0) is the component's own
top-left, not the screen's, and Tuile::Component#local_rect is its whole rect
over here. Passing Tuile::Component#rect instead lands the write at twice the
offset, silently. Everything outside painting stays screen-space — rect,
Mouse::Event, Tuile::Component#cursor_position — which D_canvas argues.
A nil #bg_color is the terminal default, never "inherit" — inheritance
is resolved before a canvas is built (Screen#canvas_for).
Implementation details
Frozen, and final by convention: #with yields a derived canvas and never
mutates, so there are no save/restore pairs and no state to leave dangling
for whatever paints next. Subclassing is not the extension point — Backend
is, and it is the half that varies. See D_canvas.
UI-thread-confined.
Defined Under Namespace
Modules: Backend
Instance Attribute Summary collapse
-
#backend ⇒ Backend
readonly
@return — where the cells land.
-
#bg_color ⇒ Color?
readonly
@return — the background painted behind content that states none;
nilleaves the terminal default showing. -
#clip ⇒ Rect?
readonly
The region of the #backend's grid this canvas may write to — Screen#clip_for's answer moved into backend coordinates, or
nilfor a canvas nobody bounded, which costs one test per write and nothing else. -
#origin ⇒ Point
readonly
Where this canvas's
(0, 0)sits in #backend coordinates — the component's Tuile::Component#rect.top_left, as Screen#canvas_for built it.
Instance Method Summary collapse
-
#clipped_row?(row) ⇒ Boolean
@param
row— a row in backend coordinates. -
#fill(area) ⇒ void
Blanks
areato #bg_color. -
#initialize(backend, bg_color: nil, origin: Point::ZERO, clip: nil) ⇒ Object
constructor
@param
backend. -
#set_char(x, y, grapheme, style = StyledString::Style::DEFAULT) ⇒ Object
#set_text's single-grapheme counterpart.
-
#set_text(x, y, styled) ⇒ void
Writes a StyledString, filling #bg_color behind any span that states no background of its own — so an inherited tint, or an invalid field's error well, shows through content the component did not colour.
-
#with(bg_color:) ⇒ Object
Yields a canvas onto the same backend with a different background, for the span of the block — the only way to change it.
-
#write_clipped_text(col, row, styled) ⇒ void
#set_text for the case that has to think: cut
styledto the clip's columns and write what survived where it really belongs.
Constructor Details
#initialize(backend, bg_color: nil, origin: Point::ZERO, clip: nil) ⇒ Object
@param backend
@param bg_color — already resolved — a canvas consults no component and no theme.
@param origin — the #origin; the default paints in backend coordinates, which is what Screen#canvas is.
@param clip — the #clip, in backend coordinates — Screen#canvas_for converts.
83 84 85 86 87 88 89 90 91 92 |
# File 'lib/tuile/canvas.rb', line 83 def initialize(backend, bg_color: nil, origin: Point::ZERO, clip: nil) raise Error, "#{backend.class} must include Tuile::Canvas::Backend" unless backend.is_a?(Backend) @backend = backend @bg_color = bg_color @origin = origin @clip = clip @blank_style = bg_color ? StyledString::Style.new(bg: bg_color) : StyledString::Style::DEFAULT freeze end |
Instance Attribute Details
#backend ⇒ Backend (readonly)
@return — where the cells land.
38 39 40 |
# File 'lib/tuile/canvas.rb', line 38 def backend @backend end |
#bg_color ⇒ Color? (readonly)
@return — the background painted behind content that states
none; nil leaves the terminal default showing.
42 43 44 |
# File 'lib/tuile/canvas.rb', line 42 def bg_color @bg_color end |
#clip ⇒ Rect? (readonly)
The region of the #backend's grid this canvas may write to —
Screen#clip_for's answer moved into backend coordinates, or nil for a
canvas nobody bounded, which costs one test per write and nothing else.
Every canvas Screen#canvas_for builds carries one; Screen#canvas, the
root, is the nil case.
In backend coordinates, like #origin and unlike every argument the
three paint methods take: a canvas's state says where it sits in the
world, its arguments are in paint coordinates (D_clip).
An empty clip is not nil: it means paint nothing, and it
is what a component that can show nothing gets — collapsed, or scrolled
clean out of its viewport. Every write is judged against the clip on its
own terms, so this needs no special case; it is simply the case where they
all fail.
The one cell it does not protect: Buffer#put_char blanks the head of a wide glyph whose continuation half a clipped write overwrites, one column outside. That is the terminal's physical truth, and better than the dangling half-glyph the alternative leaves.
72 73 74 |
# File 'lib/tuile/canvas.rb', line 72 def clip @clip end |
#origin ⇒ Point (readonly)
Where this canvas's (0, 0) sits in #backend coordinates — the
component's Tuile::Component#rect.top_left, as Screen#canvas_for built it.
A component never reads it: adding the offset back by hand is the one
thing it exists to make unnecessary.
49 50 51 |
# File 'lib/tuile/canvas.rb', line 49 def origin @origin end |
Instance Method Details
#clipped_row?(row) ⇒ Boolean
@param row — a row in backend coordinates.
@return — whether #clip keeps it. Callers have already checked that there is a clip.
181 |
# File 'lib/tuile/canvas.rb', line 181 def clipped_row?(row) = row >= @clip.top && row < @clip.top + @clip.height |
#fill(area) ⇒ void
This method returns an undefined value.
Blanks area to #bg_color.
Only for cells nothing is about to paint over: Buffer::Cell#set dirties on a real change, so blanking a cell that is then redrawn re-emits it.
@param area — relative to #origin — Tuile::Component#local_rect for the whole of a component, never its Tuile::Component#rect.
167 168 169 170 171 172 173 174 |
# File 'lib/tuile/canvas.rb', line 167 def fill(area) # The early-out {#set_text} gets from `clipped_row?`: an empty clip keeps # no cell at all, so neither rectangle below is worth building. return if @clip && @clip.empty? area = area.moved_by(@origin) @backend.fill(@clip.nil? ? area : area.intersect(@clip), @blank_style) end |
#set_char(x, y, grapheme, style = StyledString::Style::DEFAULT) ⇒ Object
139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
# File 'lib/tuile/canvas.rb', line 139 def set_char(x, y, grapheme, style = StyledString::Style::DEFAULT) style = style.merge(bg: @bg_color) if @bg_color && style.bg.nil? col = x + @origin.x row = y + @origin.y return @backend.set_char(col, row, grapheme, style) if @clip.nil? return unless clipped_row?(row) # A zero-width cluster still lands in one cell, and that cell is what the # clip judges. width = [Buffer.display_width(grapheme), 1].max from = [col, @clip.left].max to = [col + width, @clip.left + @clip.width].min return if to <= from # Half of a wide glyph is unrenderable, so the columns the clip keeps are # blanked instead — {Buffer#put_char}'s own policy at the terminal's edge. return @backend.fill(Rect.new(from, row, to - from, 1), @blank_style) if to - from < width @backend.set_char(col, row, grapheme, style) end |
#set_text(x, y, styled) ⇒ void
This method returns an undefined value.
Writes a StyledString, filling #bg_color behind any span that states no background of its own — so an inherited tint, or an invalid field's error well, shows through content the component did not colour.
@param x — starting column, relative to #origin.
@param y — row, relative to #origin.
@param styled — the text of one row; newlines are not handled.
124 125 126 127 128 129 130 131 |
# File 'lib/tuile/canvas.rb', line 124 def set_text(x, y, styled) col = x + @origin.x row = y + @origin.y return @backend.set_text(col, row, styled.under_bg(@bg_color)) if @clip.nil? return unless clipped_row?(row) write_clipped_text(col, row, styled) end |
#with(bg_color:) ⇒ Object
Yields a canvas onto the same backend with a different background, for the span of the block — the only way to change it.
canvas.with(bg_color: bg.ambient) do |c|
c.fill(right)
c.fill(below)
end
The receiver is untouched, so nothing has to be restored afterwards and the block cannot leave the wrong background on for whatever paints next. The #origin and the #clip ride along, so a derived canvas paints in the same coordinates and is bounded the same way.
@param bg_color — the background inside the block.
@return — the block's value.
110 111 112 113 114 115 |
# File 'lib/tuile/canvas.rb', line 110 def with(bg_color:) raise Error, "Canvas#with needs a block: with(bg_color:) { |canvas| … }" unless block_given? return yield self if bg_color == @bg_color yield Canvas.new(@backend, bg_color:, origin: @origin, clip: @clip) end |
#write_clipped_text(col, row, styled) ⇒ void
This method returns an undefined value.
#set_text for the case that has to think: cut styled to the clip's
columns and write what survived where it really belongs.
StyledString#slice drops a cluster the boundary falls inside rather
than splitting one, at the start as readily as at the end — so the kept
text can begin a column later than the cut asked for, and the write
position is derived from it rather than assumed. The column a dropped
cluster half-covered is blanked (D_clip).
@param col — starting column, in backend coordinates.
@param row — row, likewise; the caller has checked the clip keeps it.
@param styled — the text of one row, uncoloured as handed in.
195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 |
# File 'lib/tuile/canvas.rb', line 195 def write_clipped_text(col, row, styled) right = @clip.left + @clip.width width = styled.display_width from = [col, @clip.left].max to = [col + width, right].min return if to <= from kept = col < @clip.left ? styled.slice(@clip.left - col, width) : styled start = col + width - kept.display_width kept = kept.slice(0, right - start) if start + kept.display_width > right stop = start + kept.display_width @backend.fill(Rect.new(from, row, start - from, 1), @blank_style) if start > from @backend.fill(Rect.new(stop, row, to - stop, 1), @blank_style) if to > stop @backend.set_text(start, row, kept.under_bg(@bg_color)) unless kept.empty? end |