tuile 0.3.0 → 0.5.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.
@@ -8,8 +8,9 @@ module Tuile
8
8
  #
9
9
  # The window's `content` is unset by default; assign one via {#content=}.
10
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.
11
+ # Window is considered invisible if {#rect} is empty. The window won't
12
+ # draw when invisible. (Repaint of detached windows is short-circuited
13
+ # by {Component#invalidate}; subclasses don't need to re-check.)
13
14
  class Window < Component
14
15
  include Component::HasContent
15
16
 
@@ -125,12 +126,6 @@ module Tuile
125
126
  Size.new(inner_w + 2, inner_h + 2)
126
127
  end
127
128
 
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
129
  # Fully repaints the window: both frame and contents.
135
130
  #
136
131
  # Window deliberately paints over its entire rect (border around the
@@ -143,7 +138,7 @@ module Tuile
143
138
  # cycle.
144
139
  # @return [void]
145
140
  def repaint
146
- return unless visible?
141
+ return if rect.empty?
147
142
 
148
143
  super
149
144
  repaint_border
@@ -168,7 +163,7 @@ module Tuile
168
163
  # Paints the window border.
169
164
  # @return [void]
170
165
  def repaint_border
171
- return unless visible?
166
+ return if rect.empty?
172
167
 
173
168
  frame = build_frame(frame_caption)
174
169
  frame = Rainbow(frame).green if active?
@@ -4,8 +4,10 @@ module Tuile
4
4
  # A UI component which is positioned on the screen and draws characters into
5
5
  # its bounding rectangle (in {#repaint}).
6
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.
7
+ # Painting is gated by attachment: a detached component (one whose {#root}
8
+ # isn't {Screen#pane}) is never enqueued for repaint via {#invalidate}, and
9
+ # any stale invalidation entries are filtered out at drain time. Subclasses
10
+ # can paint freely in {#repaint} without re-asserting attachment.
9
11
  class Component
10
12
  def initialize
11
13
  @rect = Rect.new(0, 0, 0, 0)
@@ -66,9 +68,11 @@ module Tuile
66
68
  # responsibility for {#rect}. Everything else should call super.
67
69
  #
68
70
  # A component must not draw outside of {#rect}.
71
+ #
72
+ # Only called when the component is attached.
69
73
  # @return [void]
70
74
  def repaint
71
- return if rect.empty? || rect.left.negative? || rect.top.negative?
75
+ return if rect.empty?
72
76
  return if children.any? && children_tile_rect?
73
77
 
74
78
  clear_background
@@ -251,8 +255,16 @@ module Tuile
251
255
 
252
256
  # Invalidates the component: {Screen} records this component as
253
257
  # needs-repaint and once all events are processed, will call {#repaint}.
258
+ #
259
+ # No-op when the component is not {#attached?} — a detached component has
260
+ # no place on the screen to paint to, so {Screen} must never end up
261
+ # repainting it. Callers don't need to guard their own `invalidate` calls;
262
+ # mutating a detached component (e.g. setting `lines=` on a {List} sitting
263
+ # inside a closed {Component::Popup}) is silent.
254
264
  # @return [void]
255
265
  def invalidate
266
+ return unless attached?
267
+
256
268
  screen.invalidate(self)
257
269
  end
258
270
 
@@ -47,17 +47,56 @@ module Tuile
47
47
  latch.wait
48
48
  end
49
49
 
50
+ # Schedules `block` to fire on the event-loop thread roughly `fps` times
51
+ # per second, passing a 0-based monotonically increasing tick counter. Use
52
+ # it for animations (e.g. a `/-\|` spinner in a {Component::Label}) or
53
+ # periodic UI refresh from a background task.
54
+ #
55
+ # The returned {Ticker} controls the schedule — call {Ticker#cancel} to
56
+ # stop it.
57
+ #
58
+ # **Errors:** if `block` raises, the {Ticker} cancels itself and the
59
+ # exception flows through the normal event-loop error path — i.e.
60
+ # {Screen#on_error} for the default Tuile setup. Auto-cancel prevents a
61
+ # broken block from spamming `on_error` at the tick rate.
62
+ #
63
+ # Tickers reuse `concurrent-ruby`'s shared timer thread
64
+ # ({Concurrent}.global_timer_set) — adding more tickers does not add more
65
+ # threads, just more work on the shared scheduler.
66
+ #
67
+ # @param fps [Numeric] firings per second, must be positive. Fractional
68
+ # values are fine (`fps: 0.5` ⇒ one tick every two seconds).
69
+ # @yield [tick] called on the event-loop thread each firing.
70
+ # @yieldparam tick [Integer] 0-based monotonically increasing counter.
71
+ # @yieldreturn [void]
72
+ # @return [Ticker]
73
+ def tick(fps, &block)
74
+ raise ArgumentError, "block required" unless block
75
+ unless fps.is_a?(Numeric) && fps.positive?
76
+ raise ArgumentError, "fps must be a positive Numeric, got #{fps.inspect}"
77
+ end
78
+
79
+ Ticker.new(self, fps, block)
80
+ end
81
+
50
82
  # Runs the event loop and blocks. Must be run from at most one thread at the
