tuile 0.9.0 → 0.11.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -36
  3. data/DECISIONS.md +2566 -0
  4. data/README.md +37 -24
  5. data/book/03-layout.md +153 -8
  6. data/book/04-event-loop.md +86 -0
  7. data/book/05-focus.md +84 -51
  8. data/book/06-theming.md +53 -1
  9. data/book/07-components.md +458 -9
  10. data/book/08-testing.md +21 -8
  11. data/book/09-styled-text.md +132 -0
  12. data/book/README.md +22 -14
  13. data/examples/sampler.rb +632 -67
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +118 -0
  16. data/ideas/per-component-buffers.md +55 -0
  17. data/lib/tuile/buffer.rb +52 -51
  18. data/lib/tuile/color.rb +4 -10
  19. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  20. data/lib/tuile/component/big_decimal_field.rb +199 -0
  21. data/lib/tuile/component/button.rb +25 -21
  22. data/lib/tuile/component/checkbox.rb +134 -0
  23. data/lib/tuile/component/checkbox_group.rb +188 -0
  24. data/lib/tuile/component/combo_box.rb +263 -0
  25. data/lib/tuile/component/float_field.rb +161 -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/integer_field.rb +135 -0
  30. data/lib/tuile/component/label.rb +13 -10
  31. data/lib/tuile/component/layout/box.rb +316 -0
  32. data/lib/tuile/component/layout/horizontal.rb +40 -0
  33. data/lib/tuile/component/layout/vertical.rb +41 -0
  34. data/lib/tuile/component/layout.rb +149 -15
  35. data/lib/tuile/component/list.rb +8 -7
  36. data/lib/tuile/component/list_dropdown.rb +157 -0
  37. data/lib/tuile/component/password_field.rb +105 -0
  38. data/lib/tuile/component/popup.rb +10 -14
  39. data/lib/tuile/component/progress_bar.rb +278 -0
  40. data/lib/tuile/component/radio_group.rb +188 -0
  41. data/lib/tuile/component/select.rb +251 -0
  42. data/lib/tuile/component/text_area.rb +189 -65
  43. data/lib/tuile/component/text_field.rb +170 -32
  44. data/lib/tuile/component/text_view.rb +57 -114
  45. data/lib/tuile/component/window.rb +32 -47
  46. data/lib/tuile/component.rb +250 -87
  47. data/lib/tuile/event_queue.rb +14 -17
  48. data/lib/tuile/fake_event_queue.rb +11 -2
  49. data/lib/tuile/fake_screen.rb +4 -5
  50. data/lib/tuile/fraction.rb +6 -10
  51. data/lib/tuile/screen.rb +202 -104
  52. data/lib/tuile/screen_pane.rb +51 -41
  53. data/lib/tuile/styled_string.rb +125 -86
  54. data/lib/tuile/theme.rb +78 -41
  55. data/lib/tuile/version.rb +1 -1
  56. data/lib/tuile.rb +4 -0
  57. data/sig/tuile.rbs +2962 -680
  58. metadata +25 -7
@@ -3,7 +3,13 @@
3
3
  module Tuile
4
4
  class Component
5
5
  # A layout doesn't paint anything by itself: its job is to position child
6
- # components.
6
+ # components. Two families, both top-down (see book ch3):
7
+ #
8
+ # - {Absolute} — you override {Component#rect=} and compute every child's
9
+ # rectangle yourself. Total control, and the base for anything unusual.
10
+ # - {Box} / {Vertical} / {Horizontal} — you declare each child's extent as
11
+ # a {Fixed}, {Percent} or {Expand} constraint and the layout does the
12
+ # arithmetic. Sugar over the same `rect=` assignment, for the common case.
7
13
  #
8
14
  # Children that fully tile the layout's rect repaint themselves and
9
15
  # cover everything; children that leave gaps (e.g. a form with widgets
@@ -11,13 +17,147 @@ module Tuile
11
17
  # the background is cleared and children are re-invalidated so they
12
18
  # paint over a clean surface.
13
19
  class Layout < Component
14
- def initialize
15
- super
16
- @children = []
20
+ # How much space a child gets along one axis of a {Box}: exactly {#cells},
21
+ # clamped to whatever is still unassigned.
22
+ #
23
+ # add(prompt, Fixed[4]) # 4 rows in a Vertical
24
+ # add(field, Fixed[1], cross: Fixed[30]) # 1 row, 30 columns wide
25
+ #
26
+ # `Fixed[0]` hides the child — it gets an empty rect and paints nothing.
27
+ #
28
+ # @!attribute [r] cells
29
+ # @return [Integer] cell count along the axis.
30
+ class Fixed < Data.define(:cells)
31
+ # @param cells [Integer] cell count along the axis; `>= 0`.
32
+ # @raise [ArgumentError] unless `cells` is a non-negative Integer.
33
+ def initialize(cells:)
34
+ unless cells.is_a?(Integer) && !cells.negative?
35
+ raise ArgumentError, "Fixed expects a non-negative Integer, got #{cells.inspect}"
36
+ end
37
+
38
+ super
39
+ end
17
40
  end
