tuile 0.1.0 → 0.2.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.
@@ -22,6 +22,8 @@ module Tuile
22
22
  @scrollbar_visibility = :gone
23
23
  @show_cursor_when_inactive = false
24
24
  @on_item_chosen = nil
25
+ @on_cursor_changed = nil
26
+ @last_cursor_state = cursor_state
25
27
  end
26
28
 
27
29
  # @return [Proc, nil] callback fired when an item is chosen — by pressing
@@ -31,6 +33,16 @@ module Tuile
31
33
  # {Cursor::None}, or empty content).
32
34
  attr_accessor :on_item_chosen
33
35
 
36
+ # @return [Proc, nil] callback fired when the `(index, line)` tuple under
37
+ # the cursor changes. Called as `proc.call(index, line)` where `line`
38
+ # is `nil` when the cursor is off-content ({Cursor::None}, empty list,
39
+ # or `index` past the last line). Fires on cursor moves (key, mouse,
40
+ # search), on {#cursor=}, and on {#lines=}/{#add_lines} when the line
41
+ # at the cursor's index changes (or its in-range/out-of-range status
42
+ # flips). Useful for keeping a details pane in sync with the
43
+ # highlighted row.
44
+ attr_accessor :on_cursor_changed
45
+
34
46
  # @return [Boolean] if true and a line is added or new content is set,
35
47
  # auto-scrolls to the bottom.
36
48
  attr_reader :auto_scroll
@@ -83,6 +95,7 @@ module Tuile
83
95
  old_position = @cursor.position
84
96
  @cursor = cursor
85
97
  invalidate if old_position != cursor.position
98
+ notify_cursor_changed
86
99
  end
87
100
 
88
101
  # Sets the top line.
@@ -107,6 +120,7 @@ module Tuile
107
120
  @lines = lines.flat_map { it.to_s.split("\n") }.map(&:rstrip)
108
121
  @content_size = nil
109
122
  update_top_line_if_auto_scroll
123
+ notify_cursor_changed
110
124
  invalidate
111
125
  end
112
126
 
@@ -146,6 +160,7 @@ module Tuile
146
160
  @lines += lines.flat_map { it.to_s.split("\n") }.map(&:rstrip)
147
161
  @content_size = nil
148
162
  update_top_line_if_auto_scroll
163
+ notify_cursor_changed
149
164
  invalidate
150
165
  end
151
166
 
@@ -160,6 +175,8 @@ module Tuile
160
175
 
161
176
  def focusable? = true
162
177
 
178
+ def tab_stop? = true
179
+
163
180
  # @param key [String] a key.
164
181
  # @return [Boolean] true if the key was handled.
165
182
  def handle_key(key)
@@ -178,6 +195,7 @@ module Tuile
178
195
  true
179
196
  elsif @cursor.handle_key(key, @lines.size, viewport_lines)
180
197
  move_viewport_to_cursor
198
+ notify_cursor_changed
181
199
  invalidate
182
200
  true
183
201
  else
@@ -222,6 +240,7 @@ module Tuile
222
240
  line = event.y - rect.top + top_line
223
241
  if @cursor.handle_mouse(line, event, @lines.size)
224
242
  move_viewport_to_cursor
243
+ notify_cursor_changed
225
244
  invalidate
226
245
  end
227
246
  fire_item_chosen if event.button == :left && line >= 0 && line < @lines.size && cursor_on_item?
@@ -229,9 +248,13 @@ module Tuile
229
248
  end
230
249
 
231
250
  # Paints the list items into {#rect}.
251
+ #
252
+ # Skips the {Component#repaint} default's auto-clear: every row of
253
+ # {#rect} is painted below (with padded content past the last item),
254
+ # so the parent contract — "fully draw over your rect" — is met
255
+ # without an upfront wipe.
232
256
  # @return [void]
233
257
  def repaint
234
- super
235
258
  return if rect.empty?
236
259
 
237
260
  width = rect.width
@@ -302,9 +325,9 @@ module Tuile
302
325
  go_down_by(1, line_count)
303
326
  when *Keys::UP_ARROWS
304
327
  go_up_by(1)
305
- when Keys::HOME
328
+ when *Keys::HOMES
306
329
  go_to_first
307
- when Keys::END_
330
+ when *Keys::ENDS_
308
331
  go_to_last(line_count)
309
332
  when Keys::CTRL_U
310
333
  go_up_by(viewport_lines / 2)
