Class: Tuile::Component::TextArea::WrappedText

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

Instance Method Summary collapse

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.

Parameters:

  • text (String)
  • width (Integer)


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

#textString (readonly)

Returns:

  • (String)


65
66
67
# File 'lib/tuile/component/text_area/wrapped_text.rb', line 65

def text
  @text
end

#widthInteger (readonly)

Returns:

  • (Integer)


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).

Parameters:

  • cluster (::Hash[Symbol, Object])

Returns:

  • (Boolean)


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.

Parameters:

  • row (Row)
  • column (Integer)

Returns:

  • (Integer)


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>}.

Returns:

  • (::Array[::Hash[Symbol, Object]])


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

@returnindex's column offset within row.

Parameters:

  • row (Row)
  • index (Integer)

Returns:

  • (Integer)


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.

Parameters:

  • str (String)

Returns:

  • (Integer)


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.

Returns:

  • (::Array[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.

Parameters:

Returns:

  • (String)


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]

Parameters:

  • clusters (::Array[::Hash[Symbol, Object]])
  • index (Integer)

Returns:

  • ([Integer, Integer, Integer])


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.

Parameters:

  • row (Integer)
  • column (Integer)

Returns:

  • (Integer)


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.

Parameters:

  • clusters (::Array[::Hash[Symbol, Object]])
  • index (Integer)

Returns:

  • ([Integer, Integer, Integer])


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.

Parameters:

  • cluster (::Hash[Symbol, Object])

Returns:

  • (Boolean)


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.

Parameters:

  • index (Integer)

Returns:

  • ([Integer, Integer])


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.

Parameters:

  • index (Integer)

Returns:

  • (Integer)


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_countInteger

@return — rows the text occupies; always >= 1, since empty text still wraps to one (empty) row.

Returns:

  • (Integer)


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.

Parameters:

  • row (Integer)

Returns:

  • (Integer)


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.

Parameters:

  • row (Integer)

Returns:

  • (Integer)


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

Parameters:

  • row (Integer)

Returns:

  • (String)


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]

Parameters:

  • row_start (Integer)
  • row_chars (Integer)
  • row_cols (Integer)

Returns:

  • ([Integer, Integer])


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