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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/DECISIONS.md +1961 -0
- data/README.md +31 -24
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +82 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +363 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +19 -13
- data/examples/sampler.rb +435 -20
- data/ideas/new-components.md +109 -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/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +133 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +281 -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.rb +3 -17
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +106 -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/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 +112 -83
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2043 -614
- 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
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
@children.
|
|
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.
|
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,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
|
|
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
|
|