@@ -446,6 +469,25 @@ module Tuile
446
469
  @on_item_chosen&.call(pos, @lines[pos])
447
470
  end
448
471
 
472
+ # @return [Array((Integer, String, nil))] `[position, line_at_position]`,
473
+ # with `line` nil when the cursor is off-content.
474
+ def cursor_state
475
+ pos = @cursor.position
476
+ line = pos >= 0 && pos < @lines.size ? @lines[pos] : nil
477
+ [pos, line]
478
+ end
479
+
480
+ # Fires {#on_cursor_changed} if {#cursor_state} differs from the last
481
+ # fired state. Idempotent — safe to call after any mutation.
482
+ # @return [void]
483
+ def notify_cursor_changed
484
+ state = cursor_state
485
+ return if state == @last_cursor_state
486
+
487
+ @last_cursor_state = state
488
+ @on_cursor_changed&.call(*state)
489
+ end
490
+
449
491
  # @param query [String]
450
492
  # @param include_current [Boolean]
451
493
  # @param reverse [Boolean]
@@ -463,6 +505,7 @@ module Tuile
463
505
 
464
506
  @cursor.go(match)
465
507
  move_viewport_to_cursor
508
+ notify_cursor_changed
466
509
  invalidate
467
510
  true
468
511
  end
@@ -548,7 +591,7 @@ module Tuile
548
591
  def trim_to(str, width)
549
592
  return " " * width if str.empty?
550
593
 
551
- truncated_line = Strings::Truncation.truncate(str, length: width)
594
+ truncated_line = Truncate.truncate(str, length: width)
552
595
  return truncated_line unless truncated_line == str
553
596
 
554
597
  length = Unicode::DisplayWidth.of(Rainbow.uncolor(str))
