tuile 0.1.0 → 0.3.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.
@@ -0,0 +1,376 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A multi-line, word-wrapping text input.
6
+ #
7
+ # Sized by the caller — {#rect} is fixed; the area does not grow with
8
+ # content. Text is wrapped to {Rect#width} columns and any text that
9
+ # doesn't fit vertically is reached by scrolling: {#top_display_row}
10
+ # follows the caret so the line being edited stays visible. There is no
11
+ # horizontal scrolling.
12
+ #
13
+ # The caret is a logical index in `0..text.length`. When the caret falls
14
+ # inside a whitespace run that was absorbed by a soft wrap, it displays
15
+ # at the end of the previous row (which is visually identical to the
16
+ # start of the next row in nearly all cases).
17
+ #
18
+ # Currently only {#on_change} is wired; Enter inserts a newline as in any
19
+ # plain `<textarea>` or text editor. A future `on_enter`/`on_submit`
20
+ # callback may opt out of that by consuming Enter instead.
21
+ class TextArea < Component
22
+ def initialize
23
+ super
24
+ @text = +""
25
+ @caret = 0
26
+ @top_display_row = 0
27
+ @on_change = nil
28
+ @display_rows = nil
29
+ end
30
+
31
+ # @return [String] current text contents (may contain embedded `\n`).
32
+ attr_reader :text
33
+
34
+ # @return [Integer] caret index in `0..text.length`.
35
+ attr_reader :caret
36
+
37
+ # @return [Integer] index of the topmost display row currently visible.
38
+ attr_reader :top_display_row
39
+
40
+ # Optional callback fired whenever {#text} changes. Receives the new text
41
+ # as a single argument. Not fired by {#caret=} (text unchanged), not
42
+ # fired by a no-op setter, and not fired by a re-wrap caused by a width
43
+ # change ({#text} itself is unchanged).
44
+ # @return [Proc, Method, nil] one-arg callable, or nil.
45
+ attr_accessor :on_change
46
+
47
+ # Sets the text. Caret is clamped to the new text length; vertical scroll
48
+ # is adjusted to keep the caret visible.
49
+ # @param new_text [String]
50
+ def text=(new_text)
51
+ new_text = new_text.to_s
52
+ return if @text == new_text
53
+
54
+ @text = +new_text
55
+ @caret = @caret.clamp(0, @text.length)
56
+ @display_rows = nil
57
+ adjust_top_display_row
58
+ invalidate
59
+ @on_change&.call(@text)
60
+ end
61
+
62
+ # Sets the caret position. Clamped to `0..text.length`; vertical scroll
63
+ # is adjusted to keep the caret visible.
64
+ # @param new_caret [Integer]
65
+ def caret=(new_caret)
66
+ new_caret = new_caret.clamp(0, @text.length)
67
+ return if @caret == new_caret
68
+
69
+ @caret = new_caret
70
+ adjust_top_display_row
71
+ invalidate
72
+ end
73
+
74
+ def focusable? = true
75
+
76
+ def tab_stop? = true
77
+
78
+ # @return [Point, nil]
79
+ def cursor_position
80
+ return nil if rect.empty?
81
+
82
+ row, col = caret_to_display(@caret)
83
+ screen_row = row - @top_display_row
84
+ return nil if screen_row.negative? || screen_row >= rect.height
85
+
86
+ # Cap so the hardware cursor never lands at rect.left+rect.width
87
+ # (one past the rect). Terminals with auto-wrap interpret that as
88
+ # column 0 of the row below; capping pins the cursor on the last
89
+ # visible cell instead.
90
+ Point.new(rect.left + col.clamp(0, rect.width - 1), rect.top + screen_row)
91
+ end
92
+
93
+ # @param key [String]
94
+ # @return [Boolean]
95
+ def handle_key(key)
96
+ return false unless active?
97
+ return true if super
98
+
99
+ case key
100
+ when Keys::LEFT_ARROW then self.caret = @caret - 1
101
+ when Keys::RIGHT_ARROW then self.caret = @caret + 1
102
+ when Keys::CTRL_LEFT_ARROW then self.caret = word_left
103
+ when Keys::CTRL_RIGHT_ARROW then self.caret = word_right
104
+ when Keys::UP_ARROW then move_caret_vertical(-1)
105
+ when Keys::DOWN_ARROW then move_caret_vertical(1)
106
+ when *Keys::HOMES then move_caret_to_row_start
107
+ when *Keys::ENDS_ then move_caret_to_row_end
108
+ when *Keys::BACKSPACES then delete_before_caret
109
+ when Keys::DELETE then delete_at_caret
110
+ when Keys::ENTER then insert_char("\n")
111
+ else
112
+ return insert_char(key) if printable?(key)
113
+
114
+ return false
115
+ end
116
+ true
117
+ end
118
+
119
+ # @param event [MouseEvent]
120
+ # @return [void]
121
+ def handle_mouse(event)
122
+ super
123
+ return unless event.button == :left && rect.contains?(event.point)
124
+
125
+ target_row = (event.y - rect.top) + @top_display_row
126
+ target_col = event.x - rect.left
127
+ rows = display_rows
128
+ if target_row >= rows.size
129
+ self.caret = @text.length
130
+ else
131
+ r = rows[target_row]
132
+ self.caret = r[:start] + target_col.clamp(0, r[:length])
133
+ end
134
+ end
135
+
136
+ # Same SGR palette as {Component::TextField} for visual consistency.
137
+ # @return [String]
138
+ ACTIVE_BG_SGR = TextField::ACTIVE_BG_SGR
139
+ # @return [String]
140
+ INACTIVE_BG_SGR = TextField::INACTIVE_BG_SGR
141
+
142
+ # @return [void]
143
+ def repaint
144
+ return if rect.empty?
145
+
146
+ bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
147
+ rows = display_rows
148
+ (0...rect.height).each do |screen_row|
149
+ row_idx = screen_row + @top_display_row
150
+ line = if row_idx >= rows.size
151
+ " " * rect.width
152
+ else
153
+ r = rows[row_idx]
154
+ chunk = @text[r[:start], r[:length]] || ""
155
+ chunk + (" " * (rect.width - r[:length]))
156
+ end
157
+ screen.print TTY::Cursor.move_to(rect.left, rect.top + screen_row), bg, line, Ansi::RESET
158
+ end
159
+ end
160
+
161
+ protected
162
+
163
+ # @return [void]
164
+ def on_width_changed
165
+ super
166
+ @display_rows = nil
167
+ adjust_top_display_row
168
+ end
169
+
170
+ private
171
+
172
+ # @return [Array<Hash{Symbol=>Integer}>] cached wrap of {#text} for the
173
+ # current {Rect#width}. Each entry is `{start:, length:}`.
174
+ def display_rows
175
+ @display_rows ||= compute_display_rows
176
+ end
177
+
178
+ # Greedy word-wrap. Whitespace at a soft-wrap break point is absorbed
179
+ # (not rendered on either row). A token longer than {Rect#width} hard-
180
+ # wraps inside the token. Newlines force a hard break and the wrap
181
+ # restarts on the next character.
182
+ # @return [Array<Hash{Symbol=>Integer}>]
183
+ def compute_display_rows
184
+ width = rect.width
185
+ return [{ start: 0, length: 0 }] if width <= 0 || @text.empty?
186
+
187
+ rows = []
188
+ pos = 0
189
+ n = @text.length
190
+
191
+ while pos < n
192
+ row_start = pos
193
+ row_chars = 0
194
+
195
+ while pos < n
196
+ c = @text[pos]
197
+ break if c == "\n"
198
+
199
+ if c.match?(/[ \t]/)
200
+ if row_chars < width
201
+ row_chars += 1
202
+ pos += 1
203
+ else
204
+ row_chars = trim_trailing_whitespace(row_start, row_chars)
205
+ pos += 1 while pos < n && @text[pos].match?(/[ \t]/)
206
+ break
207
+ end
208
+ else
209
+ word_end = pos
210
+ word_end += 1 while word_end < n && !@text[word_end].match?(/\s/)
211
+ word_len = word_end - pos
212
+
213
+ if row_chars + word_len <= width
214
+ row_chars += word_len
215
+ pos = word_end
216
+ elsif row_chars.zero?
217
+ row_chars = width
218
+ pos += width
219
+ break
220
+ else
221
+ row_chars = trim_trailing_whitespace(row_start, row_chars)
222
+ break
223
+ end
224
+ end
225
+ end
226
+
227
+ rows << { start: row_start, length: row_chars }
228
+
229
+ if pos < n && @text[pos] == "\n"
230
+ pos += 1
231
+ rows << { start: pos, length: 0 } if pos == n
232
+ end
233
+ end
234
+
235
+ rows << { start: 0, length: 0 } if rows.empty?
236
+ rows
237
+ end
238
+
239
+ # Trims trailing space/tab characters off a row's visible length so the
240
+ # whitespace at a soft-wrap point is absorbed (not rendered) rather than
241
+ # left at the end of the row. Without this, soft-wrapping `"foo bar"`
242
+ # to width 4 would yield row 0 length 4 (`"foo "`) and the natural
243
+ # end-of-row caret position would coincide with row 1's start.
244
+ # @param row_start [Integer]
245
+ # @param row_chars [Integer]
246
+ # @return [Integer] new row_chars.
247
+ def trim_trailing_whitespace(row_start, row_chars)
248
+ row_chars -= 1 while row_chars.positive? && @text[row_start + row_chars - 1].match?(/[ \t]/)
249
+ row_chars
250
+ end
251
+
252
+ # @param caret [Integer]
253
+ # @return [Array(Integer, Integer)] `[row_index, column]` for `caret`.
254
+ def caret_to_display(caret)
255
+ rows = display_rows
256
+ rows.each_with_index do |r, i|
257
+ next_start = i + 1 < rows.size ? rows[i + 1][:start] : @text.length + 1
258
+ next unless caret >= r[:start] && caret < next_start
259
+
260
+ return [i, (caret - r[:start]).clamp(0, r[:length])]
261
+ end
262
+ r = rows.last
263
+ [rows.size - 1, (caret - r[:start]).clamp(0, r[:length])]
264
+ end
265
+
266
+ # @param delta [Integer] `+1` for down, `-1` for up.
267
+ # @return [void]
268
+ def move_caret_vertical(delta)
269
+ rows = display_rows
270
+ cur_row, cur_col = caret_to_display(@caret)
271
+ new_row = (cur_row + delta).clamp(0, rows.size - 1)
272
+ if new_row == cur_row
273
+ # Already at the top/bottom display row. Snap to the absolute
274
+ # start/end of the text so the user has a quick way to reach it.
275
+ self.caret = delta.positive? ? @text.length : 0
276
+ return
277
+ end
278
+
279
+ r = rows[new_row]
280
+ self.caret = r[:start] + cur_col.clamp(0, r[:length])
281
+ end
282
+
283
+ # @return [void]
284
+ def move_caret_to_row_start
285
+ rows = display_rows
286
+ cur_row, = caret_to_display(@caret)
287
+ self.caret = rows[cur_row][:start]
288
+ end
289
+
290
+ # @return [void]
291
+ def move_caret_to_row_end
292
+ rows = display_rows
293
+ cur_row, = caret_to_display(@caret)
294
+ r = rows[cur_row]
295
+ self.caret = r[:start] + r[:length]
296
+ end
297
+
298
+ # @param char [String]
299
+ # @return [Boolean] always true.
300
+ def insert_char(char)
301
+ @text = @text.dup.insert(@caret, char)
302
+ @caret += char.length
303
+ @display_rows = nil
304
+ adjust_top_display_row
305
+ invalidate
306
+ @on_change&.call(@text)
307
+ true
308
+ end
309
+
310
+ # @return [void]
311
+ def delete_before_caret
312
+ return if @caret.zero?
313
+
314
+ @text = @text.dup
315
+ @text.slice!(@caret - 1)
316
+ @caret -= 1
317
+ @display_rows = nil
318
+ adjust_top_display_row
319
+ invalidate
320
+ @on_change&.call(@text)
321
+ end
322
+
323
+ # @return [void]
324
+ def delete_at_caret
325
+ return if @caret >= @text.length
326
+
327
+ @text = @text.dup
328
+ @text.slice!(@caret)
329
+ @display_rows = nil
330
+ adjust_top_display_row
331
+ invalidate
332
+ @on_change&.call(@text)
333
+ end
334
+
335
+ # Keeps the caret visible by scrolling vertically.
336
+ # @return [void]
337
+ def adjust_top_display_row
338
+ return if rect.empty?
339
+
340
+ rows = display_rows
341
+ cur_row, = caret_to_display(@caret)
342
+ if cur_row < @top_display_row
343
+ @top_display_row = cur_row
344
+ elsif cur_row >= @top_display_row + rect.height
345
+ @top_display_row = cur_row - rect.height + 1
346
+ end
347
+ max_top = (rows.size - rect.height).clamp(0, nil)
348
+ @top_display_row = @top_display_row.clamp(0, max_top)
349
+ end
350
+
351
+ # @param key [String]
352
+ # @return [Boolean]
353
+ def printable?(key)
354
+ key.length == 1 && key.ord >= 0x20 && key.ord < 0x7f
355
+ end
356
+
357
+ # Same semantics as {TextField}'s ctrl+left.
358
+ # @return [Integer]
359
+ def word_left
360
+ c = @caret
361
+ c -= 1 while c.positive? && @text[c - 1].match?(/\s/)
362
+ c -= 1 while c.positive? && !@text[c - 1].match?(/\s/)
363
+ c
364
+ end
365
+
366
+ # Same semantics as {TextField}'s ctrl+right.
367
+ # @return [Integer]
368
+ def word_right
369
+ c = @caret
370
+ c += 1 while c < @text.length && !@text[c].match?(/\s/)
371
+ c += 1 while c < @text.length && @text[c].match?(/\s/)
372
+ c
373
+ end
374
+ end
375
+ end
376
+ end
@@ -87,6 +87,8 @@ module Tuile
87
87
 
