Class: Muxr::Terminal

Inherits:
Object
  • Object
show all
Defined in:
lib/muxr/terminal.rb

Overview

A minimal VT100/ANSI terminal emulator. It maintains a fixed grid of cells plus a cursor and parser state. Bytes fed from a PTY are interpreted into mutations of the grid which the Renderer then composites into the final frame. The emulator implements enough of the protocol to host typical interactive shells (bash, zsh) and line-oriented programs.

Defined Under Namespace

Classes: Cell

Constant Summary collapse

BOLD =
1
UNDERLINE =
2
REVERSE =
4
DIM =
8
SCROLLBACK_MAX =
5000
WIDE_RANGES =

Codepoint ranges that occupy two display columns (East Asian Wide / Fullwidth per UAX #11, plus the common emoji blocks). A wide glyph is stored in its lead cell with a continuation cell (char “”) to its right reserving the second column — see #put_char. Kept as a flat, sorted list of ranges; #char_width only consults it for codepoints >= 0x300, so the ASCII/Latin-1 fast path never pays for the scan.

[
  0x1100..0x115F,   # Hangul Jamo
  0x2329..0x232A,   # angle brackets
  0x2E80..0x303E,   # CJK radicals, Kangxi, CJK symbols & punctuation
  0x3041..0x33FF,   # Hiragana … CJK compatibility
  0x3400..0x4DBF,   # CJK Unified Ext A
  0x4E00..0x9FFF,   # CJK Unified Ideographs
  0xA000..0xA4CF,   # Yi
  0xA960..0xA97F,   # Hangul Jamo Ext-A
  0xAC00..0xD7A3,   # Hangul Syllables
  0xF900..0xFAFF,   # CJK Compatibility Ideographs
  0xFE10..0xFE19,   # vertical forms
  0xFE30..0xFE6F,   # CJK compatibility / small form variants
  0xFF00..0xFF60,   # Fullwidth Forms
  0xFFE0..0xFFE6,   # Fullwidth signs
  0x1B000..0x1B16F, # Kana supplement / extended
  0x1F300..0x1F64F, # Misc symbols & pictographs, emoticons
  0x1F680..0x1F6FF, # transport & map symbols
  0x1F900..0x1F9FF, # supplemental symbols & pictographs
  0x1FA70..0x1FAFF, # symbols & pictographs extended-A
  0x20000..0x3FFFD  # CJK Unified Ext B and beyond
].freeze
ZERO_WIDTH_RANGES =

Codepoint ranges that occupy zero display columns: combining marks, variation selectors, and zero-width formatting characters. These fold onto the preceding glyph rather than consuming a column (#attach_combining) so the cursor stays aligned with what a real terminal would do.

[
  0x0300..0x036F,     # combining diacritical marks
  0x0483..0x0489,     # Cyrillic combining
  0x0591..0x05BD, 0x05BF..0x05BF, 0x05C1..0x05C2, 0x05C4..0x05C5,
  0x0610..0x061A, 0x064B..0x065F, 0x0670..0x0670,
  0x06D6..0x06DC, 0x06DF..0x06E4, 0x06E7..0x06E8, 0x06EA..0x06ED,
  0x0711..0x0711, 0x0730..0x074A,
  0x200B..0x200F,     # zero-width space/joiner/non-joiner, marks
  0x2028..0x202E, 0x2060..0x2064,
  0x20D0..0x20FF,     # combining marks for symbols
  0x1AB0..0x1AFF, 0x1DC0..0x1DFF, # combining extensions
  0xFE00..0xFE0F,     # variation selectors
  0xFE20..0xFE2F,     # combining half marks
  0xFEFF..0xFEFF,     # BOM / zero-width no-break space
  0xE0100..0xE01EF    # variation selectors supplement
].freeze
OSC_MAX_LEN =

Cap on the OSC payload we buffer before parsing. URLs in OSC 8 can be long but rarely exceed a few hundred bytes; 4 KiB lets the parser stay tolerant of weird inputs without giving an attacker an unbounded sink.

4096
URL_REGEX =

Match plain-text URLs the inner program printed without wrapping them in OSC 8. We stamp the matching cells with a synthetic hyperlink so the outer terminal treats a wrapped URL as one click target instead of two truncated halves. The character class excludes whitespace, control bytes, and the punctuation that almost never sits inside a URL.

%r{(?:https?|ftp)://[^\s<>"\\^`\x00-\x1f\x7f]+}
URL_TRIM_TRAILING =

Trailing punctuation we trim from a detected URL — these usually belong to the surrounding sentence (“see x.com.”) rather than the URL itself. Parens/brackets are intentionally left alone since they’re commonly part of Wikipedia-style URLs.

".,;:!?'\""
SYNTH_URL_PREFIX =

Prefix on the OSC 8 payload of cells we tagged ourselves. Used to tell synthetic links apart from program-emitted ones so we never clobber OSC 8 links the inner program set.

"8;id=muxr-url-"
SYNC_TIMEOUT =

Inner programs (fzf ≥ 0.41, neovim, helix, …) bracket coherent screen updates with ‘e[?2026h … e[?2026l` (DECSET 2026 — “Synchronized Output”). When we see the open, we know more bytes are coming that belong to the same logical frame; rendering before the close shows a half-painted state. SYNC_TIMEOUT is the safety cap so a crashed inner program (which left ?2026h open) cannot wedge the pane indefinitely.

0.2

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(rows: 24, cols: 80) ⇒ Terminal

Returns a new instance of Terminal.



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/muxr/terminal.rb', line 125

def initialize(rows: 24, cols: 80)
  @rows = rows
  @cols = cols
  @buffer = Array.new(rows) { Array.new(cols) { blank_cell } }
  @cursor_row = 0
  @cursor_col = 0
  @saved_cursor = [0, 0]
  @fg = nil
  @bg = nil
  @attrs = 0
  @autowrap_pending = false
  @scroll_top = 0
  @scroll_bottom = rows - 1
  @parser_state = :ground
  @parser_params = +""
  @parser_osc = +""
  @feed_remainder = +"".b
  # Currently-active OSC 8 hyperlink body (the "8;params;URI" payload that
  # we'll wrap back around runs of cells when rendering), or nil when no
  # hyperlink is open. Interned via @hyperlink_intern so repeated identical
  # links share one frozen string for fast equality and small memory.
  @current_hyperlink = nil
  @hyperlink_intern = {}
  # Stable interning for synthetic URL hyperlinks. Keyed by the URI text
  # so the same URL produces the same payload string across scans —
  # otherwise every feed would churn the renderer diff.
  @synth_url_intern = {}
  @dirty = true
  @scrollback = []
  @view_offset = 0
  @selection_anchor = nil
  @selection_cursor = nil
  @selection_mode = :linear
  @sync_pending = false
  @sync_started_at = nil
  # True once the inner program enables bracketed-paste mode (DECSET
  # 2004). The Application consults this to decide whether to forward the
  # \e[200~…\e[201~ paste markers the outer terminal wraps around a paste
  # or strip them — see Application#send_to_focused.
  @bracketed_paste = false
  @pending_replies = +"".b
  @search_query = nil
  @search_direction = :forward
  @search_matches = []
  @search_current = nil
end

Instance Attribute Details

#colsObject (readonly)

Returns the value of attribute cols.



123
124
125
# File 'lib/muxr/terminal.rb', line 123

def cols
  @cols
end

#cursor_colObject (readonly)

Returns the value of attribute cursor_col.



123
124
125
# File 'lib/muxr/terminal.rb', line 123

def cursor_col
  @cursor_col
end

#cursor_rowObject (readonly)

Returns the value of attribute cursor_row.



123
124
125
# File 'lib/muxr/terminal.rb', line 123

def cursor_row
  @cursor_row
end

#rowsObject (readonly)

Returns the value of attribute rows.



123
124
125
# File 'lib/muxr/terminal.rb', line 123

def rows
  @rows
end

#search_currentObject (readonly)

———- search ———-

Substring search over the full timeline (scrollback + live buffer). Smart-case: case-insensitive if the query is all-lowercase, sensitive otherwise. Matches are kept in timeline coordinates so they stay anchored to the same text as the user pages history.



459
460
461
# File 'lib/muxr/terminal.rb', line 459

def search_current
  @search_current
end

#search_directionObject (readonly)

———- search ———-

Substring search over the full timeline (scrollback + live buffer). Smart-case: case-insensitive if the query is all-lowercase, sensitive otherwise. Matches are kept in timeline coordinates so they stay anchored to the same text as the user pages history.



459
460
461
# File 'lib/muxr/terminal.rb', line 459

def search_direction
  @search_direction
end

#search_matchesObject (readonly)

———- search ———-

Substring search over the full timeline (scrollback + live buffer). Smart-case: case-insensitive if the query is all-lowercase, sensitive otherwise. Matches are kept in timeline coordinates so they stay anchored to the same text as the user pages history.



459
460
461
# File 'lib/muxr/terminal.rb', line 459

def search_matches
  @search_matches
end

#search_queryObject (readonly)

———- search ———-

Substring search over the full timeline (scrollback + live buffer). Smart-case: case-insensitive if the query is all-lowercase, sensitive otherwise. Matches are kept in timeline coordinates so they stay anchored to the same text as the user pages history.



459
460
461
# File 'lib/muxr/terminal.rb', line 459

def search_query
  @search_query
end

#selection_modeObject (readonly)

Returns the value of attribute selection_mode.



211
212
213
# File 'lib/muxr/terminal.rb', line 211

def selection_mode
  @selection_mode
end

#view_offsetObject (readonly)

Returns the value of attribute view_offset.



123
124
125
# File 'lib/muxr/terminal.rb', line 123

def view_offset
  @view_offset
end

Class Method Details

.char_width(cp) ⇒ Object

Display width of a codepoint in terminal columns: 0 (combining / zero-width), 2 (East Asian wide / emoji), or 1 (everything else). The Renderer uses this to advance its emit cursor by the right number of columns; #put_char uses it to lay glyphs into the grid.



69
70
71
72
73
74
# File 'lib/muxr/terminal.rb', line 69

def self.char_width(cp)
  return 1 if cp < 0x0300
  return 0 if ZERO_WIDTH_RANGES.any? { |r| r.cover?(cp) }
  return 2 if WIDE_RANGES.any? { |r| r.cover?(cp) }
  1
end

Instance Method Details

#anchor_selection!(mode: :linear) ⇒ Object

Drop the anchor at the cursor’s current position. ‘mode` controls the selection shape: :linear (character-by-character, reading order) or :block (rectangular).



296
297
298
299
300
301
# File 'lib/muxr/terminal.rb', line 296

def anchor_selection!(mode: :linear)
  return unless @selection_cursor
  @selection_anchor = @selection_cursor.dup
  @selection_mode = mode
  @dirty = true
end

#bracketed_paste?Boolean

True iff the inner program has enabled bracketed-paste mode (DECSET 2004). When false, the Application strips paste markers before writing so a program that doesn’t speak bracketed paste never prints a literal “^[[200~”.

Returns:

  • (Boolean)


207
208
209
# File 'lib/muxr/terminal.rb', line 207

def bracketed_paste?
  @bracketed_paste
end

#cell(r, c) ⇒ Object



213
214
215
# File 'lib/muxr/terminal.rb', line 213

def cell(r, c)
  @buffer[r][c]
end

#cell_in_match?(visible_r, c) ⇒ Boolean

Returns:

  • (Boolean)


506
507
508
509
510
511
512
513
# File 'lib/muxr/terminal.rb', line 506

def cell_in_match?(visible_r, c)
  return false if @search_matches.empty?
  tr = timeline_row_for_visible(visible_r)
  # Linear scan is fine: SCROLLBACK_MAX caps matches at O(rows*cols), and
  # the renderer touches each visible cell once per frame. A row-indexed
  # cache would matter at much larger buffer sizes than ours.
  @search_matches.any? { |mr, sc, ec| mr == tr && c >= sc && c <= ec }
end

#clear_anchor!Object

Drop the anchor but keep the cursor so the user can continue navigating (vim’s behavior when pressing v while already in linear visual mode).



305
306
307
308
309
# File 'lib/muxr/terminal.rb', line 305

def clear_anchor!
  return unless @selection_anchor
  @selection_anchor = nil
  @dirty = true
end

#clear_dirty!Object



582
583
584
# File 'lib/muxr/terminal.rb', line 582

def clear_dirty!
  @dirty = false
end

#clear_searchObject



519
520
521
522
523
524
525
# File 'lib/muxr/terminal.rb', line 519

def clear_search
  return if @search_query.nil? && @search_matches.empty?
  @search_query = nil
  @search_matches = []
  @search_current = nil
  @dirty = true
end

#clear_selectionObject



445
446
447
448
449
450
# File 'lib/muxr/terminal.rb', line 445

def clear_selection
  return unless @selection_anchor
  @selection_anchor = nil
  @selection_cursor = nil
  @dirty = true
end

#detect_urls!Object

Walk the buffer (plus the last scrollback row so wraps across the scrollback boundary still join), find plain-text URLs, and stamp the covering cells with an OSC 8 hyperlink carrying an ‘id=` parameter. Outer terminals (Ghostty, iTerm2, kitty, WezTerm) use `id=` to merge spans that wrap across rows into a single click target — without this a wrapped URL like very.long.example.com/path-that-wraps would be detected as two truncated URLs on consecutive lines.



643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
# File 'lib/muxr/terminal.rb', line 643

def detect_urls!
  rows = []
  rows << @scrollback.last if @scrollback.any?
  rows.concat(@buffer)

  rows.each do |row|
    row.each do |cell|
      link = cell.hyperlink
      cell.hyperlink = nil if link && link.start_with?(SYNTH_URL_PREFIX)
    end
  end

  # Build the scan text alongside a codepoint→cell map. A wide
  # continuation half (char "") contributes no codepoints, and a
  # base+combining cell contributes more than one, so we can't assume the
  # old 1:1 cell↔codepoint indexing — map every codepoint back to its
  # source cell instead. URLs are ASCII, but a wide glyph earlier on the
  # line would otherwise shift every later offset off its cell.
  text = String.new(capacity: rows.length * @cols)
  cells = []
  rows.each do |row|
    row.each do |cell|
      ch = cell.char
      next if ch.empty?
      ch.each_char { cells << cell }
      text << ch
    end
  end

  pos = 0
  while (md = URL_REGEX.match(text, pos))
    start_off = md.begin(0)
    end_off = md.end(0)
    while end_off > start_off + 1 && URL_TRIM_TRAILING.include?(text[end_off - 1])
      end_off -= 1
    end
    uri = text[start_off...end_off]
    payload = (@synth_url_intern[uri] ||=
      "#{SYNTH_URL_PREFIX}#{uri.hash.abs.to_s(16)};#{uri}".freeze)

    (start_off...end_off).each do |off|
      cell = cells[off]
      existing = cell.hyperlink
      next if existing && !existing.start_with?(SYNTH_URL_PREFIX)
      cell.hyperlink = payload
    end

    pos = end_off
  end
end

#dirty?Boolean

Returns:

  • (Boolean)


578
579
580
# File 'lib/muxr/terminal.rb', line 578

def dirty?
  @dirty
end

#dump_textObject

Return the currently-visible grid as a text string (rows joined by “n”, trailing whitespace stripped on each row). Used by the control surface to expose pane contents to programmatic clients (the MCP bridge in particular). This walks visible_cell so callers see whatever the user is currently looking at, including scrollback.



222
223
224
225
226
227
228
229
# File 'lib/muxr/terminal.rb', line 222

def dump_text
  lines = Array.new(@rows) do |r|
    row = String.new(capacity: @cols)
    @cols.times { |c| row << visible_cell(r, c).char }
    row.rstrip
  end
  lines.join("\n")
end

#extract_selection_textObject



541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
# File 'lib/muxr/terminal.rb', line 541

def extract_selection_text
  return "" unless @selection_anchor
  if @selection_mode == :block
    ar, ac = @selection_anchor
    br, bc = @selection_cursor
    min_r, max_r = ar <= br ? [ar, br] : [br, ar]
    min_c, max_c = ac <= bc ? [ac, bc] : [bc, ac]
    lines = []
    (min_r..max_r).each do |tr|
      row = timeline_row(tr)
      if row.nil? || min_c >= row.length
        lines << ""
        next
      end
      last = [max_c, row.length - 1].min
      chars = (min_c..last).map { |c| row[c]&.char || " " }
      lines << chars.join.rstrip
    end
    return lines.join("\n")
  end
  sr, sc, er, ec = ordered_selection
  lines = []
  (sr..er).each do |tr|
    row = timeline_row(tr)
    if row.nil?
      lines << ""
      next
    end
    first = (tr == sr) ? sc : 0
    last = (tr == er) ? ec : row.length - 1
    last = [last, row.length - 1].min
    chars = (first..last).map { |c| row[c]&.char || " " }
    lines << chars.join.rstrip
  end
  lines.join("\n")
end

#feed(data) ⇒ Object



612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
# File 'lib/muxr/terminal.rb', line 612

def feed(data)
  bytes = @feed_remainder + data.b
  @feed_remainder = +"".b
  str = bytes.dup.force_encoding(Encoding::UTF_8)
  unless str.valid_encoding?
    # Find the longest valid UTF-8 prefix and stash the remainder for the
    # next feed call so multi-byte characters don't get garbled across PTY
    # read boundaries.
    raw = bytes.bytes
    while raw.any?
      candidate = raw.pack("C*").force_encoding(Encoding::UTF_8)
      break if candidate.valid_encoding?
      @feed_remainder = ([raw.last] + @feed_remainder.bytes).pack("C*").b
      raw.pop
    end
    str = raw.pack("C*").force_encoding(Encoding::UTF_8)
    # Bail out completely if we couldn't decode anything yet.
    return if str.empty?
  end
  str.each_char { |c| process_char(c) }
  detect_urls!
  @dirty = true
end

#find_in_direction(direction) ⇒ Object

Move to the next/previous match in the given direction, wrapping. Returns the new current match index, or nil if there are no matches. Anchors on the current match (not the viewport top) so n always advances even when scroll_view_to_match has centered the previous hit and dragged the viewport top behind it.



487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
# File 'lib/muxr/terminal.rb', line 487

def find_in_direction(direction)
  return nil if @search_matches.empty?
  anchor_tr =
    if @search_current && @search_matches[@search_current]
      @search_matches[@search_current][0]
    else
      current_search_anchor_row
    end
  idx = strict_next_in_direction(anchor_tr, direction)
  if idx.nil?
    # Wrap: pick the far end depending on direction.
    idx = direction == :forward ? 0 : @search_matches.length - 1
  end
  @search_current = idx
  scroll_view_to_match(idx)
  @dirty = true
  idx
end

#move_selection_cursor_by(dr, dc) ⇒ Object



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

def move_selection_cursor_by(dr, dc)
  return unless @selection_cursor
  tr, tc = @selection_cursor
  ntr = (tr + dr).clamp(0, timeline_size - 1)
  ntc = (tc + dc).clamp(0, @cols - 1)
  return if ntr == tr && ntc == tc
  @selection_cursor = [ntr, ntc]
  ensure_selection_cursor_visible
  @dirty = true
end

#place_selection_cursor(r, c) ⇒ Object

Place the moving cursor at a viewport position without dropping an anchor — the user is still navigating, not yet selecting.



285
286
287
288
289
290
291
# File 'lib/muxr/terminal.rb', line 285

def place_selection_cursor(r, c)
  tr = timeline_row_for_visible(r).clamp(0, timeline_size - 1)
  tc = c.clamp(0, @cols - 1)
  @selection_cursor = [tr, tc]
  @selection_anchor = nil
  @dirty = true
end

#resize(rows, cols) ⇒ Object



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
# File 'lib/muxr/terminal.rb', line 586

def resize(rows, cols)
  return if rows == @rows && cols == @cols
  new_buf = Array.new(rows) { Array.new(cols) { blank_cell } }
  keep_rows = [rows, @rows].min
  keep_cols = [cols, @cols].min
  src_start = @rows - keep_rows
  keep_rows.times do |i|
    keep_cols.times do |j|
      new_buf[i][j].copy_from(@buffer[src_start + i][j])
    end
  end
  @buffer = new_buf
  @rows = rows
  @cols = cols
  @scroll_top = 0
  @scroll_bottom = rows - 1
  @cursor_row = @cursor_row.clamp(0, rows - 1)
  @cursor_col = @cursor_col.clamp(0, cols - 1)
  @autowrap_pending = false
  # Selection points at timeline rows whose shape can't be remapped
  # meaningfully through a resize, so drop it rather than show a smear.
  @selection_anchor = nil
  @selection_cursor = nil
  @dirty = true
end

#scroll_back(n = 1) ⇒ Object



255
256
257
# File 'lib/muxr/terminal.rb', line 255

def scroll_back(n = 1)
  set_view_offset(@view_offset + n)
end

#scroll_forward(n = 1) ⇒ Object



259
260
261
# File 'lib/muxr/terminal.rb', line 259

def scroll_forward(n = 1)
  set_view_offset(@view_offset - n)
end

#scroll_to_bottomObject



267
268
269
# File 'lib/muxr/terminal.rb', line 267

def scroll_to_bottom
  set_view_offset(0)
end

#scroll_to_topObject



263
264
265
# File 'lib/muxr/terminal.rb', line 263

def scroll_to_top
  set_view_offset(@scrollback.size)
end

#scrollback_sizeObject



247
248
249
# File 'lib/muxr/terminal.rb', line 247

def scrollback_size
  @scrollback.size
end

#scrolled_back?Boolean

Returns:

  • (Boolean)


251
252
253
# File 'lib/muxr/terminal.rb', line 251

def scrolled_back?
  @view_offset > 0
end

#search(query, direction: :forward) ⇒ Object



461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
# File 'lib/muxr/terminal.rb', line 461

def search(query, direction: :forward)
  query = query.to_s
  if query.empty?
    clear_search
    return 0
  end
  @search_query = query
  @search_direction = direction
  @search_matches = collect_matches(query)
  if @search_matches.empty?
    @search_current = nil
    @dirty = true
    return 0
  end
  @search_current = nearest_match_in_direction(current_search_anchor_row, direction, inclusive: true)
  @search_current ||= direction == :forward ? 0 : @search_matches.length - 1
  scroll_view_to_match(@search_current)
  @dirty = true
  @search_matches.length
end

#search_active?Boolean

Returns:

  • (Boolean)


515
516
517
# File 'lib/muxr/terminal.rb', line 515

def search_active?
  !(@search_query.nil? || @search_matches.empty?)
end

#selected_at_visible?(r, c) ⇒ Boolean

Returns:

  • (Boolean)


527
528
529
530
531
# File 'lib/muxr/terminal.rb', line 527

def selected_at_visible?(r, c)
  return false unless @selection_anchor
  tr = timeline_row_for_visible(r)
  inside_selection?(tr, c)
end

#selection_active?Boolean

———- selection ———-

Selection coordinates are in the combined “timeline”:

0..scrollback.size-1                 → @scrollback rows
scrollback.size..scrollback.size+rows-1 → @buffer rows

so the selection stays anchored to the same text as the user pages through history.

Returns:

  • (Boolean)


279
280
281
# File 'lib/muxr/terminal.rb', line 279

def selection_active?
  !@selection_anchor.nil?
end

#selection_cursor_to(tr, tc) ⇒ Object



328
329
330
331
332
333
334
335
# File 'lib/muxr/terminal.rb', line 328

def selection_cursor_to(tr, tc)
  return unless @selection_cursor
  ntr = tr.clamp(0, timeline_size - 1)
  ntc = tc.clamp(0, @cols - 1)
  @selection_cursor = [ntr, ntc]
  ensure_selection_cursor_visible
  @dirty = true
end

#selection_cursor_to_bottomObject



351
352
353
# File 'lib/muxr/terminal.rb', line 351

def selection_cursor_to_bottom
  selection_cursor_to(timeline_size - 1, @cols - 1)
end

#selection_cursor_to_first_non_blankObject



355
356
357
358
359
# File 'lib/muxr/terminal.rb', line 355

def selection_cursor_to_first_non_blank
  return unless @selection_cursor
  tr = @selection_cursor[0]
  selection_cursor_to(tr, first_non_blank_col(tr))
end

#selection_cursor_to_line_endObject



342
343
344
345
# File 'lib/muxr/terminal.rb', line 342

def selection_cursor_to_line_end
  return unless @selection_cursor
  selection_cursor_to(@selection_cursor[0], @cols - 1)
end

#selection_cursor_to_line_startObject



337
338
339
340
# File 'lib/muxr/terminal.rb', line 337

def selection_cursor_to_line_start
  return unless @selection_cursor
  selection_cursor_to(@selection_cursor[0], 0)
end

#selection_cursor_to_topObject



347
348
349
# File 'lib/muxr/terminal.rb', line 347

def selection_cursor_to_top
  selection_cursor_to(0, 0)
end

#selection_cursor_to_viewport(where) ⇒ Object

Jump to top/middle/bottom of the visible viewport (vim H/M/L), landing on the first non-blank column of the destination line.



363
364
365
366
367
368
369
370
371
372
373
# File 'lib/muxr/terminal.rb', line 363

def selection_cursor_to_viewport(where)
  return unless @selection_cursor
  vr = case where
       when :top    then 0
       when :middle then @rows / 2
       when :bottom then @rows - 1
       end
  return if vr.nil?
  tr = timeline_row_for_visible(vr).clamp(0, timeline_size - 1)
  selection_cursor_to(tr, first_non_blank_col(tr))
end

#selection_cursor_visibleObject



533
534
535
536
537
538
539
# File 'lib/muxr/terminal.rb', line 533

def selection_cursor_visible
  return nil unless @selection_cursor
  tr, tc = @selection_cursor
  vr = tr - (@scrollback.size - @view_offset)
  return nil unless vr.between?(0, @rows - 1)
  [vr, tc]
end

#selection_cursor_word_backward(big: false) ⇒ Object



420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
# File 'lib/muxr/terminal.rb', line 420

def selection_cursor_word_backward(big: false)
  return unless @selection_cursor
  tr, tc = @selection_cursor
  pos = step_backward(tr, tc)
  return unless pos
  tr, tc = pos
  while char_class_at(tr, tc, big: big) == :space
    pos = step_backward(tr, tc)
    unless pos
      selection_cursor_to(tr, tc)
      return
    end
    tr, tc = pos
  end
  cls = char_class_at(tr, tc, big: big)
  loop do
    pos = step_backward(tr, tc)
    if pos.nil? || pos[0] != tr || char_class_at(pos[0], pos[1], big: big) != cls
      selection_cursor_to(tr, tc)
      return
    end
    tr, tc = pos
  end
end

#selection_cursor_word_end(big: false) ⇒ Object



397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
# File 'lib/muxr/terminal.rb', line 397

def selection_cursor_word_end(big: false)
  return unless @selection_cursor
  tr, tc = @selection_cursor
  pos = step_forward(tr, tc)
  return unless pos
  tr, tc = pos
  while char_class_at(tr, tc, big: big) == :space
    pos = step_forward(tr, tc)
    break unless pos
    tr, tc = pos
  end
  return if char_class_at(tr, tc, big: big) == :space
  cls = char_class_at(tr, tc, big: big)
  loop do
    pos = step_forward(tr, tc)
    if pos.nil? || pos[0] != tr || char_class_at(pos[0], pos[1], big: big) != cls
      selection_cursor_to(tr, tc)
      return
    end
    tr, tc = pos
  end
end

#selection_cursor_word_forward(big: false) ⇒ Object



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

def selection_cursor_word_forward(big: false)
  return unless @selection_cursor
  tr, tc = @selection_cursor
  prev_cls = char_class_at(tr, tc, big: big)
  loop do
    nxt = step_forward(tr, tc)
    break unless nxt
    ntr, ntc = nxt
    cur_cls = char_class_at(ntr, ntc, big: big)
    # Row boundaries act as whitespace breaks even when the row is fully
    # packed (no trailing pad) — visually the user sees a new line.
    effective_prev = (ntr != tr) ? :space : prev_cls
    if effective_prev != cur_cls && cur_cls != :space
      selection_cursor_to(ntr, ntc)
      return
    end
    tr, tc = ntr, ntc
    prev_cls = cur_cls
  end
  selection_cursor_to(timeline_size - 1, @cols - 1)
end

#start_selection_at_visible(r, c, mode: :linear) ⇒ Object

Convenience for tests: place cursor at (r,c) AND anchor immediately.



312
313
314
315
# File 'lib/muxr/terminal.rb', line 312

def start_selection_at_visible(r, c, mode: :linear)
  place_selection_cursor(r, c)
  anchor_selection!(mode: mode)
end

#sync_deadlineObject



198
199
200
201
# File 'lib/muxr/terminal.rb', line 198

def sync_deadline
  return nil unless @sync_pending && @sync_started_at
  @sync_started_at + SYNC_TIMEOUT
end

#sync_pending?Boolean

True iff the inner program has opened a synchronized-output block (e[?2026h) and not yet closed it, and the safety timeout has not elapsed. The Application uses this to defer rendering so the diff lands on a fully-formed frame instead of a half-painted one.

Returns:

  • (Boolean)


188
189
190
191
192
193
194
195
196
# File 'lib/muxr/terminal.rb', line 188

def sync_pending?
  return false unless @sync_pending
  if @sync_started_at && (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @sync_started_at) > SYNC_TIMEOUT
    @sync_pending = false
    @sync_started_at = nil
    return false
  end
  true
end

#take_pending_replies!Object

Bytes the emulator owes back to the inner program in response to a query (currently DSR / Device Status Report — ‘e[5n` and `e[6n`). The Pane drains this after each feed and writes it to the PTY’s input side. Without it, programs like the AWS CLI fall back with a warning (“your terminal doesn’t support cursor position requests (CPR)”).



177
178
179
180
181
182
# File 'lib/muxr/terminal.rb', line 177

def take_pending_replies!
  return nil if @pending_replies.empty?
  data = @pending_replies
  @pending_replies = +"".b
  data
end

#visible_cell(r, c) ⇒ Object

Returns the Cell that should be visible at (r, c) given the current scrollback view_offset. When view_offset == 0 this is the live grid. When view_offset > 0, rows in the top of the visible area are sourced from @scrollback instead.



235
236
237
238
239
240
241
242
243
244
245
# File 'lib/muxr/terminal.rb', line 235

def visible_cell(r, c)
  return @buffer[r][c] if @view_offset.zero?
  idx = @scrollback.size - @view_offset + r
  if idx < @scrollback.size
    row = @scrollback[idx]
    return blank_cell if row.nil? || c >= row.length
    row[c]
  else
    @buffer[idx - @scrollback.size][c]
  end
end