muxr 0.1.10 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +275 -1
- data/README.md +763 -387
- data/bin/muxr +68 -33
- data/bin/muxr-mcp +172 -16
- data/lib/muxr/application.rb +639 -35
- data/lib/muxr/client.rb +26 -4
- data/lib/muxr/command_dispatcher.rb +69 -0
- data/lib/muxr/config.rb +156 -0
- data/lib/muxr/control_server.rb +301 -3
- data/lib/muxr/history_row.rb +264 -0
- data/lib/muxr/image_store.rb +61 -0
- data/lib/muxr/input_handler.rb +169 -17
- data/lib/muxr/layout_manager.rb +83 -57
- data/lib/muxr/mouse_report.rb +24 -0
- data/lib/muxr/pane.rb +128 -2
- data/lib/muxr/pane_picker.rb +49 -0
- data/lib/muxr/pane_transfer.rb +172 -0
- data/lib/muxr/protocol.rb +51 -5
- data/lib/muxr/pty_process.rb +54 -7
- data/lib/muxr/remote_pane.rb +263 -0
- data/lib/muxr/renderer.rb +297 -74
- data/lib/muxr/session.rb +19 -2
- data/lib/muxr/session_directory.rb +102 -0
- data/lib/muxr/terminal.rb +903 -87
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr/width_probe.rb +121 -0
- data/lib/muxr/window.rb +50 -3
- data/lib/muxr.rb +8 -0
- data/muxr.gemspec +10 -0
- data/skills/muxr-control/SKILL.md +158 -8
- metadata +18 -1
data/lib/muxr/terminal.rb
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
require_relative "image_store"
|
|
2
|
+
require_relative "history_row"
|
|
3
|
+
|
|
1
4
|
module Muxr
|
|
2
5
|
# A minimal VT100/ANSI terminal emulator. It maintains a fixed grid of cells
|
|
3
6
|
# plus a cursor and parser state. Bytes fed from a PTY are interpreted into
|
|
@@ -10,12 +13,188 @@ module Muxr
|
|
|
10
13
|
REVERSE = 4
|
|
11
14
|
DIM = 8
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
SCROLLBACK_DEFAULT = 50_000
|
|
17
|
+
SCROLLBACK_BOUNDS = 100..500_000
|
|
18
|
+
|
|
19
|
+
ALT_SCREEN_MODES = [47, 1047, 1049].freeze
|
|
20
|
+
MOUSE_TRACKING_MODES = [1000, 1002, 1003].freeze
|
|
21
|
+
MOUSE_SGR_MODE = 1006
|
|
22
|
+
APP_CURSOR_KEYS_MODE = 1
|
|
23
|
+
|
|
24
|
+
# Codepoint ranges that occupy two display columns (East Asian Wide /
|
|
25
|
+
# Fullwidth per UAX #11, plus the common emoji blocks). A wide glyph is
|
|
26
|
+
# stored in its lead cell with a continuation cell (char "") to its right
|
|
27
|
+
# reserving the second column — see #put_char. Kept as a flat, sorted list
|
|
28
|
+
# of ranges; #char_width only consults it for codepoints >= 0x300, so the
|
|
29
|
+
# ASCII/Latin-1 fast path never pays for the scan.
|
|
30
|
+
WIDE_RANGES = [
|
|
31
|
+
0x1100..0x115F, # Hangul Jamo
|
|
32
|
+
0x2329..0x232A, # angle brackets
|
|
33
|
+
0x2E80..0x303E, # CJK radicals, Kangxi, CJK symbols & punctuation
|
|
34
|
+
0x3041..0x33FF, # Hiragana … CJK compatibility
|
|
35
|
+
0x3400..0x4DBF, # CJK Unified Ext A
|
|
36
|
+
0x4E00..0x9FFF, # CJK Unified Ideographs
|
|
37
|
+
0xA000..0xA4CF, # Yi
|
|
38
|
+
0xA960..0xA97F, # Hangul Jamo Ext-A
|
|
39
|
+
0xAC00..0xD7A3, # Hangul Syllables
|
|
40
|
+
0xF900..0xFAFF, # CJK Compatibility Ideographs
|
|
41
|
+
0xFE10..0xFE19, # vertical forms
|
|
42
|
+
0xFE30..0xFE6F, # CJK compatibility / small form variants
|
|
43
|
+
0xFF00..0xFF60, # Fullwidth Forms
|
|
44
|
+
0xFFE0..0xFFE6, # Fullwidth signs
|
|
45
|
+
0x1B000..0x1B16F, # Kana supplement / extended
|
|
46
|
+
0x1F300..0x1F64F, # Misc symbols & pictographs, emoticons
|
|
47
|
+
0x1F680..0x1F6FF, # transport & map symbols
|
|
48
|
+
0x1F900..0x1F9FF, # supplemental symbols & pictographs
|
|
49
|
+
0x1FA70..0x1FAFF, # symbols & pictographs extended-A
|
|
50
|
+
0x20000..0x3FFFD # CJK Unified Ext B and beyond
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
# Codepoint ranges that occupy zero display columns: combining marks,
|
|
54
|
+
# variation selectors, and zero-width formatting characters. These fold
|
|
55
|
+
# onto the preceding glyph rather than consuming a column (#attach_combining)
|
|
56
|
+
# so the cursor stays aligned with what a real terminal would do.
|
|
57
|
+
ZERO_WIDTH_RANGES = [
|
|
58
|
+
0x0300..0x036F, # combining diacritical marks
|
|
59
|
+
0x0483..0x0489, # Cyrillic combining
|
|
60
|
+
0x0591..0x05BD, 0x05BF..0x05BF, 0x05C1..0x05C2, 0x05C4..0x05C5,
|
|
61
|
+
0x0610..0x061A, 0x064B..0x065F, 0x0670..0x0670,
|
|
62
|
+
0x06D6..0x06DC, 0x06DF..0x06E4, 0x06E7..0x06E8, 0x06EA..0x06ED,
|
|
63
|
+
0x0711..0x0711, 0x0730..0x074A,
|
|
64
|
+
0x200B..0x200F, # zero-width space/joiner/non-joiner, marks
|
|
65
|
+
0x2028..0x202E, 0x2060..0x2064,
|
|
66
|
+
0x20D0..0x20FF, # combining marks for symbols
|
|
67
|
+
0x1AB0..0x1AFF, 0x1DC0..0x1DFF, # combining extensions
|
|
68
|
+
0xFE00..0xFE0F, # variation selectors
|
|
69
|
+
0xFE20..0xFE2F, # combining half marks
|
|
70
|
+
0xFEFF..0xFEFF, # BOM / zero-width no-break space
|
|
71
|
+
0xE0100..0xE01EF # variation selectors supplement
|
|
72
|
+
].freeze
|
|
73
|
+
|
|
74
|
+
# East Asian *Ambiguous* width (UAX #11 class "A"): codepoints that some
|
|
75
|
+
# terminals draw one column wide and others two, depending on the font and
|
|
76
|
+
# the terminal's "ambiguous-as-wide" setting. This is where muxr and the
|
|
77
|
+
# outer terminal most often disagree — and the disagreement is exactly what
|
|
78
|
+
# corrupts in-place animations (Claude Code's ⏺/✻/❯ spinner, ·, …, ●, arrows).
|
|
79
|
+
# We don't hardcode a width for them: #char_width consults `ambiguous_wide`,
|
|
80
|
+
# which the width probe (see WidthProbe / the client handshake) sets to
|
|
81
|
+
# match the actual outer terminal. Curated to the BMP symbol/punctuation
|
|
82
|
+
# ranges that show up in practice rather than the full UAX #11 table.
|
|
83
|
+
AMBIGUOUS_RANGES = [
|
|
84
|
+
0x00A1..0x00A1, 0x00A4..0x00A4, 0x00A7..0x00A8, 0x00AA..0x00AA,
|
|
85
|
+
0x00AD..0x00AE, 0x00B0..0x00B4, 0x00B6..0x00BA, 0x00BC..0x00BF,
|
|
86
|
+
0x00C6..0x00C6, 0x00D0..0x00D0, 0x00D7..0x00D8, 0x00DE..0x00E1,
|
|
87
|
+
0x00E6..0x00E6, 0x00E8..0x00EA, 0x00EC..0x00ED, 0x00F0..0x00F0,
|
|
88
|
+
0x00F2..0x00F3, 0x00F7..0x00FA, 0x00FC..0x00FC, 0x00FE..0x00FE,
|
|
89
|
+
0x2010..0x2010, 0x2013..0x2016, 0x2018..0x2019, 0x201C..0x201D,
|
|
90
|
+
0x2020..0x2022, 0x2024..0x2027, 0x2030..0x2030, 0x2032..0x2033,
|
|
91
|
+
0x2035..0x2035, 0x203B..0x203B, 0x203E..0x203E, 0x2074..0x2074,
|
|
92
|
+
0x207F..0x207F, 0x2081..0x2084, 0x20AC..0x20AC, 0x2103..0x2103,
|
|
93
|
+
0x2105..0x2105, 0x2109..0x2109, 0x2113..0x2113, 0x2116..0x2116,
|
|
94
|
+
0x2121..0x2122, 0x2126..0x2126, 0x212B..0x212B, 0x2153..0x2154,
|
|
95
|
+
0x215B..0x215E, 0x2160..0x216B, 0x2170..0x2179, 0x2189..0x2189,
|
|
96
|
+
0x2190..0x2199, 0x21B8..0x21B9, 0x21D2..0x21D2, 0x21D4..0x21D4,
|
|
97
|
+
0x21E7..0x21E7, 0x2200..0x2200, 0x2202..0x2203, 0x2207..0x2208,
|
|
98
|
+
0x220B..0x220B, 0x220F..0x220F, 0x2211..0x2211, 0x2215..0x2215,
|
|
99
|
+
0x221A..0x221A, 0x221D..0x2220, 0x2223..0x2223, 0x2225..0x2225,
|
|
100
|
+
0x2227..0x222C, 0x222E..0x222E, 0x2234..0x2237, 0x223C..0x223D,
|
|
101
|
+
0x2248..0x2248, 0x224C..0x224C, 0x2252..0x2252, 0x2260..0x2261,
|
|
102
|
+
0x2264..0x2267, 0x226A..0x226B, 0x226E..0x226F, 0x2282..0x2283,
|
|
103
|
+
0x2286..0x2287, 0x2295..0x2295, 0x2299..0x2299, 0x22A5..0x22A5,
|
|
104
|
+
0x22BF..0x22BF, 0x2312..0x2312, 0x2460..0x24E9, 0x24EB..0x24FF,
|
|
105
|
+
# NOTE: the box-drawing / block-element band 0x2500-0x259F is excluded here
|
|
106
|
+
# because it gets its own measured class — see BOX_RANGES / box_wide.
|
|
107
|
+
# Geometric shapes (0x25A0+) are fair game.
|
|
108
|
+
0x25A0..0x25A1,
|
|
109
|
+
0x25A3..0x25A9, 0x25B2..0x25B3, 0x25B6..0x25B7, 0x25BC..0x25BD,
|
|
110
|
+
0x25C0..0x25C1, 0x25C6..0x25C8, 0x25CB..0x25CB, 0x25CE..0x25D1,
|
|
111
|
+
0x25E2..0x25E5, 0x25EF..0x25EF, 0x2605..0x2606, 0x2609..0x2609,
|
|
112
|
+
0x260E..0x260F, 0x261C..0x261C, 0x261E..0x261E, 0x2640..0x2640,
|
|
113
|
+
0x2642..0x2642, 0x2660..0x2661, 0x2663..0x2665, 0x2667..0x266A,
|
|
114
|
+
0x266C..0x266D, 0x266F..0x266F, 0x269E..0x269F, 0x26BF..0x26BF,
|
|
115
|
+
0x26C6..0x26CD, 0x26CF..0x26D3, 0x26D5..0x26E1, 0x273D..0x273D,
|
|
116
|
+
0x2776..0x277F, 0xFFFD..0xFFFD
|
|
117
|
+
].freeze
|
|
118
|
+
|
|
119
|
+
# Box-drawing and block elements. Much of this band is formally East Asian
|
|
120
|
+
# Ambiguous, so a terminal set to draw ambiguous glyphs wide draws these two
|
|
121
|
+
# columns wide too — but plenty of terminals special-case the band and keep
|
|
122
|
+
# it narrow even then, so it can't ride on `ambiguous_wide`. It gets its own
|
|
123
|
+
# probed verdict (`box_wide`) instead of a guess, because Renderer's
|
|
124
|
+
# contiguity shortcut trusts this band and a wrong guess here shifts every
|
|
125
|
+
# subsequent cell on the line: it's the band TUI borders are built from, and
|
|
126
|
+
# Claude Code's UI is mostly borders.
|
|
127
|
+
BOX_RANGES = [0x2500..0x259F].freeze
|
|
128
|
+
|
|
129
|
+
class << self
|
|
130
|
+
# Whether the outer terminal draws East Asian Ambiguous glyphs two columns
|
|
131
|
+
# wide. Set per attach by the width probe (default narrow, matching most
|
|
132
|
+
# modern terminals). Covers the long tail of ambiguous glyphs the probe
|
|
133
|
+
# doesn't sample individually.
|
|
134
|
+
attr_accessor :ambiguous_wide
|
|
135
|
+
# Whether the outer terminal draws the box-drawing / block band two columns
|
|
136
|
+
# wide. Set per attach by the width probe; default narrow, which is what
|
|
137
|
+
# nearly every terminal does.
|
|
138
|
+
attr_accessor :box_wide
|
|
139
|
+
# Exact per-codepoint widths the probe measured against the live terminal,
|
|
140
|
+
# cp => 1|2. These WIN over every heuristic below, because a direct
|
|
141
|
+
# measurement is ground truth. This is what catches glyphs whose width no
|
|
142
|
+
# class captures — Claude Code's ⏺/✻/❯, which a font may draw two columns
|
|
143
|
+
# wide via emoji presentation even when the terminal's ambiguous setting
|
|
144
|
+
# is narrow (the exact case the class toggle alone would miss).
|
|
145
|
+
attr_reader :width_overrides
|
|
146
|
+
attr_reader :scrollback_max
|
|
147
|
+
end
|
|
148
|
+
# Both are process-global: the server hosts exactly one session / outer
|
|
149
|
+
# terminal at a time, and both #char_width call sites (the emulator and the
|
|
150
|
+
# Renderer) must agree.
|
|
151
|
+
self.ambiguous_wide = false
|
|
152
|
+
self.box_wide = false
|
|
153
|
+
@width_overrides = {}
|
|
154
|
+
|
|
155
|
+
def self.width_overrides=(map)
|
|
156
|
+
@width_overrides = map || {}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.scrollback_max=(rows)
|
|
160
|
+
n = rows.to_i
|
|
161
|
+
@scrollback_max = n.positive? ? n.clamp(SCROLLBACK_BOUNDS) : SCROLLBACK_DEFAULT
|
|
162
|
+
end
|
|
163
|
+
self.scrollback_max = ENV["MUXR_SCROLLBACK"]
|
|
164
|
+
|
|
165
|
+
# Display width of a codepoint in terminal columns: 0 (combining /
|
|
166
|
+
# zero-width), 2 (East Asian wide / emoji, plus anything the probe measured
|
|
167
|
+
# or Ambiguous when the probed terminal draws those wide), or 1 (everything
|
|
168
|
+
# else). The Renderer uses this to advance its emit cursor by the right
|
|
169
|
+
# number of columns; #put_char uses it to lay glyphs into the grid.
|
|
170
|
+
def self.char_width(cp)
|
|
171
|
+
return 1 if cp < 0x0300
|
|
172
|
+
return 0 if ZERO_WIDTH_RANGES.any? { |r| r.cover?(cp) }
|
|
173
|
+
ov = @width_overrides[cp]
|
|
174
|
+
return ov if ov
|
|
175
|
+
return 2 if WIDE_RANGES.any? { |r| r.cover?(cp) }
|
|
176
|
+
return 2 if @ambiguous_wide && AMBIGUOUS_RANGES.any? { |r| r.cover?(cp) }
|
|
177
|
+
return 2 if @box_wide && BOX_RANGES.any? { |r| r.cover?(cp) }
|
|
178
|
+
1
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Cap on the OSC payload we buffer before parsing. URLs in OSC 8 rarely
|
|
182
|
+
# exceed a few hundred bytes, but OSC 52 clipboard writes (a vim yank, etc.)
|
|
183
|
+
# carry the base64 of whatever the inner program copied, so the cap doubles
|
|
184
|
+
# as the practical clipboard size limit. 100 KiB stays bounded per parser
|
|
185
|
+
# (it's freed on finalize) while comfortably fitting normal yanks; a payload
|
|
186
|
+
# longer than this is truncated rather than copied wholesale.
|
|
187
|
+
OSC_MAX_LEN = 100 * 1024
|
|
188
|
+
|
|
189
|
+
# Cap on the outbound notification queue (bell + notification OSCs). While a
|
|
190
|
+
# client is attached the Application drains it every read, so it stays tiny;
|
|
191
|
+
# the cap only matters for a detached session, where nobody is listening and
|
|
192
|
+
# a noisy inner program would otherwise grow it without bound.
|
|
193
|
+
NOTIFY_MAX = 64 * 1024
|
|
194
|
+
|
|
195
|
+
GRAPHICS_MAX_LEN = 8 * 1024 * 1024
|
|
196
|
+
|
|
197
|
+
IMAGE_LINK_PREFIX = "8;id=muxr-img-"
|
|
19
198
|
|
|
20
199
|
# Match plain-text URLs the inner program printed without wrapping them
|
|
21
200
|
# in OSC 8. We stamp the matching cells with a synthetic hyperlink so the
|
|
@@ -41,9 +220,18 @@ module Muxr
|
|
|
41
220
|
# program (which left ?2026h open) cannot wedge the pane indefinitely.
|
|
42
221
|
SYNC_TIMEOUT = 0.2
|
|
43
222
|
|
|
223
|
+
BLANK_CHAR = " ".freeze
|
|
224
|
+
CONTINUATION_CHAR = "".freeze
|
|
225
|
+
ASCII_CHARS = (0...128).map { |cp| cp.chr.freeze }.freeze
|
|
226
|
+
|
|
227
|
+
def self.intern_char(ch)
|
|
228
|
+
return CONTINUATION_CHAR if ch.empty?
|
|
229
|
+
ch.bytesize == 1 ? ASCII_CHARS[ch.getbyte(0)] : ch
|
|
230
|
+
end
|
|
231
|
+
|
|
44
232
|
Cell = Struct.new(:char, :fg, :bg, :attrs, :hyperlink) do
|
|
45
233
|
def reset!
|
|
46
|
-
self.char =
|
|
234
|
+
self.char = BLANK_CHAR
|
|
47
235
|
self.fg = nil
|
|
48
236
|
self.bg = nil
|
|
49
237
|
self.attrs = 0
|
|
@@ -61,9 +249,12 @@ module Muxr
|
|
|
61
249
|
|
|
62
250
|
attr_reader :rows, :cols, :cursor_row, :cursor_col, :view_offset
|
|
63
251
|
|
|
64
|
-
def initialize(rows: 24, cols: 80)
|
|
252
|
+
def initialize(rows: 24, cols: 80, image_store: nil)
|
|
65
253
|
@rows = rows
|
|
66
254
|
@cols = cols
|
|
255
|
+
@image_store = image_store || ImageStore.new
|
|
256
|
+
@parser_apc = +""
|
|
257
|
+
@graphics_pending = nil
|
|
67
258
|
@buffer = Array.new(rows) { Array.new(cols) { blank_cell } }
|
|
68
259
|
@cursor_row = 0
|
|
69
260
|
@cursor_col = 0
|
|
@@ -74,6 +265,7 @@ module Muxr
|
|
|
74
265
|
@autowrap_pending = false
|
|
75
266
|
@scroll_top = 0
|
|
76
267
|
@scroll_bottom = rows - 1
|
|
268
|
+
@saved_primary = nil
|
|
77
269
|
@parser_state = :ground
|
|
78
270
|
@parser_params = +""
|
|
79
271
|
@parser_osc = +""
|
|
@@ -96,7 +288,38 @@ module Muxr
|
|
|
96
288
|
@selection_mode = :linear
|
|
97
289
|
@sync_pending = false
|
|
98
290
|
@sync_started_at = nil
|
|
291
|
+
# True once the inner program enables bracketed-paste mode (DECSET
|
|
292
|
+
# 2004). The Application consults this to decide whether to forward the
|
|
293
|
+
# \e[200~…\e[201~ paste markers the outer terminal wraps around a paste
|
|
294
|
+
# or strip them — see Application#send_to_focused.
|
|
295
|
+
@bracketed_paste = false
|
|
296
|
+
@mouse_tracking = []
|
|
297
|
+
@mouse_sgr = false
|
|
298
|
+
@app_cursor_keys = false
|
|
299
|
+
@selection_screen_only = false
|
|
300
|
+
# Whether the inner program wants its cursor shown (DECTCEM, DEC private
|
|
301
|
+
# mode 25). Claude Code and other Ink UIs hide the cursor (\e[?25l) for
|
|
302
|
+
# the whole render and only show it (\e[?25h) at a text-input prompt —
|
|
303
|
+
# the Renderer consults this so we don't paint a stray block cursor on
|
|
304
|
+
# top of an animating progress line.
|
|
305
|
+
@cursor_visible = true
|
|
99
306
|
@pending_replies = +"".b
|
|
307
|
+
# Out-of-band bytes the emulator owes the OUTER terminal (not the inner
|
|
308
|
+
# program): the bell and desktop-notification OSCs (OSC 9, OSC 777) that
|
|
309
|
+
# Claude Code emits to get your attention. We don't model these on the
|
|
310
|
+
# grid — they're forwarded verbatim so the user's real terminal rings /
|
|
311
|
+
# raises the notification, even when the emitting pane isn't focused.
|
|
312
|
+
# The Application drains this in consume_pane_io. Capped so a detached
|
|
313
|
+
# session (nobody to forward to) can't grow it without bound.
|
|
314
|
+
@pending_notifications = +"".b
|
|
315
|
+
# The most recent clipboard write the inner program requested via OSC 52
|
|
316
|
+
# (`\e]52;c;<base64>\a` — what vim/tmux/Ghostty emit on a "copy to system
|
|
317
|
+
# clipboard" yank), already base64-decoded to raw bytes. Not grid state;
|
|
318
|
+
# the Application drains it in consume_pane_io and pipes it to pbcopy.
|
|
319
|
+
# Last-write-wins (a clipboard holds one value) so this can't grow without
|
|
320
|
+
# bound, and it's drained even while detached since pbcopy is local to the
|
|
321
|
+
# server host and doesn't need an attached client.
|
|
322
|
+
@pending_clipboard = nil
|
|
100
323
|
@search_query = nil
|
|
101
324
|
@search_direction = :forward
|
|
102
325
|
@search_matches = []
|
|
@@ -134,6 +357,58 @@ module Muxr
|
|
|
134
357
|
@sync_started_at + SYNC_TIMEOUT
|
|
135
358
|
end
|
|
136
359
|
|
|
360
|
+
# True iff the inner program has enabled bracketed-paste mode (DECSET
|
|
361
|
+
# 2004). When false, the Application strips paste markers before writing
|
|
362
|
+
# so a program that doesn't speak bracketed paste never prints a literal
|
|
363
|
+
# "^[[200~".
|
|
364
|
+
def bracketed_paste?
|
|
365
|
+
@bracketed_paste
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# True iff the inner program currently wants its cursor shown (DEC private
|
|
369
|
+
# mode 25, DECTCEM). The Renderer suppresses the focused pane's cursor when
|
|
370
|
+
# this is false so a hidden-cursor UI (Claude Code mid-render, a spinner)
|
|
371
|
+
# doesn't get a phantom block painted at its last write position.
|
|
372
|
+
def cursor_visible?
|
|
373
|
+
@cursor_visible
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def alt_screen?
|
|
377
|
+
!@saved_primary.nil?
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def mouse_tracking?
|
|
381
|
+
!@mouse_tracking.empty?
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def mouse_encoding
|
|
385
|
+
@mouse_sgr ? :sgr : :x10
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def app_cursor_keys?
|
|
389
|
+
@app_cursor_keys
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Bytes the emulator owes the OUTER terminal: bell + desktop-notification
|
|
393
|
+
# OSCs the inner program emitted. The Application drains this after each
|
|
394
|
+
# read and forwards it to the attached client so the user's real terminal
|
|
395
|
+
# rings / notifies. Returns nil when empty (mirrors take_pending_replies!).
|
|
396
|
+
def take_pending_notifications!
|
|
397
|
+
return nil if @pending_notifications.empty?
|
|
398
|
+
data = @pending_notifications
|
|
399
|
+
@pending_notifications = +"".b
|
|
400
|
+
data
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# The latest OSC 52 clipboard write (raw, already base64-decoded), or nil if
|
|
404
|
+
# the inner program hasn't asked to set the clipboard since the last drain.
|
|
405
|
+
# The Application pulls this in consume_pane_io and hands it to pbcopy.
|
|
406
|
+
def take_pending_clipboard!
|
|
407
|
+
data = @pending_clipboard
|
|
408
|
+
@pending_clipboard = nil
|
|
409
|
+
data
|
|
410
|
+
end
|
|
411
|
+
|
|
137
412
|
attr_reader :selection_mode
|
|
138
413
|
|
|
139
414
|
def cell(r, c)
|
|
@@ -154,6 +429,192 @@ module Muxr
|
|
|
154
429
|
lines.join("\n")
|
|
155
430
|
end
|
|
156
431
|
|
|
432
|
+
def dump_history_text
|
|
433
|
+
screen = @saved_primary ? @saved_primary[:buffer] : @buffer
|
|
434
|
+
lines = (@scrollback + screen).map { |row| row_search_text(row)[0].rstrip }
|
|
435
|
+
lines.pop while lines.last&.empty?
|
|
436
|
+
lines.empty? ? "" : lines.join("\n") << "\n"
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Serialize the live grid as a self-contained ANSI repaint: feeding these
|
|
440
|
+
# bytes to a blank emulator of the same size reproduces this screen exactly.
|
|
441
|
+
# Seeds a mirrored copy of the pane in another muxr server, which then keeps
|
|
442
|
+
# up from the raw PTY byte stream alone.
|
|
443
|
+
def dump_ansi
|
|
444
|
+
out = +""
|
|
445
|
+
out << primary_repaint if @saved_primary
|
|
446
|
+
out << "\e[0m\e[H\e[2J"
|
|
447
|
+
emit_grid(out, @buffer)
|
|
448
|
+
out << dump_modes
|
|
449
|
+
out << "\e[#{@cursor_row + 1};#{@cursor_col + 1}H"
|
|
450
|
+
out << (@cursor_visible ? "\e[?25h" : "\e[?25l")
|
|
451
|
+
out << self.class.sgr(Cell.new(" ", @fg, @bg, @attrs, nil))
|
|
452
|
+
out
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Modes a repaint has to carry alongside the cells. The scroll region is
|
|
456
|
+
# the load-bearing one: a full-screen program sets DECSTBM once and then
|
|
457
|
+
# relies on plain index/reverse-index to scroll only that band, so a copy
|
|
458
|
+
# of this screen that missed it scrolls the whole display instead.
|
|
459
|
+
# DECSTBM homes the cursor, hence emitting this before the final position.
|
|
460
|
+
def dump_modes
|
|
461
|
+
out = +""
|
|
462
|
+
unless @scroll_top.zero? && @scroll_bottom == @rows - 1
|
|
463
|
+
out << "\e[#{@scroll_top + 1};#{@scroll_bottom + 1}r"
|
|
464
|
+
end
|
|
465
|
+
out << "\e[?2004h" if @bracketed_paste
|
|
466
|
+
out << "\e[?1h" if @app_cursor_keys
|
|
467
|
+
@mouse_tracking.each { |mode| out << "\e[?#{mode}h" }
|
|
468
|
+
out << "\e[?#{MOUSE_SGR_MODE}h" if @mouse_sgr
|
|
469
|
+
out
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Everything a blank emulator needs to become this one, including the
|
|
473
|
+
# history above the screen. Only used when a pane moves between servers —
|
|
474
|
+
# dropping scrollback there would put exactly the kind of hole in the
|
|
475
|
+
# timeline that #resize goes out of its way to avoid.
|
|
476
|
+
def dump_transfer
|
|
477
|
+
{
|
|
478
|
+
"rows" => @rows,
|
|
479
|
+
"cols" => @cols,
|
|
480
|
+
"screen" => dump_ansi,
|
|
481
|
+
"scrollback" => @scrollback.map { |row| row_ansi(row) }
|
|
482
|
+
}
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def restore_transfer!(state)
|
|
486
|
+
restore_scrollback!(state["scrollback"])
|
|
487
|
+
feed(state["screen"].to_s)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Rebuild the scrollback ring by replaying each row through a one-row
|
|
491
|
+
# emulator, which keeps the cell attributes the ANSI encoding carries
|
|
492
|
+
# without duplicating the parser here.
|
|
493
|
+
def restore_scrollback!(lines)
|
|
494
|
+
return if lines.nil? || lines.empty?
|
|
495
|
+
scratch = self.class.new(rows: 1, cols: @cols)
|
|
496
|
+
@scrollback = lines.last(self.class.scrollback_max).map do |line|
|
|
497
|
+
scratch.feed("\e[0m\e[H\e[2K")
|
|
498
|
+
scratch.feed(line.to_s)
|
|
499
|
+
pack_row(Array.new(@cols) { |c| scratch.cell(0, c) })
|
|
500
|
+
end
|
|
501
|
+
@view_offset = 0
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def row_ansi(row)
|
|
505
|
+
return row.to_ansi if row.is_a?(HistoryRow)
|
|
506
|
+
last = last_significant_column(row)
|
|
507
|
+
return "" if last.nil?
|
|
508
|
+
out = +""
|
|
509
|
+
_sgr, link = emit_row(out, row, last, nil, nil)
|
|
510
|
+
out << "\e]8;;\e\\" if link
|
|
511
|
+
out
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def emit_grid(out, buffer)
|
|
515
|
+
sgr = nil
|
|
516
|
+
link = nil
|
|
517
|
+
buffer.each_with_index do |row, r|
|
|
518
|
+
last = last_significant_column(row)
|
|
519
|
+
next if last.nil?
|
|
520
|
+
out << "\e[#{r + 1};1H"
|
|
521
|
+
sgr, link = emit_row(out, row, last, sgr, link)
|
|
522
|
+
end
|
|
523
|
+
out << "\e]8;;\e\\" if link
|
|
524
|
+
out << "\e[0m"
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def primary_repaint
|
|
528
|
+
out = +"\e[0m\e[H\e[2J"
|
|
529
|
+
emit_grid(out, @saved_primary[:buffer])
|
|
530
|
+
row, col = @saved_primary[:cursor]
|
|
531
|
+
out << "\e[#{row + 1};#{col + 1}H\e[?1049h"
|
|
532
|
+
out
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# Append cells 0..last of +row+ to +out+, carrying the caller's current SGR
|
|
536
|
+
# and hyperlink so a multi-row dump only re-states them when they change.
|
|
537
|
+
# Returns the state it left the stream in.
|
|
538
|
+
def emit_row(out, row, last, sgr, link)
|
|
539
|
+
skip = 0
|
|
540
|
+
(0..last).each do |c|
|
|
541
|
+
if skip.positive?
|
|
542
|
+
skip -= 1
|
|
543
|
+
next
|
|
544
|
+
end
|
|
545
|
+
cell = row[c] || blank_cell
|
|
546
|
+
char = cell.char.to_s
|
|
547
|
+
if (s = self.class.sgr(cell)) != sgr
|
|
548
|
+
out << s
|
|
549
|
+
sgr = s
|
|
550
|
+
end
|
|
551
|
+
if cell.hyperlink != link
|
|
552
|
+
out << "\e]8;;\e\\" if link
|
|
553
|
+
out << "\e]#{cell.hyperlink}\e\\" if cell.hyperlink
|
|
554
|
+
link = cell.hyperlink
|
|
555
|
+
end
|
|
556
|
+
if char.empty?
|
|
557
|
+
out << " "
|
|
558
|
+
else
|
|
559
|
+
out << char
|
|
560
|
+
skip = 1 if self.class.char_width(char.codepoints.first) == 2
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
[sgr, link]
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Rightmost cell in +row+ that differs from a freshly-erased one, or nil
|
|
567
|
+
# when the whole row is blank. A dump opens with \e[2J, so blank tails
|
|
568
|
+
# (the common case) cost nothing on the wire.
|
|
569
|
+
def last_significant_column(row)
|
|
570
|
+
(row.length - 1).downto(0) do |c|
|
|
571
|
+
cell = row[c]
|
|
572
|
+
next if cell.nil?
|
|
573
|
+
next if cell.fg.nil? && cell.bg.nil? && cell.attrs.to_i.zero? &&
|
|
574
|
+
cell.hyperlink.nil? && (cell.char == " " || cell.char == "")
|
|
575
|
+
return c
|
|
576
|
+
end
|
|
577
|
+
nil
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# SGR prelude that puts a terminal into the cell's colors and attributes.
|
|
581
|
+
# Shared with the Renderer's diff-emit so a snapshot and an incremental
|
|
582
|
+
# frame describe the same cell identically.
|
|
583
|
+
def self.sgr(cell)
|
|
584
|
+
sgr_for(cell.fg, cell.bg, cell.attrs)
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def self.sgr_for(fg, bg, attrs)
|
|
588
|
+
attrs = attrs.to_i
|
|
589
|
+
parts = ["0"]
|
|
590
|
+
parts << "1" if (attrs & BOLD) != 0
|
|
591
|
+
parts << "2" if (attrs & DIM) != 0
|
|
592
|
+
parts << "4" if (attrs & UNDERLINE) != 0
|
|
593
|
+
parts << "7" if (attrs & REVERSE) != 0
|
|
594
|
+
append_color(parts, fg, true)
|
|
595
|
+
append_color(parts, bg, false)
|
|
596
|
+
"\e[#{parts.join(';')}m"
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def self.append_color(parts, color, fg)
|
|
600
|
+
return if color.nil?
|
|
601
|
+
case color
|
|
602
|
+
when Integer
|
|
603
|
+
if color < 8
|
|
604
|
+
parts << ((fg ? 30 : 40) + color).to_s
|
|
605
|
+
else
|
|
606
|
+
parts << ((fg ? 90 : 100) + (color - 8)).to_s
|
|
607
|
+
end
|
|
608
|
+
when Array
|
|
609
|
+
case color[0]
|
|
610
|
+
when :c256
|
|
611
|
+
parts << "#{fg ? 38 : 48};5;#{color[1]}"
|
|
612
|
+
when :rgb
|
|
613
|
+
parts << "#{fg ? 38 : 48};2;#{color[1]};#{color[2]};#{color[3]}"
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
157
618
|
# Returns the Cell that should be visible at (r, c) given the current
|
|
158
619
|
# scrollback view_offset. When view_offset == 0 this is the live grid.
|
|
159
620
|
# When view_offset > 0, rows in the top of the visible area are sourced
|
|
@@ -163,8 +624,7 @@ module Muxr
|
|
|
163
624
|
idx = @scrollback.size - @view_offset + r
|
|
164
625
|
if idx < @scrollback.size
|
|
165
626
|
row = @scrollback[idx]
|
|
166
|
-
|
|
167
|
-
row[c]
|
|
627
|
+
(row && row[c]) || blank_cell
|
|
168
628
|
else
|
|
169
629
|
@buffer[idx - @scrollback.size][c]
|
|
170
630
|
end
|
|
@@ -206,10 +666,23 @@ module Muxr
|
|
|
206
666
|
!@selection_anchor.nil?
|
|
207
667
|
end
|
|
208
668
|
|
|
669
|
+
def confine_selection_to_screen!(confined)
|
|
670
|
+
@selection_screen_only = confined
|
|
671
|
+
set_view_offset(0) if confined
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def selection_confined_to_screen?
|
|
675
|
+
@selection_screen_only
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def selection_floor
|
|
679
|
+
@selection_screen_only ? @scrollback.size : 0
|
|
680
|
+
end
|
|
681
|
+
|
|
209
682
|
# Place the moving cursor at a viewport position without dropping an
|
|
210
683
|
# anchor — the user is still navigating, not yet selecting.
|
|
211
684
|
def place_selection_cursor(r, c)
|
|
212
|
-
tr = timeline_row_for_visible(r).clamp(
|
|
685
|
+
tr = timeline_row_for_visible(r).clamp(selection_floor, timeline_size - 1)
|
|
213
686
|
tc = c.clamp(0, @cols - 1)
|
|
214
687
|
@selection_cursor = [tr, tc]
|
|
215
688
|
@selection_anchor = nil
|
|
@@ -243,7 +716,7 @@ module Muxr
|
|
|
243
716
|
def move_selection_cursor_by(dr, dc)
|
|
244
717
|
return unless @selection_cursor
|
|
245
718
|
tr, tc = @selection_cursor
|
|
246
|
-
ntr = (tr + dr).clamp(
|
|
719
|
+
ntr = (tr + dr).clamp(selection_floor, timeline_size - 1)
|
|
247
720
|
ntc = (tc + dc).clamp(0, @cols - 1)
|
|
248
721
|
return if ntr == tr && ntc == tc
|
|
249
722
|
@selection_cursor = [ntr, ntc]
|
|
@@ -253,7 +726,7 @@ module Muxr
|
|
|
253
726
|
|
|
254
727
|
def selection_cursor_to(tr, tc)
|
|
255
728
|
return unless @selection_cursor
|
|
256
|
-
ntr = tr.clamp(
|
|
729
|
+
ntr = tr.clamp(selection_floor, timeline_size - 1)
|
|
257
730
|
ntc = tc.clamp(0, @cols - 1)
|
|
258
731
|
@selection_cursor = [ntr, ntc]
|
|
259
732
|
ensure_selection_cursor_visible
|
|
@@ -294,7 +767,7 @@ module Muxr
|
|
|
294
767
|
when :bottom then @rows - 1
|
|
295
768
|
end
|
|
296
769
|
return if vr.nil?
|
|
297
|
-
tr = timeline_row_for_visible(vr).clamp(
|
|
770
|
+
tr = timeline_row_for_visible(vr).clamp(selection_floor, timeline_size - 1)
|
|
298
771
|
selection_cursor_to(tr, first_non_blank_col(tr))
|
|
299
772
|
end
|
|
300
773
|
|
|
@@ -511,16 +984,15 @@ module Muxr
|
|
|
511
984
|
|
|
512
985
|
def resize(rows, cols)
|
|
513
986
|
return if rows == @rows && cols == @cols
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
end
|
|
987
|
+
was = [@rows, @cols]
|
|
988
|
+
shape = [rows, cols]
|
|
989
|
+
if (primary = @saved_primary)
|
|
990
|
+
primary[:buffer] = refit(primary[:buffer], was, shape, history: true)
|
|
991
|
+
primary[:cursor] = clamp_within(primary[:cursor], shape)
|
|
992
|
+
primary[:saved_cursor] = clamp_within(primary[:saved_cursor], shape)
|
|
993
|
+
primary[:scroll] = [0, rows - 1]
|
|
522
994
|
end
|
|
523
|
-
@buffer =
|
|
995
|
+
@buffer = refit(@buffer, was, shape, history: primary.nil?)
|
|
524
996
|
@rows = rows
|
|
525
997
|
@cols = cols
|
|
526
998
|
@scroll_top = 0
|
|
@@ -540,18 +1012,18 @@ module Muxr
|
|
|
540
1012
|
@feed_remainder = +"".b
|
|
541
1013
|
str = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
542
1014
|
unless str.valid_encoding?
|
|
543
|
-
#
|
|
544
|
-
#
|
|
545
|
-
#
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
@feed_remainder = (
|
|
551
|
-
|
|
1015
|
+
# Hold back only a genuinely truncated multi-byte character at the very
|
|
1016
|
+
# end, so a glyph split across PTY read boundaries still joins up. Any
|
|
1017
|
+
# other malformed byte is substituted rather than deferred: deferring it
|
|
1018
|
+
# would prepend the same bad byte to every later feed, so the stream
|
|
1019
|
+
# could never become valid and the pane would stall forever.
|
|
1020
|
+
hold = incomplete_tail_length(bytes)
|
|
1021
|
+
if hold.positive?
|
|
1022
|
+
@feed_remainder = bytes.byteslice(bytes.bytesize - hold, hold).b
|
|
1023
|
+
bytes = bytes.byteslice(0, bytes.bytesize - hold)
|
|
552
1024
|
end
|
|
553
|
-
str =
|
|
554
|
-
|
|
1025
|
+
str = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
1026
|
+
str = str.scrub("�") unless str.valid_encoding?
|
|
555
1027
|
return if str.empty?
|
|
556
1028
|
end
|
|
557
1029
|
str.each_char { |c| process_char(c) }
|
|
@@ -559,6 +1031,24 @@ module Muxr
|
|
|
559
1031
|
@dirty = true
|
|
560
1032
|
end
|
|
561
1033
|
|
|
1034
|
+
# Byte length of an incomplete UTF-8 character at the end of +bytes+, or 0
|
|
1035
|
+
# when the tail isn't a truncated character. A UTF-8 character is at most 4
|
|
1036
|
+
# bytes, so only the last 3 can be waiting on continuation bytes.
|
|
1037
|
+
def incomplete_tail_length(bytes)
|
|
1038
|
+
max = [3, bytes.bytesize].min
|
|
1039
|
+
(1..max).each do |back|
|
|
1040
|
+
b = bytes.getbyte(bytes.bytesize - back)
|
|
1041
|
+
return 0 if b < 0x80
|
|
1042
|
+
next if b < 0xc0
|
|
1043
|
+
needed = if b >= 0xf0 then 4
|
|
1044
|
+
elsif b >= 0xe0 then 3
|
|
1045
|
+
else 2
|
|
1046
|
+
end
|
|
1047
|
+
return needed > back ? back : 0
|
|
1048
|
+
end
|
|
1049
|
+
0
|
|
1050
|
+
end
|
|
1051
|
+
|
|
562
1052
|
# Walk the buffer (plus the last scrollback row so wraps across the
|
|
563
1053
|
# scrollback boundary still join), find plain-text URLs, and stamp the
|
|
564
1054
|
# covering cells with an OSC 8 hyperlink carrying an `id=` parameter.
|
|
@@ -567,8 +1057,9 @@ module Muxr
|
|
|
567
1057
|
# a wrapped URL like https://very.long.example.com/path-that-wraps would
|
|
568
1058
|
# be detected as two truncated URLs on consecutive lines.
|
|
569
1059
|
def detect_urls!
|
|
1060
|
+
history = @scrollback.last if @saved_primary.nil? && @scrollback.any?
|
|
570
1061
|
rows = []
|
|
571
|
-
rows <<
|
|
1062
|
+
rows << history if history
|
|
572
1063
|
rows.concat(@buffer)
|
|
573
1064
|
|
|
574
1065
|
rows.each do |row|
|
|
@@ -578,12 +1069,20 @@ module Muxr
|
|
|
578
1069
|
end
|
|
579
1070
|
end
|
|
580
1071
|
|
|
1072
|
+
# Build the scan text alongside a codepoint→cell map. A wide
|
|
1073
|
+
# continuation half (char "") contributes no codepoints, and a
|
|
1074
|
+
# base+combining cell contributes more than one, so we can't assume the
|
|
1075
|
+
# old 1:1 cell↔codepoint indexing — map every codepoint back to its
|
|
1076
|
+
# source cell instead. URLs are ASCII, but a wide glyph earlier on the
|
|
1077
|
+
# line would otherwise shift every later offset off its cell.
|
|
581
1078
|
text = String.new(capacity: rows.length * @cols)
|
|
582
1079
|
cells = []
|
|
583
1080
|
rows.each do |row|
|
|
584
1081
|
row.each do |cell|
|
|
585
|
-
|
|
586
|
-
|
|
1082
|
+
ch = cell.char
|
|
1083
|
+
next if ch.empty?
|
|
1084
|
+
ch.each_char { cells << cell }
|
|
1085
|
+
text << ch
|
|
587
1086
|
end
|
|
588
1087
|
end
|
|
589
1088
|
|
|
@@ -607,22 +1106,49 @@ module Muxr
|
|
|
607
1106
|
|
|
608
1107
|
pos = end_off
|
|
609
1108
|
end
|
|
1109
|
+
|
|
1110
|
+
history&.repack!
|
|
610
1111
|
end
|
|
611
1112
|
|
|
612
1113
|
private
|
|
613
1114
|
|
|
614
1115
|
def blank_cell
|
|
615
|
-
Cell.new(
|
|
1116
|
+
Cell.new(BLANK_CHAR, nil, nil, 0, nil)
|
|
616
1117
|
end
|
|
617
1118
|
|
|
618
|
-
#
|
|
619
|
-
#
|
|
1119
|
+
# Queue bytes for the outer terminal (bell / notification OSC). Dropped once
|
|
1120
|
+
# the buffer is full so a detached, never-drained session can't grow without
|
|
1121
|
+
# bound — see NOTIFY_MAX.
|
|
1122
|
+
def queue_notification(bytes)
|
|
1123
|
+
return if @pending_notifications.bytesize >= NOTIFY_MAX
|
|
1124
|
+
@pending_notifications << bytes
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
# Parse the just-completed OSC payload. We care about three families:
|
|
1128
|
+
# OSC 8 (hyperlinks): `8;params;URI` — modeled on the grid (below).
|
|
1129
|
+
# OSC 52 (clipboard): `52;<targets>;<base64>` — decoded and queued for
|
|
1130
|
+
# pbcopy (a vim yank with OSC 52 enabled lands here).
|
|
1131
|
+
# OSC 9 / OSC 777 (desktop notifications) — not grid state; forwarded to
|
|
1132
|
+
# the outer terminal verbatim so the user's real terminal raises the
|
|
1133
|
+
# notification. Claude Code emits these (alongside the bell) when it
|
|
1134
|
+
# wants your attention.
|
|
620
1135
|
# Anything else (window-title OSC 0/1/2, palette OSC 4, …) is silently
|
|
621
1136
|
# consumed — the emulator doesn't model it.
|
|
622
1137
|
def finalize_osc
|
|
623
1138
|
payload = @parser_osc
|
|
624
1139
|
@parser_osc = +""
|
|
625
1140
|
return if payload.empty?
|
|
1141
|
+
if payload.start_with?("9;", "777;")
|
|
1142
|
+
# Re-wrap with a BEL terminator (universally accepted) — the original
|
|
1143
|
+
# ST/BEL was consumed by the parser. The OUTPUT path carries raw bytes,
|
|
1144
|
+
# so this reaches the outer terminal unchanged.
|
|
1145
|
+
queue_notification("\e]#{payload}\a")
|
|
1146
|
+
return
|
|
1147
|
+
end
|
|
1148
|
+
if payload.start_with?("52;")
|
|
1149
|
+
finalize_clipboard(payload)
|
|
1150
|
+
return
|
|
1151
|
+
end
|
|
626
1152
|
return unless payload.start_with?("8;")
|
|
627
1153
|
parts = payload.split(";", 3)
|
|
628
1154
|
uri = parts[2]
|
|
@@ -633,6 +1159,107 @@ module Muxr
|
|
|
633
1159
|
end
|
|
634
1160
|
end
|
|
635
1161
|
|
|
1162
|
+
# Decode an OSC 52 clipboard write and stash it for the Application to pipe
|
|
1163
|
+
# to pbcopy. The payload is `52;<targets>;<base64>`; <targets> selects which
|
|
1164
|
+
# X-style clipboard ("c" clipboard, "p" primary, …) and is irrelevant here
|
|
1165
|
+
# since the host has one system clipboard. A base64 of "?" is a *query*
|
|
1166
|
+
# ("tell me the clipboard"), and an empty body is a clear request — we honor
|
|
1167
|
+
# neither (we can't safely read the host clipboard, and silently wiping it on
|
|
1168
|
+
# an inner program's whim would be surprising), so both are dropped.
|
|
1169
|
+
def finalize_clipboard(payload)
|
|
1170
|
+
b64 = payload.split(";", 3)[2]
|
|
1171
|
+
return if b64.nil? || b64.empty? || b64 == "?"
|
|
1172
|
+
decoded = b64.unpack1("m")
|
|
1173
|
+
@pending_clipboard = decoded unless decoded.nil? || decoded.empty?
|
|
1174
|
+
end
|
|
1175
|
+
|
|
1176
|
+
def finalize_apc
|
|
1177
|
+
payload = @parser_apc
|
|
1178
|
+
@parser_apc = +""
|
|
1179
|
+
return unless payload.start_with?("G")
|
|
1180
|
+
control, _, data = payload[1..].partition(";")
|
|
1181
|
+
opts = parse_graphics_keys(control)
|
|
1182
|
+
return reply_graphics_query(opts) if opts["a"] == "q"
|
|
1183
|
+
if @graphics_pending
|
|
1184
|
+
@graphics_pending[:data] << data
|
|
1185
|
+
else
|
|
1186
|
+
@graphics_pending = { opts: opts, data: +data }
|
|
1187
|
+
end
|
|
1188
|
+
return if opts["m"] == "1"
|
|
1189
|
+
image = @graphics_pending
|
|
1190
|
+
@graphics_pending = nil
|
|
1191
|
+
save_graphics(image)
|
|
1192
|
+
end
|
|
1193
|
+
|
|
1194
|
+
def parse_graphics_keys(control)
|
|
1195
|
+
control.split(",").each_with_object({}) do |pair, acc|
|
|
1196
|
+
key, _, value = pair.partition("=")
|
|
1197
|
+
acc[key] = value unless key.empty?
|
|
1198
|
+
end
|
|
1199
|
+
end
|
|
1200
|
+
|
|
1201
|
+
def reply_graphics_query(opts)
|
|
1202
|
+
ids = %w[i I].filter_map { |k| "#{k}=#{opts[k]}" if opts[k] }
|
|
1203
|
+
ids << "i=0" if ids.empty?
|
|
1204
|
+
@pending_replies << "\e_G#{ids.join(',')};OK\e\\"
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
def save_graphics(image)
|
|
1208
|
+
bytes = graphics_bytes(image[:opts], image[:data])
|
|
1209
|
+
return if bytes.nil? || bytes.empty?
|
|
1210
|
+
announce_image(@image_store.write(bytes), bytes)
|
|
1211
|
+
rescue SystemCallError
|
|
1212
|
+
write_announcement("[image: could not be saved]", nil)
|
|
1213
|
+
end
|
|
1214
|
+
|
|
1215
|
+
def graphics_bytes(opts, data)
|
|
1216
|
+
raw = decode_graphics_data(opts, data)
|
|
1217
|
+
return nil if raw.nil? || raw.empty?
|
|
1218
|
+
case opts["f"] || "32"
|
|
1219
|
+
when "100" then raw
|
|
1220
|
+
when "24" then ImageStore.encode_png(raw, opts["s"].to_i, opts["v"].to_i, 3)
|
|
1221
|
+
when "32" then ImageStore.encode_png(raw, opts["s"].to_i, opts["v"].to_i, 4)
|
|
1222
|
+
end
|
|
1223
|
+
end
|
|
1224
|
+
|
|
1225
|
+
def decode_graphics_data(opts, data)
|
|
1226
|
+
return nil if data.empty?
|
|
1227
|
+
decoded = data.unpack1("m")
|
|
1228
|
+
return decoded unless %w[f t].include?(opts["t"])
|
|
1229
|
+
return nil unless decoded && File.file?(decoded)
|
|
1230
|
+
File.binread(decoded)
|
|
1231
|
+
rescue SystemCallError
|
|
1232
|
+
nil
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
def announce_image(path, bytes)
|
|
1236
|
+
width, height = ImageStore.png_dimensions(bytes)
|
|
1237
|
+
size = width && height ? " #{width}×#{height}" : ""
|
|
1238
|
+
write_announcement("[image#{size} → #{abbreviate_home(path)}]", path)
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
def write_announcement(text, path)
|
|
1242
|
+
if @cursor_col.positive? || @autowrap_pending
|
|
1243
|
+
@cursor_col = 0
|
|
1244
|
+
line_feed
|
|
1245
|
+
@autowrap_pending = false
|
|
1246
|
+
end
|
|
1247
|
+
saved_hyperlink = @current_hyperlink
|
|
1248
|
+
if path
|
|
1249
|
+
link = "#{IMAGE_LINK_PREFIX}#{path.hash.abs.to_s(16)};file://#{path}"
|
|
1250
|
+
@current_hyperlink = (@hyperlink_intern[link] ||= link.freeze)
|
|
1251
|
+
end
|
|
1252
|
+
text.each_char { |c| put_char(c) }
|
|
1253
|
+
@current_hyperlink = saved_hyperlink
|
|
1254
|
+
@cursor_col = 0
|
|
1255
|
+
@autowrap_pending = false
|
|
1256
|
+
line_feed
|
|
1257
|
+
end
|
|
1258
|
+
|
|
1259
|
+
def abbreviate_home(path)
|
|
1260
|
+
path.start_with?(Dir.home) ? path.sub(Dir.home, "~") : path
|
|
1261
|
+
end
|
|
1262
|
+
|
|
636
1263
|
def process_char(ch)
|
|
637
1264
|
b = ch.ord
|
|
638
1265
|
case @parser_state
|
|
@@ -657,6 +1284,23 @@ module Muxr
|
|
|
657
1284
|
# payload on slightly buggy emitters.
|
|
658
1285
|
finalize_osc
|
|
659
1286
|
@parser_state = :ground
|
|
1287
|
+
when :apc
|
|
1288
|
+
if b == 0x9c
|
|
1289
|
+
finalize_apc
|
|
1290
|
+
@parser_state = :ground
|
|
1291
|
+
elsif b == 0x1b
|
|
1292
|
+
@parser_state = :apc_esc
|
|
1293
|
+
elsif @parser_apc.bytesize < GRAPHICS_MAX_LEN
|
|
1294
|
+
@parser_apc << ch
|
|
1295
|
+
end
|
|
1296
|
+
when :apc_esc
|
|
1297
|
+
finalize_apc
|
|
1298
|
+
@parser_state = :ground
|
|
1299
|
+
when :string_discard
|
|
1300
|
+
@parser_state = :string_discard_esc if b == 0x1b
|
|
1301
|
+
@parser_state = :ground if b == 0x9c
|
|
1302
|
+
when :string_discard_esc
|
|
1303
|
+
@parser_state = :ground
|
|
660
1304
|
when :charset
|
|
661
1305
|
@parser_state = :ground
|
|
662
1306
|
end
|
|
@@ -667,7 +1311,7 @@ module Muxr
|
|
|
667
1311
|
when 0x1b
|
|
668
1312
|
@parser_state = :escape
|
|
669
1313
|
when 0x07 # BEL
|
|
670
|
-
|
|
1314
|
+
queue_notification("\a")
|
|
671
1315
|
when 0x08 # BS
|
|
672
1316
|
@cursor_col -= 1 if @cursor_col > 0
|
|
673
1317
|
@autowrap_pending = false
|
|
@@ -695,13 +1339,18 @@ module Muxr
|
|
|
695
1339
|
when 0x5d # ]
|
|
696
1340
|
@parser_state = :osc
|
|
697
1341
|
@parser_osc = +""
|
|
1342
|
+
when 0x5f # _
|
|
1343
|
+
@parser_state = :apc
|
|
1344
|
+
@parser_apc = +""
|
|
1345
|
+
when 0x50, 0x58, 0x5e # P X ^
|
|
1346
|
+
@parser_state = :string_discard
|
|
698
1347
|
when 0x28, 0x29, 0x2a, 0x2b # ( ) * +
|
|
699
1348
|
@parser_state = :charset
|
|
700
1349
|
when 0x37 # 7 save cursor
|
|
701
1350
|
@saved_cursor = [@cursor_row, @cursor_col]
|
|
702
1351
|
@parser_state = :ground
|
|
703
1352
|
when 0x38 # 8 restore cursor
|
|
704
|
-
|
|
1353
|
+
restore_cursor
|
|
705
1354
|
@parser_state = :ground
|
|
706
1355
|
when 0x44 # D index
|
|
707
1356
|
line_feed
|
|
@@ -773,19 +1422,26 @@ module Muxr
|
|
|
773
1422
|
when ">", "<", "=", "!"
|
|
774
1423
|
return
|
|
775
1424
|
when "?"
|
|
776
|
-
# DEC private modes — most we treat as no-ops, but
|
|
777
|
-
# (Synchronized Output)
|
|
778
|
-
#
|
|
1425
|
+
# DEC private modes — most we treat as no-ops, but two we track:
|
|
1426
|
+
# 2026 (Synchronized Output) — a render-timing hint we honor so the
|
|
1427
|
+
# outer paint lands on fully-formed frames from fzf/nvim/helix.
|
|
1428
|
+
# 2004 (Bracketed Paste) — whether the inner program wants pastes
|
|
1429
|
+
# wrapped in \e[200~…\e[201~; the Application strips those
|
|
1430
|
+
# markers when it's off (see Application#send_to_focused).
|
|
1431
|
+
# 25 (DECTCEM) — cursor show/hide; the Renderer honors it so a
|
|
1432
|
+
# hidden-cursor UI doesn't get a phantom block painted on it.
|
|
779
1433
|
if final == "h" || final == "l"
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
@sync_pending = false
|
|
786
|
-
@sync_started_at = nil
|
|
787
|
-
end
|
|
1434
|
+
enabled = (final == "h")
|
|
1435
|
+
params = csi_params
|
|
1436
|
+
if params.include?(2026)
|
|
1437
|
+
@sync_pending = enabled
|
|
1438
|
+
@sync_started_at = enabled ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
|
|
788
1439
|
end
|
|
1440
|
+
@bracketed_paste = enabled if params.include?(2004)
|
|
1441
|
+
@cursor_visible = enabled if params.include?(25)
|
|
1442
|
+
@app_cursor_keys = enabled if params.include?(APP_CURSOR_KEYS_MODE)
|
|
1443
|
+
apply_mouse_modes(params, enabled)
|
|
1444
|
+
apply_screen_modes(params, enabled)
|
|
789
1445
|
end
|
|
790
1446
|
return
|
|
791
1447
|
end
|
|
@@ -861,7 +1517,7 @@ module Muxr
|
|
|
861
1517
|
when "s"
|
|
862
1518
|
@saved_cursor = [@cursor_row, @cursor_col]
|
|
863
1519
|
when "u"
|
|
864
|
-
|
|
1520
|
+
restore_cursor
|
|
865
1521
|
when "n"
|
|
866
1522
|
# DSR — Device Status Report. `\e[5n` asks if the terminal is OK,
|
|
867
1523
|
# `\e[6n` (CPR) asks for the cursor position. The reply rides back
|
|
@@ -878,23 +1534,134 @@ module Muxr
|
|
|
878
1534
|
end
|
|
879
1535
|
end
|
|
880
1536
|
|
|
1537
|
+
def apply_mouse_modes(params, enabled)
|
|
1538
|
+
(params & MOUSE_TRACKING_MODES).each do |mode|
|
|
1539
|
+
enabled ? (@mouse_tracking |= [mode]) : @mouse_tracking.delete(mode)
|
|
1540
|
+
end
|
|
1541
|
+
@mouse_sgr = enabled if params.include?(MOUSE_SGR_MODE)
|
|
1542
|
+
end
|
|
1543
|
+
|
|
1544
|
+
def apply_screen_modes(params, enabled)
|
|
1545
|
+
if params.include?(1048)
|
|
1546
|
+
enabled ? @saved_cursor = [@cursor_row, @cursor_col] : restore_cursor
|
|
1547
|
+
end
|
|
1548
|
+
return if (params & ALT_SCREEN_MODES).empty?
|
|
1549
|
+
enabled ? enter_alt_screen : leave_alt_screen
|
|
1550
|
+
end
|
|
1551
|
+
|
|
1552
|
+
def enter_alt_screen
|
|
1553
|
+
return if @saved_primary
|
|
1554
|
+
scroll_to_bottom
|
|
1555
|
+
@saved_primary = {
|
|
1556
|
+
buffer: @buffer,
|
|
1557
|
+
cursor: [@cursor_row, @cursor_col],
|
|
1558
|
+
saved_cursor: @saved_cursor,
|
|
1559
|
+
scroll: [@scroll_top, @scroll_bottom],
|
|
1560
|
+
fg: @fg,
|
|
1561
|
+
bg: @bg,
|
|
1562
|
+
attrs: @attrs,
|
|
1563
|
+
hyperlink: @current_hyperlink
|
|
1564
|
+
}
|
|
1565
|
+
@buffer = Array.new(@rows) { Array.new(@cols) { blank_cell } }
|
|
1566
|
+
@scroll_top = 0
|
|
1567
|
+
@scroll_bottom = @rows - 1
|
|
1568
|
+
@saved_cursor = [@cursor_row, @cursor_col]
|
|
1569
|
+
@autowrap_pending = false
|
|
1570
|
+
@dirty = true
|
|
1571
|
+
end
|
|
1572
|
+
|
|
1573
|
+
def leave_alt_screen
|
|
1574
|
+
primary = @saved_primary
|
|
1575
|
+
return unless primary
|
|
1576
|
+
@saved_primary = nil
|
|
1577
|
+
@buffer = primary[:buffer]
|
|
1578
|
+
@cursor_row, @cursor_col = clamp_to_grid(primary[:cursor])
|
|
1579
|
+
@saved_cursor = clamp_to_grid(primary[:saved_cursor])
|
|
1580
|
+
@scroll_top = primary[:scroll][0].clamp(0, @rows - 1)
|
|
1581
|
+
@scroll_bottom = primary[:scroll][1].clamp(@scroll_top, @rows - 1)
|
|
1582
|
+
@fg = primary[:fg]
|
|
1583
|
+
@bg = primary[:bg]
|
|
1584
|
+
@attrs = primary[:attrs]
|
|
1585
|
+
@current_hyperlink = primary[:hyperlink]
|
|
1586
|
+
@autowrap_pending = false
|
|
1587
|
+
@dirty = true
|
|
1588
|
+
end
|
|
1589
|
+
|
|
1590
|
+
def clamp_to_grid(position)
|
|
1591
|
+
clamp_within(position, [@rows, @cols])
|
|
1592
|
+
end
|
|
1593
|
+
|
|
1594
|
+
def clamp_within(position, shape)
|
|
1595
|
+
[position[0].clamp(0, shape[0] - 1), position[1].clamp(0, shape[1] - 1)]
|
|
1596
|
+
end
|
|
1597
|
+
|
|
1598
|
+
def restore_cursor
|
|
1599
|
+
row, col = @saved_cursor
|
|
1600
|
+
@cursor_row = row.clamp(0, @rows - 1)
|
|
1601
|
+
@cursor_col = col.clamp(0, @cols - 1)
|
|
1602
|
+
@autowrap_pending = false
|
|
1603
|
+
end
|
|
1604
|
+
|
|
881
1605
|
def put_char(ch)
|
|
1606
|
+
width = self.class.char_width(ch.ord)
|
|
1607
|
+
|
|
1608
|
+
# Zero-width: fold the mark onto the preceding glyph instead of taking a
|
|
1609
|
+
# column, so the cursor stays where a real terminal would leave it.
|
|
1610
|
+
return attach_combining(ch) if width.zero?
|
|
1611
|
+
|
|
882
1612
|
if @autowrap_pending
|
|
883
1613
|
@cursor_col = 0
|
|
884
1614
|
line_feed
|
|
885
1615
|
@autowrap_pending = false
|
|
886
1616
|
end
|
|
887
|
-
|
|
888
|
-
|
|
1617
|
+
|
|
1618
|
+
# A wide glyph needs two columns; if only the last column is free, leave
|
|
1619
|
+
# it blank and wrap first (matching xterm/VTE deferral behavior).
|
|
1620
|
+
if width == 2 && @cursor_col == @cols - 1
|
|
1621
|
+
@buffer[@cursor_row][@cursor_col].reset!
|
|
1622
|
+
@cursor_col = 0
|
|
1623
|
+
line_feed
|
|
1624
|
+
end
|
|
1625
|
+
|
|
1626
|
+
c = @cursor_col
|
|
1627
|
+
write_cell(@buffer[@cursor_row][c], ch)
|
|
1628
|
+
if width == 2
|
|
1629
|
+
# The continuation half carries no glyph (char "") but inherits the
|
|
1630
|
+
# lead's colors so a styled wide cell paints both columns; the Renderer
|
|
1631
|
+
# skips emitting it since the lead already covers both columns.
|
|
1632
|
+
write_cell(@buffer[@cursor_row][c + 1], "")
|
|
1633
|
+
end
|
|
1634
|
+
|
|
1635
|
+
last_col = c + width - 1
|
|
1636
|
+
if last_col >= @cols - 1
|
|
1637
|
+
@cursor_col = @cols - 1
|
|
1638
|
+
@autowrap_pending = true
|
|
1639
|
+
else
|
|
1640
|
+
@cursor_col = last_col + 1
|
|
1641
|
+
end
|
|
1642
|
+
end
|
|
1643
|
+
|
|
1644
|
+
def write_cell(cell, ch)
|
|
1645
|
+
cell.char = self.class.intern_char(ch)
|
|
889
1646
|
cell.fg = @fg
|
|
890
1647
|
cell.bg = @bg
|
|
891
1648
|
cell.attrs = @attrs
|
|
892
1649
|
cell.hyperlink = @current_hyperlink
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1650
|
+
end
|
|
1651
|
+
|
|
1652
|
+
# Fold a zero-width mark (combining accent, variation selector, …) onto the
|
|
1653
|
+
# glyph in the cell the cursor just left, so the outer terminal composes
|
|
1654
|
+
# them (e + ◌́ → é) without the mark consuming a column. Marks with nothing
|
|
1655
|
+
# to attach to — start of line, or landing on a wide continuation half —
|
|
1656
|
+
# are dropped; column alignment matters more than the lost accent.
|
|
1657
|
+
def attach_combining(ch)
|
|
1658
|
+
target =
|
|
1659
|
+
if @autowrap_pending then @buffer[@cursor_row][@cols - 1]
|
|
1660
|
+
elsif @cursor_col > 0 then @buffer[@cursor_row][@cursor_col - 1]
|
|
1661
|
+
end
|
|
1662
|
+
return unless target
|
|
1663
|
+
return if target.char.empty?
|
|
1664
|
+
target.char += ch
|
|
898
1665
|
end
|
|
899
1666
|
|
|
900
1667
|
def line_feed
|
|
@@ -909,35 +1676,67 @@ module Muxr
|
|
|
909
1676
|
# Only the default full-screen region contributes to scrollback. Partial
|
|
910
1677
|
# regions (vi/less status lines) scroll inner content that's not really
|
|
911
1678
|
# "off the top of the screen" and shouldn't pollute history.
|
|
912
|
-
if
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1679
|
+
push_scrollback(@buffer[0]) if history_scrolls?
|
|
1680
|
+
@buffer[@scroll_top, @scroll_bottom - @scroll_top + 1] =
|
|
1681
|
+
@buffer[(@scroll_top + 1)..@scroll_bottom] + [Array.new(@cols) { blank_cell }]
|
|
1682
|
+
end
|
|
1683
|
+
|
|
1684
|
+
def refit(buffer, old_shape, new_shape, history:)
|
|
1685
|
+
old_rows, old_cols = old_shape
|
|
1686
|
+
rows, cols = new_shape
|
|
1687
|
+
grid = Array.new(rows) { Array.new(cols) { blank_cell } }
|
|
1688
|
+
keep_rows = [rows, old_rows].min
|
|
1689
|
+
keep_cols = [cols, old_cols].min
|
|
1690
|
+
src_start = old_rows - keep_rows
|
|
1691
|
+
src_start.times { |i| push_scrollback(buffer[i]) } if history
|
|
1692
|
+
keep_rows.times do |i|
|
|
1693
|
+
keep_cols.times do |j|
|
|
1694
|
+
grid[i][j].copy_from(buffer[src_start + i][j])
|
|
1695
|
+
end
|
|
1696
|
+
end
|
|
1697
|
+
grid
|
|
1698
|
+
end
|
|
1699
|
+
|
|
1700
|
+
def pack_row(row)
|
|
1701
|
+
last = last_significant_column(row)
|
|
1702
|
+
last += 1 if last && row[last + 1] && wide_lead?(row[last])
|
|
1703
|
+
HistoryRow.pack(row, last)
|
|
1704
|
+
end
|
|
1705
|
+
|
|
1706
|
+
def wide_lead?(cell)
|
|
1707
|
+
ch = cell.char
|
|
1708
|
+
!ch.empty? && self.class.char_width(ch.ord) == 2
|
|
1709
|
+
end
|
|
1710
|
+
|
|
1711
|
+
def history_scrolls?
|
|
1712
|
+
@saved_primary.nil? && @scroll_top.zero? && @scroll_bottom == @rows - 1
|
|
1713
|
+
end
|
|
1714
|
+
|
|
1715
|
+
def push_scrollback(row)
|
|
1716
|
+
@scrollback << pack_row(row)
|
|
1717
|
+
if @scrollback.size > self.class.scrollback_max
|
|
1718
|
+
@scrollback.shift
|
|
1719
|
+
# Selection coordinates are timeline-indexed; an eviction shifts the
|
|
1720
|
+
# whole timeline down by one. Track that or selection points at the
|
|
1721
|
+
# wrong row.
|
|
1722
|
+
if @selection_anchor
|
|
1723
|
+
@selection_anchor[0] = [@selection_anchor[0] - 1, 0].max
|
|
1724
|
+
@selection_cursor[0] = [@selection_cursor[0] - 1, 0].max
|
|
928
1725
|
end
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
@
|
|
1726
|
+
unless @search_matches.empty?
|
|
1727
|
+
@search_matches.each { |m| m[0] -= 1 }
|
|
1728
|
+
@search_matches.reject! { |m| m[0] < 0 }
|
|
1729
|
+
@search_current = nil if @search_current && @search_current >= @search_matches.length
|
|
933
1730
|
end
|
|
934
1731
|
end
|
|
935
|
-
|
|
936
|
-
|
|
1732
|
+
# Keep the user's view frozen on the same content when new rows arrive
|
|
1733
|
+
# while they're scrolled back.
|
|
1734
|
+
return unless @view_offset.positive?
|
|
1735
|
+
@view_offset = (@view_offset + 1).clamp(0, @scrollback.size)
|
|
937
1736
|
end
|
|
938
1737
|
|
|
939
1738
|
def set_view_offset(v)
|
|
940
|
-
new_v = v.clamp(0, @scrollback.size)
|
|
1739
|
+
new_v = @saved_primary ? 0 : v.clamp(0, @scrollback.size)
|
|
941
1740
|
return if new_v == @view_offset
|
|
942
1741
|
@view_offset = new_v
|
|
943
1742
|
@dirty = true
|
|
@@ -1028,6 +1827,20 @@ module Muxr
|
|
|
1028
1827
|
end
|
|
1029
1828
|
end
|
|
1030
1829
|
|
|
1830
|
+
def row_search_text(row)
|
|
1831
|
+
return row.search_text(@cols) if row.is_a?(HistoryRow)
|
|
1832
|
+
line = String.new(capacity: @cols)
|
|
1833
|
+
col_at = []
|
|
1834
|
+
@cols.times do |c|
|
|
1835
|
+
ch = row[c]&.char
|
|
1836
|
+
next if ch == ""
|
|
1837
|
+
ch = BLANK_CHAR if ch.nil?
|
|
1838
|
+
ch.each_char { col_at << c }
|
|
1839
|
+
line << ch
|
|
1840
|
+
end
|
|
1841
|
+
[line, col_at]
|
|
1842
|
+
end
|
|
1843
|
+
|
|
1031
1844
|
def collect_matches(query)
|
|
1032
1845
|
case_sensitive = query.match?(/[A-Z]/)
|
|
1033
1846
|
needle = case_sensitive ? query : query.downcase
|
|
@@ -1035,12 +1848,13 @@ module Muxr
|
|
|
1035
1848
|
timeline_size.times do |tr|
|
|
1036
1849
|
row = timeline_row(tr)
|
|
1037
1850
|
next if row.nil?
|
|
1038
|
-
line =
|
|
1039
|
-
@cols.times { |c| line << (row[c]&.char || " ") }
|
|
1851
|
+
line, col_at = row_search_text(row)
|
|
1040
1852
|
haystack = case_sensitive ? line : line.downcase
|
|
1041
1853
|
start = 0
|
|
1042
1854
|
while (idx = haystack.index(needle, start))
|
|
1043
|
-
|
|
1855
|
+
last = idx + needle.length - 1
|
|
1856
|
+
matches << [tr, col_at ? col_at[idx] : idx,
|
|
1857
|
+
col_at ? (col_at[last] || col_at.last || idx) : last]
|
|
1044
1858
|
# Advance past the start of this match so overlapping needles
|
|
1045
1859
|
# ("aa" in "aaaa") still emit one match per starting position.
|
|
1046
1860
|
start = idx + 1
|
|
@@ -1274,6 +2088,7 @@ module Muxr
|
|
|
1274
2088
|
end
|
|
1275
2089
|
|
|
1276
2090
|
def reset_terminal
|
|
2091
|
+
@saved_primary = nil
|
|
1277
2092
|
@buffer = Array.new(@rows) { Array.new(@cols) { blank_cell } }
|
|
1278
2093
|
@cursor_row = 0
|
|
1279
2094
|
@cursor_col = 0
|
|
@@ -1284,6 +2099,7 @@ module Muxr
|
|
|
1284
2099
|
@scroll_bottom = @rows - 1
|
|
1285
2100
|
@autowrap_pending = false
|
|
1286
2101
|
@current_hyperlink = nil
|
|
2102
|
+
@cursor_visible = true
|
|
1287
2103
|
end
|
|
1288
2104
|
end
|
|
1289
2105
|
end
|