tuile 0.8.0 → 0.10.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +82 -48
  5. data/book/01-first-app.md +186 -0
  6. data/book/02-repaint.md +177 -0
  7. data/book/03-layout.md +379 -0
  8. data/book/04-event-loop.md +295 -0
  9. data/book/05-focus.md +219 -0
  10. data/book/06-theming.md +302 -0
  11. data/book/07-components.md +585 -0
  12. data/book/08-testing.md +199 -0
  13. data/book/09-styled-text.md +132 -0
  14. data/book/README.md +85 -0
  15. data/examples/hello_world.rb +1 -2
  16. data/examples/sampler.rb +435 -20
  17. data/ideas/new-components.md +109 -0
  18. data/ideas/per-component-buffers.md +55 -0
  19. data/lib/tuile/buffer.rb +113 -43
  20. data/lib/tuile/color.rb +4 -10
  21. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  22. data/lib/tuile/component/button.rb +25 -29
  23. data/lib/tuile/component/checkbox.rb +133 -0
  24. data/lib/tuile/component/checkbox_group.rb +188 -0
  25. data/lib/tuile/component/combo_box.rb +281 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/info_window.rb +4 -2
  30. data/lib/tuile/component/integer_field.rb +135 -0
  31. data/lib/tuile/component/label.rb +20 -25
  32. data/lib/tuile/component/layout.rb +3 -26
  33. data/lib/tuile/component/list.rb +8 -33
  34. data/lib/tuile/component/list_dropdown.rb +106 -0
  35. data/lib/tuile/component/log_window.rb +0 -14
  36. data/lib/tuile/component/password_field.rb +105 -0
  37. data/lib/tuile/component/popup.rb +70 -79
  38. data/lib/tuile/component/progress_bar.rb +278 -0
  39. data/lib/tuile/component/radio_group.rb +188 -0
  40. data/lib/tuile/component/text_area.rb +189 -65
  41. data/lib/tuile/component/text_field.rb +170 -32
  42. data/lib/tuile/component/text_view.rb +57 -137
  43. data/lib/tuile/component/window.rb +88 -121
  44. data/lib/tuile/component.rb +246 -142
  45. data/lib/tuile/event_queue.rb +39 -21
  46. data/lib/tuile/fake_event_queue.rb +32 -7
  47. data/lib/tuile/fake_screen.rb +4 -5
  48. data/lib/tuile/fraction.rb +42 -0
  49. data/lib/tuile/screen.rb +210 -109
  50. data/lib/tuile/screen_pane.rb +56 -44
  51. data/lib/tuile/styled_string.rb +112 -83
  52. data/lib/tuile/theme.rb +78 -41
  53. data/lib/tuile/version.rb +1 -1
  54. data/sig/tuile.rbs +2291 -890
  55. metadata +28 -9
  56. data/ideas/back-buffer.md +0 -217
  57. data/lib/tuile/sizing.rb +0 -59
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # The value seam every input component shares: a settable/gettable {#value}
6
+ # of *any* type, an {#on_value_change} listener, {#empty?}, and {#clear}. A
7
+ # form (a future binder) drives a mix of field types uniformly through it,
8
+ # not caring that a {TextField}'s value is a `String` while another field's
9
+ # is a domain object.
10
+ #
11
+ # field.on_value_change = ->(v) { puts "now: #{v.inspect}" }
12
+ # field.value = "hello" # fires the listener
13
+ # field.clear # value = empty_value, fires again
14
+ #
15
+ # The default {#value=}/{#value} keep the value in `@value` and are enough
16
+ # for a component with nothing more natural — you get a repaint and the
17
+ # listener for free. An includer whose value lives elsewhere overrides both
18
+ # ({AbstractStringField} backs them with its text buffer). Override {#empty_value}
19
+ # when the empty sentinel isn't `nil` (a text field's is `""`).
20
+ #
21
+ # == Implementation details
22
+ # Deliberately smaller than Vaadin's `HasValue`: read-only,
23
+ # required-indicator, the from-client/old-value event payload, and
24
+ # converters all belong to the not-yet-built form layer, not here.
25
+ module HasValue
26
+ # @return [Proc, Method, nil] one-arg callable fired with the new value
27
+ # whenever {#value} actually changes — never on a no-op set.
28
+ attr_accessor :on_value_change
29
+
30
+ # @return [Object] the current value; `nil` until first set.
31
+ def value = @value
32
+
33
+ # No-op (no repaint, no listener) when equal to the current value.
34
+ # @param new_value [Object]
35
+ # @return [void]
36
+ def value=(new_value)
37
+ return if value == new_value
38
+
39
+ @value = new_value
40
+ invalidate
41
+ on_value_change&.call(new_value)
42
+ end
43
+
44
+ # @return [Boolean] true iff {#value} equals {#empty_value}.
45
+ def empty? = value == empty_value
46
+
47
+ # Resets {#value} to {#empty_value}.
48
+ # @return [void]
49
+ def clear = (self.value = empty_value)
50
+
51
+ # @return [Object] the value {#empty?}/{#clear} treat as empty; `nil`
52
+ # unless an includer overrides it.
53
+ def empty_value = nil
54
+
55
+ # Input fields are focusable by default (overrides {Component#focusable?});
56
+ # a read-only display field could override back to `false`. Only
57
+ # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
58
+ # composing wrappers, so it stays per-class (`DECISIONS.md`
59
+ # `D-integer-field`).
60
+ # @return [Boolean]
61
+ def focusable? = true
62
+ end
63
+ end
64
+ end
@@ -21,9 +21,11 @@ module Tuile
21
21
  # Opens the info window as a popup.