18
41
 
19
- # @return [Array<Component>]
20
- def children = @children.to_a
42
+ # A percentage of the space *available* along a {Box}'s axis — measured
43
+ # after {Box#padding} and {Box#spacing} have come off, so two `Percent[50]`
44
+ # children fit exactly rather than overflowing by the gap between them.
45
+ #
46
+ # add(left, Percent[60])
47
+ # add(right, Percent[40])
48
+ #
49
+ # @!attribute [r] percent
50
+ # @return [Numeric] percentage of the available extent, `0..100`.
51
+ class Percent < Data.define(:percent)
52
+ # @param percent [Numeric] percentage of the available extent, `0..100`.
53
+ # @raise [ArgumentError] unless `percent` is a Numeric in `0..100`.
54
+ def initialize(percent:)
55
+ unless percent.is_a?(Numeric) && percent.between?(0, 100)
56
+ raise ArgumentError, "Percent expects a Numeric in 0..100, got #{percent.inspect}"
57
+ end
58
+
59
+ super
60
+ end
61
+ end
62
+
63
+ # A share of whatever a {Box} has left once its {Fixed} and {Percent}
64
+ # children have taken theirs, split between the `Expand` children in
65
+ # proportion to their weights:
66
+ #
67
+ # add(header, Fixed[1])
68
+ # add(body, Expand[2]) # gets twice…
69
+ # add(side, Expand[1]) # …what this one gets
70
+ #
71
+ # Main axis only — {Box#add} rejects one passed as `cross:`, where a child
72
+ # has no siblings to compete with and so nothing for a weight to mean.
73
+ #
74
+ # @!attribute [r] weight
75
+ # @return [Integer] relative share of the leftover space.
76
+ class Expand < Data.define(:weight)
77
+ # @param weight [Integer] relative share; `>= 1`.
78
+ # @raise [ArgumentError] unless `weight` is a positive Integer.
79
+ def initialize(weight:)
80
+ unless weight.is_a?(Integer) && weight.positive?
81
+ raise ArgumentError, "Expand expects a positive Integer weight, got #{weight.inspect}"
82
+ end
83
+
84
+ super
85
+ end
86
+ end
87
+
88
+ # Per-edge padding for a {Box}, in cells:
89
+ #
90
+ # Insets[top: 1] # one blank row above the children
91
+ # Insets[top: 1, left: 2, right: 2] # unnamed edges default to 0
92
+ # Insets.coerce(1) # uniform on all four edges
93
+ #
94
+ # Keyword-only: AWT and JavaFX order these same four numbers differently,
95
+ # so a positional form would be a coin flip.
96
+ #
97
+ # @!attribute [r] top
98
+ # @return [Integer] cells inset from the top edge.
99
+ # @!attribute [r] right
100
+ # @return [Integer] cells inset from the right edge.
101
+ # @!attribute [r] bottom
102
+ # @return [Integer] cells inset from the bottom edge.
103
+ # @!attribute [r] left
104
+ # @return [Integer] cells inset from the left edge.
105
+ class Insets < Data.define(:top, :right, :bottom, :left)
106
+ # @param positional [Array] must be empty — see the class doc.
107
+ # @param kwargs [Hash{Symbol => Integer}] any of `top:`/`right:`/`bottom:`/`left:`.
108
+ # @raise [ArgumentError] if any positional argument is given.
109
+ # @return [Insets]
110
+ def self.new(*positional, **kwargs)
111
+ raise ArgumentError, "Insets is keyword-only, got #{positional.inspect}" unless positional.empty?
112
+
113
+ super(**kwargs)
114
+ end
115
+
116
+ # Needed because `Data`'s inherited `[]` never dispatches through a `new`
117
+ # override, so the guard above alone would miss `Insets[1, 2, 3, 4]`.
118
+ # @param positional [Array] must be empty.
119
+ # @param kwargs [Hash{Symbol => Integer}] any of `top:`/`right:`/`bottom:`/`left:`.
120
+ # @raise [ArgumentError] if any positional argument is given.
121
+ # @return [Insets]
122
+ def self.[](*positional, **kwargs) = new(*positional, **kwargs)
123
+
124
+ # @param value [Insets, Integer] an Integer becomes a uniform inset.
125
+ # @raise [ArgumentError] on anything else, or a negative Integer.
126
+ # @return [Insets]
127
+ def self.coerce(value)
128
+ return value if value.is_a?(Insets)
129
+ unless value.is_a?(Integer) && !value.negative?
130
+ raise ArgumentError, "expected Insets or a non-negative Integer, got #{value.inspect}"
131
+ end
132
+
133
+ new(top: value, right: value, bottom: value, left: value)
134
+ end
135
+
136
+ # @param top [Integer] cells inset from the top edge; `>= 0`.
137
+ # @param right [Integer] cells inset from the right edge; `>= 0`.
138
+ # @param bottom [Integer] cells inset from the bottom edge; `>= 0`.
139
+ # @param left [Integer] cells inset from the left edge; `>= 0`.
140
+ # @raise [ArgumentError] unless every edge is a non-negative Integer.
141
+ def initialize(top: 0, right: 0, bottom: 0, left: 0)
142
+ { top:, right:, bottom:, left: }.each do |edge, cells|
143
+ unless cells.is_a?(Integer) && !cells.negative?
144
+ raise ArgumentError, "Insets #{edge}: expected a non-negative Integer, got #{cells.inspect}"
145
+ end
146
+ end
147
+
148
+ super
149
+ end
150
+
151
+ # @return [Integer] `left` + `right`.
152
+ def horizontal = left + right
153
+
154
+ # @return [Integer] `top` + `bottom`.
155
+ def vertical = top + bottom
156
+
157
+ # No padding on any edge.
158
+ # @return [Insets]
159
+ ZERO = new
160
+ end
21
161
 
