Class: Muxr::Terminal
- Inherits:
-
Object
- Object
- Muxr::Terminal
- 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
-
#cols ⇒ Object
readonly
Returns the value of attribute cols.
-
#cursor_col ⇒ Object
readonly
Returns the value of attribute cursor_col.
-
#cursor_row ⇒ Object
readonly
Returns the value of attribute cursor_row.
-
#rows ⇒ Object
readonly
Returns the value of attribute rows.
-
#search_current ⇒ Object
readonly
———- search ———-.
-
#search_direction ⇒ Object
readonly
———- search ———-.
-
#search_matches ⇒ Object
readonly
———- search ———-.
-
#search_query ⇒ Object
readonly
———- search ———-.
-
#selection_mode ⇒ Object
readonly
Returns the value of attribute selection_mode.
-
#view_offset ⇒ Object
readonly
Returns the value of attribute view_offset.
Class Method Summary collapse
-
.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).
Instance Method Summary collapse
-
#anchor_selection!(mode: :linear) ⇒ Object
Drop the anchor at the cursor’s current position.
-
#bracketed_paste? ⇒ Boolean
True iff the inner program has enabled bracketed-paste mode (DECSET 2004).
- #cell(r, c) ⇒ Object
- #cell_in_match?(visible_r, c) ⇒ Boolean
-
#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).
- #clear_dirty! ⇒ Object
- #clear_search ⇒ Object
- #clear_selection ⇒ Object
-
#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.
- #dirty? ⇒ Boolean
-
#dump_text ⇒ Object
Return the currently-visible grid as a text string (rows joined by “n”, trailing whitespace stripped on each row).
- #extract_selection_text ⇒ Object
- #feed(data) ⇒ Object
-
#find_in_direction(direction) ⇒ Object
Move to the next/previous match in the given direction, wrapping.
-
#initialize(rows: 24, cols: 80) ⇒ Terminal
constructor
A new instance of Terminal.
- #move_selection_cursor_by(dr, dc) ⇒ Object
-
#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.
- #resize(rows, cols) ⇒ Object
- #scroll_back(n = 1) ⇒ Object
- #scroll_forward(n = 1) ⇒ Object
- #scroll_to_bottom ⇒ Object
- #scroll_to_top ⇒ Object
- #scrollback_size ⇒ Object
- #scrolled_back? ⇒ Boolean
- #search(query, direction: :forward) ⇒ Object
- #search_active? ⇒ Boolean
- #selected_at_visible?(r, c) ⇒ Boolean
-
#selection_active? ⇒ Boolean
———- selection ———-.
- #selection_cursor_to(tr, tc) ⇒ Object
- #selection_cursor_to_bottom ⇒ Object
- #selection_cursor_to_first_non_blank ⇒ Object
- #selection_cursor_to_line_end ⇒ Object
- #selection_cursor_to_line_start ⇒ Object
- #selection_cursor_to_top ⇒ Object
-
#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.
- #selection_cursor_visible ⇒ Object
- #selection_cursor_word_backward(big: false) ⇒ Object
- #selection_cursor_word_end(big: false) ⇒ Object
- #selection_cursor_word_forward(big: false) ⇒ Object
-
#start_selection_at_visible(r, c, mode: :linear) ⇒ Object
Convenience for tests: place cursor at (r,c) AND anchor immediately.
- #sync_deadline ⇒ Object
-
#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..
-
#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)”)..
-
#visible_cell(r, c) ⇒ Object
Returns the Cell that should be visible at (r, c) given the current scrollback view_offset.
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
#cols ⇒ Object (readonly)
Returns the value of attribute cols.
123 124 125 |
# File 'lib/muxr/terminal.rb', line 123 def cols @cols end |
#cursor_col ⇒ Object (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_row ⇒ Object (readonly)
Returns the value of attribute cursor_row.
123 124 125 |
# File 'lib/muxr/terminal.rb', line 123 def cursor_row @cursor_row end |
#rows ⇒ Object (readonly)
Returns the value of attribute rows.
123 124 125 |
# File 'lib/muxr/terminal.rb', line 123 def rows @rows end |
#search_current ⇒ Object (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_direction ⇒ Object (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_matches ⇒ Object (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_query ⇒ Object (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_mode ⇒ Object (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_offset ⇒ Object (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~”.
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
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_search ⇒ Object
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_selection ⇒ Object
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
578 579 580 |
# File 'lib/muxr/terminal.rb', line 578 def dirty? @dirty end |
#dump_text ⇒ Object
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_text ⇒ Object
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_bottom ⇒ Object
267 268 269 |
# File 'lib/muxr/terminal.rb', line 267 def scroll_to_bottom set_view_offset(0) end |
#scroll_to_top ⇒ Object
263 264 265 |
# File 'lib/muxr/terminal.rb', line 263 def scroll_to_top set_view_offset(@scrollback.size) end |
#scrollback_size ⇒ Object
247 248 249 |
# File 'lib/muxr/terminal.rb', line 247 def scrollback_size @scrollback.size end |
#scrolled_back? ⇒ 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
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
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.
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_bottom ⇒ Object
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_blank ⇒ Object
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_end ⇒ Object
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_start ⇒ Object
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_top ⇒ Object
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 (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_visible ⇒ Object
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_deadline ⇒ Object
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.
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 |