22
22
  # @param caption [String]
23
23
  # @param lines [Array<String>] the content, may contain formatting.
24
+ # @param size [Size, Fraction] the popup's size, applied top-down; the
25
+ # list wraps and scrolls within it. Defaults to {Fraction::HALF}.
24
26
  # @return [Popup] the opened popup.
25
- def self.open(caption, lines)
26
- Popup.open(content: InfoWindow.new(caption, lines))
27
+ def self.open(caption, lines, size: Fraction::HALF)
28
+ Popup.open(content: InfoWindow.new(caption, lines), size: size)
27
29
  end
28
30
  end
29
31
  end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A single-line field whose {#value} is an `Integer` (or `nil` when empty).
6
+ # The user may type only `0`–`9` and a single leading `-`; anything else is
7
+ # silently rejected without moving the caret. Up/Down step the value by one
8
+ # (an empty field counting as `0`). An empty or otherwise un-parseable
9
+ # buffer reads back as `nil`:
10
+ #
11
+ # field = Component::IntegerField.new
12
+ # field.on_value_change = ->(n) { puts n.inspect } # Integer or nil, per change
13
+ # field.value = 42 # field shows "42"
14
+ # field.value # => 42
15
+ # field.clear # empties it; value => nil
16
+ #
17
+ # Like {ComboBox}, it *composes* a {TextField} (its single {HasContent}
18
+ # child) rather than subclassing one — its face carries only the typed
19
+ # {HasValue} value seam, never the widget's `String`-typed `text`. It's the
20
+ # same wrapper shape as {ComboBox} minus the dropdown: a digit-filtered text
21
+ # field re-exposed as a typed input. Give it a single-row {#rect}.
22
+ #
23
+ # == The value is a *derived parse* of the buffer
24
+ # {#value} is `Integer(buffer, 10)` (or `nil`), recomputed on read — the
25
+ # buffer is the single source of truth, {#value=} just writes it. So `"-"`
26
+ # alone and `""` both read as `nil`, and `on_value_change` fires eagerly
27
+ # once per real *value* change: typing `0`→`7` in `"07"` shifts the buffer
28
+ # but not the value (`7`), so it does not fire. No normalization — a typed
29
+ # `"007"` stays `"007"` on screen though its value is `7`.
30
+ #
31
+ # `min`/`max`, a `+` sign, and thousands separators are deliberately out of
32
+ # scope (range and formatting are a forms concern).
33
+ #
34
+ # UI-thread-confined, like every component (see {Screen}).
35
+ class IntegerField < Component
36
+ include HasContent
37
+ include HasValue
38
+
39
+ def initialize
40
+ super()
41
+ @last_value = nil
42
+ field = TextField.new
43
+ field.on_change = ->(_text) { fire_if_changed }
44
+ field.on_key = method(:field_key)
45
+ self.content = field
46
+ end
47
+
48
+ # @return [Integer, nil] the parsed buffer; `nil` when empty or not a
49
+ # valid integer (e.g. a lone `"-"`).
50
+ def value
51
+ Integer(content.text, 10)
52
+ rescue ArgumentError
53
+ nil
54
+ end
55
+
56
+ # Writes `new_value` into the buffer and parks the caret at its end; fires
57
+ # {#on_value_change} only if the value actually changed.
58
+ # @param new_value [Integer, nil] `nil` empties the field.
59
+ # @return [void]
60
+ def value=(new_value)
61
+ content.text = new_value.nil? ? "" : new_value.to_s
62
+ content.caret = content.text.length
63
+ end
64
+
65
+ # `nil`, not `""`: an integer field with no parseable number is empty.
66
+ # @return [nil]
67
+ def empty_value = nil
68
+
69
+ # @return [Point, nil] the field's caret (the hardware cursor is delegated
70
+ # to the inner field).
71
+ def cursor_position = content.cursor_position
72
+
73
+ # Fired when ENTER is pressed in the field; see {TextField#on_enter}.
74
+ # @return [Proc, Method, nil] no-arg callable, or nil.
75
+ def on_enter = content.on_enter
76
+
77
+ # @param callback [Proc, Method, nil]
78
+ # @return [void]
79
+ def on_enter=(callback)
80
+ content.on_enter = callback
81
+ end
82
+
83
+ protected
84
+
85
+ # Places the wrapped field across the whole rect ({HasContent} hook).
86
+ # @param field [Component]
87
+ # @return [void]
88
+ def layout(field) = (field.rect = rect)
89
+
90
+ private
91
+
92
+ # The field's key interceptor, consulted *before* the field acts on the
93
+ # key: Up/Down step the value; a printable key the field mustn't accept is
94
+ # swallowed (so a rejected key never moves the caret); everything else —
95
+ # digits, the leading sign, and all editing/navigation keys — falls
96
+ # through.
97
+ # @param key [String]
98
+ # @return [Boolean] true to consume the key.
99
+ def field_key(key)
100
+ case key
101
+ when Keys::UP_ARROW then step(1)
102
+ when Keys::DOWN_ARROW then step(-1)
103
+ else return Keys.printable?(key) && !accepts?(key)
104
+ end
105
+ true
106
+ end
107
+
108
+ # Nudges {#value} by `delta`, treating an empty/un-parseable field as `0`.
109
+ # @param delta [Integer]
110
+ # @return [void]
111
+ def step(delta) = (self.value = (value || 0) + delta)
112
+
113
+ # A digit anywhere, or a `-` only as the very first character.
114
+ # @param char [String] a single printable character.
115
+ # @return [Boolean]
116
+ def accepts?(char)
117
+ return true if char.match?(/\A[0-9]\z/)
118
+
119
+ char == "-" && content.caret.zero? && !content.text.start_with?("-")
120
+ end
121
+
122
+ # Re-emits {#on_value_change} with the freshly-parsed {#value}, but only
123
+ # when it differs from the last one fired — so a buffer edit that leaves
124
+ # the value unchanged (`"7"`→`"07"`) stays silent.
125
+ # @return [void]
126
+ def fire_if_changed
127
+ v = value
128
+ return if v == @last_value
129
+
130
+ @last_value = v
131
+ on_value_change&.call(v)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -8,21 +8,28 @@ module Tuile
8
8
  # embedded ANSI is honored) or a {StyledString} directly. {#text}
9
9
  # always returns the {StyledString}.
10
10
  class Label < Component
11
- def initialize
12
- super
11
+ # @param text [String, StyledString, nil] initial text, coerced the same
12
+ # way {#text=} coerces it (a `String` is parsed via
13
+ # {StyledString.parse}; `nil` is an empty label). Equivalent to
14
+ # constructing empty and assigning {#text=}.
15
+ def initialize(text = nil)
16
+ super()
13
17
  @text = StyledString::EMPTY
14
18
  @bg = nil
15
19
  @clipped_lines = []
16
20
  @blank_line = ""
21
+ self.text = text unless text.nil?
17
22
  end
18
23
 
19
24
  # @return [StyledString] the current text. Defaults to an empty
20
25
  # {StyledString}.
21
26
  attr_reader :text
22
27
 
23
- # @return [Color, nil] background color applied uniformly across every
24
- # painted row (including padding past the text). `nil` (default)
25
- # leaves whatever bg the text's own styling carries.
28
+ # @return [Color, nil] a local background laid over *every* span and the
29
+ # row padding (via {StyledString#with_bg}), overriding the text's own
30
+ # span bgs — stronger than the inherited {#bg_color}. `nil` (default)
31
+ # keeps each span's bg and lets the inherited {#effective_bg_color}
32
+ # fill the rest.
26
33
  attr_reader :bg
27
34
 
28
35
  # Replaces the text. A `String` is parsed via {StyledString.parse}
@@ -38,14 +45,13 @@ module Tuile
38
45
  @text = new_text
39
46
  update_clipped_lines
40
47
  invalidate
41
- self.content_size = compute_content_size
42
48
  end
43
49
 
44
- # Sets the background color. Coerced via {Color.coerce}, so a Symbol,
45
- # Integer, Array, {Color}, or `nil` all work. `nil` clears the override
46
- # — the label paints with whatever bg the text's own styling provides.
47
- # Otherwise the bg overlays every span (including the trailing pad and
48
- # blank rows past the last text line).
50
+ # Sets a local background painted over every span and the row padding
51
+ # (trailing pad and blank rows included), overriding the text's own span
52
+ # bgs. Coerced via {Color.coerce} (Symbol, Integer, Array, {Color}, or
53
+ # `nil`). `nil` clears the override — spans keep their own bg and the
54
+ # inherited {#bg_color} fills the rest.
49
55
  #
50
56
  # @param value [Color, Symbol, Integer, Array<Integer>, nil]
51
57
  # @return [void]
@@ -63,14 +69,15 @@ module Tuile
63
69
  # Skips the {Component#repaint} default's auto-clear: every row is
64
70
  # painted explicitly (with pre-padded blanks past the last line), so
65
71
  # the "fully draw over your rect" contract is met without an upfront
66
- # wipe.
72
+ # wipe. Rows go through {Component#draw_line}, so the padding and blank
73
+ # rows inherit {Component#effective_bg_color} when {#bg} is unset.
67
74
  # @return [void]
68
75
  def repaint
69
76
  return if rect.empty?
70
77
 
71
78
  (0...rect.height).each do |row|
72
79
  line = @clipped_lines[row] || @blank_line
73
- screen.buffer.set_line(rect.left, rect.top + row, line)
80
+ draw_line(rect.left, rect.top + row, line)
74
81
  end
75
82
  end
76
83
 
@@ -84,18 +91,6 @@ module Tuile
84
91
 
85
92
  private
86
93
 
87
- # Natural size: longest hard-line's display width × number of hard
88
- # lines. Computed on the *unclipped* text — sizing is intrinsic to the
89
- # content, not the viewport. Empty text yields {Size::ZERO}.
90
- # @return [Size]
91
- def compute_content_size
92
- return Size::ZERO if @text.empty?
93
-
94
- hard_lines = @text.lines
95
- width = hard_lines.map(&:display_width).max || 0
96
- Size.new(width, hard_lines.size)
97
- end
98
-
99
94
  # Recomputes {@clipped_lines} for the current text and rect width.
100
95
  # Each line is ellipsized to fit and padded with trailing spaces out to
101
96
  # the full width, so {#repaint} is just a lookup + {Buffer#set_line} per
@@ -11,14 +11,6 @@ module Tuile
11
11
  # the background is cleared and children are re-invalidated so they
12
12
  # paint over a clean surface.
13
13
  class Layout < Component
14
- def initialize
15
- super
16
- @children = []
17
- end
18
-
19
- # @return [Array<Component>]
20
- def children = @children.to_a
21
-
22
14
  # Layouts are focusable containers — like {Window} and {Popup}, they
23
15
  # don't accept input themselves but they need to participate in the
24
16
  # {HasContent} focus cascade so a Popup wrapping a Layout wrapping a
@@ -36,11 +28,7 @@ module Tuile
36
28
  if child.is_a? Enumerable
37
29
  child.each { add(_1) }
38
30
  else
39
- raise TypeError, "expected Component, got #{child.inspect}" unless child.is_a? Component
40
- raise ArgumentError, "#{child} already has a parent #{child.parent}" unless child.parent.nil?
41
-
42
- @children << child
43
- child.parent = self
31
+ add_child(child)
44
32
  end
45
33
  end
46
34
 
@@ -50,19 +38,8 @@ module Tuile
50
38
  raise TypeError, "expected Component, got #{child.inspect}" unless child.is_a? Component
51
39
  raise ArgumentError, "#{child}'s parent is #{child.parent}, not this layout #{self}" if child.parent != self
52
40
 
53
- child.parent = nil
54
- @children.delete(child)
55
- invalidate if @children.empty?
56
- on_child_removed(child)
57
- end
58
-
59
- # @return [Size]
60
- def content_size
61
- return Size::ZERO if @children.empty?
62
-
63
- right = @children.map { |c| c.rect.left + c.rect.width }.max
64
- bottom = @children.map { |c| c.rect.top + c.rect.height }.max
65
- Size.new(right - rect.left, bottom - rect.top)
41
+ remove_child(child)
42
+ invalidate if @children.empty? # nothing left to paint over the gap
66
43
  end
67
44
 
68
45
  # Dispatches the event to the child under the mouse cursor.
@@ -6,11 +6,9 @@ module Tuile
6
6
  #
7
7
  # Items are modeled as {StyledString}s and painted directly into the
8
8
  # component's {#rect}. Lines wider than the viewport are ellipsized via
9
- # {StyledString#ellipsize} (span styles are preserved across the cut —
10
- # unlike the older ANSI-as-bytes truncation, color does *not* get
11
- # dropped on the surviving characters). Vertical scrolling is supported
12
- # via {#top_line}; the list can also automatically scroll to the bottom
13
- # if {#auto_scroll} is enabled.
9
+ # {StyledString#ellipsize} with span styles preserved across the cut.
10
+ # Vertical scrolling is via {#top_line}; enable {#auto_scroll} to keep the
11
+ # bottom in view.
14
12
  #
15
13
  # Cursor is supported; call {#cursor=} to change cursor behavior. The
16
14
  # cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
@@ -147,7 +145,6 @@ module Tuile
147
145
  update_top_line_if_auto_scroll
148
146
  notify_cursor_changed
149
147
  invalidate
150
- self.content_size = compute_content_size
151
148
  end
152
149
 
153
150
  # Without a block, returns the current lines. With a block, fully
@@ -194,7 +191,6 @@ module Tuile
194
191
  update_top_line_if_auto_scroll
195
192
  notify_cursor_changed
196
193
  invalidate
197
- grow_content_size(new_lines)
198
194
  end
199
195
 
200
196
  def focusable? = true
@@ -274,7 +270,10 @@ module Tuile
274
270
  # Skips the {Component#repaint} default's auto-clear: every row of
275
271
  # {#rect} is painted below (with blank padding past the last item),
276
272
  # so the parent contract — "fully draw over your rect" — is met
277
- # without an upfront wipe.
273
+ # without an upfront wipe. Rows go through {Component#draw_line}, so
274
+ # content *and* blank filler inherit {Component#effective_bg_color}
275
+ # (a {#bg_color} set here or on an ancestor); the cursor row's
276
+ # {Theme#active_bg_color} highlight composes on top of it.
278
277
  # @return [void]
279
278
  def repaint
280
279
  return if rect.empty?
@@ -284,7 +283,7 @@ module Tuile
284
283
  end
285
284
  (0...rect.height).each do |row|
286
285
  line = paintable_line(row + @top_line, row, scrollbar)
287
- screen.buffer.set_line(rect.left, row + rect.top, line)
286
+ draw_line(rect.left, row + rect.top, line)
288
287
  end
289
288
  end
290
289
 
@@ -508,30 +507,6 @@ module Tuile
508
507
 
509
508
  private
510
509
 
511
- # Natural size from scratch: longest line's display width plus the two
512
- # single-space gutters {#pad_to_row} adds, × line count. An empty list
513
- # is {Size::ZERO} (no gutters for no content).
514
- # @return [Size]
515
- def compute_content_size
516
- content_w = @lines.map(&:display_width).max || 0
517
- width = @lines.empty? ? 0 : content_w + 2
518
- Size.new(width, @lines.size)
519
- end
520
-
521
- # Incremental {#content_size} update for appends: folds just the
522
- # appended lines into the running maximum, keeping {#add_lines}
523
- # O(appended) instead of re-scanning the whole list (LogWindow appends
524
- # a line per log statement).
525
- # @param appended [Array<StyledString>] the just-appended lines
526
- # (already concatenated onto {@lines}).
527
- # @return [void]
528
- def grow_content_size(appended)
529
- return if appended.empty?
530
-
531
- appended_w = appended.map(&:display_width).max + 2
532
- self.content_size = Size.new([content_size.width, appended_w].max, @lines.size)
533
- end
534
-
535
510
  # Coerces and flattens a list of input entries into trimmed
536
511
  # {StyledString} lines. Each entry becomes a {StyledString} (String
537
512
  # via {StyledString.parse}, StyledString passed through, anything else
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A borderless, tinted, non-focusable floating selection list — the dropdown
6
+ # a text input drops open, drives by forwarding movement keys, and commits a
7
+ # pick from: a non-modal {Popup} wrapping a {List} that never takes focus, so
8
+ # the caret stays in the driving input while the caller refills the rows,
9
+ # moves the highlight, and reads the pick.
10
+ #
11
+ # drop = Component::ListDropdown.new
12
+ # drop.on_item_chosen = ->(index, _line) { commit(index) } # caller commits
13
+ # # …then, per keystroke in the driving input's key handler:
14
+ # drop.lines = matches.map { |m| render(m) } # caller filters + renders
15
+ # drop.rect = Rect.new(...) # caller anchors + sizes it
16
+ # drop.open
17
+ # return true if drop.move(key) # Up/Down/PgUp/PgDn/^U/^D → list scroll
18
+ # drop.choose if key == Keys::ENTER # commit the highlight
19
+ #
20
+ # It owns only what every such dropdown shares; everything that varies stays
21
+ # with the driver: geometry/anchoring, filtering, row rendering, the commit
22
+ # action, and ESC/Enter handling. ESC and Enter carry driver-specific tails
23
+ # (ESC may revert a query; Enter may commit via {#choose} *or* via a separate
24
+ # submit path), so {#move} claims neither — the driver calls {#choose} and
25
+ # {#close} from its own branches.
26
+ #
27
+ # == Theming
28
+ # Borderless, told apart from the content beneath by a background tint —
29
+ # {Theme#input_bg_color} by default, assigned as a live {Theme::Ref} so it
30
+ # tracks light/dark flips with no hook. Reassign {Component#bg_color=} for a
31
+ # different tint (a `Theme.ref(:token)` keeps the flip-tracking).
32
+ #
33
+ # UI-thread-confined, like every component (see {Screen}).
34
+ class ListDropdown < Popup
35
+ # The dropdown's {List}. Non-focusable on purpose: the driver forwards keys
36
+ # while focus (and the caret) stay in its input, and a mouse click selects
37
+ # an item without stealing focus — so the input never loses the cursor
38
+ # mid-interaction.
39
+ class Menu < List
40
+ def focusable? = false
41
+ def tab_stop? = false
42
+ end
43
+
44
+ # Cursor-movement keys forwarded to the list by {#move}: the two vertical
45
+ # arrows, page up/down, and Ctrl+U/D half-page jumps. Deliberately excludes
46
+ # Home/End and `j`/`k` (they belong to the driving field — caret movement
47
+ # and typing) and Enter/ESC (they carry driver-specific tails — see the
48
+ # class docs).
49
+ # @return [Array<String>]
50
+ MOVE_KEYS = [Keys::UP_ARROW, Keys::DOWN_ARROW, Keys::PAGE_UP, Keys::PAGE_DOWN,
51
+ Keys::CTRL_U, Keys::CTRL_D].freeze
52
+
53
+ def initialize
54
+ @list = Menu.new
55
+ @list.cursor = List::Cursor.new
56
+ @list.show_cursor_when_inactive = true # highlight the selection though focus stays in the input
57
+ super(content: @list, modal: false)
58
+ self.bg_color = Theme.ref(:input_bg_color)
59
+ end
60
+
61
+ # @param lines [Array] the rows to show; see {List#lines=}.
62
+ # @return [void]
63
+ def lines=(lines)
64
+ @list.lines = lines
65
+ end
66
+
67
+ # @return [Array<StyledString>] the current rows.
68
+ def lines = @list.lines
69
+
70
+ # @param proc [Proc, Method, nil] commit callback; see {List#on_item_chosen}.
71
+ # @return [void]
72
+ def on_item_chosen=(proc)
73
+ @list.on_item_chosen = proc
74
+ end
75
+
76
+ # @param cursor [List::Cursor] the highlight; see {List#cursor=}.
77
+ # @return [void]
78
+ def cursor=(cursor)
79
+ @list.cursor = cursor
80
+ end
81
+
82
+ # @return [List::Cursor] the list's cursor (the current highlight).
83
+ def cursor = @list.cursor
84
+
85
+ # Forwards a cursor-movement key to the list. The driver calls this from
86
+ # its own key handler; a truthy return means "consumed — stop here", falsy
87
+ # means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
88
+ # are claimed, and only while open.
89
+ # @param key [String]
90
+ # @return [Boolean] true iff the key was consumed.
91
+ def move(key)
92
+ return false unless open? && MOVE_KEYS.include?(key)
93
+
94
+ @list.handle_key(key)
95
+ true
96
+ end
97
+
98
+ # Commits the highlighted row by firing {List#on_item_chosen}, exactly as
99
+ # pressing Enter on the focused list would — the driver calls this from its
100
+ # own Enter branch.
101
+ # @return [Boolean] true iff a row was chosen (false when the cursor is
102
+ # off-content).
103
+ def choose = @list.handle_key(Keys::ENTER)
104
+ end
105
+ end
106
+ end
@@ -24,20 +24,6 @@ module Tuile
24
24
  self.scrollbar = true
25
25
  end
26
26
 
27
- # Keep the log pane at least half the screen tall even when only a few
28
- # lines have been logged: a {Component::Popup} sizes to its content, which
29
- # would collapse a near-empty log to two or three rows. Advice consulted
30
- # by {Component::Popup#min_height} when this window is a popup's content.
31
- # @return [Integer]
32
- def popup_min_height = screen.size.height / 2
33
-
34
- # Let a busy log grow past the popup's base 12-row cap (up to the
35
- # 4/5-of-screen ceiling {Component::Popup#update_rect} applies) so the
36
- # diagnostic stream stays scrollable in a tall window. Advice consulted
37
- # by {Component::Popup#max_height} when this window is a popup's content.
38
- # @return [Integer]
39
- def popup_max_height = screen.size.height
40
-
41
27
  # Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
42
28
  # @param string [String, nil] the line (or multiple lines) to log.
43
29
  # @return [void]
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A {TextField} that paints one mask glyph per character instead of the
6
+ # text. Editing, caret, clicks and horizontal scrolling are the field's,
7
+ # unchanged:
8
+ #
9
+ # pf = Component::PasswordField.new
10
+ # pf.rect = Rect.new(0, 0, 20, 1)
11
+ # pf.value # => the plaintext String
12
+ # pf.mask_char = "•" # default "*"
13
+ # pf.revealed = true # show the plaintext, e.g. behind a Checkbox
14
+ #
15
+ # A password's value *is* its text, so this subclasses {TextField} rather
16
+ # than composing one the way {IntegerField} does — the delta is presentation
17
+ # only, and it lands entirely on {TextField#display_text}.
18
+ #
19
+ # == What it hides, and what it doesn't
20
+ # The plaintext is an ordinary Ruby `String`: not pinned, not wiped, not
21
+ # kept out of GC. Anything stronger needs a frozen-buffer type and the
22
+ # cooperation of every consumer, which is out of scope for a widget.
23
+ #
24
+ # The mask shows the text's *length* — accepted, since a caret has to sit
25
+ # somewhere. Its *word structure* is hidden: CTRL+LEFT / CTRL+RIGHT jump to
26
+ # the ends while masked instead of hopping the spaces a watcher could then
27
+ # read off the caret. They resume word-jumping when {#revealed}.
28
+ #
29
+ # UI-thread-confined, like every component (see {Screen}).
30
+ class PasswordField < TextField
31
+ def initialize
32
+ super
33
+ @mask_char = "*"
34
+ @revealed = false
35
+ end
36
+
37
+ # @return [String] the glyph painted per character; `"*"` by default.
38
+ attr_reader :mask_char
39
+
40
+ # The default is `"*"` rather than a prettier `"•"` because U+2022 is
41
+ # East-Asian-*Ambiguous*: a CJK-configured terminal draws it two columns
42
+ # wide, shifting every column past the caret. Validation can't catch that
43
+ # one — Tuile measures Ambiguous as 1 by construction — so the default
44
+ # carries it, and this setter is the knob for someone who knows their
45
+ # terminal.
46
+ # @param char [String] one grapheme cluster, one column wide.
47
+ # @return [void]
48
+ # @raise [TypeError] unless `char` is a String.
49
+ # @raise [ArgumentError] if `char` isn't exactly one single-column
50
+ # grapheme cluster — a multi-cluster mask would break the
51
+ # one-glyph-per-character contract {TextField#display_text} rests on,
52
+ # a wide one the column axis.
53
+ def mask_char=(char)
54
+ raise TypeError, "expected String, got #{char.inspect}" unless char.is_a?(String)
55
+ raise ArgumentError, "expected one grapheme cluster, got #{char.inspect}" unless single_cluster?(char)
56
+ raise ArgumentError, "expected a 1-column glyph, got #{char.inspect}" unless Buffer.display_width(char) == 1
57
+
58
+ return if @mask_char == char
59
+
60
+ @mask_char = char
61
+ invalidate
62
+ end
63
+
64
+ # @return [Boolean] whether the plaintext is shown; `false` by default.
65
+ attr_reader :revealed
66
+
67
+ # @return [Boolean] {#revealed} in predicate form.
68
+ def revealed? = @revealed
69
+
70
+ # Shows or re-masks the plaintext. There is no built-in reveal *button* —
71
+ # a TTY field has no room for an in-field affordance — so an app flips
72
+ # this from a key binding or a sibling {Checkbox}.
73
+ # @param flag [Object] anything; truthiness decides.
74
+ # @return [void]
75
+ def revealed=(flag)
76
+ flag = flag ? true : false
77
+ return if @revealed == flag
78
+
79
+ @revealed = flag
80
+ # The plaintext and the mask differ in *columns* (a CJK passphrase is
81
+ # wider than its mask), so the scroll window has to be recomputed —
82
+ # the caret index it must keep visible has not moved.
83
+ adjust_left_column
84
+ invalidate
85
+ end
86
+
87
+ protected
88
+
89
+ # @return [String] the mask, one glyph per character, unless {#revealed}.
90
+ def display_text = revealed? ? super : @mask_char * @text.length
91
+
92
+ private
93
+
94
+ # @param char [String]
95
+ # @return [Boolean]
96
+ def single_cluster?(char) = char.each_grapheme_cluster.take(2).size == 1
97
+
98
+ # @return [Integer] caret target for CTRL+LEFT: the start, while masked.
99
+ def word_left = revealed? ? super : 0
100
+
101
+ # @return [Integer] caret target for CTRL+RIGHT: the end, while masked.
102
+ def word_right = revealed? ? super : @text.length
103
+ end
104
+ end
105
+ end