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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +81 -36
- data/DECISIONS.md +2566 -0
- data/README.md +37 -24
- data/book/03-layout.md +153 -8
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +84 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +458 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +22 -14
- data/examples/sampler.rb +632 -67
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +118 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +134 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +263 -0
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout/box.rb +316 -0
- data/lib/tuile/component/layout/horizontal.rb +40 -0
- data/lib/tuile/component/layout/vertical.rb +41 -0
- data/lib/tuile/component/layout.rb +149 -15
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +157 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +125 -86
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +2962 -680
- 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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
#
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
@children.
|
|
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.
|
data/lib/tuile/component/list.rb
CHANGED
|
@@ -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}
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
|
|
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
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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
|
|
44
|
-
# the
|
|
45
|
-
#
|
|
46
|
-
#
|
|
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
|
|