22
162
  # Layouts are focusable containers — like {Window} and {Popup}, they
23
163
  # don't accept input themselves but they need to participate in the
@@ -36,11 +176,7 @@ module Tuile
36
176
  if child.is_a? Enumerable
37
177
  child.each { add(_1) }
38
178
  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
179
+ add_child(child)
44
180
  end
45
181
  end
46
182
 
@@ -50,10 +186,8 @@ module Tuile
50
186
  raise TypeError, "expected Component, got #{child.inspect}" unless child.is_a? Component
51
187
  raise ArgumentError, "#{child}'s parent is #{child.parent}, not this layout #{self}" if child.parent != self
52
188
 
53
- child.parent = nil
54
- @children.delete(child)
55
- invalidate if @children.empty?
56
- on_child_removed(child)
189
+ remove_child(child)
190
+ invalidate if @children.empty? # nothing left to paint over the gap
57
191
  end
58
192
 
59
193
  # 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,157 @@
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 *driver* 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
+ # focus stays on the driver while the caller refills the rows, moves the
9
+ # highlight, and reads the pick.
10
+ #
11
+ # drop = Component::ListDropdown.new
12
+ # drop.on_item_chosen = ->(index, _line) { commit(index) } # caller commits
13
+ # # …then, from the driver's key handler:
14
+ # drop.lines = matches.map { |m| render(m) } # caller filters + renders
15
+ # drop.anchor_to(rect, rows: matches.size) # below the driver, or flipped
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 — *placement* included, via
21
+ # {#anchor_to}. What stays with the driver: the width **policy** ({#anchor_to}
22
+ # measures nothing itself), filtering, row rendering, the commit action, and
23
+ # ESC/Enter handling. ESC and Enter carry driver-specific tails (ESC may
24
+ # revert a query; Enter may commit via {#choose} *or* via a separate submit
25
+ # path), so {#move} claims neither — the driver calls {#choose} and {#close}
26
+ # from its own branches.
27
+ #
28
+ # == Theming
29
+ # Borderless, told apart from the content beneath by a background tint —
30
+ # {Theme#input_bg_color} by default, assigned as a live {Theme::Ref} so it
31
+ # tracks light/dark flips with no hook. Reassign {Component#bg_color=} for a
32
+ # different tint (a `Theme.ref(:token)` keeps the flip-tracking).
33
+ #
34
+ # UI-thread-confined, like every component (see {Screen}).
35
+ class ListDropdown < Popup
36
+ # The dropdown's {List}. Non-focusable on purpose: the driver forwards keys
37
+ # while focus stays on it, and a mouse click selects an item without
38
+ # stealing focus — so a driving text input never loses its caret
39
+ # mid-interaction.
40
+ class Menu < List
41
+ def focusable? = false
42
+ def tab_stop? = false
43
+ end
44
+
45
+ # Cursor-movement keys forwarded to the list by {#move}: the two vertical
46
+ # arrows, page up/down, and Ctrl+U/D half-page jumps. Deliberately excludes
47
+ # Home/End and `j`/`k` — a jump to the first/last row is the driver's call,
48
+ # and both drivers decline it ({ComboBox}'s field needs Home/End for the
49
+ # caret; {Select} would spend a branch on what a second arrow press already
50
+ # does) — and Enter/ESC, which carry driver-specific tails (see the class
51
+ # docs).
52
+ # @return [Array<String>]
53
+ MOVE_KEYS = [Keys::UP_ARROW, Keys::DOWN_ARROW, Keys::PAGE_UP, Keys::PAGE_DOWN,
54
+ Keys::CTRL_U, Keys::CTRL_D].freeze
55
+
56
+ # Most rows shown before the list scrolls; {#anchor_to}'s `max_rows`
57
+ # default.
58
+ # @return [Integer]
59
+ MAX_VISIBLE_ROWS = 10
60
+
61
+ def initialize
62
+ @list = Menu.new
63
+ @list.cursor = List::Cursor.new
64
+ @list.show_cursor_when_inactive = true # highlight the selection though focus stays on the driver
65
+ super(content: @list, modal: false)
66
+ self.bg_color = Theme.ref(:input_bg_color)
67
+ end
68
+
69
+ # @param lines [Array] the rows to show; see {List#lines=}.
70
+ # @return [void]
71
+ def lines=(lines)
72
+ @list.lines = lines
73
+ end
74
+
75
+ # @return [Array<StyledString>] the current rows.
76
+ def lines = @list.lines
77
+
78
+ # @param proc [Proc, Method, nil] commit callback; see {List#on_item_chosen}.
79
+ # @return [void]
80
+ def on_item_chosen=(proc)
81
+ @list.on_item_chosen = proc
82
+ end
83
+
84
+ # @param cursor [List::Cursor] the highlight; see {List#cursor=}.
85
+ # @return [void]
86
+ def cursor=(cursor)
87
+ @list.cursor = cursor
88
+ end
89
+
90
+ # @return [List::Cursor] the list's cursor (the current highlight).
91
+ def cursor = @list.cursor
92
+
93
+ # Sizes and places the dropdown against `anchor`: directly beneath it,
94
+ # flipped above when `rows` won't fit below, clamped — with the list
95
+ # scrolling — when neither side has room. Horizontally the left edges line
96
+ # up, sliding left only far enough to keep the panel on screen.
97
+ #
98
+ # drop.anchor_to(field.rect, rows: matches.size) # field width
99
+ # drop.anchor_to(rect, rows: items.size, width: measured) # own width
100
+ #
101
+ # Vertical flips but horizontal slides because covering the driver would
102
+ # hide what is being chosen, while sharing its columns is the point.
103
+ #
104
+ # @param anchor [Rect] the driver's rect; the dropdown never covers it.
105
+ # @param rows [Integer] how many rows there are to show — the content
106
+ # count, not the height: more than fits turns the scrollbar on. `0`
107
+ # collapses the dropdown to an empty rect (drivers close instead).
108
+ # @param width [Integer] the panel's width in columns, clamped to the
109
+ # screen. Defaults to the anchor's, which lines both edges up with a
110
+ # field; a driver that measured its labels passes its own. A label wider
111
+ # than the screen clips — {List} has no horizontal scrolling.
112
+ # @param max_rows [Integer] rows shown before the list scrolls.
113
+ # @return [void]
114
+ def anchor_to(anchor, rows:, width: anchor.width, max_rows: MAX_VISIBLE_ROWS)
115
+ desired = [rows, max_rows].min
116
+ below = screen.size.height - (anchor.top + 1)
117
+ above = anchor.top
118
+ if desired <= below
119
+ top = anchor.top + 1
120
+ height = desired
121
+ elsif above >= below
122
+ height = [desired, above].min
123
+ top = anchor.top - height
124
+ else
125
+ height = below
126
+ top = anchor.top + 1
127
+ end
128
+ width = [width, screen.size.width].min
129
+ self.size = Size.new(width, height)
130
+ self.rect = Rect.new([anchor.left, screen.size.width - width].min.clamp(0, nil), top, width, height)
131
+ # After the geometry: the setter rebuilds the list's padded rows against
132
+ # the width it can see, and the gutter takes a column off it.
133
+ @list.scrollbar_visibility = rows > height ? :visible : :gone
134
+ end
135
+
136
+ # Forwards a cursor-movement key to the list. The driver calls this from
137
+ # its own key handler; a truthy return means "consumed — stop here", falsy
138
+ # means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
139
+ # are claimed, and only while open.
140
+ # @param key [String]
141
+ # @return [Boolean] true iff the key was consumed.
142
+ def move(key)
143
+ return false unless open? && MOVE_KEYS.include?(key)
144
+
145
+ @list.handle_key(key)
146
+ true
147
+ end
148
+
149
+ # Commits the highlighted row by firing {List#on_item_chosen}, exactly as
150
+ # pressing Enter on the focused list would — the driver calls this from its
151
+ # own Enter branch.
152
+ # @return [Boolean] true iff a row was chosen (false when the cursor is
153
+ # off-content).
154
+ def choose = @list.handle_key(Keys::ENTER)
155
+ end
156
+ end
157
+ 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