@@ -0,0 +1,378 @@
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
+ # @return [String]
142
+ SGR_RESET = TextField::SGR_RESET
143
+
144
+ # @return [void]
145
+ def repaint
146
+ return if rect.empty?
147
+
148
+ bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
149
+ rows = display_rows
150
+ (0...rect.height).each do |screen_row|
151
+ row_idx = screen_row + @top_display_row
152
+ line = if row_idx >= rows.size
153
+ " " * rect.width
154
+ else
155
+ r = rows[row_idx]
156
+ chunk = @text[r[:start], r[:length]] || ""
157
+ chunk + (" " * (rect.width - r[:length]))
158
+ end
159
+ screen.print TTY::Cursor.move_to(rect.left, rect.top + screen_row), bg, line, SGR_RESET
160
+ end
161
+ end
162
+
163
+ protected
164
+
165
+ # @return [void]
166
+ def on_width_changed
167
+ super
168
+ @display_rows = nil
169
+ adjust_top_display_row
170
+ end
171
+
172
+ private
173
+
174
+ # @return [Array<Hash{Symbol=>Integer}>] cached wrap of {#text} for the
175
+ # current {Rect#width}. Each entry is `{start:, length:}`.
176
+ def display_rows
177
+ @display_rows ||= compute_display_rows
178
+ end
179
+
180
+ # Greedy word-wrap. Whitespace at a soft-wrap break point is absorbed
181
+ # (not rendered on either row). A token longer than {Rect#width} hard-
182
+ # wraps inside the token. Newlines force a hard break and the wrap
183
+ # restarts on the next character.
184
+ # @return [Array<Hash{Symbol=>Integer}>]
185
+ def compute_display_rows
186
+ width = rect.width
187
+ return [{ start: 0, length: 0 }] if width <= 0 || @text.empty?
188
+
189
+ rows = []
190
+ pos = 0
191
+ n = @text.length
192
+
193
+ while pos < n
194
+ row_start = pos
195
+ row_chars = 0
196
+
197
+ while pos < n
198
+ c = @text[pos]
199
+ break if c == "\n"
200
+
201
+ if c.match?(/[ \t]/)
202
+ if row_chars < width
203
+ row_chars += 1
204
+ pos += 1
205
+ else
206
+ row_chars = trim_trailing_whitespace(row_start, row_chars)
207
+ pos += 1 while pos < n && @text[pos].match?(/[ \t]/)
208
+ break
209
+ end
210
+ else
211
+ word_end = pos
212
+ word_end += 1 while word_end < n && !@text[word_end].match?(/\s/)
213
+ word_len = word_end - pos
214
+
215
+ if row_chars + word_len <= width
216
+ row_chars += word_len
217
+ pos = word_end
218
+ elsif row_chars.zero?
219
+ row_chars = width
220
+ pos += width
221
+ break
222
+ else
223
+ row_chars = trim_trailing_whitespace(row_start, row_chars)
224
+ break
225
+ end
226
+ end
227
+ end
228
+
229
+ rows << { start: row_start, length: row_chars }
230
+
231
+ if pos < n && @text[pos] == "\n"
232
+ pos += 1
233
+ rows << { start: pos, length: 0 } if pos == n
234
+ end
235
+ end
236
+
237
+ rows << { start: 0, length: 0 } if rows.empty?
238
+ rows
239
+ end
240
+
241
+ # Trims trailing space/tab characters off a row's visible length so the
242
+ # whitespace at a soft-wrap point is absorbed (not rendered) rather than
243
+ # left at the end of the row. Without this, soft-wrapping `"foo bar"`
244
+ # to width 4 would yield row 0 length 4 (`"foo "`) and the natural
245
+ # end-of-row caret position would coincide with row 1's start.
246
+ # @param row_start [Integer]
247
+ # @param row_chars [Integer]
248
+ # @return [Integer] new row_chars.
249
+ def trim_trailing_whitespace(row_start, row_chars)
250
+ row_chars -= 1 while row_chars.positive? && @text[row_start + row_chars - 1].match?(/[ \t]/)
251
+ row_chars
252
+ end
253
+
254
+ # @param caret [Integer]
255
+ # @return [Array(Integer, Integer)] `[row_index, column]` for `caret`.
256
+ def caret_to_display(caret)
257
+ rows = display_rows
258
+ rows.each_with_index do |r, i|
259
+ next_start = i + 1 < rows.size ? rows[i + 1][:start] : @text.length + 1
260
+ next unless caret >= r[:start] && caret < next_start
261
+
262
+ return [i, (caret - r[:start]).clamp(0, r[:length])]
263
+ end
264
+ r = rows.last
265
+ [rows.size - 1, (caret - r[:start]).clamp(0, r[:length])]
266
+ end
267
+
268
+ # @param delta [Integer] `+1` for down, `-1` for up.
269
+ # @return [void]
270
+ def move_caret_vertical(delta)
271
+ rows = display_rows
272
+ cur_row, cur_col = caret_to_display(@caret)
273
+ new_row = (cur_row + delta).clamp(0, rows.size - 1)
274
+ if new_row == cur_row
275
+ # Already at the top/bottom display row. Snap to the absolute
276
+ # start/end of the text so the user has a quick way to reach it.
277
+ self.caret = delta.positive? ? @text.length : 0
278
+ return
279
+ end
280
+
281
+ r = rows[new_row]
282
+ self.caret = r[:start] + cur_col.clamp(0, r[:length])
283
+ end
284
+
285
+ # @return [void]
286
+ def move_caret_to_row_start
287
+ rows = display_rows
288
+ cur_row, = caret_to_display(@caret)
289
+ self.caret = rows[cur_row][:start]
290
+ end
291
+
292
+ # @return [void]
293
+ def move_caret_to_row_end
294
+ rows = display_rows
295
+ cur_row, = caret_to_display(@caret)
296
+ r = rows[cur_row]
297
+ self.caret = r[:start] + r[:length]
298
+ end
299
+
300
+ # @param char [String]
301
+ # @return [Boolean] always true.
302
+ def insert_char(char)
303
+ @text = @text.dup.insert(@caret, char)
304
+ @caret += char.length
305
+ @display_rows = nil
306
+ adjust_top_display_row
307
+ invalidate
308
+ @on_change&.call(@text)
309
+ true
310
+ end
311
+
312
+ # @return [void]
313
+ def delete_before_caret
314
+ return if @caret.zero?
315
+
316
+ @text = @text.dup
317
+ @text.slice!(@caret - 1)
318
+ @caret -= 1
319
+ @display_rows = nil
320
+ adjust_top_display_row
321
+ invalidate
322
+ @on_change&.call(@text)
323
+ end
324
+
325
+ # @return [void]
326
+ def delete_at_caret
327
+ return if @caret >= @text.length
328
+
329
+ @text = @text.dup
330
+ @text.slice!(@caret)
331
+ @display_rows = nil
332
+ adjust_top_display_row
333
+ invalidate
334
+ @on_change&.call(@text)
335
+ end
336
+
337
+ # Keeps the caret visible by scrolling vertically.
338
+ # @return [void]
339
+ def adjust_top_display_row
340
+ return if rect.empty?
341
+
342
+ rows = display_rows
343
+ cur_row, = caret_to_display(@caret)
344
+ if cur_row < @top_display_row
345
+ @top_display_row = cur_row
346
+ elsif cur_row >= @top_display_row + rect.height
347
+ @top_display_row = cur_row - rect.height + 1
348
+ end
349
+ max_top = (rows.size - rect.height).clamp(0, nil)
350
+ @top_display_row = @top_display_row.clamp(0, max_top)
351
+ end
352
+
353
+ # @param key [String]
354
+ # @return [Boolean]
355
+ def printable?(key)
356
+ key.length == 1 && key.ord >= 0x20 && key.ord < 0x7f
357
+ end
358
+
359
+ # Same semantics as {TextField}'s ctrl+left.
360
+ # @return [Integer]
361
+ def word_left
362
+ c = @caret
363
+ c -= 1 while c.positive? && @text[c - 1].match?(/\s/)
364
+ c -= 1 while c.positive? && !@text[c - 1].match?(/\s/)
365
+ c
366
+ end
367
+
368
+ # Same semantics as {TextField}'s ctrl+right.
369
+ # @return [Integer]
370
+ def word_right
371
+ c = @caret
372
+ c += 1 while c < @text.length && !@text[c].match?(/\s/)
373
+ c += 1 while c < @text.length && @text[c].match?(/\s/)
374
+ c
375
+ end
376
+ end
377
+ end
378
+ 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,31 @@ 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
+ # SGR reset.
162
+ # @return [String]
163
+ SGR_RESET = "\e[0m"
164
+
143
165
  # @return [void]
