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.
@@ -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