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,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # A UI component which is positioned on the screen and draws characters into
5
+ # its bounding rectangle (in {#repaint}).
6
+ #
7
+ # Component is considered invisible if {#rect} is empty or one of left/top is
8
+ # negative. The component won't draw when invisible.
9
+ class Component
10
+ def initialize
11
+ @rect = Rect.new(0, 0, 0, 0)
12
+ @active = false
13
+ end
14
+
15
+ # @return [Rect] the rectangle the component occupies on screen.
16
+ attr_reader :rect
17
+
18
+ # Sets new position of the component. This is the absolute component
19
+ # positioning on screen, not a relative positioning relative to component's
20
+ # {#parent}.
21
+ #
22
+ # The component must not stick outside of {#parent}'s rect.
23
+ #
24
+ # The component is invalidated and will paint over the new rectangle. It is
25
+ # parent's job to paint over the old component position.
26
+ # @param new_rect [Rect] new position. Does nothing if the new rectangle is
27
+ # the same as the old one.
28
+ def rect=(new_rect)
29
+ raise TypeError, "expected Rect, got #{new_rect.inspect}" unless new_rect.is_a? Rect
30
+ return if @rect == new_rect
31
+
32
+ prev_width = @rect.width
33
+ @rect = new_rect
34
+ on_width_changed if prev_width != new_rect.width
35
+ invalidate
36
+ end
37
+
38
+ # @return [Screen] the screen which owns this component.
39
+ def screen = Screen.instance
40
+
41
+ # Focuses this component. Equivalent to `screen.focused = self`.
42
+ # @return [void]
43
+ def focus
44
+ screen.focused = self
45
+ end
46
+
47
+ # Repaints the component. Default implementation does nothing.
48
+ #
49
+ # The component must fully draw over {#rect}, and must not draw outside of
50
+ # {#rect}.
51
+ #
52
+ # Tip: use {#clear_background} to clear component background before painting.
53
+ # @return [void]
54
+ def repaint; end
55
+
56
+ # Called when a character is pressed on the keyboard.
57
+ #
58
+ # Also called for inactive components. Inactive component should just return
59
+ # false.
60
+ #
61
+ # Default implementation searches for a component with {#key_shortcut} and
62
+ # focuses it. The shortcut search is suppressed while the focused component
63
+ # owns the hardware cursor (e.g. a {Component::TextField} the user is
64
+ # typing into) so that hotkeys don't steal printable keys from the editor.
65
+ # @param key [String] a key.
66
+ # @return [Boolean] true if the key was handled, false if not.
67
+ def handle_key(key)
68
+ return false unless screen.cursor_position.nil?
69
+
70
+ c = find_shortcut_component(key)
71
+ if !c.nil?
72
+ screen.focused = c
73
+ true
74
+ else
75
+ false
76
+ end
77
+ end
78
+
79
+ # A global keyboard shortcut. When pressed, will focus this component.
80
+ # @return [String, nil] shortcut, `nil` by default.
81
+ attr_accessor :key_shortcut
82
+
83
+ # @param key [String] keyboard key to look up.
84
+ # @return [Component, nil] the component whose {#key_shortcut} matches `key`,
85
+ # or nil.
86
+ def find_shortcut_component(key)
87
+ return self if key_shortcut == key
88
+
89
+ children.each do |child|
90
+ sc = child.find_shortcut_component(key)
91
+ return sc unless sc.nil?
92
+ end
93
+ nil
94
+ end
95
+
96
+ # Handles mouse event. Default implementation focuses this component when
97
+ # clicked (if {#focusable?}).
98
+ # @param event [MouseEvent]
99
+ # @return [void]
100
+ def handle_mouse(event)
101
+ screen.focused = self unless event.button != :left || active? || !focusable?
102
+ end
103
+
104
+ # @return [Boolean] true if the component is on the active chain — i.e. it
105
+ # is the focused component or an ancestor of it. Set by {Screen#focused=}.
106
+ def active? = @active
107
+
108
+ # @param active [Boolean] true if active. Set by {Screen#focused=} as it
109
+ # marks the focus chain (root → focused); not meant to be called directly.
110
+ # @return [void]
111
+ def active=(active)
112
+ active = active ? true : false
113
+ return unless @active != active
114
+
115
+ @active = active
116
+ invalidate
117
+ end
118
+
119
+ # Whether this component is a valid focus target. `false` by default —
120
+ # passive components like {Label} are decoration and don't accept focus.
121
+ # The flag gates click-to-focus ({#handle_mouse}) and the focus-cascade
122
+ # in container components ({HasContent#on_focus}, {Layout#on_focus}).
123
+ # Independent from {#active?}: every component carries the active flag, but
124
+ # only focusable ones can become a focus target that puts themselves and
125
+ # their ancestors on the active chain.
126
+ # @return [Boolean] true if this component can be focused.
127
+ def focusable? = false
128
+
129
+ # @return [Component, nil] the parent component or nil if the component has
130
+ # no parent.
131
+ attr_reader :parent
132
+
133
+ # @return [Integer] the distance from the root component; 0 if {#parent}
134
+ # is nil.
135
+ def depth = parent.nil? ? 0 : parent.depth + 1
136
+
137
+ # @return [Component] the root component of this component hierarchy.
138
+ def root = parent.nil? ? self : parent.root
139
+
140
+ # List of child components, defaults to an empty array.
141
+ # @return [Array<Component>] child components. Must not be mutated! May be
142
+ # empty.
143
+ def children = []
144
+
145
+ # Calls block for this component and for every descendant component.
146
+ # @yield [component]
147
+ # @yieldparam component [Component]
148
+ # @yieldreturn [void]
149
+ # @return [void]
150
+ def on_tree(&block)
151
+ block.call(self)
152
+ children.each { it.on_tree(&block) }
153
+ end
154
+
155
+ # Called when the component receives focus.
156
+ # @return [void]
157
+ def on_focus; end
158
+
159
+ # @return [Boolean] true if this component's tree is currently mounted on
160
+ # the {Screen}, i.e. its root is the {ScreenPane}.
161
+ def attached? = root == screen.pane
162
+
163
+ # Called by container components after `child` has been detached from
164
+ # `self.children` (its `parent` is already nil and it is no longer in the
165
+ # children list). Default behavior repairs dangling focus: if the focused
166
+ # component lived inside the removed subtree, focus shifts to `self` so the
167
+ # cursor doesn't dangle on a detached component. No-op if `self` is not
168
+ # attached to the screen — focus state in a detached subtree is moot.
169
+ # @param child [Component] the just-detached child.
170
+ # @return [void]
171
+ def on_child_removed(child)
172
+ return unless attached?
173
+
174
+ f = screen.focused
175
+ return if f.nil?
176
+
177
+ cursor = f
178
+ until cursor.nil?
179
+ if cursor == child
180
+ screen.focused = self
181
+ return
182
+ end
183
+ cursor = cursor.parent
184
+ end
185
+ end
186
+
187
+ # The {Size} big enough to show the entire component contents without
188
+ # scrolling. Plain components have no intrinsic content and report
189
+ # {Size::ZERO}; container/decorative components (e.g. {Label}, {List},
190
+ # {Layout}, {Window}) override this to fold in their content's natural
191
+ # extent. Used by callers like {Component::Popup} to auto-size to
192
+ # whatever content was assigned, regardless of its concrete type.
193
+ # @return [Size]
194
+ def content_size = Size::ZERO
195
+
196
+ # Where the hardware terminal cursor should sit when this component is the
197
+ # cursor owner. Returns `nil` to indicate the cursor should be hidden. The
198
+ # {Screen} positions the hardware cursor after each repaint cycle by
199
+ # consulting the {Screen#focused} component only.
200
+ # @return [Point, nil] absolute screen coordinates, or nil to hide.
201
+ def cursor_position = nil
202
+
203
+ # @return [String] formatted keyboard hint surfaced in the status bar by
204
+ # {Screen} when this component is the active tiled window or the
205
+ # topmost popup. Empty by default; override to advertise shortcuts.
206
+ def keyboard_hint = ""
207
+
208
+ protected
209
+
210
+ # @param parent [Component, nil]
211
+ attr_writer :parent
212
+
213
+ # Called whenever the component width changes. Does nothing by default.
214
+ # @return [void]
215
+ def on_width_changed; end
216
+
217
+ # Invalidates the component: {Screen} records this component as
218
+ # needs-repaint and once all events are processed, will call {#repaint}.
219
+ # @return [void]
220
+ def invalidate
221
+ screen.invalidate(self)
222
+ end
223
+
224
+ # Clears the background: prints spaces into all characters occupied by the
225
+ # component's rect.
226
+ # @return [void]
227
+ def clear_background
228
+ return if rect.empty?
229
+
230
+ spaces = " " * rect.width
231
+ (rect.top..(rect.top + rect.height - 1)).each do |row|
232
+ screen.print TTY::Cursor.move_to(rect.left, row), spaces
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # An event queue. The idea is that all UI-related updates run from the thread
5
+ # which runs the event queue only; this removes any need for locking and/or
6
+ # need for thread-safety mechanisms.
7
+ #
8
+ # Any events (keypress, timer, term resize – WINCH) are captured in background
9
+ # threads; instead of processing the events directly the events are pushed
10
+ # into the event queue: this causes the events to be processed centrally,
11
+ # by a single thread only.
12
+ class EventQueue
13
+ # @param listen_for_keys [Boolean] if true, fires {KeyEvent}.
14
+ def initialize(listen_for_keys: true)
15
+ @queue = Thread::Queue.new
16
+ @listen_for_keys = listen_for_keys
17
+ @run_lock = Mutex.new
18
+ end
19
+
20
+ # Posts event into the event queue. The event may be of any type. Since the
21
+ # event is passed between threads, the event object should be frozen.
22
+ #
23
+ # The function may be called from any thread.
24
+ # @param event [Object] the event to post to the queue, should be frozen.
25
+ # @return [void]
26
+ def post(event)
27
+ raise ArgumentError, "event passed across threads must be frozen, got #{event.inspect}" unless event.frozen?
28
+
29
+ @queue << event
30
+ end
31
+
32
+ # Submits block to be run in the event queue. Returns immediately.
33
+ #
34
+ # The function may be called from any thread.
35
+ # @yield called from the event-loop thread.
36
+ # @yieldreturn [void]
37
+ # @return [void]
38
+ def submit(&block)
39
+ @queue << block
40
+ end
41
+
42
+ # Awaits until the event queue is empty (all events have been processed).
43
+ # @return [void]
44
+ def await_empty
45
+ latch = Concurrent::CountDownLatch.new(1)
46
+ submit { latch.count_down }
47
+ latch.wait
48
+ end
49
+
50
+ # Runs the event loop and blocks. Must be run from at most one thread at the
51
+ # same time. Blocks until some thread calls {#stop}. Calls block for all
52
+ # events submitted via {#post}; the block is always called from the thread
53
+ # running this function.
54
+ #
55
+ # Any exception raised by block is re-thrown, causing this function to
56
+ # terminate.
57
+ # @yield [event] called for each non-internal event.
58
+ # @yieldparam event [Object] a posted event — typically a {KeyEvent},
59
+ # {MouseEvent}, {TTYSizeEvent}, {EmptyQueueEvent}, or any object pushed
60
+ # via {#post}.
61
+ # @yieldreturn [void]
62
+ # @return [void]
63
+ def run_loop(&)
64
+ raise ArgumentError, "run_loop requires a block" unless block_given?
65
+
66
+ @run_lock.synchronize do
67
+ start_key_thread if @listen_for_keys
68
+ begin
69
+ trap_winch
70
+ event_loop(&)
71
+ ensure
72
+ Signal.trap("WINCH", "SYSTEM_DEFAULT")
73
+ @key_thread&.kill
74
+ @queue.clear
75
+ end
76
+ end
77
+ end
78
+
79
+ # @return [Boolean] true if this thread is running inside an event queue.
80
+ def locked? = @run_lock.owned?
81
+
82
+ # Stops ongoing {#run_loop}. The stop may not be immediate: {#run_loop} may
83
+ # process a bunch of events before terminating.
84
+ #
85
+ # Can be called from any thread, including the thread which runs the event
86
+ # loop.
87
+ # @return [void]
88
+ def stop
89
+ @queue.clear
90
+ post(nil)
91
+ end
92
+
93
+ # A keypress event. See {Keys} for a list of key codes.
94
+ #
95
+ # @!attribute [r] key
96
+ # @return [String] key code.
97
+ class KeyEvent < Data.define(:key)
98
+ end
99
+
100
+ # An error event, causes {EventQueue#run_loop} to throw `StandardError` with
101
+ # {#error} as its origin.
102
+ #
103
+ # @!attribute [r] error
104
+ # @return [StandardError] the underlying error.
105
+ class ErrorEvent < Data.define(:error)
106
+ end
107
+
108
+ # TTY has been resized. Contains the current width and height of the TTY
109
+ # terminal.
110
+ #
111
+ # @!attribute [r] width
112
+ # @return [Integer] terminal width in columns.
113
+ # @!attribute [r] height
114
+ # @return [Integer] terminal height in rows.
115
+ class TTYSizeEvent < Data.define(:width, :height)
116
+ # @param width [Integer]
117
+ # @param height [Integer]
118
+ def initialize(width:, height:)
119
+ super
120
+ return unless !width.is_a?(Integer) || !height.is_a?(Integer) || width.negative? || height.negative?
121
+
122
+ raise ArgumentError, "TTY size must be non-negative integers, got #{width.inspect} x #{height.inspect}"
123
+ end
124
+
125
+ # @return [TTYSizeEvent] event with current TTY size.
126
+ def self.create
127
+ height, width = TTY::Screen.size
128
+ TTYSizeEvent.new(width, height)
129
+ end
130
+
131
+ # @return [Size]
132
+ def size = Size.new(width, height)
133
+ end
134
+
135
+ # Emitted once when the queue is cleared, all messages are processed and the
136
+ # event loop will block waiting for more messages. Perfect time for
137
+ # repainting windows.
138
+ class EmptyQueueEvent
139
+ include Singleton
140
+ end
141
+
142
+ private
143
+
144
+ # @return [void]
145
+ def event_loop
146
+ loop do
147
+ yield EmptyQueueEvent.instance if @queue.empty?
148
+ event = @queue.pop
149
+ break if event.nil?
150
+
151
+ if event.is_a? ErrorEvent
152
+ begin
153
+ raise event.error
154
+ rescue StandardError
155
+ # Re-raise wrapped so the original error is preserved as `cause`
156
+ # while the loop's own backtrace shows up in the wrapper.
157
+ raise Tuile::Error, "background event raised: #{event.error.class}: #{event.error.message}"
158
+ end
159
+ elsif event.is_a? Proc
160
+ event.call
161
+ else
162
+ yield event
163
+ end
164
+ end
165
+ end
166
+
167
+ # Starts listening for stdin, firing {KeyEvent} on keypress.
168
+ # @return [void]
169
+ def start_key_thread
170
+ @key_thread = Thread.new do
171
+ loop do
172
+ key = Keys.getkey
173
+ event = MouseEvent.parse(key)
174
+ event = KeyEvent.new(key) if event.nil?
175
+ post event
176
+ end
177
+ rescue StandardError => e
178
+ post ErrorEvent.new(e)
179
+ end
180
+ end
181
+
182
+ # Trap the WINCH signal (TTY resize signal) and fire {TTYSizeEvent}.
183
+ # @return [void]
184
+ def trap_winch
185
+ Signal.trap("WINCH") do
186
+ post TTYSizeEvent.create
187
+ rescue StandardError => e
188
+ post ErrorEvent.new(e)
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # A "synchronous" event queue – no loop is run, submitted blocks are run right
5
+ # away and submitted events are thrown away. Intended for testing only.
6
+ class FakeEventQueue
7
+ # @return [Boolean]
8
+ def locked? = true
9
+ # @return [void]
10
+ def stop; end
11
+
12
+ # @return [void]
13
+ def run_loop
14
+ raise Tuile::Error, "FakeEventQueue does not run an event loop"
15
+ end
16
+
17
+ # @return [void]
18
+ def await_empty; end
19
+
20
+ # @yield runs the block synchronously.
21
+ # @yieldreturn [void]
22
+ # @return [void]
23
+ def submit
24
+ yield
25
+ end
26
+
27
+ # @param event [Object]
28
+ # @return [void]
29
+ def post(event); end
30
+ end
31
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # Testing only — a screen which doesn't paint anything and pretends that the
5
+ # lock is held. This way, the TTY running the tests is not painted over.
6
+ #
7
+ # Intended for unit-testing individual components: instantiate a component,
8
+ # mutate it, and assert against {#prints} or {#invalidated?}. It does not
9
+ # run an event loop, so it is *not* suitable for system-testing whole apps
10
+ # — for that, drive the real script through a PTY (see `spec/examples/`).
11
+ #
12
+ # Call {Screen.fake} to initialize the fake screen easily. Typical usage:
13
+ #
14
+ # before { Screen.fake }
15
+ # after { Screen.close }
16
+ #
17
+ # it "paints its content" do
18
+ # label = Component::Label.new.tap { |l| l.text = "hi" }
19
+ # Screen.instance.content = Component::Window.new("Greeting").tap { |w| w.content = label }
20
+ # Screen.instance.repaint
21
+ # assert_includes Screen.instance.prints.join, "hi"
22
+ # end
23
+ class FakeScreen < Screen
24
+ def initialize
25
+ super
26
+ @event_queue = FakeEventQueue.new
27
+ @size = Size.new(160, 50)
28
+ @prints = []
29
+ end
30
+
31
+ # @return [Array<String>] whatever {#print} printed so far.
32
+ attr_reader :prints
33
+
34
+ # @return [void]
35
+ def check_locked; end
36
+
37
+ # @return [void]
38
+ def clear
39
+ @prints.clear
40
+ end
41
+
42
+ # Doesn't print anything: collects all strings in {#prints}.
43
+ # @param args [String]
44
+ # @return [void]
45
+ def print(*args)
46
+ @prints += args
47
+ end
48
+
49
+ # @param component [Component] the component to check.
50
+ # @return [Boolean]
51
+ def invalidated?(component) = @invalidated.include?(component)
52
+
53
+ # @return [void]
54
+ def invalidated_clear
55
+ @invalidated.clear
56
+ end
57
+ end
58
+ end
data/lib/tuile/keys.rb ADDED
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # Constants for keys returned by {.getkey} and helpers for reading them from
5
+ # stdin. The constants are the raw escape sequences emitted by the terminal;
6
+ # see https://en.wikipedia.org/wiki/ANSI_escape_code for the encoding.
7
+ module Keys
8
+ # @return [String]
9
+ DOWN_ARROW = "\e[B"
10
+ # @return [String]
11
+ UP_ARROW = "\e[A"
12
+ # @return [Array<String>]
13
+ DOWN_ARROWS = [DOWN_ARROW, "j"].freeze
14
+ # @return [Array<String>]
15
+ UP_ARROWS = [UP_ARROW, "k"].freeze
16
+ # @return [String]
17
+ LEFT_ARROW = "\e[D"
18
+ # @return [String]
19
+ RIGHT_ARROW = "\e[C"
20
+ # @return [String]
21
+ ESC = "\e"
22
+ # @return [String]
23
+ HOME = "\e[H"
24
+ # @return [String]
25
+ END_ = "\e[F"
26
+ # @return [String]
27
+ PAGE_UP = "\e[5~"
28
+ # @return [String]
29
+ PAGE_DOWN = "\e[6~"
30
+ # @return [String]
31
+ BACKSPACE = "\u007f"
32
+ # @return [String]
33
+ DELETE = "\e[3~"
34
+ # @return [String]
35
+ CTRL_H = "\b"
36
+ # @return [Array<String>]
37
+ BACKSPACES = [BACKSPACE, CTRL_H].freeze
38
+ # @return [String]
39
+ CTRL_U = "\u0015"
40
+ # @return [String]
41
+ CTRL_D = "\u0004"
42
+ # @return [String]
43
+ ENTER = "\u000d"
44
+
45
+ # Grabs a key from stdin and returns it. Blocks until the key is obtained.
46
+ # Reads a full ESC key sequence; see constants above for some values returned
47
+ # by this function.
48
+ # @return [String] key, such as {DOWN_ARROW}.
49
+ def self.getkey
50
+ char = $stdin.getch
51
+ return char unless char == Keys::ESC
52
+
53
+ # Escape sequence. Try to read more data.
54
+ begin
55
+ # Read 6 chars: mouse events are e.g. `\e[Mxyz`
56
+ char += $stdin.read_nonblock(6)
57
+ rescue IO::EAGAINWaitReadable
58
+ # The "ESC" key pressed => only the \e char is emitted.
59
+ end
60
+ char
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # A mouse event.
5
+ #
6
+ # @!attribute [r] button
7
+ # @return [Symbol, nil] one of `:left`, `:middle`, `:right`, `:scroll_up`,
8
+ # `:scroll_down`; `nil` if not known.
9
+ # @!attribute [r] x
10
+ # @return [Integer] x coordinate, 0-based.
11
+ # @!attribute [r] y
12
+ # @return [Integer] y coordinate, 0-based.
13
+ class MouseEvent < Data.define(:button, :x, :y)
14
+ # @return [Point] the event's position.
15
+ def point = Point.new(x, y)
16
+
17
+ # Checks whether given key is a mouse event key
18
+ # @param key [String] key read via {Keys.getkey}
19
+ # @return [Boolean] true if it is a mouse event
20
+ def self.mouse_event?(key)
21
+ key.start_with?("\e[M") && key.size >= 6
22
+ end
23
+
24
+ # @param key [String] key read via {Keys.getkey}
25
+ # @return [MouseEvent, nil]
26
+ def self.parse(key)
27
+ return nil unless mouse_event?(key)
28
+
29
+ button = key[3].ord - 32
30
+ # XTerm reports coordinates 1-based (column N is encoded as N + 32);
31
+ # subtract 33 so that `x` and `y` are 0-based.
32
+ x = key[4].ord - 33
33
+ y = key[5].ord - 33
34
+ button = case button
35
+ when 0 then :left
36
+ when 2 then :right
37
+ when 1 then :middle
38
+ when 64 then :scroll_up
39
+ when 65 then :scroll_down
40
+ end
41
+ MouseEvent.new(button, x, y)
42
+ end
43
+
44
+ # @return [String]
45
+ def self.start_tracking = "\e[?1000h"
46
+ # @return [String]
47
+ def self.stop_tracking = "\e[?1000l"
48
+ end
49
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # A point with `x` and `y` integer coordinates, both 0-based.
5
+ #
6
+ # @!attribute [r] x
7
+ # @return [Integer] x coordinate, 0-based.
8
+ # @!attribute [r] y
9
+ # @return [Integer] y coordinate, 0-based.
10
+ class Point < Data.define(:x, :y)
11
+ # @return [String]
12
+ def to_s = "#{x},#{y}"
13
+ end
14
+ end
data/lib/tuile/rect.rb ADDED
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # A rectangle, with integer `left`, `top`, `width` and `height`, all 0-based.
5
+ #
6
+ # @!attribute [r] left
7
+ # @return [Integer] left edge, 0-based.
8
+ # @!attribute [r] top
9
+ # @return [Integer] top edge, 0-based.
10
+ # @!attribute [r] width
11
+ # @return [Integer] width.
12
+ # @!attribute [r] height
13
+ # @return [Integer] height.
14
+ class Rect < Data.define(:left, :top, :width, :height)
15
+ # @return [String]
16
+ def to_s = "#{top_left} #{size}"
17
+
18
+ # @return [Boolean] true if either {#width} or {#height} is zero or negative.
19
+ def empty?
20
+ width <= 0 || height <= 0
21
+ end
22
+
23
+ # @param point [Point] new top-left corner.
24
+ # @return [Rect] positioned at the new `left`/`top`.
25
+ def at(point)
26
+ Rect.new(point.x, point.y, width, height)
27
+ end
28
+
29
+ # Centers the rectangle — keeps {#width} and {#height} but modifies
30
+ # {#top} and {#left} so that the rectangle is centered on a screen.
31
+ # @param screen_size [Size] screen size
32
+ # @return [Rect] moved rectangle.
33
+ def centered(screen_size)
34
+ at(Point.new((screen_size.width - width) / 2, (screen_size.height - height) / 2))
35
+ end
36
+
37
+ # Clamp both width and height and return a rectangle.
38
+ # @param max_size [Size] the max size
39
+ # @return [Rect]
40
+ def clamp(max_size)
41
+ new_width = width.clamp(nil, max_size.width)
42
+ new_height = height.clamp(nil, max_size.height)
43
+ new_width == width && new_height == height ? self : Rect.new(left, top, new_width, new_height)
44
+ end
45
+
46
+ # @param point [Point]
47
+ # @return [Boolean]
48
+ def contains?(point)
49
+ point.x >= left && point.x < left + width && point.y >= top && point.y < top + height
50
+ end
51
+
52
+ # @return [Size]
53
+ def size = Size.new(width, height)
54
+
55
+ # @return [Point]
56
+ def top_left = Point.new(left, top)
57
+ end
58
+ end