51
83
  # 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.
84
+ # events; the block is always called from the thread running this function.
54
85
  #
55
- # Any exception raised by block is re-thrown, causing this function to
56
- # terminate.
57
- # @yield [event] called for each non-internal event.
86
+ # Any exception raised by the block is re-thrown, causing this function to
87
+ # terminate. Wrap the block body in `rescue` if you want to handle errors
88
+ # without tearing down the loop — see {Screen#event_loop} for an example.
89
+ #
90
+ # **Procs are yielded too.** A {#submit}ed block arrives as a `Proc` event;
91
+ # the consumer is responsible for invoking it (typically `event.call`).
92
+ # Yielding rather than dispatching inline means a raise inside the
93
+ # submitted block flows through the consumer's `rescue` like any other
94
+ # event-handler error, instead of bypassing it.
95
+ # @yield [event] called for each posted event.
58
96
  # @yieldparam event [Object] a posted event — typically a {KeyEvent},
59
- # {MouseEvent}, {TTYSizeEvent}, {EmptyQueueEvent}, or any object pushed
60
- # via {#post}.
97
+ # {MouseEvent}, {TTYSizeEvent}, {EmptyQueueEvent}, a `Proc` from {#submit},
98
+ # or any object pushed via {#post}. {ErrorEvent}s are not yielded — they
99
+ # terminate the loop directly.
61
100
  # @yieldreturn [void]
62
101
  # @return [void]
63
102
  def run_loop(&)
@@ -152,6 +191,64 @@ module Tuile
152
191
  include Singleton
153
192
  end
154
193
 
194
+ # Handle returned by {EventQueue#tick}. Cancel a running ticker via
195
+ # {#cancel}.
196
+ #
197
+ # Internally wraps a `Concurrent::TimerTask` whose firing posts a single
198
+ # submit-block to the owning {EventQueue}; the user's block therefore
199
+ # always runs on the event-loop thread and may freely mutate UI. If the
200
+ # user block raises, the Ticker auto-cancels and the exception is
201
+ # re-raised so it flows through the loop's normal error handling
202
+ # ({Screen#on_error} for the default Tuile setup).
203
+ class Ticker
204
+ # @param event_queue [EventQueue] queue to dispatch tick calls onto.
205
+ # @param fps [Numeric] firings per second (positive).
206
+ # @param block [Proc] called as `block.call(tick_count)` on each fire.
207
+ def initialize(event_queue, fps, block)
208
+ @event_queue = event_queue
209
+ @block = block
210
+ @tick = 0
211
+ # AtomicBoolean rather than a plain ivar: cancel may run on any
212
+ # thread (caller code, the event-loop thread from inside the block,
213
+ # or the IO executor on an error path), and we want both a CAS-style
214
+ # one-shot guard against double-shutdown and well-defined visibility
215
+ # on non-MRI Rubies.
216
+ @cancelled = Concurrent::AtomicBoolean.new(false)
217
+ @timer = Concurrent::TimerTask.new(execution_interval: 1.0 / fps) do
218
+ @event_queue.submit { fire }
219
+ end
220
+ @timer.execute
221
+ end
222
+
223
+ # @return [Boolean] true once {#cancel} has been called.
224
+ def cancelled? = @cancelled.true?
225
+
226
+ # Stops the ticker. Idempotent and safe to call from any thread,
227
+ # including from inside the tick block. Any tick already queued on the
228
+ # event loop at the moment of cancellation is dropped before the user
229
+ # block runs.
230
+ # @return [void]
231
+ def cancel
232
+ return unless @cancelled.make_true # CAS: only the winner shuts down
233
+
234
+ @timer.shutdown
235
+ end
236
+
237
+ private
238
+
239
+ # Runs on the event-loop thread.
240
+ # @return [void]
241
+ def fire
242
+ return if @cancelled.true?
243
+
244
+ @block.call(@tick)
245
+ @tick += 1
246
+ rescue StandardError
247
+ cancel
248
+ raise
249
+ end
250
+ end
251
+
155
252
  private
