Class: Tuile::Screen

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

Overview

The process-singleton runtime: one Screen per app, reached through Screen.instance. It owns everything the UI needs to exist — the #event_queue, the UI lock, the invalidation set, the terminal IO, the back #buffer, the #theme/#theme_def, the #focused component, the global-shortcut registry, and the single ScreenPane under which all UI lives. Construct one with Screen.new (or Screen.fake in tests), tear it down with Screen.close.

The component tree

Everything on screen hangs off #pane (a ScreenPane): the tiled #content (set via #content=, filling the whole terminal and laying out its own children), the modal/overlay #popups stack (opened via Component::Popup#open, drawn on top of the content), and the bottom status bar. Popups are not sized from their content — each carries its own top-down Component::Popup#size — and they deliberately overdraw the content without clipping.

Repaint model

Components never draw to the terminal directly. They call Component#invalidate to mark themselves dirty, and when they do paint they write styled cells into #buffer. Once the event loop drains its queue, #repaint walks the invalidated set in z-order, has each component paint into the buffer, then flushes the buffer's minimal diff (only cells that changed) to the terminal in one synchronized-output batch — which is what keeps repaint flicker-free and coalesces many invalidations into a single frame per tick. See the book (ch. 2) for the why.

Thread-safety

UI-thread-confined, where "the UI thread" changes hands once: it is the loop's thread while #run_event_loop is in progress, and the thread that created the screen whenever no loop is running (#state :idle). So an app builds its tree on its own thread, hands ownership to the loop, and gets it back for teardown — the loop needn't run on the creating thread. All UI mutations — #content=, #focused=, #theme=, Component#invalidate, rect=, … — obey it via #check_locked.

A worker marshals back with screen.event_queue.submit { … }, which runs the block only while a loop is draining the queue — outside :running it silently never fires. Terminal resize, key/mouse input and OS color-scheme flips arrive as events on that same queue.

The singleton slot survives subclassing (FakeScreen < Screen), so FakeScreen — which captures output in memory — is what Screen.instance returns under test.

Direct Known Subclasses

FakeScreen

Constant Summary collapse

EDITING_KEYS =

Keys #register_global_shortcut refuses because every editable widget needs them: the registry sits above the component tree, so binding one app-wide would silently break text entry everywhere — a Component::TextArea's newline, a caret move, a deletion. ENTER is the trap worth naming: it is unprintable, so nothing else stops it, and "bind Enter to submit" is the obvious wrong way to build a default button. The right way is a handle_key on the form itself, where a focused field still gets first refusal — see Tuile::ScreenPane#handle_key.

Deliberately not reserved: HOME/END/PAGE_UP/PAGE_DOWN. They move within a widget rather than mutate its value, and binding them app-wide (scroll the log pane) is a real use case.

Returns:

  • (Array<String>)
[
  Keys::ENTER, Keys::DELETE, *Keys::BACKSPACES,
  Keys::UP_ARROW, Keys::DOWN_ARROW, Keys::LEFT_ARROW, Keys::RIGHT_ARROW,
  Keys::CTRL_LEFT_ARROW, Keys::CTRL_RIGHT_ARROW
].freeze
@@instance =

Class variable (not class instance var) so the singleton survives subclassing — FakeScreen < Screen and Screen.instance see the same slot.

nil

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeScreen

rubocop:disable Style/ClassVars



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/tuile/screen.rb', line 57

def initialize
  @@instance = self # rubocop:disable Style/ClassVars
  @event_queue = EventQueue.new
  @size = EventQueue::TTYSizeEvent.create.size
  @invalidated = Set.new
  # Components being repainted right now. A component may invalidate its
  # children during its repaint phase; this prevents double-draw.
  @repainting = Set.new
  # The thread that owns the UI whenever no event loop is running — i.e.
  # during :idle, at both ends of the screen's life. See {#check_locked}.
  @ui_thread = Thread.current
  @closed = false
  @color_scheme = detect_scheme
  @theme_def = ThemeDef.default
  @theme = @theme_def.for(@color_scheme)
  # Structural root of the component tree: holds tiled content, popup
  # stack and status bar.
  @pane = ScreenPane.new
  @on_error = ->(e) { raise e }
  # App-level keyboard shortcuts dispatched by {#handle_key} before keys
  # reach the pane. See {#register_global_shortcut}.
  @global_shortcuts = {}
  # The back buffer components paint into. {#repaint} flushes its diff to
  # the terminal, so only changed cells are emitted (flicker-free on any
  # terminal). Sized to the current viewport; {#layout} resizes it.
  @buffer = Buffer.new(@size)
