Class: Tuile::Canvas

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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.

Returns:



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.

Returns:



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.

Returns:



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.

Returns:



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.

Parameters:

  • row (Integer)

Returns:

  • (Boolean)


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.

Parameters:



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

#set_text's single-grapheme counterpart.

@param x — column, relative to #origin.

@param y — row, relative to #origin.

@param grapheme — one grapheme cluster.

@param style — its bg, when set, wins over #bg_color.



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.

Parameters:



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.

Parameters:

Returns:

  • (Object)


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.

Parameters:



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