88
88
  def focusable? = true
89
89
 
90
+ def tab_stop? = true
91
+
90
92
  # @return [Point, nil]
91
93
  def cursor_position
92
94
  return nil unless rect.width.positive?
@@ -103,8 +105,10 @@ module Tuile
103
105
  case key
104
106
  when Keys::LEFT_ARROW then self.caret = @caret - 1
105
107
  when Keys::RIGHT_ARROW then self.caret = @caret + 1
106
- when Keys::HOME then self.caret = 0
107
- when Keys::END_ then self.caret = @text.length
108
+ when Keys::CTRL_LEFT_ARROW then self.caret = word_left
109
+ when Keys::CTRL_RIGHT_ARROW then self.caret = word_right
110
+ when *Keys::HOMES then self.caret = 0
111
+ when *Keys::ENDS_ then self.caret = @text.length
108
112
  when *Keys::BACKSPACES then delete_before_caret
109
113
  when Keys::DELETE then delete_at_caret
110
114
  when Keys::ESC
@@ -140,12 +144,28 @@ module Tuile
140
144
  self.caret = (event.x - rect.left).clamp(0, @text.length)
141
145
  end
142
146
 
147
+ # 256-color SGR for the focused-button highlight (matches what
148
+ # `Rainbow(...).bg(:darkslategray)` emits, which is what
149
+ # {Component::Button#repaint} uses for its focused state).
150
+ # @return [String]
151
+ ACTIVE_BG_SGR = "\e[48;5;59m"
152
+ # 256-color SGR for the unfocused field's "well": index 238 sits in
153
+ # the grayscale ramp (~#444444), bright enough to stand out against
154
+ # non-pure-black terminal themes (Gruvbox/Solarized/OneDark base
155
+ # backgrounds sit in the #1d–#2d range), and still distinctly darker
156
+ # than the active highlight at index 59 (~#5f5f5f). Rainbow's
157
+ # RGB-to-256 mapping snaps everything dark to palette index 16
158
+ # (terminal black), so we emit the escape directly to reach the ramp.
159
+ # @return [String]
160
+ INACTIVE_BG_SGR = "\e[48;5;238m"
161
+
143
162
  # @return [void]
