muxr 0.1.5 → 0.1.7
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 +70 -0
- data/README.md +229 -68
- data/lib/muxr/application.rb +236 -0
- data/lib/muxr/command_dispatcher.rb +1 -1
- data/lib/muxr/foreground_command.rb +86 -0
- data/lib/muxr/input_handler.rb +256 -27
- data/lib/muxr/layout_manager.rb +59 -0
- data/lib/muxr/pane.rb +10 -0
- data/lib/muxr/renderer.rb +193 -43
- data/lib/muxr/terminal.rb +195 -2
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr/window.rb +13 -0
- data/lib/muxr.rb +1 -0
- metadata +2 -1
data/lib/muxr/renderer.rb
CHANGED
|
@@ -4,13 +4,38 @@ module Muxr
|
|
|
4
4
|
# new frame against the previous one and only repositions/redraws cells
|
|
5
5
|
# whose contents changed, keeping output volume low between ticks.
|
|
6
6
|
class Renderer
|
|
7
|
-
BORDER_FOCUSED = [:c256, 11].freeze # yellow
|
|
7
|
+
BORDER_FOCUSED = [:c256, 11].freeze # yellow (fallback when no mode color)
|
|
8
8
|
BORDER_UNFOCUSED = [:c256, 8].freeze # grey
|
|
9
9
|
BORDER_DRAWER_FOCUS = [:c256, 13].freeze # magenta
|
|
10
10
|
BORDER_DRAWER_IDLE = [:c256, 5].freeze # dark magenta
|
|
11
11
|
STATUS_BG = [:c256, 236].freeze
|
|
12
12
|
STATUS_FG = [:c256, 252].freeze
|
|
13
13
|
|
|
14
|
+
# Vim-style mode palette. Used in two places: the focused pane border
|
|
15
|
+
# (so the user can see at a glance what mode they're in) and the
|
|
16
|
+
# [MODE] chip in the status bar (same color, smaller real estate).
|
|
17
|
+
# :prefix maps to the same green as :passthrough because :prefix is a
|
|
18
|
+
# transient sub-state under passthrough — sharing the color avoids a
|
|
19
|
+
# one-frame border flicker when pressing Ctrl-a.
|
|
20
|
+
MODE_COLOR = {
|
|
21
|
+
normal: [:c256, 51].freeze, # cyan
|
|
22
|
+
passthrough: [:c256, 42].freeze, # green
|
|
23
|
+
prefix: [:c256, 42].freeze, # green (passthrough sub-state)
|
|
24
|
+
command: [:c256, 226].freeze, # yellow
|
|
25
|
+
scrollback: [:c256, 214].freeze, # orange
|
|
26
|
+
search: [:c256, 214].freeze, # orange (scrollback sub-state)
|
|
27
|
+
selection: [:c256, 201].freeze, # magenta
|
|
28
|
+
confirm_quit: [:c256, 196].freeze, # red
|
|
29
|
+
confirm_close: [:c256, 196].freeze, # red
|
|
30
|
+
help: [:c256, 39].freeze # blue
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Background applied to cells that match the active scrollback search.
|
|
34
|
+
# Bright enough to stand out over typical foreground SGRs while leaving
|
|
35
|
+
# the original glyph readable.
|
|
36
|
+
SEARCH_MATCH_BG = [:c256, 226].freeze # yellow
|
|
37
|
+
SEARCH_MATCH_FG = [:c256, 16].freeze # black
|
|
38
|
+
|
|
14
39
|
HORIZONTAL = "─".freeze
|
|
15
40
|
VERTICAL = "│".freeze
|
|
16
41
|
TL = "┌".freeze
|
|
@@ -18,9 +43,9 @@ module Muxr
|
|
|
18
43
|
BL = "└".freeze
|
|
19
44
|
BR = "┘".freeze
|
|
20
45
|
|
|
21
|
-
Cell = Struct.new(:char, :fg, :bg, :attrs) do
|
|
46
|
+
Cell = Struct.new(:char, :fg, :bg, :attrs, :hyperlink) do
|
|
22
47
|
def ==(other)
|
|
23
|
-
other.is_a?(Cell) && char == other.char && fg == other.fg && bg == other.bg && attrs == other.attrs
|
|
48
|
+
other.is_a?(Cell) && char == other.char && fg == other.fg && bg == other.bg && attrs == other.attrs && hyperlink == other.hyperlink
|
|
24
49
|
end
|
|
25
50
|
end
|
|
26
51
|
|
|
@@ -32,7 +57,10 @@ module Muxr
|
|
|
32
57
|
end
|
|
33
58
|
|
|
34
59
|
def enter_alt_screen
|
|
35
|
-
|
|
60
|
+
# Close any stale OSC 8 hyperlink the outer terminal might be carrying
|
|
61
|
+
# from before we attached, so the first frame's run-tracker matches
|
|
62
|
+
# reality.
|
|
63
|
+
@out.write("\e[?1049h\e[?25l\e[2J\e[H\e[0m\e]8;;\e\\")
|
|
36
64
|
@out.flush
|
|
37
65
|
@prev = nil
|
|
38
66
|
end
|
|
@@ -46,24 +74,31 @@ module Muxr
|
|
|
46
74
|
@prev = nil
|
|
47
75
|
end
|
|
48
76
|
|
|
49
|
-
def render(session, input_state: :
|
|
77
|
+
def render(session, input_state: :normal, command_buffer: "", search_buffer: "", search_direction: :forward, message: nil, help: false)
|
|
50
78
|
w = session.width
|
|
51
79
|
h = session.height
|
|
52
80
|
return if w < 4 || h < 3
|
|
53
81
|
|
|
54
|
-
frame = Array.new(h) { Array.new(w) { Cell.new(" ", nil, nil, 0) } }
|
|
55
|
-
|
|
56
|
-
compose_panes(frame, session)
|
|
57
|
-
compose_drawer(frame, session) if session.drawer&.visible?
|
|
58
|
-
compose_status_bar(
|
|
82
|
+
frame = Array.new(h) { Array.new(w) { Cell.new(" ", nil, nil, 0, nil) } }
|
|
83
|
+
|
|
84
|
+
compose_panes(frame, session, input_state: input_state)
|
|
85
|
+
compose_drawer(frame, session, input_state: input_state) if session.drawer&.visible?
|
|
86
|
+
compose_status_bar(
|
|
87
|
+
frame, session,
|
|
88
|
+
input_state: input_state,
|
|
89
|
+
command_buffer: command_buffer,
|
|
90
|
+
search_buffer: search_buffer,
|
|
91
|
+
search_direction: search_direction,
|
|
92
|
+
message: message
|
|
93
|
+
)
|
|
59
94
|
compose_help(frame, session) if help
|
|
60
95
|
|
|
61
|
-
emit_frame(frame, session, input_state: input_state, command_buffer: command_buffer)
|
|
96
|
+
emit_frame(frame, session, input_state: input_state, command_buffer: command_buffer, search_buffer: search_buffer)
|
|
62
97
|
end
|
|
63
98
|
|
|
64
99
|
private
|
|
65
100
|
|
|
66
|
-
def compose_panes(frame, session)
|
|
101
|
+
def compose_panes(frame, session, input_state: :normal)
|
|
67
102
|
win = session.window
|
|
68
103
|
content_area = LayoutManager::Rect.new(0, 0, session.width, session.height - 1)
|
|
69
104
|
rects = LayoutManager.compute(
|
|
@@ -99,20 +134,32 @@ module Muxr
|
|
|
99
134
|
title += " #{pane.id}" if pane.respond_to?(:id) && pane.id.is_a?(String)
|
|
100
135
|
title += " [P]" if pane.respond_to?(:private?) && pane.private?
|
|
101
136
|
title += " ★" if i == win.master_index
|
|
102
|
-
|
|
137
|
+
# Foreground command (e.g. "npm test", "vim"). Set by the poller
|
|
138
|
+
# thread; nil when the shell itself is foreground. Truncate so a
|
|
139
|
+
# long invocation doesn't push the title past what draw_box will
|
|
140
|
+
# render — draw_box silently drops titles that don't fit.
|
|
141
|
+
if pane.respond_to?(:foreground_command) && pane.foreground_command
|
|
142
|
+
cmd = pane.foreground_command.to_s[0, 16]
|
|
143
|
+
title += " · #{cmd}"
|
|
144
|
+
end
|
|
103
145
|
if pane.terminal.scrolled_back?
|
|
104
146
|
title += " [scrollback #{pane.terminal.view_offset}/#{pane.terminal.scrollback_size}]"
|
|
105
147
|
end
|
|
106
148
|
draw_box(frame, rect,
|
|
107
|
-
border: focused ?
|
|
149
|
+
border: focused ? mode_color(input_state) : BORDER_UNFOCUSED,
|
|
108
150
|
bold_border: focused,
|
|
109
151
|
title: title,
|
|
110
152
|
title_focused: focused)
|
|
153
|
+
# Mode chip lives in the top-right corner of the focused container
|
|
154
|
+
# (this pane, or the drawer — see compose_drawer). Showing it on
|
|
155
|
+
# the same edge as the title but on the opposite side keeps both
|
|
156
|
+
# readable without one crowding the other.
|
|
157
|
+
draw_mode_chip(frame, rect, input_state, title) if focused
|
|
111
158
|
copy_terminal(frame, pane, rect.x + 1, rect.y + 1)
|
|
112
159
|
end
|
|
113
160
|
end
|
|
114
161
|
|
|
115
|
-
def compose_drawer(frame, session)
|
|
162
|
+
def compose_drawer(frame, session, input_state: :normal)
|
|
116
163
|
drawer = session.drawer
|
|
117
164
|
return unless drawer&.pane
|
|
118
165
|
|
|
@@ -150,10 +197,50 @@ module Muxr
|
|
|
150
197
|
bold_border: true,
|
|
151
198
|
title: title,
|
|
152
199
|
title_focused: focused)
|
|
200
|
+
draw_mode_chip(frame, rect, input_state, title) if focused
|
|
153
201
|
copy_terminal(frame, drawer.pane, rect.x + 1, rect.y + 1)
|
|
154
202
|
end
|
|
155
203
|
|
|
156
|
-
def
|
|
204
|
+
def mode_color(input_state)
|
|
205
|
+
MODE_COLOR[input_state] || BORDER_FOCUSED
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Paint the " [MODE] " chip on the top border, hugging the right corner.
|
|
209
|
+
# Skipped when there isn't at least one column of breathing room between
|
|
210
|
+
# the title (anchored at the top-left) and the chip — otherwise long
|
|
211
|
+
# titles + a wide chip would overdraw each other and produce garbage.
|
|
212
|
+
def draw_mode_chip(frame, rect, input_state, title)
|
|
213
|
+
chip = " [#{mode_label(input_state)}] "
|
|
214
|
+
chip_start = rect.x + rect.w - 1 - chip.length
|
|
215
|
+
title_text = " #{title} "
|
|
216
|
+
title_end = rect.x + 2 + title_text.length - 1
|
|
217
|
+
return if chip_start <= title_end + 1
|
|
218
|
+
chip_color = mode_color(input_state)
|
|
219
|
+
chip.each_char.with_index do |ch, j|
|
|
220
|
+
set_cell(frame, rect.y, chip_start + j, ch, fg: chip_color, attrs: Terminal::BOLD)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Two-letter-ish mode label shown in the leftmost slot of the status bar.
|
|
225
|
+
# Lets the user see at a glance whether single-key bindings are active
|
|
226
|
+
# (NORMAL) or every key passes through to the focused pane (PASS).
|
|
227
|
+
def mode_label(input_state)
|
|
228
|
+
case input_state
|
|
229
|
+
when :normal then "NORMAL"
|
|
230
|
+
when :passthrough then "PASS"
|
|
231
|
+
when :prefix then "^A"
|
|
232
|
+
when :command then "CMD"
|
|
233
|
+
when :scrollback then "SCROLL"
|
|
234
|
+
when :search then "SEARCH"
|
|
235
|
+
when :selection then "SEL"
|
|
236
|
+
when :confirm_quit then "QUIT?"
|
|
237
|
+
when :confirm_close then "CLOSE?"
|
|
238
|
+
when :help then "HELP"
|
|
239
|
+
else "?"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def compose_status_bar(frame, session, input_state:, command_buffer:, search_buffer: "", search_direction: :forward, message: nil)
|
|
157
244
|
y = session.height - 1
|
|
158
245
|
w = session.width
|
|
159
246
|
win = session.window
|
|
@@ -163,7 +250,8 @@ module Muxr
|
|
|
163
250
|
else "hidden"
|
|
164
251
|
end
|
|
165
252
|
|
|
166
|
-
left = " [#{
|
|
253
|
+
left = " [#{mode_label(input_state)}]"
|
|
254
|
+
left << " [#{session.name}]"
|
|
167
255
|
left << " panes:#{win.panes.length}"
|
|
168
256
|
left << " layout:#{win.layout}"
|
|
169
257
|
focused_label =
|
|
@@ -190,6 +278,21 @@ module Muxr
|
|
|
190
278
|
c.attrs = 0
|
|
191
279
|
end
|
|
192
280
|
|
|
281
|
+
# Recolor the leading "[MODE]" chip in the mode's accent color. The
|
|
282
|
+
# chip lives at offset 1 (after one leading space) and runs for
|
|
283
|
+
# bracket+label+bracket characters. Full-row overlays below will
|
|
284
|
+
# overwrite this when active (command/scrollback/selection) — that's
|
|
285
|
+
# fine, those modes already convey themselves loudly.
|
|
286
|
+
chip = "[#{mode_label(input_state)}]"
|
|
287
|
+
chip_color = mode_color(input_state)
|
|
288
|
+
chip_start = 1
|
|
289
|
+
chip_end = [chip_start + chip.length, w].min
|
|
290
|
+
(chip_start...chip_end).each do |x|
|
|
291
|
+
c = frame[y][x]
|
|
292
|
+
c.fg = chip_color
|
|
293
|
+
c.attrs |= Terminal::BOLD
|
|
294
|
+
end
|
|
295
|
+
|
|
193
296
|
if input_state == :command
|
|
194
297
|
overlay = ":#{command_buffer}"
|
|
195
298
|
overlay = overlay[0, w]
|
|
@@ -208,7 +311,25 @@ module Muxr
|
|
|
208
311
|
c.attrs = 0
|
|
209
312
|
end
|
|
210
313
|
elsif input_state == :scrollback
|
|
211
|
-
overlay = " SCROLLBACK j/k line d/u half f/b page g/G top/bot v select q quit "
|
|
314
|
+
overlay = " SCROLLBACK ↑↓/j/k line d/u half f/b page g/G top/bot / search n/N next/prev v select q quit "
|
|
315
|
+
overlay = overlay[0, w]
|
|
316
|
+
overlay.each_char.with_index do |ch, x|
|
|
317
|
+
c = frame[y][x]
|
|
318
|
+
c.char = ch
|
|
319
|
+
c.fg = [:c256, 232]
|
|
320
|
+
c.bg = [:c256, 214]
|
|
321
|
+
c.attrs = Terminal::BOLD
|
|
322
|
+
end
|
|
323
|
+
(overlay.length...w).each do |x|
|
|
324
|
+
c = frame[y][x]
|
|
325
|
+
c.char = " "
|
|
326
|
+
c.fg = nil
|
|
327
|
+
c.bg = [:c256, 214]
|
|
328
|
+
c.attrs = 0
|
|
329
|
+
end
|
|
330
|
+
elsif input_state == :search
|
|
331
|
+
prefix = search_direction == :backward ? "?" : "/"
|
|
332
|
+
overlay = "#{prefix}#{search_buffer}"
|
|
212
333
|
overlay = overlay[0, w]
|
|
213
334
|
overlay.each_char.with_index do |ch, x|
|
|
214
335
|
c = frame[y][x]
|
|
@@ -231,7 +352,7 @@ module Muxr
|
|
|
231
352
|
mode = focused.terminal.selection_mode == :block ? "BLOCK" : "CHAR"
|
|
232
353
|
end
|
|
233
354
|
label = mode ? "SELECTION/#{mode}" : "SELECTION (cursor)"
|
|
234
|
-
overlay = " #{label} h/j/k/l move v char C-v block y/Enter yank q cancel "
|
|
355
|
+
overlay = " #{label} h/j/k/l move v/space char C-v block y/Enter yank q cancel "
|
|
235
356
|
overlay = overlay[0, w]
|
|
236
357
|
overlay.each_char.with_index do |ch, x|
|
|
237
358
|
c = frame[y][x]
|
|
@@ -265,28 +386,35 @@ module Muxr
|
|
|
265
386
|
HELP_LINES = [
|
|
266
387
|
"muxr — keybindings",
|
|
267
388
|
"",
|
|
268
|
-
"
|
|
269
|
-
"
|
|
270
|
-
"
|
|
271
|
-
"
|
|
272
|
-
"
|
|
273
|
-
"
|
|
274
|
-
"
|
|
275
|
-
"
|
|
276
|
-
"
|
|
277
|
-
" C
|
|
278
|
-
"
|
|
279
|
-
"
|
|
280
|
-
"
|
|
281
|
-
"
|
|
282
|
-
" C-a
|
|
283
|
-
" C-a
|
|
284
|
-
" C-a
|
|
285
|
-
" C-a
|
|
286
|
-
" C-a
|
|
389
|
+
"NORMAL mode (default; no prefix)",
|
|
390
|
+
" h / j / k / l focus pane left / down / up / right",
|
|
391
|
+
" H / J / K / L move pane left / down / up / right",
|
|
392
|
+
" i drop into passthrough mode",
|
|
393
|
+
" c / x new / close pane (close asks y/n)",
|
|
394
|
+
" t / g / m layout: tall / grid / monocle",
|
|
395
|
+
" Tab / Enter cycle layout / promote to master",
|
|
396
|
+
" a / 1..9 last pane / jump by number",
|
|
397
|
+
" s enter scrollback",
|
|
398
|
+
" ~ / C / P drawer / Claude drawer / toggle private",
|
|
399
|
+
" : / ? command prompt / toggle this help",
|
|
400
|
+
" ] / d / q paste buffer / detach / kill session",
|
|
401
|
+
"",
|
|
402
|
+
"PASSTHROUGH mode (keys reach the focused pane; prefix is Ctrl-a)",
|
|
403
|
+
" C-a Esc return to normal mode",
|
|
404
|
+
" C-a c x t g m same as normal-mode bindings",
|
|
405
|
+
" C-a Tab Enter cycle layout / promote master",
|
|
406
|
+
" C-a n / p / a next / prev / last pane",
|
|
407
|
+
" C-a [ ] scrollback / paste buffer",
|
|
408
|
+
" C-a C-a send literal Ctrl-a to focused pane",
|
|
409
|
+
"",
|
|
410
|
+
"SCROLLBACK mode (exits to the mode you came from)",
|
|
411
|
+
" j/k ↑/↓ d/u f/b g/G scroll C-b/C-f page v→cursor",
|
|
412
|
+
" / search-fwd ? search-back n/N next/prev match",
|
|
413
|
+
" cursor: h/j/k/l 0/^/$ w/e/b W/E/B H/M/L g/G",
|
|
414
|
+
" v select, C-v block, y/Enter yank, q/Esc cancel",
|
|
287
415
|
"",
|
|
288
|
-
"Commands: layout {tall|grid|monocle}, drawer {toggle|show|hide|reset},
|
|
289
|
-
" save, restore, sessions, quit, new, close, next, prev",
|
|
416
|
+
"Commands: layout {tall|grid|monocle}, drawer {toggle|show|hide|reset},",
|
|
417
|
+
" claude, save, restore, sessions, quit, new, close, next, prev",
|
|
290
418
|
"",
|
|
291
419
|
"press any key to dismiss"
|
|
292
420
|
].freeze
|
|
@@ -354,6 +482,7 @@ module Muxr
|
|
|
354
482
|
rows = term.rows
|
|
355
483
|
cols = term.cols
|
|
356
484
|
selection = term.selection_active?
|
|
485
|
+
search = term.search_active?
|
|
357
486
|
rows.times do |r|
|
|
358
487
|
fy = dst_y + r
|
|
359
488
|
next if fy < 0 || fy >= frame.length
|
|
@@ -366,7 +495,15 @@ module Muxr
|
|
|
366
495
|
dst.fg = src.fg
|
|
367
496
|
dst.bg = src.bg
|
|
368
497
|
dst.attrs = src.attrs
|
|
498
|
+
if search && term.cell_in_match?(r, c)
|
|
499
|
+
# Highlight wins over the cell's own bg so matches stay visible
|
|
500
|
+
# across whatever SGR the underlying program was using. Selection
|
|
501
|
+
# still applies on top via REVERSE below.
|
|
502
|
+
dst.fg = SEARCH_MATCH_FG
|
|
503
|
+
dst.bg = SEARCH_MATCH_BG
|
|
504
|
+
end
|
|
369
505
|
dst.attrs |= Terminal::REVERSE if selection && term.selected_at_visible?(r, c)
|
|
506
|
+
dst.hyperlink = src.hyperlink
|
|
370
507
|
end
|
|
371
508
|
end
|
|
372
509
|
end
|
|
@@ -381,7 +518,7 @@ module Muxr
|
|
|
381
518
|
c.attrs = attrs
|
|
382
519
|
end
|
|
383
520
|
|
|
384
|
-
def emit_frame(frame, session, input_state:, command_buffer:)
|
|
521
|
+
def emit_frame(frame, session, input_state:, command_buffer:, search_buffer: "")
|
|
385
522
|
# \e[?2026h enters synchronized-output mode so terminals that support it
|
|
386
523
|
# (Ghostty, kitty, iTerm2 ≥3.5, WezTerm, Alacritty ≥0.13, foot) present
|
|
387
524
|
# the whole frame atomically instead of repainting incrementally as bytes
|
|
@@ -393,6 +530,9 @@ module Muxr
|
|
|
393
530
|
cur_fg = :unset
|
|
394
531
|
cur_bg = :unset
|
|
395
532
|
cur_attrs = :unset
|
|
533
|
+
# We close any open hyperlink at end-of-frame, so the outer terminal
|
|
534
|
+
# always starts a new frame in the "no hyperlink" state.
|
|
535
|
+
cur_hyperlink = nil
|
|
396
536
|
last_y = nil
|
|
397
537
|
last_x = nil
|
|
398
538
|
|
|
@@ -410,13 +550,19 @@ module Muxr
|
|
|
410
550
|
cur_bg = cell.bg
|
|
411
551
|
cur_attrs = cell.attrs
|
|
412
552
|
end
|
|
553
|
+
if cell.hyperlink != cur_hyperlink
|
|
554
|
+
out << "\e]8;;\e\\" if cur_hyperlink
|
|
555
|
+
out << "\e]#{cell.hyperlink}\e\\" if cell.hyperlink
|
|
556
|
+
cur_hyperlink = cell.hyperlink
|
|
557
|
+
end
|
|
413
558
|
out << cell.char
|
|
414
559
|
last_y = y
|
|
415
560
|
last_x = x + cell.char.length
|
|
416
561
|
end
|
|
417
562
|
end
|
|
563
|
+
out << "\e]8;;\e\\" if cur_hyperlink
|
|
418
564
|
out << "\e[0m"
|
|
419
|
-
out << cursor_position(session, input_state: input_state, command_buffer: command_buffer)
|
|
565
|
+
out << cursor_position(session, input_state: input_state, command_buffer: command_buffer, search_buffer: search_buffer)
|
|
420
566
|
out << "\e[?2026l"
|
|
421
567
|
@out.write(out)
|
|
422
568
|
@out.flush
|
|
@@ -425,11 +571,15 @@ module Muxr
|
|
|
425
571
|
@prev_h = frame.length
|
|
426
572
|
end
|
|
427
573
|
|
|
428
|
-
def cursor_position(session, input_state:, command_buffer:)
|
|
574
|
+
def cursor_position(session, input_state:, command_buffer:, search_buffer: "")
|
|
429
575
|
if input_state == :command
|
|
430
576
|
col = 1 + command_buffer.length + 1 # ':' + buffer
|
|
431
577
|
return "\e[#{session.height};#{col}H\e[?25h"
|
|
432
578
|
end
|
|
579
|
+
if input_state == :search
|
|
580
|
+
col = 1 + search_buffer.length + 1 # '/' or '?' + buffer
|
|
581
|
+
return "\e[#{session.height};#{col}H\e[?25h"
|
|
582
|
+
end
|
|
433
583
|
|
|
434
584
|
target =
|
|
435
585
|
if session.focus_drawer && session.drawer&.visible? && session.drawer.pane
|
data/lib/muxr/terminal.rb
CHANGED
|
@@ -12,6 +12,11 @@ module Muxr
|
|
|
12
12
|
|
|
13
13
|
SCROLLBACK_MAX = 5000
|
|
14
14
|
|
|
15
|
+
# Cap on the OSC payload we buffer before parsing. URLs in OSC 8 can be
|
|
16
|
+
# long but rarely exceed a few hundred bytes; 4 KiB lets the parser stay
|
|
17
|
+
# tolerant of weird inputs without giving an attacker an unbounded sink.
|
|
18
|
+
OSC_MAX_LEN = 4096
|
|
19
|
+
|
|
15
20
|
# Inner programs (fzf ≥ 0.41, neovim, helix, …) bracket coherent screen
|
|
16
21
|
# updates with `\e[?2026h … \e[?2026l` (DECSET 2026 — "Synchronized
|
|
17
22
|
# Output"). When we see the open, we know more bytes are coming that
|
|
@@ -20,12 +25,13 @@ module Muxr
|
|
|
20
25
|
# program (which left ?2026h open) cannot wedge the pane indefinitely.
|
|
21
26
|
SYNC_TIMEOUT = 0.2
|
|
22
27
|
|
|
23
|
-
Cell = Struct.new(:char, :fg, :bg, :attrs) do
|
|
28
|
+
Cell = Struct.new(:char, :fg, :bg, :attrs, :hyperlink) do
|
|
24
29
|
def reset!
|
|
25
30
|
self.char = " "
|
|
26
31
|
self.fg = nil
|
|
27
32
|
self.bg = nil
|
|
28
33
|
self.attrs = 0
|
|
34
|
+
self.hyperlink = nil
|
|
29
35
|
end
|
|
30
36
|
|
|
31
37
|
def copy_from(other)
|
|
@@ -33,6 +39,7 @@ module Muxr
|
|
|
33
39
|
self.fg = other.fg
|
|
34
40
|
self.bg = other.bg
|
|
35
41
|
self.attrs = other.attrs
|
|
42
|
+
self.hyperlink = other.hyperlink
|
|
36
43
|
end
|
|
37
44
|
end
|
|
38
45
|
|
|
@@ -53,7 +60,14 @@ module Muxr
|
|
|
53
60
|
@scroll_bottom = rows - 1
|
|
54
61
|
@parser_state = :ground
|
|
55
62
|
@parser_params = +""
|
|
63
|
+
@parser_osc = +""
|
|
56
64
|
@feed_remainder = +"".b
|
|
65
|
+
# Currently-active OSC 8 hyperlink body (the "8;params;URI" payload that
|
|
66
|
+
# we'll wrap back around runs of cells when rendering), or nil when no
|
|
67
|
+
# hyperlink is open. Interned via @hyperlink_intern so repeated identical
|
|
68
|
+
# links share one frozen string for fast equality and small memory.
|
|
69
|
+
@current_hyperlink = nil
|
|
70
|
+
@hyperlink_intern = {}
|
|
57
71
|
@dirty = true
|
|
58
72
|
@scrollback = []
|
|
59
73
|
@view_offset = 0
|
|
@@ -63,6 +77,10 @@ module Muxr
|
|
|
63
77
|
@sync_pending = false
|
|
64
78
|
@sync_started_at = nil
|
|
65
79
|
@pending_replies = +"".b
|
|
80
|
+
@search_query = nil
|
|
81
|
+
@search_direction = :forward
|
|
82
|
+
@search_matches = []
|
|
83
|
+
@search_current = nil
|
|
66
84
|
end
|
|
67
85
|
|
|
68
86
|
# Bytes the emulator owes back to the inner program in response to a
|
|
@@ -337,6 +355,81 @@ module Muxr
|
|
|
337
355
|
@dirty = true
|
|
338
356
|
end
|
|
339
357
|
|
|
358
|
+
# ---------- search ----------
|
|
359
|
+
#
|
|
360
|
+
# Substring search over the full timeline (scrollback + live buffer).
|
|
361
|
+
# Smart-case: case-insensitive if the query is all-lowercase, sensitive
|
|
362
|
+
# otherwise. Matches are kept in timeline coordinates so they stay
|
|
363
|
+
# anchored to the same text as the user pages history.
|
|
364
|
+
|
|
365
|
+
attr_reader :search_query, :search_matches, :search_current, :search_direction
|
|
366
|
+
|
|
367
|
+
def search(query, direction: :forward)
|
|
368
|
+
query = query.to_s
|
|
369
|
+
if query.empty?
|
|
370
|
+
clear_search
|
|
371
|
+
return 0
|
|
372
|
+
end
|
|
373
|
+
@search_query = query
|
|
374
|
+
@search_direction = direction
|
|
375
|
+
@search_matches = collect_matches(query)
|
|
376
|
+
if @search_matches.empty?
|
|
377
|
+
@search_current = nil
|
|
378
|
+
@dirty = true
|
|
379
|
+
return 0
|
|
380
|
+
end
|
|
381
|
+
@search_current = nearest_match_in_direction(current_search_anchor_row, direction, inclusive: true)
|
|
382
|
+
@search_current ||= direction == :forward ? 0 : @search_matches.length - 1
|
|
383
|
+
scroll_view_to_match(@search_current)
|
|
384
|
+
@dirty = true
|
|
385
|
+
@search_matches.length
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Move to the next/previous match in the given direction, wrapping.
|
|
389
|
+
# Returns the new current match index, or nil if there are no matches.
|
|
390
|
+
# Anchors on the current match (not the viewport top) so n always
|
|
391
|
+
# advances even when scroll_view_to_match has centered the previous
|
|
392
|
+
# hit and dragged the viewport top behind it.
|
|
393
|
+
def find_in_direction(direction)
|
|
394
|
+
return nil if @search_matches.empty?
|
|
395
|
+
anchor_tr =
|
|
396
|
+
if @search_current && @search_matches[@search_current]
|
|
397
|
+
@search_matches[@search_current][0]
|
|
398
|
+
else
|
|
399
|
+
current_search_anchor_row
|
|
400
|
+
end
|
|
401
|
+
idx = strict_next_in_direction(anchor_tr, direction)
|
|
402
|
+
if idx.nil?
|
|
403
|
+
# Wrap: pick the far end depending on direction.
|
|
404
|
+
idx = direction == :forward ? 0 : @search_matches.length - 1
|
|
405
|
+
end
|
|
406
|
+
@search_current = idx
|
|
407
|
+
scroll_view_to_match(idx)
|
|
408
|
+
@dirty = true
|
|
409
|
+
idx
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def cell_in_match?(visible_r, c)
|
|
413
|
+
return false if @search_matches.empty?
|
|
414
|
+
tr = timeline_row_for_visible(visible_r)
|
|
415
|
+
# Linear scan is fine: SCROLLBACK_MAX caps matches at O(rows*cols), and
|
|
416
|
+
# the renderer touches each visible cell once per frame. A row-indexed
|
|
417
|
+
# cache would matter at much larger buffer sizes than ours.
|
|
418
|
+
@search_matches.any? { |mr, sc, ec| mr == tr && c >= sc && c <= ec }
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def search_active?
|
|
422
|
+
!(@search_query.nil? || @search_matches.empty?)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def clear_search
|
|
426
|
+
return if @search_query.nil? && @search_matches.empty?
|
|
427
|
+
@search_query = nil
|
|
428
|
+
@search_matches = []
|
|
429
|
+
@search_current = nil
|
|
430
|
+
@dirty = true
|
|
431
|
+
end
|
|
432
|
+
|
|
340
433
|
def selected_at_visible?(r, c)
|
|
341
434
|
return false unless @selection_anchor
|
|
342
435
|
tr = timeline_row_for_visible(r)
|
|
@@ -448,7 +541,25 @@ module Muxr
|
|
|
448
541
|
private
|
|
449
542
|
|
|
450
543
|
def blank_cell
|
|
451
|
-
Cell.new(" ", nil, nil, 0)
|
|
544
|
+
Cell.new(" ", nil, nil, 0, nil)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Parse the just-completed OSC payload. We only care about OSC 8
|
|
548
|
+
# (hyperlinks): `8;params;URI`. An empty URI closes the active link.
|
|
549
|
+
# Anything else (window-title OSC 0/1/2, palette OSC 4, …) is silently
|
|
550
|
+
# consumed — the emulator doesn't model it.
|
|
551
|
+
def finalize_osc
|
|
552
|
+
payload = @parser_osc
|
|
553
|
+
@parser_osc = +""
|
|
554
|
+
return if payload.empty?
|
|
555
|
+
return unless payload.start_with?("8;")
|
|
556
|
+
parts = payload.split(";", 3)
|
|
557
|
+
uri = parts[2]
|
|
558
|
+
if uri.nil? || uri.empty?
|
|
559
|
+
@current_hyperlink = nil
|
|
560
|
+
else
|
|
561
|
+
@current_hyperlink = (@hyperlink_intern[payload] ||= payload.dup.freeze)
|
|
562
|
+
end
|
|
452
563
|
end
|
|
453
564
|
|
|
454
565
|
def process_char(ch)
|
|
@@ -462,11 +573,18 @@ module Muxr
|
|
|
462
573
|
csi_char(ch, b)
|
|
463
574
|
when :osc
|
|
464
575
|
if b == 0x07 || b == 0x9c
|
|
576
|
+
finalize_osc
|
|
465
577
|
@parser_state = :ground
|
|
466
578
|
elsif b == 0x1b
|
|
467
579
|
@parser_state = :osc_esc
|
|
580
|
+
elsif @parser_osc.bytesize < OSC_MAX_LEN
|
|
581
|
+
@parser_osc << ch
|
|
468
582
|
end
|
|
469
583
|
when :osc_esc
|
|
584
|
+
# ST is `ESC \`. Anything else is malformed but we still flush — most
|
|
585
|
+
# terminals are lenient here, and being strict would swallow the
|
|
586
|
+
# payload on slightly buggy emitters.
|
|
587
|
+
finalize_osc
|
|
470
588
|
@parser_state = :ground
|
|
471
589
|
when :charset
|
|
472
590
|
@parser_state = :ground
|
|
@@ -505,6 +623,7 @@ module Muxr
|
|
|
505
623
|
@parser_params = +""
|
|
506
624
|
when 0x5d # ]
|
|
507
625
|
@parser_state = :osc
|
|
626
|
+
@parser_osc = +""
|
|
508
627
|
when 0x28, 0x29, 0x2a, 0x2b # ( ) * +
|
|
509
628
|
@parser_state = :charset
|
|
510
629
|
when 0x37 # 7 save cursor
|
|
@@ -699,6 +818,7 @@ module Muxr
|
|
|
699
818
|
cell.fg = @fg
|
|
700
819
|
cell.bg = @bg
|
|
701
820
|
cell.attrs = @attrs
|
|
821
|
+
cell.hyperlink = @current_hyperlink
|
|
702
822
|
if @cursor_col >= @cols - 1
|
|
703
823
|
@autowrap_pending = true
|
|
704
824
|
else
|
|
@@ -729,6 +849,11 @@ module Muxr
|
|
|
729
849
|
@selection_anchor[0] = [@selection_anchor[0] - 1, 0].max
|
|
730
850
|
@selection_cursor[0] = [@selection_cursor[0] - 1, 0].max
|
|
731
851
|
end
|
|
852
|
+
unless @search_matches.empty?
|
|
853
|
+
@search_matches.each { |m| m[0] -= 1 }
|
|
854
|
+
@search_matches.reject! { |m| m[0] < 0 }
|
|
855
|
+
@search_current = nil if @search_current && @search_current >= @search_matches.length
|
|
856
|
+
end
|
|
732
857
|
end
|
|
733
858
|
# Keep the user's view frozen on the same content when new rows arrive
|
|
734
859
|
# while they're scrolled back.
|
|
@@ -832,6 +957,73 @@ module Muxr
|
|
|
832
957
|
end
|
|
833
958
|
end
|
|
834
959
|
|
|
960
|
+
def collect_matches(query)
|
|
961
|
+
case_sensitive = query.match?(/[A-Z]/)
|
|
962
|
+
needle = case_sensitive ? query : query.downcase
|
|
963
|
+
matches = []
|
|
964
|
+
timeline_size.times do |tr|
|
|
965
|
+
row = timeline_row(tr)
|
|
966
|
+
next if row.nil?
|
|
967
|
+
line = String.new(capacity: @cols)
|
|
968
|
+
@cols.times { |c| line << (row[c]&.char || " ") }
|
|
969
|
+
haystack = case_sensitive ? line : line.downcase
|
|
970
|
+
start = 0
|
|
971
|
+
while (idx = haystack.index(needle, start))
|
|
972
|
+
matches << [tr, idx, idx + needle.length - 1]
|
|
973
|
+
# Advance past the start of this match so overlapping needles
|
|
974
|
+
# ("aa" in "aaaa") still emit one match per starting position.
|
|
975
|
+
start = idx + 1
|
|
976
|
+
end
|
|
977
|
+
end
|
|
978
|
+
matches
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
# Top of the current viewport in timeline coordinates. Used as the
|
|
982
|
+
# reference point for "search from where the user is looking now".
|
|
983
|
+
def current_search_anchor_row
|
|
984
|
+
timeline_row_for_visible(0)
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# Smallest match index whose row is >= anchor_tr (forward) or largest
|
|
988
|
+
# match index whose row is <= anchor_tr (backward). Used by search() so
|
|
989
|
+
# the first jump lands on the nearest match in the search direction
|
|
990
|
+
# without forcing the user to press n.
|
|
991
|
+
def nearest_match_in_direction(anchor_tr, direction, inclusive:)
|
|
992
|
+
if direction == :forward
|
|
993
|
+
@search_matches.each_with_index do |(mr, _, _), i|
|
|
994
|
+
return i if inclusive ? mr >= anchor_tr : mr > anchor_tr
|
|
995
|
+
end
|
|
996
|
+
nil
|
|
997
|
+
else
|
|
998
|
+
best = nil
|
|
999
|
+
@search_matches.each_with_index do |(mr, _, _), i|
|
|
1000
|
+
if (inclusive ? mr <= anchor_tr : mr < anchor_tr)
|
|
1001
|
+
best = i
|
|
1002
|
+
else
|
|
1003
|
+
break
|
|
1004
|
+
end
|
|
1005
|
+
end
|
|
1006
|
+
best
|
|
1007
|
+
end
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
# Strict next match (n/N) — never matches the current row; that would
|
|
1011
|
+
# leave the user stuck on the same line they're already on.
|
|
1012
|
+
def strict_next_in_direction(anchor_tr, direction)
|
|
1013
|
+
nearest_match_in_direction(anchor_tr, direction, inclusive: false)
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
# Center the match in the viewport when possible. set_view_offset clamps
|
|
1017
|
+
# to [0, scrollback.size] so recent matches end up showing the live
|
|
1018
|
+
# buffer at the bottom and very old ones pin to the top.
|
|
1019
|
+
def scroll_view_to_match(idx)
|
|
1020
|
+
match = @search_matches[idx]
|
|
1021
|
+
return unless match
|
|
1022
|
+
tr = match[0]
|
|
1023
|
+
desired = @scrollback.size - tr + (@rows / 2)
|
|
1024
|
+
set_view_offset(desired)
|
|
1025
|
+
end
|
|
1026
|
+
|
|
835
1027
|
def ensure_selection_cursor_visible
|
|
836
1028
|
return unless @selection_cursor
|
|
837
1029
|
tr = @selection_cursor[0]
|
|
@@ -1020,6 +1212,7 @@ module Muxr
|
|
|
1020
1212
|
@scroll_top = 0
|
|
1021
1213
|
@scroll_bottom = @rows - 1
|
|
1022
1214
|
@autowrap_pending = false
|
|
1215
|
+
@current_hyperlink = nil
|
|
1023
1216
|
end
|
|
1024
1217
|
end
|
|
1025
1218
|
end
|