Class: Tuile::Component::TextArea::WrappedText
- Inherits:
-
Object
- Object
- Tuile::Component::TextArea::WrappedText
- Defined in:
- lib/tuile/component/text_area/wrapped_text.rb,
sig/tuile.rbs
Overview
The word-wrapped layout of a Tuile::Component::TextArea's text: which row each character lands on, and which glyphs each row paints.
wrap = WrappedText.new("hello world", 6)
wrap.row_count # => 2
wrap.position_at(8) # => [1, 2] index 8 ("r") sits at row 1, column 2
wrap.index_at(1, 2) # => 8 and back again
wrap.row_text(0) # => "hello " padded out to the width
A snapshot of (text, width) — rebuild it whenever either changes.
Implementation details
Two axes meet here, and every method name says which one it speaks: an index counts characters into #text, a column counts terminal cells. They agree only for one-column glyphs. A Row therefore carries both counts — the wrap fills each row to a column budget while recording the character span that produced it.
The wrap walks grapheme clusters, not characters: a combining mark
must add no columns and must not be split from its base across a row
break. Note "\r\n" is a single cluster, so a hard break tests
end_with?("\n") rather than equality. Every branch of the wrap
consumes at least one cluster — "\v" and "\f" match /\s/ but are
neither blank nor a newline here, and a loop that measured them as zero
and did not advance would hang the UI thread on
area.text = File.read(...).
Defined Under Namespace
Classes: Row
Instance Attribute Summary collapse
- #text ⇒ String readonly
- #width ⇒ Integer readonly
Instance Method Summary collapse
-
#blank?(cluster) ⇒ Boolean
@param
cluster. -
#chars_for_column(row, column) ⇒ Integer
@param
row. -
#cluster_table ⇒ ::Array[::Hash[Symbol, Object]]
A plain Hash rather than a Row-style Data: this table is one entry per grapheme cluster, built and discarded inside a single #compute_rows call and never handed to another method as a documented type.
-
#column_in(row, index) ⇒ Integer
@param
row. -
#columns_of(str) ⇒ Integer
Mirrors AbstractStringField's measurement primitive, which this class can't inherit.
-
#compute_rows ⇒ ::Array[Row]
Greedy word-wrap, filling each row to a column budget while recording the character span that produced it.
-
#glyphs_of(row) ⇒ String
@param
row. -
#hard_wrap(clusters, index) ⇒ [Integer, Integer, Integer]
Splits a token too wide for a whole row, taking entire glyphs while they fit.
-
#index_at(row, column) ⇒ Integer
Inverse of #position_at.
-
#initialize(text, width) ⇒ WrappedText
constructor
@param
text— the full buffer, unwrapped. -
#measure_word(clusters, index) ⇒ [Integer, Integer, Integer]
@param
clusters. -
#newline?(cluster) ⇒ Boolean
@param
cluster. -
#position_at(index) ⇒ [Integer, Integer]
@param
index— a character index into #text. -
#row_at(index) ⇒ Integer
Display row holding
index. -
#row_count ⇒ Integer
@return — rows the text occupies; always
>= 1, since empty text still wraps to one (empty) row. -
#row_end(row) ⇒ Integer
@param
row— a row index in0...row_count. -
#row_start(row) ⇒ Integer
@param
row— a row index in0...row_count. -
#row_text(row) ⇒ String
The row's glyphs, padded with spaces out to #width.
-
#trim_trailing_whitespace(row_start, row_chars, row_cols) ⇒ [Integer, Integer]
Trims trailing space/tab characters off a row's visible length so the whitespace at a soft-wrap point is absorbed (not rendered) rather than left at the end of the row.
Constructor Details
#initialize(text, width) ⇒ WrappedText
@param text — the full buffer, unwrapped.
@param width — column budget per row; 0 or less yields a single empty row.
58 59 60 61 62 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 58 def initialize(text, width) @text = text @width = width @rows = compute_rows end |
Instance Attribute Details
#text ⇒ String (readonly)
65 66 67 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 65 def text @text end |
#width ⇒ Integer (readonly)
68 69 70 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 68 def width @width end |
Instance Method Details
#blank?(cluster) ⇒ Boolean
@param cluster
@return — true for a space or tab (each exactly one column).
155 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 155 def blank?(cluster) = cluster[:text].match?(/[ \t]/) |
#chars_for_column(row, column) ⇒ Integer
@param row
@param column
@return — characters from the row's start.
294 295 296 297 298 299 300 301 302 303 304 305 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 294 def chars_for_column(row, column) chars = 0 col = 0 glyphs_of(row).each_grapheme_cluster do |g| w = Buffer.display_width(g) return chars if column < col + ((w + 1) / 2) col += w chars += g.length end chars end |
#cluster_table ⇒ ::Array[::Hash[Symbol, Object]]
A plain Hash rather than a Row-style Data: this table is one entry per grapheme cluster, built and discarded inside a single #compute_rows call and never handed to another method as a documented type.
@return — one entry per grapheme cluster of
#text: {offset: <text-index>, text: <cluster>, width: <columns>}.
144 145 146 147 148 149 150 151 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 144 def cluster_table offset = 0 @text.each_grapheme_cluster.map do |g| entry = { offset: offset, text: g, width: Buffer.display_width(g) } offset += g.length entry end end |
#column_in(row, index) ⇒ Integer
@param row
@param index
@return — index's column offset within row.
286 287 288 289 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 286 def column_in(row, index) chars = (index - row.start).clamp(0, row.length) columns_of(@text[row.start, chars] || "").clamp(0, row.columns) end |
#columns_of(str) ⇒ Integer
Mirrors AbstractStringField's measurement primitive, which this class can't inherit. Per-cluster rather than whole-string so a multi-codepoint emoji measures as the one glyph a terminal draws.
@param str
@return — columns.
316 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 316 def columns_of(str) = str.each_grapheme_cluster.sum { |g| Buffer.display_width(g) } |
#compute_rows ⇒ ::Array[Row]
Greedy word-wrap, filling each row to a column budget while recording the character span that produced it. Whitespace at a soft-wrap break point is absorbed (not rendered on either row). A token wider than #width hard-wraps inside the token. Newlines force a hard break and the wrap restarts on the next cluster.
@return — one entry per row.
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 168 def compute_rows return [Row::EMPTY] if @width <= 0 || @text.empty? cl = cluster_table rows = [] i = 0 n = cl.size while i < n start = cl[i][:offset] chars = 0 cols = 0 while i < n g = cl[i] break if newline?(g) if blank?(g) if cols < @width chars += g[:text].length cols += g[:width] i += 1 else chars, cols = trim_trailing_whitespace(start, chars, cols) i += 1 while i < n && blank?(cl[i]) break end else word_chars, word_cols, word_end = measure_word(cl, i) if cols + word_cols <= @width chars += word_chars cols += word_cols i = word_end elsif cols.zero? chars, cols, i = hard_wrap(cl, i) break else chars, cols = trim_trailing_whitespace(start, chars, cols) break end end end rows << Row.new(start: start, length: chars, columns: cols) next unless i < n && newline?(cl[i]) i += 1 rows << Row::EMPTY.with(start: @text.length) if i >= n end rows end |
#glyphs_of(row) ⇒ String
@param row
@return — the row's visible characters.
309 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 309 def glyphs_of(row) = @text[row.start, row.length] || "" |
#hard_wrap(clusters, index) ⇒ [Integer, Integer, Integer]
Splits a token too wide for a whole row, taking entire glyphs while they fit. Consumes at least one glyph even when that single glyph is wider than the row — otherwise the wrap would not terminate (the row would stay empty and the same token be reconsidered forever). Such a row reports more columns than #width holds and #row_text drops the glyph; a 2-column glyph in a 1-column area is unpaintable either way.
@param clusters
@param index
@return — [chars, columns, next_index]
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 247 def hard_wrap(clusters, index) chars = 0 cols = 0 while index < clusters.size && cols + clusters[index][:width] <= @width chars += clusters[index][:text].length cols += clusters[index][:width] index += 1 end if chars.zero? && index < clusters.size chars = clusters[index][:text].length cols = clusters[index][:width] index += 1 end [chars, cols, index] end |
#index_at(row, column) ⇒ Integer
Inverse of #position_at. A column landing in a wide glyph's right half resolves past it, as a click does in Tuile::Component::TextField.
@param row — a row index in 0...row_count.
@param column — a column offset within that row.
@return — a character index into #text.
98 99 100 101 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 98 def index_at(row, column) r = @rows[row] r.start + chars_for_column(r, column) end |
#measure_word(clusters, index) ⇒ [Integer, Integer, Integer]
@param clusters
@param index — cluster index of the word's first glyph.
@return — [chars, columns, next_index]
for the run of non-whitespace starting at index.
227 228 229 230 231 232 233 234 235 236 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 227 def measure_word(clusters, index) chars = 0 cols = 0 while index < clusters.size && !blank?(clusters[index]) && !newline?(clusters[index]) chars += clusters[index][:text].length cols += clusters[index][:width] index += 1 end [chars, cols, index] end |
#newline?(cluster) ⇒ Boolean
@param cluster
@return — true for a hard line break. Tests the suffix rather
than equality because "\r\n" is one grapheme cluster.
160 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 160 def newline?(cluster) = cluster[:text].end_with?("\n") |
#position_at(index) ⇒ [Integer, Integer]
@param index — a character index into #text.
@return — [row, column] for index.
88 89 90 91 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 88 def position_at(index) row = row_at(index) [row, column_in(@rows[row], index)] end |
#row_at(index) ⇒ Integer
Display row holding index. An index inside a whitespace run absorbed
by a soft wrap belongs to the row before the break.
@param index — a character index into #text.
@return — a row index in 0...row_count.
78 79 80 81 82 83 84 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 78 def row_at(index) @rows.each_with_index do |r, i| next_start = i + 1 < @rows.size ? @rows[i + 1].start : @text.length + 1 return i if index >= r.start && index < next_start end @rows.size - 1 end |
#row_count ⇒ Integer
@return — rows the text occupies; always >= 1, since
empty text still wraps to one (empty) row.
72 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 72 def row_count = @rows.size |
#row_end(row) ⇒ Integer
@param row — a row index in 0...row_count.
@return — character index one past the row's last visible character — whitespace absorbed by a soft wrap is excluded.
110 111 112 113 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 110 def row_end(row) r = @rows[row] r.start + r.length end |
#row_start(row) ⇒ Integer
@param row — a row index in 0...row_count.
@return — character index where the row begins.
105 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 105 def row_start(row) = @rows[row].start |
#row_text(row) ⇒ String
The row's glyphs, padded with spaces out to #width. A trailing glyph with no room left is dropped rather than half-painted. A row past the end of the text is all spaces, so a caller can paint a viewport taller than the text without a bounds check.
@param row
121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 121 def row_text(row) r = @rows[row] return " " * @width if r.nil? out = +"" cols = 0 glyphs_of(r).each_grapheme_cluster do |g| w = Buffer.display_width(g) break if cols + w > @width out << g cols += w end out << (" " * (@width - cols)) end |
#trim_trailing_whitespace(row_start, row_chars, row_cols) ⇒ [Integer, Integer]
Trims trailing space/tab characters off a row's visible length so the
whitespace at a soft-wrap point is absorbed (not rendered) rather than
left at the end of the row. Without this, soft-wrapping "foo bar"
to width 4 would yield row 0 length 4 ("foo ") and the natural
end-of-row caret position would coincide with row 1's start.
Both counts drop by one per trimmed character: a space and a tab each measure exactly one column.
@param row_start
@param row_chars
@param row_cols
@return — [row_chars, row_cols]
275 276 277 278 279 280 281 |
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 275 def trim_trailing_whitespace(row_start, row_chars, row_cols) while row_chars.positive? && @text[row_start + row_chars - 1].match?(/[ \t]/) row_chars -= 1 row_cols -= 1 end [row_chars, row_cols] end |