144
163
  def repaint
145
- clear_background
146
164
  return if rect.empty?
147
165
 
148
- screen.print TTY::Cursor.move_to(rect.left, rect.top), @text
166
+ bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
167
+ padded = @text + (" " * (rect.width - @text.length))
168
+ screen.print TTY::Cursor.move_to(rect.left, rect.top), bg, padded, Ansi::RESET
149
169
  end
150
170
 
151
171
  protected
@@ -204,6 +224,28 @@ module Tuile
204
224
  def printable?(key)
205
225
  key.length == 1 && key.ord >= 0x20 && key.ord < 0x7f
206
226
  end
227
+
228
+ # Caret target for ctrl+left: skip whitespace going left, then a run of
229
+ # non-whitespace. Lands at the beginning of the current word, or the
230
+ # beginning of the previous word if already there.
231
+ # @return [Integer]
232
+ def word_left
233
+ c = @caret
234
+ c -= 1 while c.positive? && @text[c - 1].match?(/\s/)
235
+ c -= 1 while c.positive? && !@text[c - 1].match?(/\s/)
236
+ c
237
+ end
238
+
239
+ # Caret target for ctrl+right: skip non-whitespace going right, then a
240
+ # run of whitespace. Lands at the beginning of the next word, or at the
241
+ # end of the text if no further word exists.
242
+ # @return [Integer]
243
+ def word_right
244
+ c = @caret
245
+ c += 1 while c < @text.length && !@text[c].match?(/\s/)
246
+ c += 1 while c < @text.length && @text[c].match?(/\s/)
247
+ c
248
+ end
207
249
  end
208
250
  end
209
251
  end