144
166
  def repaint
145
- clear_background
146
167
  return if rect.empty?
147
168
 
148
- screen.print TTY::Cursor.move_to(rect.left, rect.top), @text
169
+ bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
170
+ padded = @text + (" " * (rect.width - @text.length))
171
+ screen.print TTY::Cursor.move_to(rect.left, rect.top), bg, padded, SGR_RESET
149
172
  end
150
173
 
151
174
  protected
@@ -204,6 +227,28 @@ module Tuile
204
227
  def printable?(key)
205
228
  key.length == 1 && key.ord >= 0x20 && key.ord < 0x7f
206
229
  end
230
+
231
+ # Caret target for ctrl+left: skip whitespace going left, then a run of
232
+ # non-whitespace. Lands at the beginning of the current word, or the
233
+ # beginning of the previous word if already there.
234
+ # @return [Integer]
235
+ def word_left
236
+ c = @caret
237
+ c -= 1 while c.positive? && @text[c - 1].match?(/\s/)
238
+ c -= 1 while c.positive? && !@text[c - 1].match?(/\s/)
239
+ c
240
+ end
241
+
242
+ # Caret target for ctrl+right: skip non-whitespace going right, then a
243
+ # run of whitespace. Lands at the beginning of the next word, or at the
244
+ # end of the text if no further word exists.
245
+ # @return [Integer]
246
+ def word_right
247
+ c = @caret
248
+ c += 1 while c < @text.length && !@text[c].match?(/\s/)
249
+ c += 1 while c < @text.length && @text[c].match?(/\s/)
250
+ c
251
+ end
207
252
  end
208
253
  end
209
254
  end
@@ -132,13 +132,21 @@ module Tuile
132
132
  end
133
133
 
134
134
  # Fully repaints the window: both frame and contents.
135
+ #
136
+ # Window deliberately paints over its entire rect (border around the
137
+ # edge, content/footer over the interior), so we don't need the
138
+ # {Component#repaint} default's auto-clear — but we do still want its
139
+ # "re-invalidate children" effect, since the border overpaints
140
+ # whatever the content/footer drew on the perimeter. Calling super
141
+ # handles both: the auto-clear is harmless (we re-paint over it), and
142
+ # the invalidation queues content + footer for repaint in the same
143
+ # cycle.
135
144
  # @return [void]
136
145
  def repaint
146
+ return unless visible?
147
+
137
148
  super
138
149
  repaint_border
139
- # Border paints over content: invalidate the content to have it
140
- # repainted.
141
- content&.invalidate
142
150
  end
143
151
 
144
152
  # @param key [String, nil]