156
253
 
157
254
  # @return [void]
@@ -169,8 +266,6 @@ module Tuile
169
266
  # while the loop's own backtrace shows up in the wrapper.
170
267
  raise Tuile::Error, "background event raised: #{event.error.class}: #{event.error.message}"
171
268
  end
172
- elsif event.is_a? Proc
173
- event.call
174
269
  else
175
270
  yield event
176
271
  end
@@ -4,6 +4,10 @@ module Tuile
4
4
  # A "synchronous" event queue – no loop is run, submitted blocks are run right
5
5
  # away and submitted events are thrown away. Intended for testing only.
6
6
  class FakeEventQueue
7
+ def initialize
8
+ @tickers = []
9
+ end
10
+
7
11
  # @return [Boolean]
8
12
  def locked? = true
9
13
  # @return [void]
@@ -27,5 +31,70 @@ module Tuile
27
31
  # @param event [Object]
28
32
  # @return [void]
29
33
  def post(event); end
34
+
35
+ # Mirrors {EventQueue#tick} but timeless: returns a {FakeTicker} that
36
+ # only fires when a test calls {#tick_once}. The `fps` argument is
37
+ # validated the same way the real queue validates it, then discarded —
38
+ # the fake has no clock, so frame cadence is up to the test.
39
+ #
40
+ # @param fps [Numeric] firings per second, must be positive. Validated
41
+ # for parity with {EventQueue#tick}; otherwise unused.
42
+ # @yield [tick] called on each {#tick_once}.
43
+ # @yieldparam tick [Integer] 0-based monotonically increasing counter.
44
+ # @yieldreturn [void]
45
+ # @return [FakeTicker]
46
+ def tick(fps, &block)
47
+ raise ArgumentError, "block required" unless block
48
+ unless fps.is_a?(Numeric) && fps.positive?
49
+ raise ArgumentError, "fps must be a positive Numeric, got #{fps.inspect}"
50
+ end
51
+
52
+ FakeTicker.new(block).tap { |t| @tickers << t }
53
+ end
54
+
55
+ # Test helper: fires every live ticker's user block once and prunes
56
+ # cancelled tickers. No-op when no tickers are registered. Pumps once
57
+ # per call regardless of any ticker's fps — the fake has no clock, so
58
+ # tests pump N frames by calling this N times.
59
+ # @return [void]
60
+ def tick_once
61
+ @tickers.reject!(&:cancelled?)
62
+ @tickers.each(&:fire)
63
+ end
64
+
65
+ # Handle returned by {FakeEventQueue#tick}. Mirrors the public surface of
66
+ # {EventQueue::Ticker} (`cancel`, `cancelled?`) but does not auto-fire —
67
+ # the host {FakeEventQueue} drives firing via {FakeEventQueue#tick_once}.
68
+ class FakeTicker
69
+ # @param block [Proc] called as `block.call(tick_count)` on each {#fire}.
70
+ def initialize(block)
71
+ @block = block
72
+ @tick = 0
73
+ @cancelled = false
74
+ end
75
+
76
+ # @return [Boolean] true once {#cancel} has been called.
77
+ def cancelled? = @cancelled
78
+
79
+ # Marks the ticker cancelled. Idempotent. Subsequent {#fire} calls are
80
+ # no-ops; {FakeEventQueue#tick_once} also prunes the ticker on its next
81
+ # pass.
82
+ # @return [void]
83
+ def cancel
84
+ @cancelled = true
85
+ end
86
+
87
+ # Invokes the user block with the current tick counter, then advances.
88
+ # No-op when {#cancelled?}. Typically driven by
89
+ # {FakeEventQueue#tick_once}; safe to call directly from a test that
90
+ # wants to drive a single ticker.
91
+ # @return [void]
92
+ def fire
93
+ return if @cancelled
94
+
95
+ @block.call(@tick)
96
+ @tick += 1
97
+ end
98
+ end
30
99
  end
31
100
  end
data/lib/tuile/keys.rb CHANGED
@@ -42,19 +42,71 @@ module Tuile
42
42
  # @return [String]
