Class: Tuile::EventQueue

Inherits:
Object
  • Object
show all
Defined in:
lib/tuile/event_queue.rb,
sig/tuile.rbs

Overview

An event queue. The idea is that all UI-related updates run from the thread which runs the event queue only; this removes any need for locking and/or need for thread-safety mechanisms.

Any events (keypress, timer, term resize – WINCH) are captured in background threads; instead of processing the events directly the events are pushed into the event queue: this causes the events to be processed centrally, by a single thread only.

Defined Under Namespace

Classes: BackgroundColorEvent, ColorSchemeEvent, EmptyQueueEvent, ErrorEvent, KeyEvent, PasteEvent, TTYSizeEvent, Ticker

Instance Method Summary collapse

Constructor Details

#initialize(listen_for_keys: true) ⇒ EventQueue

@param listen_for_keys — if true, fires KeyEvent.

Parameters:

  • listen_for_keys: (Boolean) (defaults to: true)


14
15
16
17
18
# File 'lib/tuile/event_queue.rb', line 14

def initialize(listen_for_keys: true)
  @queue = Thread::Queue.new
  @listen_for_keys = listen_for_keys
  @run_lock = Mutex.new
end

Instance Method Details

#await_empty ⇒ void

This method returns an undefined value.

Awaits until the event queue is empty (all events have been processed).



47
48
49
50
51
# File 'lib/tuile/event_queue.rb', line 47

def await_empty
  latch = Concurrent::CountDownLatch.new(1)
  submit { latch.count_down }
  latch.wait
end

#event_loop ⇒ void

This method returns an undefined value.



351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
# File 'lib/tuile/event_queue.rb', line 351

def event_loop
  loop do
    yield EmptyQueueEvent.instance if @queue.empty?
    event = @queue.pop
    break if event.nil?

    if event.is_a? ErrorEvent
      begin
        raise event.error
      rescue StandardError
        # Re-raise wrapped so the original error is preserved as `cause`
        # while the loop's own backtrace shows up in the wrapper.
        raise Tuile::Error, "background event raised: #{event.error.class}: #{event.error.message}"
      end
    else
      yield event
    end
  end
end

#in_loop_thread? ⇒ Boolean

@return — true if this thread is the one running #run_loop.

Returns:

  • (Boolean)


153
# File 'lib/tuile/event_queue.rb', line 153

def in_loop_thread? = @run_lock.owned?

#post(event) ⇒ void

This method returns an undefined value.

Posts event into the event queue. The event may be of any type. Since the event is passed between threads, the event object should be frozen.

The function may be called from any thread.

@param event — the event to post to the queue, should be frozen.

Parameters:

  • event (Object)


26
27
28
29
30
# File 'lib/tuile/event_queue.rb', line 26

def post(event)
  raise ArgumentError, "event passed across threads must be frozen, got #{event.inspect}" unless event.frozen?

  @queue << event
end

#run_loop ⇒ void

This method returns an undefined value.

Runs the event loop and blocks. Must be run from at most one thread at the same time. Blocks until some thread calls #stop. Calls block for all events; the block is always called from the thread running this function.

Any exception raised by the block is re-thrown, causing this function to terminate. Wrap the block body in rescue if you want to handle errors without tearing down the loop — see Screen#event_loop for an example.

Procs are yielded too. A #submited block arrives as a Proc event; the consumer is responsible for invoking it (typically event.call). Yielding rather than dispatching inline means a raise inside the submitted block flows through the consumer's rescue like any other event-handler error, instead of bypassing it.



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/tuile/event_queue.rb', line 120

def run_loop(&)
  raise ArgumentError, "run_loop requires a block" unless block_given?

  @run_lock.synchronize do
    start_key_thread if @listen_for_keys
    begin
      trap_winch
      event_loop(&)
    ensure
      Signal.trap("WINCH", "SYSTEM_DEFAULT")
      if @key_thread
        # Kill returns immediately, but the key thread is typically
        # blocked inside $stdin.getch with a termios snapshot saved in
        # io-console's C-level ensure. If we let it run to completion
        # *after* the outer $stdin.raw block has exited (e.g. when an
        # exception is escaping run_event_loop), the late tcsetattr
        # restores raw mode and leaves the terminal with ONLCR off —
        # the stack trace then prints as one un-wrapped soft line.
        # Joining here forces the restore to happen while we're still
        # nested inside $stdin.raw, so raw's own restoration is the
        # final write and the terminal lands in cooked mode.
        @key_thread.kill
        @key_thread.join
      end
      @queue.clear
    end
  end
