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- 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.
Instance Method Summary collapse
-
#anchor_selection!(mode: :linear) ⇒ Object
Drop the anchor at the cursor’s current position.
- #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.
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/muxr/terminal.rb', line 64 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 @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.
62 63 64 |
# File 'lib/muxr/terminal.rb', line 62 def cols @cols end |
#cursor_col ⇒ Object (readonly)
Returns the value of attribute cursor_col.
62 63 64 |
# File 'lib/muxr/terminal.rb', line 62 def cursor_col @cursor_col end |
#cursor_row ⇒ Object (readonly)
Returns the value of attribute cursor_row.
62 63 64 |
# File 'lib/muxr/terminal.rb', line 62 def cursor_row @cursor_row end |
#rows ⇒ Object (readonly)
Returns the value of attribute rows.
62 63 64 |
# File 'lib/muxr/terminal.rb', line 62 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.
385 386 387 |
# File 'lib/muxr/terminal.rb', line 385 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.
385 386 387 |
# File 'lib/muxr/terminal.rb', line 385 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.
385 386 387 |
# File 'lib/muxr/terminal.rb', line 385 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.
385 386 387 |
# File 'lib/muxr/terminal.rb', line 385 def search_query @search_query end |
#selection_mode ⇒ Object (readonly)
Returns the value of attribute selection_mode.
137 138 139 |
# File 'lib/muxr/terminal.rb', line 137 def selection_mode @selection_mode end |
#view_offset ⇒ Object (readonly)
Returns the value of attribute view_offset.
62 63 64 |
# File 'lib/muxr/terminal.rb', line 62 def view_offset @view_offset 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).
222 223 224 225 226 227 |
# File 'lib/muxr/terminal.rb', line 222 def anchor_selection!(mode: :linear) return unless @selection_cursor @selection_anchor = @selection_cursor.dup @selection_mode = mode @dirty = true end |
#cell(r, c) ⇒ Object
139 140 141 |
# File 'lib/muxr/terminal.rb', line 139 def cell(r, c) @buffer[r][c] end |
#cell_in_match?(visible_r, c) ⇒ Boolean
432 433 434 435 436 437 438 439 |
# File 'lib/muxr/terminal.rb', line 432 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).
231 232 233 234 235 |
# File 'lib/muxr/terminal.rb', line 231 def clear_anchor! return unless @selection_anchor @selection_anchor = nil @dirty = true end |
#clear_dirty! ⇒ Object
508 509 510 |
# File 'lib/muxr/terminal.rb', line 508 def clear_dirty! @dirty = false end |
#clear_search ⇒ Object
445 446 447 448 449 450 451 |
# File 'lib/muxr/terminal.rb', line 445 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
371 372 373 374 375 376 |
# File 'lib/muxr/terminal.rb', line 371 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.
569 570 571 572 573 574 575 576 577 578 579 580 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 |
# File 'lib/muxr/terminal.rb', line 569 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 text = String.new(capacity: rows.length * @cols) cells = [] rows.each do |row| row.each do |cell| text << cell.char cells << cell 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
504 505 506 |
# File 'lib/muxr/terminal.rb', line 504 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.
148 149 150 151 152 153 154 155 |
# File 'lib/muxr/terminal.rb', line 148 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
467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 |
# File 'lib/muxr/terminal.rb', line 467 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
538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 |
# File 'lib/muxr/terminal.rb', line 538 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.
413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 |
# File 'lib/muxr/terminal.rb', line 413 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
243 244 245 246 247 248 249 250 251 252 |
# File 'lib/muxr/terminal.rb', line 243 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.
211 212 213 214 215 216 217 |
# File 'lib/muxr/terminal.rb', line 211 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
512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 |
# File 'lib/muxr/terminal.rb', line 512 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
181 182 183 |
# File 'lib/muxr/terminal.rb', line 181 def scroll_back(n = 1) set_view_offset(@view_offset + n) end |
#scroll_forward(n = 1) ⇒ Object
185 186 187 |
# File 'lib/muxr/terminal.rb', line 185 def scroll_forward(n = 1) set_view_offset(@view_offset - n) end |
#scroll_to_bottom ⇒ Object
193 194 195 |
# File 'lib/muxr/terminal.rb', line 193 def scroll_to_bottom set_view_offset(0) end |
#scroll_to_top ⇒ Object
189 190 191 |
# File 'lib/muxr/terminal.rb', line 189 def scroll_to_top set_view_offset(@scrollback.size) end |
#scrollback_size ⇒ Object
173 174 175 |
# File 'lib/muxr/terminal.rb', line 173 def scrollback_size @scrollback.size end |
#scrolled_back? ⇒ Boolean
177 178 179 |
# File 'lib/muxr/terminal.rb', line 177 def scrolled_back? @view_offset > 0 end |
#search(query, direction: :forward) ⇒ Object
387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 |
# File 'lib/muxr/terminal.rb', line 387 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
441 442 443 |
# File 'lib/muxr/terminal.rb', line 441 def search_active? !(@search_query.nil? || @search_matches.empty?) end |
#selected_at_visible?(r, c) ⇒ Boolean
453 454 455 456 457 |
# File 'lib/muxr/terminal.rb', line 453 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.
205 206 207 |
# File 'lib/muxr/terminal.rb', line 205 def selection_active? !@selection_anchor.nil? end |
#selection_cursor_to(tr, tc) ⇒ Object
254 255 256 257 258 259 260 261 |
# File 'lib/muxr/terminal.rb', line 254 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
277 278 279 |
# File 'lib/muxr/terminal.rb', line 277 def selection_cursor_to_bottom selection_cursor_to(timeline_size - 1, @cols - 1) end |
#selection_cursor_to_first_non_blank ⇒ Object
281 282 283 284 285 |
# File 'lib/muxr/terminal.rb', line 281 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
268 269 270 271 |
# File 'lib/muxr/terminal.rb', line 268 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
263 264 265 266 |
# File 'lib/muxr/terminal.rb', line 263 def selection_cursor_to_line_start return unless @selection_cursor selection_cursor_to(@selection_cursor[0], 0) end |
#selection_cursor_to_top ⇒ Object
273 274 275 |
# File 'lib/muxr/terminal.rb', line 273 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.
289 290 291 292 293 294 295 296 297 298 299 |
# File 'lib/muxr/terminal.rb', line 289 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
459 460 461 462 463 464 465 |
# File 'lib/muxr/terminal.rb', line 459 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
346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 |
# File 'lib/muxr/terminal.rb', line 346 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
323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 |
# File 'lib/muxr/terminal.rb', line 323 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
301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 |
# File 'lib/muxr/terminal.rb', line 301 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.
238 239 240 241 |
# File 'lib/muxr/terminal.rb', line 238 def start_selection_at_visible(r, c, mode: :linear) place_selection_cursor(r, c) anchor_selection!(mode: mode) end |
#sync_deadline ⇒ Object
132 133 134 135 |
# File 'lib/muxr/terminal.rb', line 132 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.
122 123 124 125 126 127 128 129 130 |
# File 'lib/muxr/terminal.rb', line 122 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)”).
111 112 113 114 115 116 |
# File 'lib/muxr/terminal.rb', line 111 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.
161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/muxr/terminal.rb', line 161 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 |