43
43
  PAGE_DOWN = "\e[6~"
44
44
  # @return [String]
45
- BACKSPACE = ""
45
+ BACKSPACE = "\x7f"
46
46
  # @return [String]
47
47
  DELETE = "\e[3~"
48
+
49
+ # Ctrl+letter sends bytes 0x01..0x1a. Note that {CTRL_H} == `"\b"`,
50
+ # {CTRL_I} == {TAB}, {CTRL_J} == `"\n"`, and {CTRL_M} == {ENTER} —
51
+ # terminals deliver these key combinations indistinguishably from the
52
+ # corresponding named keys.
53
+ # @return [String]
54
+ CTRL_A = "\x01"
55
+ # @return [String]
56
+ CTRL_B = "\x02"
57
+ # @return [String]
58
+ CTRL_C = "\x03"
59
+ # @return [String]
60
+ CTRL_D = "\x04"
61
+ # @return [String]
62
+ CTRL_E = "\x05"
63
+ # @return [String]
64
+ CTRL_F = "\x06"
65
+ # @return [String]
66
+ CTRL_G = "\x07"
48
67
  # @return [String]
49
68
  CTRL_H = "\b"
50
- # @return [Array<String>]
51
- BACKSPACES = [BACKSPACE, CTRL_H].freeze
52
69
  # @return [String]
53
- CTRL_U = ""
70
+ CTRL_I = "\t"
71
+ # @return [String]
72
+ CTRL_J = "\n"
73
+ # @return [String]
74
+ CTRL_K = "\x0b"
75
+ # @return [String]
76
+ CTRL_L = "\x0c"
77
+ # @return [String]
78
+ CTRL_M = "\r"
79
+ # @return [String]
80
+ CTRL_N = "\x0e"
81
+ # @return [String]
82
+ CTRL_O = "\x0f"
83
+ # @return [String]
84
+ CTRL_P = "\x10"
54
85
  # @return [String]
55
- CTRL_D = ""
86
+ CTRL_Q = "\x11"
56
87
  # @return [String]
57
- ENTER = "
88
+ CTRL_R = "\x12"
89
+ # @return [String]
90
+ CTRL_S = "\x13"
91
+ # @return [String]
92
+ CTRL_T = "\x14"
93
+ # @return [String]
94
+ CTRL_U = "\x15"
95
+ # @return [String]
96
+ CTRL_V = "\x16"
97
+ # @return [String]
98
+ CTRL_W = "\x17"
99
+ # @return [String]
100
+ CTRL_X = "\x18"
101
+ # @return [String]
102
+ CTRL_Y = "\x19"
103
+ # @return [String]
104
+ CTRL_Z = "\x1a"
105
+
106
+ # @return [Array<String>]
107
+ BACKSPACES = [BACKSPACE, CTRL_H].freeze
108
+ # @return [String]
109
+ ENTER = "\r"
58
110
  # @return [String]
59
111
  TAB = "\t"
60
112
  # The terminal sequence emitted by Shift+Tab in xterm-style terminals
@@ -62,6 +114,22 @@ module Tuile
62
114
  # @return [String]
63
115
  SHIFT_TAB = "\e[Z"
64
116
 
117
+ # True iff `key` is a single printable character — a one-character string
118
+ # whose codepoint is not in Unicode's C (Other) category. Rejects multi-
119
+ # character escape sequences ({UP_ARROW}, mouse events, …), control bytes
120
+ # ({TAB}, {ENTER}, {ESC}, {CTRL_A}..{CTRL_Z}, {BACKSPACE}), and the empty
121
+ # string; accepts ASCII letters/digits/punctuation/space *and* non-ASCII
122
+ # printables like "é".
123
+ #
124
+ # Used by {Screen#register_global_shortcut} to reject keys that would
125
+ # collide with typing, and by {Tuile::Component::TextField} to decide
126
+ # whether to insert a key at the caret.
127
+ # @param key [String]
128
+ # @return [Boolean]
129
+ def self.printable?(key)
130
+ key.length == 1 && !key.match?(/\p{C}/)
131
+ end
132
+
65
133
  # Grabs a key from stdin and returns it. Blocks until the key is obtained.
66
134
  # Reads a full ESC key sequence; see constants above for some values returned
67
135
  # by this function.
@@ -72,11 +140,26 @@ module Tuile
72
140
 
