tuile 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +378 -0
- data/examples/file_commander.rb +196 -0
- data/examples/hello_world.rb +29 -0
- data/lib/tuile/component/has_content.rb +69 -0
- data/lib/tuile/component/info_window.rb +30 -0
- data/lib/tuile/component/label.rb +63 -0
- data/lib/tuile/component/layout.rb +98 -0
- data/lib/tuile/component/list.rb +583 -0
- data/lib/tuile/component/log_window.rb +59 -0
- data/lib/tuile/component/picker_window.rb +97 -0
- data/lib/tuile/component/popup.rb +127 -0
- data/lib/tuile/component/text_field.rb +209 -0
- data/lib/tuile/component/window.rb +215 -0
- data/lib/tuile/component.rb +236 -0
- data/lib/tuile/event_queue.rb +192 -0
- data/lib/tuile/fake_event_queue.rb +31 -0
- data/lib/tuile/fake_screen.rb +58 -0
- data/lib/tuile/keys.rb +63 -0
- data/lib/tuile/mouse_event.rb +49 -0
- data/lib/tuile/point.rb +14 -0
- data/lib/tuile/rect.rb +58 -0
- data/lib/tuile/screen.rb +377 -0
- data/lib/tuile/screen_pane.rb +174 -0
- data/lib/tuile/size.rb +42 -0
- data/lib/tuile/version.rb +6 -0
- data/lib/tuile/vertical_scroll_bar.rb +46 -0
- data/lib/tuile.rb +37 -0
- data/sig/tuile.rbs +1502 -0
- metadata +197 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A {Window} that lists options identified by single keyboard keys, asks
|
|
6
|
+
# the user to pick one, and fires a callback with the picked key.
|
|
7
|
+
#
|
|
8
|
+
# Usable tiled (just add to a {Layout} and read picks via the block) or
|
|
9
|
+
# as a popup via {.open}, which wraps it in a {Popup} that closes itself
|
|
10
|
+
# after a pick. ESC / `q` close without firing the callback.
|
|
11
|
+
class PickerWindow < Window
|
|
12
|
+
# Scrolls the window when more items.
|
|
13
|
+
# @return [Integer]
|
|
14
|
+
MAX_ITEMS = 10
|
|
15
|
+
|
|
16
|
+
# One picker option.
|
|
17
|
+
#
|
|
18
|
+
# @!attribute [r] key
|
|
19
|
+
# @return [String] the keyboard key that picks this option.
|
|
20
|
+
# @!attribute [r] caption
|
|
21
|
+
# @return [String] the option caption.
|
|
22
|
+
class Option < Data.define(:key, :caption)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param caption [String] the window caption.
|
|
26
|
+
# @param options [Array<Array(String, String)>] pairs of keyboard key and
|
|
27
|
+
# option caption. No Rainbow formatting must be used.
|
|
28
|
+
# @yield [key] called with the option key once one is selected by the
|
|
29
|
+
# user. Not called if the picker is dismissed without picking.
|
|
30
|
+
# @yieldparam key [String] the picked option key.
|
|
31
|
+
# @yieldreturn [void]
|
|
32
|
+
def initialize(caption, options, &block)
|
|
33
|
+
raise ArgumentError, "block required" unless block
|
|
34
|
+
raise ArgumentError, "options must not be empty" if options.empty?
|
|
35
|
+
|
|
36
|
+
super(caption)
|
|
37
|
+
@options = options.map { Option.new(it[0], it[1]) }
|
|
38
|
+
@block = block
|
|
39
|
+
list = Component::List.new
|
|
40
|
+
list.lines = @options.map { "#{it.key} #{Rainbow(it.caption).cadetblue}" }
|
|
41
|
+
list.cursor = Component::List::Cursor.new
|
|
42
|
+
list.on_item_chosen = ->(index, _line) { select_option(@options[index].key) }
|
|
43
|
+
self.content = list
|
|
44
|
+
# Optional hook for a containing Popup to dismiss itself after a pick.
|
|
45
|
+
@on_pick = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Callback invoked after the user picks an option (after the block
|
|
49
|
+
# fires). The {Popup} returned by {.open} sets this to its own `close`.
|
|
50
|
+
# @return [Proc, nil]
|
|
51
|
+
attr_accessor :on_pick
|
|
52
|
+
|
|
53
|
+
# @param key [String]
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def handle_key(key)
|
|
56
|
+
return true if super
|
|
57
|
+
|
|
58
|
+
if @options.any? { it.key == key }
|
|
59
|
+
select_option(key)
|
|
60
|
+
true
|
|
61
|
+
else
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [String]
|
|
67
|
+
def keyboard_hint
|
|
68
|
+
@options.map { "#{it.key} #{Rainbow(it.caption).cadetblue}" }.join(" ")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Opens a picker as a popup. Picking an option fires `block`, then
|
|
72
|
+
# closes the popup; ESC / `q` close without firing `block`.
|
|
73
|
+
# @param caption [String]
|
|
74
|
+
# @param options [Array<Array(String, String)>]
|
|
75
|
+
# @yield [key]
|
|
76
|
+
# @yieldparam key [String]
|
|
77
|
+
# @yieldreturn [void]
|
|
78
|
+
# @return [Popup] the wrapping popup.
|
|
79
|
+
def self.open(caption, options, &block)
|
|
80
|
+
picker = PickerWindow.new(caption, options, &block)
|
|
81
|
+
popup = Popup.new(content: picker)
|
|
82
|
+
picker.on_pick = -> { popup.close }
|
|
83
|
+
popup.open
|
|
84
|
+
popup
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
protected
|
|
88
|
+
|
|
89
|
+
# @param key [String]
|
|
90
|
+
# @return [void]
|
|
91
|
+
def select_option(key)
|
|
92
|
+
@block.call(key)
|
|
93
|
+
@on_pick&.call
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A modal overlay that wraps any {Component} as its content. Popup itself
|
|
6
|
+
# paints nothing — it's a transparent host that handles modality
|
|
7
|
+
# ({#open} / {#close} / {#open?}, ESC/q to close), centers itself on the
|
|
8
|
+
# screen, and auto-sizes to the wrapped content.
|
|
9
|
+
#
|
|
10
|
+
# The wrapped content fills the popup's full {#rect}; if you want a frame
|
|
11
|
+
# and caption, wrap a {Component::Window} (or any subclass — including
|
|
12
|
+
# {Component::LogWindow}) and let it draw its own border:
|
|
13
|
+
#
|
|
14
|
+
# window = Component::Window.new("Help")
|
|
15
|
+
# window.content = Component::List.new.tap { _1.lines = lines }
|
|
16
|
+
# Component::Popup.new(content: window).open
|
|
17
|
+
#
|
|
18
|
+
# Bare content also works (a {Component::Label}, a {Component::List}…), in
|
|
19
|
+
# which case the popup is borderless.
|
|
20
|
+
#
|
|
21
|
+
# `q` and ESC close the popup. Any nested {Component::TextField} that owns
|
|
22
|
+
# the hardware cursor swallows printable keys first via the standard
|
|
23
|
+
# cursor-owner suppression in {Component#handle_key}, so typing `q` into a
|
|
24
|
+
# text field doesn't dismiss the popup.
|
|
25
|
+
class Popup < Component
|
|
26
|
+
include Component::HasContent
|
|
27
|
+
|
|
28
|
+
# @param content [Component, nil] initial content; can be set later via
|
|
29
|
+
# {#content=}. When provided here, the popup auto-sizes to fit.
|
|
30
|
+
def initialize(content: nil)
|
|
31
|
+
super()
|
|
32
|
+
@content = nil
|
|
33
|
+
# Off-screen sentinel until the content sets a real size and the popup
|
|
34
|
+
# is centered on open.
|
|
35
|
+
@rect = Rect.new(-1, -1, 0, 0)
|
|
36
|
+
self.content = content unless content.nil?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def focusable? = true
|
|
40
|
+
|
|
41
|
+
# Mounts this popup on the {Screen}.
|
|
42
|
+
# @return [void]
|
|
43
|
+
def open
|
|
44
|
+
screen.add_popup(self)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Constructs and opens a popup in one call.
|
|
48
|
+
# @param content [Component, nil]
|
|
49
|
+
# @return [Popup] the opened popup.
|
|
50
|
+
def self.open(content: nil)
|
|
51
|
+
Popup.new(content: content).tap(&:open)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Removes this popup from the {Screen}. No-op if not currently open.
|
|
55
|
+
# @return [void]
|
|
56
|
+
def close
|
|
57
|
+
screen.remove_popup(self)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Boolean] true if this popup is currently mounted on the screen.
|
|
61
|
+
def open?
|
|
62
|
+
screen.has_popup?(self)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Recenters the popup on the screen, preserving its current width/height.
|
|
66
|
+
# Called automatically by the screen's layout pass and by {#content=}
|
|
67
|
+
# when the popup is open.
|
|
68
|
+
# @return [void]
|
|
69
|
+
def center
|
|
70
|
+
self.rect = rect.centered(screen.size)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Integer] max height the popup will grow to fit its content,
|
|
74
|
+
# defaults to 12. Override in a subclass to allow taller popups.
|
|
75
|
+
def max_height = 12
|
|
76
|
+
|
|
77
|
+
# Sets the popup's content and auto-sizes the popup to fit.
|
|
78
|
+
# @param new_content [Component, nil]
|
|
79
|
+
def content=(new_content)
|
|
80
|
+
super
|
|
81
|
+
update_rect unless new_content.nil?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Hint for the status bar: own "q Close" plus the wrapped content's hint.
|
|
85
|
+
# @return [String]
|
|
86
|
+
def keyboard_hint
|
|
87
|
+
prefix = "q #{Rainbow("Close").cadetblue}"
|
|
88
|
+
child_hint = @content&.keyboard_hint.to_s
|
|
89
|
+
child_hint.empty? ? prefix : "#{prefix} #{child_hint}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @param key [String]
|
|
93
|
+
# @return [Boolean] true if the key was handled.
|
|
94
|
+
def handle_key(key)
|
|
95
|
+
return true if super
|
|
96
|
+
|
|
97
|
+
if [Keys::ESC, "q"].include?(key)
|
|
98
|
+
close
|
|
99
|
+
true
|
|
100
|
+
else
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
protected
|
|
106
|
+
|
|
107
|
+
# Content fills the popup's full rect — Popup has no border to subtract.
|
|
108
|
+
# @param content [Component]
|
|
109
|
+
# @return [void]
|
|
110
|
+
def layout(content)
|
|
111
|
+
content.rect = rect
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
# Recompute width/height from {#content}'s natural size and recenter
|
|
117
|
+
# if currently open. Called whenever content is (re)assigned.
|
|
118
|
+
# @return [void]
|
|
119
|
+
def update_rect
|
|
120
|
+
size = @content.content_size.clamp_height(max_height)
|
|
121
|
+
size = size.clamp(Size.new(screen.size.width * 4 / 5, screen.size.height * 4 / 5))
|
|
122
|
+
self.rect = Rect.new(-1, -1, size.width, size.height)
|
|
123
|
+
center if open?
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A single-line text input field with hardware-cursor caret.
|
|
6
|
+
#
|
|
7
|
+
# The field does not scroll. Any keystroke that would make {#text} longer
|
|
8
|
+
# than `rect.width - 1` (the last column is reserved for the caret past the
|
|
9
|
+
# last char) is rejected.
|
|
10
|
+
#
|
|
11
|
+
# The caret is a logical index in `0..text.length`. The hardware cursor is
|
|
12
|
+
# positioned by {Screen} after each repaint cycle when this component is
|
|
13
|
+
# focused; see {Component#cursor_position}.
|
|
14
|
+
class TextField < Component
|
|
15
|
+
def initialize
|
|
16
|
+
super
|
|
17
|
+
@text = +""
|
|
18
|
+
@caret = 0
|
|
19
|
+
@on_escape = nil
|
|
20
|
+
@on_change = nil
|
|
21
|
+
@on_key_up = nil
|
|
22
|
+
@on_key_down = nil
|
|
23
|
+
@on_enter = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [String] current text contents.
|
|
27
|
+
attr_reader :text
|
|
28
|
+
|
|
29
|
+
# @return [Integer] caret index in `0..text.length`.
|
|
30
|
+
attr_reader :caret
|
|
31
|
+
|
|
32
|
+
# Optional callback fired when ESC is pressed. When set, ESC is consumed
|
|
33
|
+
# by the field; when nil, ESC falls through to the parent (default
|
|
34
|
+
# behavior).
|
|
35
|
+
# @return [Proc, Method, nil] no-arg callable, or nil.
|
|
36
|
+
attr_accessor :on_escape
|
|
37
|
+
|
|
38
|
+
# Optional callback fired whenever {#text} changes. Receives the new text
|
|
39
|
+
# as a single argument. Not fired by {#caret=} (text unchanged) and not
|
|
40
|
+
# fired when a setter is a no-op.
|
|
41
|
+
# @return [Proc, Method, nil] one-arg callable, or nil.
|
|
42
|
+
attr_accessor :on_change
|
|
43
|
+
|
|
44
|
+
# Optional callback fired when the UP arrow key is pressed. When set, UP
|
|
45
|
+
# is consumed by the field; when nil, UP falls through to the parent
|
|
46
|
+
# (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
|
|
47
|
+
# since `k` is a printable character inserted into {#text}.
|
|
48
|
+
# @return [Proc, Method, nil] no-arg callable, or nil.
|
|
49
|
+
attr_accessor :on_key_up
|
|
50
|
+
|
|
51
|
+
# Optional callback fired when the DOWN arrow key is pressed. When set,
|
|
52
|
+
# DOWN is consumed by the field; when nil, DOWN falls through to the
|
|
53
|
+
# parent (default behavior). Only triggered by {Keys::DOWN_ARROW}, not by
|
|
54
|
+
# `j`, since `j` is a printable character inserted into {#text}.
|
|
55
|
+
# @return [Proc, Method, nil] no-arg callable, or nil.
|
|
56
|
+
attr_accessor :on_key_down
|
|
57
|
+
|
|
58
|
+
# Optional callback fired when ENTER is pressed. When set, ENTER is
|
|
59
|
+
# consumed by the field; when nil, ENTER falls through to the parent
|
|
60
|
+
# (default behavior).
|
|
61
|
+
# @return [Proc, Method, nil] no-arg callable, or nil.
|
|
62
|
+
attr_accessor :on_enter
|
|
63
|
+
|
|
64
|
+
# Sets the text. Truncates to fit if longer than `rect.width - 1`. Caret
|
|
65
|
+
# is clamped to the new text length.
|
|
66
|
+
# @param new_text [String]
|
|
67
|
+
def text=(new_text)
|
|
68
|
+
new_text = new_text.to_s
|
|
69
|
+
new_text = new_text[0, max_text_length] if new_text.length > max_text_length
|
|
70
|
+
return if @text == new_text
|
|
71
|
+
|
|
72
|
+
@text = +new_text
|
|
73
|
+
@caret = @caret.clamp(0, @text.length)
|
|
74
|
+
invalidate
|
|
75
|
+
@on_change&.call(@text)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Sets the caret position. Clamped to `0..text.length`.
|
|
79
|
+
# @param new_caret [Integer]
|
|
80
|
+
def caret=(new_caret)
|
|
81
|
+
new_caret = new_caret.clamp(0, @text.length)
|
|
82
|
+
return if @caret == new_caret
|
|
83
|
+
|
|
84
|
+
@caret = new_caret
|
|
85
|
+
invalidate
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def focusable? = true
|
|
89
|
+
|
|
90
|
+
# @return [Point, nil]
|
|
91
|
+
def cursor_position
|
|
92
|
+
return nil unless rect.width.positive?
|
|
93
|
+
|
|
94
|
+
Point.new(rect.left + @caret, rect.top)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @param key [String]
|
|
98
|
+
# @return [Boolean]
|
|
99
|
+
def handle_key(key)
|
|
100
|
+
return false unless active?
|
|
101
|
+
return true if super
|
|
102
|
+
|
|
103
|
+
case key
|
|
104
|
+
when Keys::LEFT_ARROW then self.caret = @caret - 1
|
|
105
|
+
when Keys::RIGHT_ARROW then self.caret = @caret + 1
|
|
106
|
+
when Keys::HOME then self.caret = 0
|
|
107
|
+
when Keys::END_ then self.caret = @text.length
|
|
108
|
+
when *Keys::BACKSPACES then delete_before_caret
|
|
109
|
+
when Keys::DELETE then delete_at_caret
|
|
110
|
+
when Keys::ESC
|
|
111
|
+
return false if @on_escape.nil?
|
|
112
|
+
|
|
113
|
+
@on_escape.call
|
|
114
|
+
when Keys::UP_ARROW
|
|
115
|
+
return false if @on_key_up.nil?
|
|
116
|
+
|
|
117
|
+
@on_key_up.call
|
|
118
|
+
when Keys::DOWN_ARROW
|
|
119
|
+
return false if @on_key_down.nil?
|
|
120
|
+
|
|
121
|
+
@on_key_down.call
|
|
122
|
+
when Keys::ENTER
|
|
123
|
+
return false if @on_enter.nil?
|
|
124
|
+
|
|
125
|
+
@on_enter.call
|
|
126
|
+
else
|
|
127
|
+
return insert(key) if printable?(key)
|
|
128
|
+
|
|
129
|
+
return false
|
|
130
|
+
end
|
|
131
|
+
true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @param event [MouseEvent]
|
|
135
|
+
# @return [void]
|
|
136
|
+
def handle_mouse(event)
|
|
137
|
+
super
|
|
138
|
+
return unless event.button == :left && rect.contains?(event.point)
|
|
139
|
+
|
|
140
|
+
self.caret = (event.x - rect.left).clamp(0, @text.length)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @return [void]
|
|
144
|
+
def repaint
|
|
145
|
+
clear_background
|
|
146
|
+
return if rect.empty?
|
|
147
|
+
|
|
148
|
+
screen.print TTY::Cursor.move_to(rect.left, rect.top), @text
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
protected
|
|
152
|
+
|
|
153
|
+
# @return [void]
|
|
154
|
+
def on_width_changed
|
|
155
|
+
super
|
|
156
|
+
return if @text.length <= max_text_length
|
|
157
|
+
|
|
158
|
+
@text = @text[0, [max_text_length, 0].max]
|
|
159
|
+
@caret = @caret.clamp(0, @text.length)
|
|
160
|
+
@on_change&.call(@text)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
# Maximum number of characters {#text} can hold given current width.
|
|
166
|
+
# @return [Integer]
|
|
167
|
+
def max_text_length = (rect.width - 1).clamp(0, nil)
|
|
168
|
+
|
|
169
|
+
# @param char [String]
|
|
170
|
+
# @return [Boolean]
|
|
171
|
+
def insert(char)
|
|
172
|
+
return false if @text.length >= max_text_length
|
|
173
|
+
|
|
174
|
+
@text = @text.dup.insert(@caret, char)
|
|
175
|
+
@caret += 1
|
|
176
|
+
invalidate
|
|
177
|
+
@on_change&.call(@text)
|
|
178
|
+
true
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @return [void]
|
|
182
|
+
def delete_before_caret
|
|
183
|
+
return if @caret.zero?
|
|
184
|
+
|
|
185
|
+
@text = @text.dup
|
|
186
|
+
@text.slice!(@caret - 1)
|
|
187
|
+
@caret -= 1
|
|
188
|
+
invalidate
|
|
189
|
+
@on_change&.call(@text)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @return [void]
|
|
193
|
+
def delete_at_caret
|
|
194
|
+
return if @caret >= @text.length
|
|
195
|
+
|
|
196
|
+
@text = @text.dup
|
|
197
|
+
@text.slice!(@caret)
|
|
198
|
+
invalidate
|
|
199
|
+
@on_change&.call(@text)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# @param key [String]
|
|
203
|
+
# @return [Boolean]
|
|
204
|
+
def printable?(key)
|
|
205
|
+
key.length == 1 && key.ord >= 0x20 && key.ord < 0x7f
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A window with a frame, a {#caption} and a content {Component}. Doesn't
|
|
6
|
+
# support overlapping with other windows: it paints its entire contents and
|
|
7
|
+
# doesn't clip if there are other overlapping windows.
|
|
8
|
+
#
|
|
9
|
+
# The window's `content` is unset by default; assign one via {#content=}.
|
|
10
|
+
#
|
|
11
|
+
# Window is considered invisible if {#rect} is empty or one of left/top is
|
|
12
|
+
# negative. The window won't draw when invisible.
|
|
13
|
+
class Window < Component
|
|
14
|
+
include Component::HasContent
|
|
15
|
+
|
|
16
|
+
# @param caption [String]
|
|
17
|
+
def initialize(caption = "")
|
|
18
|
+
super()
|
|
19
|
+
@border_right = 1
|
|
20
|
+
@caption = caption
|
|
21
|
+
# Optional bottom-row chrome that overlays the bottom border (e.g. a
|
|
22
|
+
# search field).
|
|
23
|
+
@footer = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def focusable? = true
|
|
27
|
+
|
|
28
|
+
# @return [Component, nil] optional component overlaying the bottom border
|
|
29
|
+
# row.
|
|
30
|
+
attr_reader :footer
|
|
31
|
+
|
|
32
|
+
# Sets the bottom-row chrome slot. The footer overlays the bottom border at
|
|
33
|
+
# full inner width and is positioned automatically; pass `nil` to remove.
|
|
34
|
+
#
|
|
35
|
+
# Symmetric to {#content=}: validates the new component, swaps parent
|
|
36
|
+
# pointers, invalidates the old/new components and the window border, and
|
|
37
|
+
# repairs focus via {#on_child_removed} if the removed footer held it.
|
|
38
|
+
# @param new_footer [Component, nil]
|
|
39
|
+
def footer=(new_footer)
|
|
40
|
+
unless new_footer.nil? || new_footer.is_a?(Component)
|
|
41
|
+
raise TypeError, "expected Component or nil, got #{new_footer.inspect}"
|
|
42
|
+
end
|
|
43
|
+
return if @footer == new_footer
|
|
44
|
+
if !new_footer.nil? && !new_footer.parent.nil?
|
|
45
|
+
raise ArgumentError, "#{new_footer} already has a parent #{new_footer.parent}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
old = @footer
|
|
49
|
+
old&.parent = nil
|
|
50
|
+
@footer = new_footer
|
|
51
|
+
unless new_footer.nil?
|
|
52
|
+
new_footer.parent = self
|
|
53
|
+
new_footer.invalidate
|
|
54
|
+
layout_footer
|
|
55
|
+
end
|
|
56
|
+
invalidate # repaint border row that the footer covers/uncovers
|
|
57
|
+
on_child_removed(old) unless old.nil?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Array<Component>]
|
|
61
|
+
def children
|
|
62
|
+
@footer.nil? ? super : super + [@footer]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param key [String]
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def handle_key(key)
|
|
68
|
+
return @footer.handle_key(key) if @footer&.active?
|
|
69
|
+
|
|
70
|
+
super
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param event [MouseEvent]
|
|
74
|
+
# @return [void]
|
|
75
|
+
def handle_mouse(event)
|
|
76
|
+
if @footer&.rect&.contains?(event.point)
|
|
77
|
+
@footer.handle_mouse(event)
|
|
78
|
+
else
|
|
79
|
+
super
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @param new_rect [Rect]
|
|
84
|
+
# @return [void]
|
|
85
|
+
def rect=(new_rect)
|
|
86
|
+
super
|
|
87
|
+
layout_footer
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @param value [Boolean]
|
|
91
|
+
# @return [void]
|
|
92
|
+
def scrollbar=(value)
|
|
93
|
+
unless content.is_a?(Component::List)
|
|
94
|
+
raise Tuile::Error,
|
|
95
|
+
"scrollbar= requires a Component::List as content, got #{content.inspect}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
content.scrollbar_visibility = value ? :visible : :gone
|
|
99
|
+
@border_right = value ? 0 : 1
|
|
100
|
+
invalidate
|
|
101
|
+
layout(content)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [String] the current caption, empty by default.
|
|
105
|
+
attr_reader :caption
|
|
106
|
+
|
|
107
|
+
# Sets new caption and repaints the window.
|
|
108
|
+
# @param new_caption [String]
|
|
109
|
+
def caption=(new_caption)
|
|
110
|
+
@caption = new_caption
|
|
111
|
+
invalidate
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [Size] the size needed to fit the window's content, footer
|
|
115
|
+
# (width only — footer overlays the bottom border), and caption,
|
|
116
|
+
# plus the 2-character border. Returns {Size}`.new(2, 2)` when the
|
|
117
|
+
# window has no content, footer, or caption.
|
|
118
|
+
def content_size
|
|
119
|
+
inner_w = [
|
|
120
|
+
content&.content_size&.width || 0,
|
|
121
|
+
@footer&.content_size&.width || 0,
|
|
122
|
+
frame_caption.length
|
|
123
|
+
].max
|
|
124
|
+
inner_h = content&.content_size&.height || 0
|
|
125
|
+
Size.new(inner_w + 2, inner_h + 2)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [Boolean] true if {#rect} is off screen and the window won't
|
|
129
|
+
# paint.
|
|
130
|
+
def visible?
|
|
131
|
+
!@rect.empty? && !@rect.top.negative? && !@rect.left.negative?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Fully repaints the window: both frame and contents.
|
|
135
|
+
# @return [void]
|
|
136
|
+
def repaint
|
|
137
|
+
super
|
|
138
|
+
repaint_border
|
|
139
|
+
# Border paints over content: invalidate the content to have it
|
|
140
|
+
# repainted.
|
|
141
|
+
content&.invalidate
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @param key [String, nil]
|
|
145
|
+
# @return [void]
|
|
146
|
+
def key_shortcut=(key)
|
|
147
|
+
super
|
|
148
|
+
# The shortcut key is shown in the caption — repaint.
|
|
149
|
+
invalidate
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
protected
|
|
153
|
+
|
|
154
|
+
# @param content [Component]
|
|
155
|
+
# @return [void]
|
|
156
|
+
def layout(content)
|
|
157
|
+
content.rect = Rect.new(rect.left + 1, rect.top + 1, rect.width - 1 - @border_right, rect.height - 2)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Paints the window border.
|
|
161
|
+
# @return [void]
|
|
162
|
+
def repaint_border
|
|
163
|
+
return unless visible?
|
|
164
|
+
|
|
165
|
+
frame = build_frame(frame_caption)
|
|
166
|
+
frame = Rainbow(frame).green if active?
|
|
167
|
+
screen.print frame
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The caption text as it appears in the rendered border, including the
|
|
171
|
+
# shortcut prefix when {#key_shortcut} is set.
|
|
172
|
+
# @return [String]
|
|
173
|
+
def frame_caption
|
|
174
|
+
c = @caption || ""
|
|
175
|
+
key_shortcut.nil? ? c : "[#{key_shortcut}]-#{c}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Builds the border as a single string with embedded cursor-positioning
|
|
179
|
+
# escapes, mirroring the layout {TTY::Box.frame} used to produce. Title
|
|
180
|
+
# is clipped to fit the inner width so the box never overflows {#rect}.
|
|
181
|
+
# @param caption [String]
|
|
182
|
+
# @return [String]
|
|
183
|
+
def build_frame(caption)
|
|
184
|
+
w = @rect.width
|
|
185
|
+
h = @rect.height
|
|
186
|
+
top = @rect.top
|
|
187
|
+
left = @rect.left
|
|
188
|
+
inner_w = [w - 2, 0].max
|
|
189
|
+
|
|
190
|
+
title = caption.to_s
|
|
191
|
+
title = title[0, inner_w] if title.length > inner_w
|
|
192
|
+
dashes = "─" * (inner_w - title.length)
|
|
193
|
+
|
|
194
|
+
out = +""
|
|
195
|
+
out << TTY::Cursor.move_to(left, top) << "┌#{title}#{dashes}┐"
|
|
196
|
+
(1..(h - 2)).each do |dy|
|
|
197
|
+
out << TTY::Cursor.move_to(left, top + dy) << "│"
|
|
198
|
+
out << TTY::Cursor.move_to(left + w - 1, top + dy) << "│"
|
|
199
|
+
end
|
|
200
|
+
out << TTY::Cursor.move_to(left, top + h - 1) << "└#{"─" * inner_w}┘" if h >= 2
|
|
201
|
+
out
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
# @return [void]
|
|
207
|
+
def layout_footer
|
|
208
|
+
return if @footer.nil? || rect.empty?
|
|
209
|
+
|
|
210
|
+
width = [rect.width - 2, 0].max
|
|
211
|
+
@footer.rect = Rect.new(rect.left + 1, rect.top + rect.height - 1, width, 1)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|