end

Instance Attribute Details

#bufferBuffer (readonly)

@return — the back buffer components paint into (Buffer#set_line / Buffer#fill / Buffer#set_char).

Returns:



118
119
120
# File 'lib/tuile/screen.rb', line 118

def buffer
  @buffer
end

#color_schemeSymbol (readonly)

@return:light or :dark

Returns:

  • (Symbol)


114
115
116
# File 'lib/tuile/screen.rb', line 114

def color_scheme
  @color_scheme
end

#event_queueEventQueue (readonly)

@return — the event queue.

Returns:



221
222
223
# File 'lib/tuile/screen.rb', line 221

def event_queue
  @event_queue
end

#focusedComponent?

@return — currently focused component.

Returns:



278
279
280
# File 'lib/tuile/screen.rb', line 278

def focused
  @focused
end

#on_errorProc

Handler invoked when a StandardError escapes an event handler inside the event loop (e.g. a Component::TextField's on_change raises).

The default re-raises, so the exception propagates out of #run_event_loop and crashes the script with a stacktrace — unhandled exceptions are bugs and should be surfaced loudly.

Replace it when the host has somewhere visible to put errors, e.g. a Component::LogWindow wired to Tuile.logger:

screen.on_error = lambda do |e|
Tuile.logger.error("#{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}")
end

The handler runs on the event-loop thread with the UI lock held. Returning normally keeps the loop alive; raising from within the handler tears the loop down and propagates out of #run_event_loop.

@return — one-arg callable receiving the StandardError instance.

Returns:

  • (Proc)


138
139
140
# File 'lib/tuile/screen.rb', line 138

def on_error
  @on_error
end

#paneScreenPane (readonly)

@return — the structural root of the component tree.

Returns:



111
112
113
# File 'lib/tuile/screen.rb', line 111

def pane
  @pane
end

#sizeSize (readonly)

@return — current screen size.

Returns:



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

def size
  @size
end

#themeTheme

The color Theme built-in components read at paint time: the member of #theme_def matching the terminal background detected at construction (see TerminalBackground.detect; inconclusive means dark). While the event loop runs, terminals supporting mode 2031 push OS appearance changes (EventQueue::ColorSchemeEvent) and the screen re-picks from #theme_def.

Returns:



170
171
172
# File 'lib/tuile/screen.rb', line 170

def theme
  @theme
end

#theme_defThemeDef

The app's ThemeDef — the dark/light Theme pair the screen picks #theme from, at startup and on every OS appearance flip. Starts as ThemeDef.default (ThemeDef::DEFAULT unless reassigned — tests do, see ThemeDef.default=). Assigning a custom definition is the durable way to theme an app: unlike a bare #theme=, it survives the user toggling the OS appearance.

Returns:



179
180
181
# File 'lib/tuile/screen.rb', line 179

def theme_def
  @theme_def
end

Class Method Details

.closevoid

This method returns an undefined value.



559
560
561
# File 'lib/tuile/screen.rb', line 559

def self.close
  @@instance&.close
end

.fakeFakeScreen

Testing only — creates new screen, locks the UI, and prevents any redraws, so that test TTY is not painted over. FakeScreen#initialize self-installs as the singleton, so subsequent instance calls return the same object.

Returns:



529
# File 'lib/tuile/screen.rb', line 529

def self.fake = FakeScreen.new

.instanceScreen

@return — the singleton instance.

Returns:



141
142
143
144
145
# File 'lib/tuile/screen.rb', line 141

def self.instance
  raise Tuile::Error, "Screen not initialized; call Screen.new first" if @@instance.nil?

  @@instance
end

Instance Method Details

#active_windowComponent?

@return — current active tiled component.

Returns:



480
481
482
483
484
485
# File 'lib/tuile/screen.rb', line 480

def active_window
  check_locked
  result = nil
  @pane.content&.on_tree { result = _1 if _1.is_a?(Component::Window) && _1.active? }
  result
end

#add_popup(window) ⇒ void

This method returns an undefined value.

Internal — use Component::Popup#open instead. Adds the popup to #pane, centers and focuses it.

@param window

Parameters:



351
352
353
354
355
356
# File 'lib/tuile/screen.rb', line 351

def add_popup(window)
  check_locked
  @pane.add_popup(window)
  # No need to fully repaint the scene: a popup simply paints over the
  # current screen contents.
end

#check_lockedvoid

This method returns an undefined value.

Raises unless the calling thread currently owns the UI (see the class-level threading contract).

screen.check_locked   # from a worker: raises; wrap the work in
                    # screen.event_queue.submit { ... } instead


243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/tuile/screen.rb', line 243

def check_locked
  raise Tuile::Error, "Screen is closed: no UI mutation is possible after Screen#close" if @closed
  return if @event_queue.running? ? @event_queue.on_loop_thread? : Thread.current.equal?(@ui_thread)

  # `submit` is the wrong remedy with no loop running — nothing would drain
  # the queue, so the block silently never fires.
  message = if @event_queue.running?
              "UI lock not held: UI mutations must run on the event-loop thread; " \
              "marshal via screen.event_queue.submit { ... }"
            else
              "UI not owned by #{Thread.current}: no event loop is running, so UI mutations must " \
              "come from #{@ui_thread}, the thread that created this screen " \
              "(or start the event loop first)"
            end
  raise Tuile::Error, message
end

#clearvoid

This method returns an undefined value.

Clears the TTY screen.



262
263
264
# File 'lib/tuile/screen.rb', line 262

def clear
  print TTY::Cursor.move_to(0, 0), TTY::Cursor.clear_screen
end

#closevoid

This method returns an undefined value.

Tears the screen down and vacates the singleton slot, moving #state to the terminal :closed. Unmounts the tree first, so every component gets its Component#on_detached. Idempotent.



539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
# File 'lib/tuile/screen.rb', line 539

def close
  return if @closed

  raise Tuile::Error, "Screen is running: stop the event loop before closing" if state == :running

  check_locked
  begin
    @pane.detach_all
  ensure
    # A raising on_detached propagates — it's a bug to fix, not something the
    # framework guards — but teardown still has to finish, or one such bug
    # leaves a half-closed screen behind and every later example fails with it.
    clear
    @pane = nil
    @closed = true
    @@instance = nil # rubocop:disable Style/ClassVars
  end
end

#contentComponent?

@return — tiled content (forwarded to Tuile::ScreenPane).

Returns:



148
# File 'lib/tuile/screen.rb', line 148

def content = @pane.content

#content=(content) ⇒ void

This method returns an undefined value.

@param content

Parameters:



152
153
154
155
156
157
158
# File 'lib/tuile/screen.rb', line 152

def content=(content)
  # Not left to ScreenPane#content='s own checks: after #close there's no
  # pane to forward to, and NoMethodError-for-nil is a poor error.
  check_locked
  @pane.content = content
  layout
end

#cursor_positionPoint?

Returns the absolute screen coordinates where the hardware cursor should sit, or nil if it should be hidden. Only the #focused component owns the cursor: there can be multiple active components (the focus path), but only one focused.

Returns:



649
# File 'lib/tuile/screen.rb', line 649

def cursor_position = @focused&.cursor_position

#cursor_sequenceString

The escape sequence positioning the hardware cursor for the current focus state: hidden when nothing owns it, else moved to the focused component's Component#cursor_position and shown. Appended to each frame's flush.

Returns:

  • (String)


706
707
708
709
# File 'lib/tuile/screen.rb', line 706

def cursor_sequence
  pos = cursor_position
  pos.nil? ? TTY::Cursor.hide : "#{TTY::Cursor.move_to(pos.x, pos.y)}#{TTY::Cursor.show}"
end

#cycle_focus(forward:) ⇒ Boolean

Walks the current modal scope in pre-order, collects tab stops, and advances focus by one (wrapping). When the focused component isn't in the tab order (e.g. focus is parked on a popup/window chrome with no interactable widgets), Tab goes to the first stop and Shift+Tab to the last.

@param forward

@return — true if focus moved.

Parameters:

  • forward: (Boolean)

Returns:

  • (Boolean)


681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
# File 'lib/tuile/screen.rb', line 681

def cycle_focus(forward:)
  check_locked
  scope = @pane.modal_popup || @pane.content
  return false if scope.nil?

  stops = []
  scope.on_tree { |c| stops << c if c.tab_stop? }
  return false if stops.empty?

  idx = @focused.nil? ? nil : stops.index(@focused)
  target = if idx.nil?
             forward ? stops.first : stops.last
           else
             stops[(idx + (forward ? 1 : -1)) % stops.size]
           end
  return false if target.equal?(@focused)

  self.focused = target
  true
end

#detect_schemeSymbol

Startup color scheme: :light when TerminalBackground.detect reports a light terminal background, :dark otherwise (including when detection is inconclusive). Runs in the constructor — the OSC 11 reply arrives on stdin, which is only safe to read before EventQueue#start_key_thread owns it. FakeScreen overrides this to pin :dark, keeping specs deterministic and off the test runner's TTY.

@return:dark or :light.

Returns:

  • (Symbol)


661
662
663
# File 'lib/tuile/screen.rb', line 661

def detect_scheme
  TerminalBackground.detect == :light ? :light : :dark
end

#emit(str) ⇒ void

This method returns an undefined value.

Writes an assembled frame (escape string) to the terminal. The single sink for repaint output; FakeScreen overrides it to capture instead.

@param str

Parameters:

  • str (String)


715
716
717
718
# File 'lib/tuile/screen.rb', line 715

def emit(str)
  $stdout.write(str)
  $stdout.flush
end

#event_loopvoid

This method returns an undefined value.



774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
# File 'lib/tuile/screen.rb', line 774

def event_loop
  @event_queue.run_loop do |event|
    case event
    when EventQueue::KeyEvent
      key = event.key
      handled = handle_key(key)
      @event_queue.stop if !handled && ["q", Keys::ESC].include?(key)
    when MouseEvent
      handle_mouse(event)
    when EventQueue::TTYSizeEvent
      @size = event.size
      layout
    when EventQueue::ColorSchemeEvent
      on_color_scheme(event.scheme)
    when EventQueue::EmptyQueueEvent
      repaint
    when Proc
      event.call
    end
  rescue StandardError => e
    @on_error.call(e)
  end
end

#focus_nextBoolean

Advances focus to the next Component#tab_stop? in tree order, wrapping around. Scope is the topmost popup if one is open, otherwise #content — this keeps Tab confined inside a modal popup. No-op (returns false) if the modal scope has no tab stops or no content at all.

@return — true if focus moved.

Returns:

  • (Boolean)


404
# File 'lib/tuile/screen.rb', line 404

def focus_next = cycle_focus(forward: true)

#focus_previousBoolean

Mirror of #focus_next that walks backwards through the tab order.

@return — true if focus moved.

Returns:

  • (Boolean)


408
# File 'lib/tuile/screen.rb', line 408

def focus_previous = cycle_focus(forward: false)

#global_shortcut_hints(popup_open:) ⇒ ::Array[String]

Status-bar hints from currently-registered global shortcuts. When a popup is open, only over_popups: true shortcuts contribute — the rest don't fire in that context, so showing them would be a lie. Insertion order is preserved (Hash iteration order).

@param popup_open

Parameters:

  • popup_open: (Boolean)

Returns:

  • (::Array[String])


336
337
338
339
340
341
342
343
# File 'lib/tuile/screen.rb', line 336

def global_shortcut_hints(popup_open:)
  @global_shortcuts.each_value.filter_map do |s|
    next if s.hint.nil? || s.hint.empty?
    next if popup_open && !s.over_popups

    s.hint
  end
end

#handle_key(key) ⇒ Boolean

A key has been pressed on the keyboard. Handle it, or forward to active window.

Dispatch order:

1. Tab / Shift+Tab — reserved focus navigation, intercepted before
 anything else so a focused {Component::TextField} (which swallows
 printable keys) can't trap them.
2. App-level shortcuts from {#register_global_shortcut}. An entry
 registered with `over_popups: true` always fires; one with the
 default `over_popups: false` fires only when no modal popup is open
 (otherwise the modal popup receives the key normally). A non-modal
 overlay doesn't suppress global shortcuts.
3. {ScreenPane#handle_key} — delivery to {#focused}, bubbling up the
 focus chain to the scope root.

@param key

@return — true if the key was handled by some window.

Parameters:

  • key (String)

Returns:

  • (Boolean)


749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
# File 'lib/tuile/screen.rb', line 749

def handle_key(key)
  case key
  when Keys::TAB
    focus_next
    true
  when Keys::SHIFT_TAB
    focus_previous
    true
  else
    shortcut = @global_shortcuts[key]
    if !shortcut.nil? && (shortcut.over_popups || @pane.modal_popup.nil?)
      shortcut.block.call
      true
    else
      @pane.handle_key(key)
    end
  end
end

#handle_mouse(event) ⇒ void

This method returns an undefined value.

Finds target window and calls Component::Window#handle_mouse.

@param event

Parameters:



771
# File 'lib/tuile/screen.rb', line 771

def handle_mouse(event) = @pane.handle_mouse(event)

#has_popup?(window) ⇒ Boolean

Internal — use Component::Popup#open? instead.

@param window

@return — true if this popup is currently mounted.

Parameters:

Returns:

  • (Boolean)


519
520
521
522
# File 'lib/tuile/screen.rb', line 519

def has_popup?(window) # rubocop:disable Naming/PredicatePrefix
  check_locked
  @pane.has_popup?(window)
end

#invalidate(component) ⇒ void

This method returns an undefined value.

Invalidates a component: causes the component to be repainted on next call to #repaint.

@param component

Parameters:



270
271
272
273
274
275
# File 'lib/tuile/screen.rb', line 270

def invalidate(component)
  check_locked
  raise TypeError, "expected Component, got #{component.inspect}" unless component.is_a? Component

  @invalidated << component unless @repainting.include? component
end

#layoutvoid

This method returns an undefined value.

Resizes #buffer and #pane to the current #size, invalidates the whole tree and repaints. Run whenever the terminal size changes (the EventQueue::TTYSizeEvent path) and once at startup via the first #content=.



725
726
727
728
729
730
731
# File 'lib/tuile/screen.rb', line 725

def layout
  check_locked
  @buffer.resize(size) unless @buffer.size == size
  needs_full_repaint
  @pane.rect = Rect.new(0, 0, size.width, size.height)
  repaint
end

#needs_full_repaintvoid

This method returns an undefined value.

Invalidates the entire attached tree, forcing every component to repaint on the next cycle. Needed whenever something overdraws the scene without clipping and then exposes what was underneath — a closing popup (#remove_popup), or a popup that shrinks or moves so its new #rect no longer covers the cells it previously painted (Component::Popup#rect=). The popup-only fast path in #repaint can't clear those vacated cells on its own, so we accept the cost of a full repaint.



511
512
513
# File 'lib/tuile/screen.rb', line 511

def needs_full_repaint
  @pane&.on_tree { invalidate _1 }
end

#on_color_scheme(scheme) ⇒ void

This method returns an undefined value.

An OS appearance flip arrived (mode-2031 report): remember the scheme and apply the matching member of #theme_def.

@param scheme:dark or :light.

Parameters:

  • scheme (Symbol)


669
670
671
672
# File 'lib/tuile/screen.rb', line 669

def on_color_scheme(scheme)
  @color_scheme = scheme
  self.theme = @theme_def.for(@color_scheme)
end

#popups::Array[Component]

@return — currently active popup components (forwarded to Tuile::ScreenPane). The array must not be modified!

Returns:



218
# File 'lib/tuile/screen.rb', line 218

def popups = @pane.popups

This method returns an undefined value.

Writes terminal-housekeeping escapes straight to stdout: #clear, mouse-tracking start/stop, the color-scheme notify toggles, cursor-show on teardown. Component painting does not go through here anymore — it writes into #buffer, which #repaint diffs and #emits. FakeScreen overrides this (and #emit) to capture into @prints instead of the test runner's stdout.

@param args — stuff to print.

Parameters:

  • args (String)


571
572
573
# File 'lib/tuile/screen.rb', line 571

def print(*args)
  Kernel.print(*args)
end

#refresh_status_barvoid

This method returns an undefined value.

Rebuild the status-bar text from the current focus and global-shortcut registry. Called from #focused= and whenever the global registry changes. Popups own their own "q Close" prefix in #keyboard_hint; for the tiled case Screen tacks on the global "q quit" instead. Global-shortcut hints get spliced in too — see #global_shortcut_hints for the over_popups filter rule.



317
318
319
320
321
322
323
324
325
326
# File 'lib/tuile/screen.rb', line 317

def refresh_status_bar
  top_popup = @pane.modal_popup
  globals = global_shortcut_hints(popup_open: !top_popup.nil?)
  @pane.status_bar.text = if top_popup.nil?
                            ["q #{@theme.hint("quit")}", *globals,
                             active_window&.keyboard_hint].compact.reject(&:empty?).join("  ")
                          else
                            [*globals, top_popup.keyboard_hint].reject(&:empty?).join("  ")
                          end
end

#register_global_shortcut(key, over_popups: false, hint: nil, &block) ⇒ void

This method returns an undefined value.

Registers an app-level keyboard shortcut: when key arrives, the block runs on the event-loop thread (free to mutate UI) before the key reaches any component. Re-registering a key replaces its binding.

This registry is the only keyboard mechanism above the component tree, and nothing suppresses it — so it accepts only keys no widget can need. Three groups raise at registration rather than misbehaving at runtime:

  • Printable keys — they'd hijack typing into a Component::TextField. A scope-wide one-key binding belongs on the scope root's own handle_key, where a focused field consumes it first (see Tuile::ScreenPane#handle_key).

  • TAB / SHIFT_TAB#handle_key intercepts them for focus navigation before the registry is consulted, so a binding would never fire.

  • EDITING_KEYSENTER, BACKSPACE, DELETE and the arrows, which every editable widget needs.

    screen.register_global_shortcut(Keys::CTRL_L, over_popups: true, hint: "^L #Tuile::Screen.screenscreen.themescreen.theme.hint("log")") do log_popup.open end

@param key — unprintable key (e.g. Keys::CTRL_L, Keys::ESC).

@param over_popups — when true, fires even while a modal popup is open (pre-empting the popup); when false (default), suppressed while any popup is open so the popup gets the key.

@param hint — preformatted status-bar hint; nil (default) is silent. Colors are baked in — re-register after a #theme= to recolor.

Parameters:

  • key (String)
  • over_popups: (Boolean) (defaults to: false)
  • hint: (String, nil) (defaults to: nil)


442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
# File 'lib/tuile/screen.rb', line 442

def register_global_shortcut(key, over_popups: false, hint: nil, &block)
  raise ArgumentError, "block required" if block.nil?
  raise ArgumentError, "key must be a String, got #{key.inspect}" unless key.is_a?(String)
  raise ArgumentError, "key cannot be empty" if key.empty?
  if Keys.printable?(key)
    raise ArgumentError,
          "global shortcut key must be unprintable; got #{key.inspect}. " \
          "For a one-key binding, override handle_key on the scope root " \
          "(your content layout, or the popup) — a focused text field then " \
          "consumes the key first, so typing isn't hijacked."
  end
  if [Keys::TAB, Keys::SHIFT_TAB].include?(key)
    raise ArgumentError,
          "#{key == Keys::TAB ? "TAB" : "SHIFT_TAB"} is reserved for focus navigation"
  end
  if EDITING_KEYS.include?(key)
    raise ArgumentError,
          "#{key.inspect} is reserved: every editable widget needs it, and this registry " \
          "sits above the component tree with nothing to suppress it. For a default " \
          "button, handle ENTER in the form's own handle_key instead — a focused " \
          "TextArea/TextField gets first refusal there."
  end
  raise ArgumentError, "hint must be a String or nil, got #{hint.inspect}" unless hint.nil? || hint.is_a?(String)

  @global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups, hint: hint)
  refresh_status_bar
end

#remove_popup(window) ⇒ void

This method returns an undefined value.

Internal — use Component::Popup#close instead. Removes the popup from #pane, repairs focus, and repaints the scene.

Does nothing if the window is not open on this screen.

@param window

Parameters:



494
495
496
497
498
499
500
# File 'lib/tuile/screen.rb', line 494

def remove_popup(window)
  check_locked
  return unless @pane.has_popup?(window)

  @pane.remove_popup(window)
  needs_full_repaint
end

#repaintvoid

This method returns an undefined value.

Repaints the screen; tries to be as effective as possible, by only considering invalidated components and flushing just the changed cells of #buffer. Called once per event-loop tick (on EventQueue::EmptyQueueEvent); components should Component#invalidate and let the loop coalesce rather than call this directly.



581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
# File 'lib/tuile/screen.rb', line 581

def repaint
  check_locked
  # This simple TUI framework doesn't support window clipping since tiled
  # windows are not expected to overlap. If there rarely is a popup, we
  # just repaint all windows in correct order — sure they will paint over
  # other windows, but if this is done in the right order, the final
  # drawing will look okay. Not the most effective algorithm, but very
  # simple and very fast in common cases.

  did_paint = false
  until @invalidated.empty?
    # Defensive filter: a component can become detached between enqueue
    # and drain (popup close, sibling removed mid-event-handling, focus
    # repair). Detached components have no place on the screen and must
    # never paint, even though Component#invalidate already gates them
    # out — this catches the case where attachment changed since.
    @invalidated.delete_if { |c| !c.attached? }
    break if @invalidated.empty?

    did_paint = true
    popups = @pane.popups

    # Build the repaint list in z-order, leaning on the tree itself rather
    # than a depth sort. The pane's pre-order traversal already orders the
    # tiled layer (content subtree + status bar) parent-before-child; the
    # popups are the top layer and must paint last. The status bar is a
    # *late* pane child yet sits under the popups, so a single pane.on_tree
    # walk won't do — we collect the tiled layer first, then append popups.
    popup_members = Set.new
    popups.each { |p| p.on_tree { popup_members << _1 } }

    # Tiled layer: invalidated non-popup components, in tree order.
    repaint = []
    tiled_invalidated = false
    @pane.on_tree do |c|
      next if popup_members.include?(c)
      next unless @invalidated.include?(c)

      repaint << c
      tiled_invalidated = true
    end

    # Popups on top: the whole stack when a tiled repaint may have clobbered
    # cells they share in the buffer, else just the invalidated popup
    # components. Overdraw into the buffer is free (only net-visible cell
    # changes reach the terminal), so reasserting the stack is cheap.
    popups.each do |p|
      p.on_tree { |c| repaint << c if tiled_invalidated || @invalidated.include?(c) }
    end

    @repainting = repaint.to_set
    @invalidated.clear

    repaint.each(&:repaint)
    @repainting.clear
  end
  return unless did_paint

  # Flush only the changed cells, then reposition the cursor — all inside
  # one synchronized-output batch so the terminal composites it atomically.
  emit("#{Ansi::SYNC_BEGIN}#{@buffer.flush}#{cursor_sequence}#{Ansi::SYNC_END}")
end

#run_event_loop(capture_mouse: true) ⇒ void

This method returns an undefined value.

Runs the event loop on the calling thread, taking over stdin (raw mode, echo off): keys and mouse events are dispatched via #handle_key / #handle_mouse, and the loop repaints once per drained tick. Returns when q or ESC is pressed unhandled. Restores terminal state on exit.

For the duration this thread owns the UI (#state is :running); ownership reverts to the creating thread once it returns.

@param capture_mouse — when true (default), enables xterm mouse tracking so clicks and scroll wheel arrive as MouseEvents and feed Component#handle_mouse. When false, no tracking escape sequence is written: the terminal keeps its native click handling, which is what you want if the app benefits more from select-to-copy than from click-to-focus. Components' handle_mouse is simply never invoked from the loop in that mode (the terminal stops sending the bytes).

Parameters:

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


375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
# File 'lib/tuile/screen.rb', line 375

def run_event_loop(capture_mouse: true)
  raise Tuile::Error, "Screen is closed: cannot run the event loop" if @closed

  # The guard above stays outside the begin: teardown for a setup that never
  # happened restores echo on a non-TTY stdin, and the ENOTTY masks the
  # real error.
  begin
    $stdin.echo = false
    print MouseEvent.start_tracking if capture_mouse
    # Follow OS light/dark flips live: terminals supporting mode 2031
    # push color-scheme reports that the key thread turns into
    # {EventQueue::ColorSchemeEvent}s.
    print TerminalBackground::NOTIFY_ON
    $stdin.raw do
      event_loop
    end
  ensure
    print TerminalBackground::NOTIFY_OFF
    print MouseEvent.stop_tracking if capture_mouse
    print TTY::Cursor.show
    $stdin.echo = true
  end
end

#stateSymbol

:idle covers both ends of the screen's life — before the first #run_event_loop and after it returns — and a screen may cycle :idle:running:idle repeatedly. :closed is terminal.

@return:idle (no event loop running), :running (a #run_event_loop is in progress) or :closed (after #close).

Returns:

  • (Symbol)


228
229
230
231
232
# File 'lib/tuile/screen.rb', line 228

def state
  return :closed if @closed

  @event_queue.running? ? :running : :idle
end

#unregister_global_shortcut(key) ⇒ void

This method returns an undefined value.

Removes a shortcut previously installed by #register_global_shortcut. No-op if key was not registered.

@param key

Parameters:

  • key (String)


474
475
476
477
# File 'lib/tuile/screen.rb', line 474

def unregister_global_shortcut(key)
  @global_shortcuts.delete(key)
  refresh_status_bar
end