tuile 0.9.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +31 -24
  5. data/book/04-event-loop.md +86 -0
  6. data/book/05-focus.md +82 -51
  7. data/book/06-theming.md +53 -1
  8. data/book/07-components.md +363 -9
  9. data/book/08-testing.md +21 -8
  10. data/book/09-styled-text.md +132 -0
  11. data/book/README.md +19 -13
  12. data/examples/sampler.rb +435 -20
  13. data/ideas/new-components.md +109 -0
  14. data/ideas/per-component-buffers.md +55 -0
  15. data/lib/tuile/buffer.rb +52 -51
  16. data/lib/tuile/color.rb +4 -10
  17. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  18. data/lib/tuile/component/button.rb +25 -21
  19. data/lib/tuile/component/checkbox.rb +133 -0
  20. data/lib/tuile/component/checkbox_group.rb +188 -0
  21. data/lib/tuile/component/combo_box.rb +281 -0
  22. data/lib/tuile/component/has_caption.rb +44 -0
  23. data/lib/tuile/component/has_content.rb +5 -5
  24. data/lib/tuile/component/has_value.rb +64 -0
  25. data/lib/tuile/component/integer_field.rb +135 -0
  26. data/lib/tuile/component/label.rb +13 -10
  27. data/lib/tuile/component/layout.rb +3 -17
  28. data/lib/tuile/component/list.rb +8 -7
  29. data/lib/tuile/component/list_dropdown.rb +106 -0
  30. data/lib/tuile/component/password_field.rb +105 -0
  31. data/lib/tuile/component/popup.rb +10 -14
  32. data/lib/tuile/component/progress_bar.rb +278 -0
  33. data/lib/tuile/component/radio_group.rb +188 -0
  34. data/lib/tuile/component/text_area.rb +189 -65
  35. data/lib/tuile/component/text_field.rb +170 -32
  36. data/lib/tuile/component/text_view.rb +57 -114
  37. data/lib/tuile/component/window.rb +32 -47
  38. data/lib/tuile/component.rb +250 -87
  39. data/lib/tuile/event_queue.rb +14 -17
  40. data/lib/tuile/fake_event_queue.rb +11 -2
  41. data/lib/tuile/fake_screen.rb +4 -5
  42. data/lib/tuile/fraction.rb +6 -10
  43. data/lib/tuile/screen.rb +202 -104
  44. data/lib/tuile/screen_pane.rb +51 -41
  45. data/lib/tuile/styled_string.rb +112 -83
  46. data/lib/tuile/theme.rb +78 -41
  47. data/lib/tuile/version.rb +1 -1
  48. data/sig/tuile.rbs +2043 -614
  49. metadata +18 -7
@@ -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
@@ -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
@@ -25,9 +25,11 @@ module Tuile
25
25
  # {StyledString}.
26
26
  attr_reader :text
27
27
 
28
- # @return [Color, nil] background color applied uniformly across every
29
- # painted row (including padding past the text). `nil` (default)
30
- # 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.
31
33
  attr_reader :bg
32
34
 
33
35
  # Replaces the text. A `String` is parsed via {StyledString.parse}
@@ -45,11 +47,11 @@ module Tuile
45
47
  invalidate
46
48
  end
47
49
 
48
- # Sets the background color. Coerced via {Color.coerce}, so a Symbol,
49
- # Integer, Array, {Color}, or `nil` all work. `nil` clears the override
50
- # — the label paints with whatever bg the text's own styling provides.
51
- # Otherwise the bg overlays every span (including the trailing pad and
52
- # 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.
53
55
  #
54
56
  # @param value [Color, Symbol, Integer, Array<Integer>, nil]
55
57
  # @return [void]
@@ -67,14 +69,15 @@ module Tuile
67
69
  # Skips the {Component#repaint} default's auto-clear: every row is
68
70
  # painted explicitly (with pre-padded blanks past the last line), so
69
71
  # the "fully draw over your rect" contract is met without an upfront
70
- # 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.
71
74
  # @return [void]
72
75
  def repaint
73
76
  return if rect.empty?
74
77
 
75
78
  (0...rect.height).each do |row|
76
79
  line = @clipped_lines[row] || @blank_line
77
- screen.buffer.set_line(rect.left, rect.top + row, line)
80
+ draw_line(rect.left, rect.top + row, line)
78
81
  end
79
82
  end
80
83
 
@@ -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,10 +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)
41
+ remove_child(child)
42
+ invalidate if @children.empty? # nothing left to paint over the gap
57
43
  end
58
44
 
59
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
@@ -272,7 +270,10 @@ module Tuile
272
270
  # Skips the {Component#repaint} default's auto-clear: every row of
273
271
  # {#rect} is painted below (with blank padding past the last item),
274
272
  # so the parent contract — "fully draw over your rect" — is met
275
- # 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.
276
277
  # @return [void]
277
278
  def repaint
278
279
  return if rect.empty?
@@ -282,7 +283,7 @@ module Tuile
282
283
  end
283
284
  (0...rect.height).each do |row|
284
285
  line = paintable_line(row + @top_line, row, scrollbar)
285
- screen.buffer.set_line(rect.left, row + rect.top, line)
286
+ draw_line(rect.left, row + rect.top, line)
286
287
  end
287
288
  end
288
289
 
@@ -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
@@ -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
@@ -18,16 +18,11 @@ module Tuile
18
18
  #
19
19
  # Modal by default: it centers on the screen, grabs focus, eats keys, and
20
20
  # blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
21
- # that floats above the content (still painted on top) without taking focus
22
- # or capturing input — the caller positions it (via {#rect=}) and drives it
23
- # from app code. That is the building block for an autocomplete/slash-command
24
- # list anchored to a {Component::TextField} or {Component::TextArea} caret:
25
- # typing keeps focus (and the cursor) in the input, an
26
- # {Component::TextInput#on_change} listener refills the list, and an
27
- # {Component::TextInput#on_key} interceptor forwards Up/Down/Enter to it.
28
- # Such a caller owns the list data, so it sizes the overlay itself
29
- # (`overlay.size = Size.new(longest, [items.size, 8].min)`) — still
30
- # caller-decides, top-down.
21
+ # that floats above the content without taking focus or capturing input —
22
+ # the caller positions it (via {#rect=}), sizes it, and drives it from app
23
+ # code. That's the building block for an autocomplete/slash-command list
24
+ # anchored to a text field's caret: typing keeps focus in the input while
25
+ # the caller refills and drives the overlay.
31
26
  #
32
27
  # The wrapped content fills the popup's full {#rect}; if you want a frame
33
28
  # and caption, wrap a {Component::Window} (or any subclass — including
@@ -40,10 +35,11 @@ module Tuile
40
35
  # Bare content also works (a {Component::Label}, a {Component::List}…), in
41
36
  # which case the popup is borderless.
42
37
  #
43
- # `q` and ESC close the popup. Any nested {Component::TextField} that owns
44
- # the hardware cursor swallows printable keys first via the standard
45
- # cursor-owner suppression in {Component#handle_key}, so typing `q` into a
46
- # text field doesn't dismiss the popup.
38
+ # `q` and ESC close the popup — handled here, at the top of the popup's own
39
+ # subtree, so the key only arrives after every component on the focus chain
40
+ # declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
41
+ # nested {Component::TextField} doesn't dismiss the popup: the field
42
+ # consumes it first.
47
43
  class Popup < Component
48
44
  include Component::HasContent
49
45