end

#running? ⇒ Boolean

@return — true if a #run_loop is in progress on any thread.

Returns:

  • (Boolean)


150
# File 'lib/tuile/event_queue.rb', line 150

def running? = @run_lock.locked?

#start_key_thread ⇒ void

This method returns an undefined value.

Starts listening for stdin, firing KeyEvent on keypress.



373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
# File 'lib/tuile/event_queue.rb', line 373

def start_key_thread
  @key_thread = Thread.new do
    loop do
      key = Keys.getkey
      event = if key == Keys::PASTE_START
                PasteEvent.new(Keys.read_paste)
              else
                Mouse.parse(key) || ColorSchemeEvent.parse(key) ||
                  BackgroundColorEvent.parse(key) || KeyEvent.new(key)
              end
      post event
    end
  rescue StandardError => e
    post ErrorEvent.new(e)
  end
end

#stop ⇒ void

This method returns an undefined value.

Stops ongoing #run_loop. The stop may not be immediate: #run_loop may process a bunch of events before terminating.

Can be called from any thread, including the thread which runs the event loop.



161
162
163
164
# File 'lib/tuile/event_queue.rb', line 161

def stop
  @queue.clear
  post(nil)
end

#submit(&block) ⇒ void

This method returns an undefined value.

Submits block to be run in the event queue. Returns immediately.

The function may be called from any thread.

The block runs only while a loop is draining: submitted before the first #run_loop it waits for one, submitted after the last it never runs.



41
42
43
# File 'lib/tuile/event_queue.rb', line 41

def submit(&block)
  @queue << block
end

#tick(seconds, &block) ⇒ void

This method returns an undefined value.

Schedules block to fire on the event-loop thread every seconds, passing a 0-based monotonically increasing tick counter — tick(0.2) fires five times a second. Use it for periodic UI refresh (poll a status, redraw a clock); for animation, #tick_fps reads more naturally. The returned Ticker controls the schedule — Tuile::EventQueue::Ticker#cancel stops it.

Errors: if block raises, the Ticker cancels itself and the exception flows through the normal event-loop error path (Screen#on_error by default) — auto-cancel keeps a broken block from spamming on_error at the tick rate.

Tickers reuse concurrent-ruby's shared timer thread, so adding more tickers doesn't add threads.

@param seconds — interval between firings, must be positive. Fractional values are fine (tick(0.05) ⇒ ~20 firings a second).

Parameters:

  • seconds (Numeric)


73
74
75
76
77
78
79
80
# File 'lib/tuile/event_queue.rb', line 73

def tick(seconds, &block)
  raise ArgumentError, "block required" unless block
  unless seconds.is_a?(Numeric) && seconds.positive?
    raise ArgumentError, "seconds must be a positive Numeric, got #{seconds.inspect}"
  end

  Ticker.new(self, seconds, block)
end

#tick_fps(fps, &block) ⇒ void

This method returns an undefined value.

Frames-per-second convenience over #tick: tick_fps(15) is exactly tick(1.0 / 15). Reads naturally for animation (a /-\| spinner, a progress pulse) where you think in frames, not intervals.

@param fps — firings per second, must be positive. Fractional values are fine (tick_fps(0.5) ⇒ one firing every two seconds).

Parameters:

  • fps (Numeric)


91
92
93
94
95
96
97
98
# File 'lib/tuile/event_queue.rb', line 91

def tick_fps(fps, &block)
  raise ArgumentError, "block required" unless block
  unless fps.is_a?(Numeric) && fps.positive?
    raise ArgumentError, "fps must be a positive Numeric, got #{fps.inspect}"
  end

  tick(1.0 / fps, &block)
end

#trap_winch ⇒ void

This method returns an undefined value.

Trap the WINCH signal (TTY resize signal) and fire TTYSizeEvent.



392
393
394
395
396
397
398
# File 'lib/tuile/event_queue.rb', line 392

def trap_winch
  Signal.trap("WINCH") do
    post TTYSizeEvent.create
  rescue StandardError => e
    post ErrorEvent.new(e)
  end
end