73
141
  # Escape sequence. Try to read more data.
74
142
  begin
75
- # Read 6 chars: mouse events are e.g. `\e[Mxyz`
76
- char += $stdin.read_nonblock(6)
143
+ # Read up to 5 bytes: that's the maximum tail length of any escape
144
+ # sequence Tuile recognizes after the initial \e (X10 mouse `[Mbxy`,
145
+ # CTRL+arrow `[1;5D`, etc.). Reading 6 here would over-read into the
146
+ # next sequence on tight mouse-event bursts — we'd silently steal
147
+ # the next event's leading \e and the rest of it would surface as
148
+ # individual printable keypresses in focused inputs.
149
+ char += $stdin.read_nonblock(5)
77
150
  rescue IO::EAGAINWaitReadable
78
151
  # The "ESC" key pressed => only the \e char is emitted.
152
+ return char
153
+ end
154
+
155
+ # If `read_nonblock` returned a partial X10 mouse-report prefix (the
156
+ # sequence is fixed-length: 3 bytes after `\e[M`), drain the remainder
157
+ # with a blocking read so the parser downstream sees a complete event
158
+ # instead of leaking tail bytes as keypresses.
159
+ if char.start_with?("\e[M") && char.bytesize < 6
160
+ char += $stdin.read(6 - char.bytesize)
79
161
  end
162
+
80
163
  char
81
164
  end
82
165
  end
@@ -5,7 +5,7 @@ module Tuile
5
5
  #
6
6
  # @!attribute [r] button
7
7
  # @return [Symbol, nil] one of `:left`, `:middle`, `:right`, `:scroll_up`,
8
- # `:scroll_down`; `nil` if not known.
8
+ # `:scroll_down`, `:scroll_left`, `:scroll_right`; `nil` if not known.
9
9
  # @!attribute [r] x
10
10
  # @return [Integer] x coordinate, 0-based.
11
11
  # @!attribute [r] y
@@ -14,17 +14,34 @@ module Tuile
14
14
  # @return [Point] the event's position.
15
15
  def point = Point.new(x, y)
16
16
 
17
- # Checks whether given key is a mouse event key
17
+ # Checks whether given key is a mouse event key. Returns true on the X10
18
+ # `\e[M` prefix regardless of length — {.parse} is the place that
19
+ # validates the full 6-byte shape and raises on malformed input.
18
20
  # @param key [String] key read via {Keys.getkey}
19
21
  # @return [Boolean] true if it is a mouse event
20
22
  def self.mouse_event?(key)
21
- key.start_with?("\e[M") && key.size >= 6
23
+ key.start_with?("\e[M")
22
24
  end
23
25
 
26
+ # Parses an X10 mouse report (`\e[M` + 3 bytes: button, x, y).
27
+ #
28
+ # Raises {Tuile::Error} when `key` starts with the mouse prefix but is
29
+ # not exactly 6 bytes long. Both shorter and longer inputs are bugs in
30
+ # the upstream key-reader: a shorter prefix means the tail was lost on
31
+ # the way in, and a longer one means we over-consumed into the next
32
+ # escape sequence. We refuse to silently truncate either case because
33
+ # the trailing `\e` of an over-read corrupts the *next* getkey, and the
34
+ # corruption then surfaces as garbled keystrokes in focused inputs
35
+ # rather than as a parser failure pointing at the actual cause.
24
36
  # @param key [String] key read via {Keys.getkey}
25
- # @return [MouseEvent, nil]
37
+ # @return [MouseEvent, nil] `nil` if `key` is not a mouse event
38
+ # @raise [Tuile::Error] if `key` is a malformed mouse event
26
39
  def self.parse(key)
27
40
  return nil unless mouse_event?(key)
41
+ unless key.bytesize == 6
42
+ raise Tuile::Error,
43
+ "malformed mouse event: expected 6 bytes after \\e[M prefix, got #{key.bytesize}: #{key.inspect}"
44
+ end
28
45
 
29
46
  button = key[3].ord - 32
30
47
  # XTerm reports coordinates 1-based (column N is encoded as N + 32);
@@ -37,6 +54,8 @@ module Tuile
37
54
  when 1 then :middle
38
55
  when 64 then :scroll_up
39
56
  when 65 then :scroll_down
57
+ when 66 then :scroll_left
58
+ when 67 then :scroll_right
40
59
  end
41
60
  MouseEvent.new(button, x, y)
42
61
  end