muxr 0.1.10 → 0.1.11
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 +51 -1
- data/lib/muxr/application.rb +127 -13
- data/lib/muxr/input_handler.rb +59 -5
- data/lib/muxr/pane.rb +6 -0
- data/lib/muxr/pty_process.rb +20 -0
- data/lib/muxr/renderer.rb +43 -3
- data/lib/muxr/terminal.rb +160 -21
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr/window.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d0bd4e1e9036cc3f25aaa50e92b4183eb123230b3c33877d369d5350b1de8cc4
|
|
4
|
+
data.tar.gz: 55cd2910b19e1f546e6278c7d4532f34f4627be5e30866420e3ebe6b1997f3d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4782c572a8e4ab2ba193bfd38edb0f11c37e8033decc79b5ebda1b2e2629c56adc8363e9ac000c7314eec508faf98f8b6c40fa9dfa03570ed42e9ccee0c2cfca
|
|
7
|
+
data.tar.gz: 9f39b068f63bfcae8f72dd7caba1a539723d98534d34cac0b15048f902df3440f3e4c51ac18fa6cf8154fc4eecfec8bc7eacc2c692cbe7ca03774db6cb2bc8bb
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,50 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.11] - 2026-06-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Wide (CJK/emoji) and combining character support in the emulator.
|
|
13
|
+
`Terminal.char_width` classifies codepoints as 0/1/2 columns; a width-2
|
|
14
|
+
glyph occupies a lead cell plus an empty continuation cell, and
|
|
15
|
+
zero-width marks fold onto the preceding cell. Search highlights and
|
|
16
|
+
synthetic URL hyperlinks stay aligned on rows containing wide or
|
|
17
|
+
combining glyphs.
|
|
18
|
+
- `r` / `Ctrl-a r` refresh keybinding: a SIGWINCH winsize-wiggle nudges
|
|
19
|
+
the focused program to repaint itself, and a forced full re-emit
|
|
20
|
+
repaints the outer terminal — recovering from display drift whichever
|
|
21
|
+
layer is at fault.
|
|
22
|
+
- Scrollback is now pane-bound: `Ctrl-a` works from inside scrollback
|
|
23
|
+
and selection so pane-switch bindings work without leaving the mode,
|
|
24
|
+
focus returning to a scrolled-back pane resumes where you were
|
|
25
|
+
reading, yank returns to scrollback instead of snapping to the live
|
|
26
|
+
bottom, and `i` drops into insert without losing your place.
|
|
27
|
+
- Opt-in `MUXR_TRACE_OUTPUT` tap: when the env var names a writable
|
|
28
|
+
path, the server appends every byte it sends to the client, so a
|
|
29
|
+
rendering bug can be reproduced from the byte stream alone.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- The diff renderer now forces an absolute cursor move after
|
|
33
|
+
width-ambiguous glyphs (East Asian Ambiguous symbols, CJK, emoji)
|
|
34
|
+
instead of trusting cursor contiguity, so a width disagreement with
|
|
35
|
+
the outer terminal clips a single glyph rather than shifting the
|
|
36
|
+
entire rest of the line — the "text doesn't line up until I resize"
|
|
37
|
+
bug. Verified against pyte as a reference emulator.
|
|
38
|
+
- Bracketed-paste markers (`\e[200~`/`\e[201~`) are stripped before
|
|
39
|
+
writing to panes whose program never enabled the mode, so pastes no
|
|
40
|
+
longer show literal `^[[200~` text; programs that did enable it still
|
|
41
|
+
receive the markers, and markers split across read boundaries are
|
|
42
|
+
recombined.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
- `spiral` is the default layout for new windows (was `tall`). Saved
|
|
46
|
+
sessions are unaffected.
|
|
47
|
+
- New panes and the drawer start in the session origin cwd — the
|
|
48
|
+
directory `bin/muxr` was launched from — instead of the focused
|
|
49
|
+
pane's live cwd. Explicit cwds (MCP `panes.create`, restored
|
|
50
|
+
sessions) still win, and pane creation no longer pays the synchronous
|
|
51
|
+
~100–300ms `lsof` call on macOS.
|
|
52
|
+
|
|
9
53
|
## [0.1.10] - 2026-05-29
|
|
10
54
|
|
|
11
55
|
### Added
|
|
@@ -288,7 +332,13 @@ Initial release.
|
|
|
288
332
|
boundaries.
|
|
289
333
|
- Renderer that composes one frame and diff-emits ANSI to STDOUT.
|
|
290
334
|
|
|
291
|
-
[Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.
|
|
335
|
+
[Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.11...HEAD
|
|
336
|
+
[0.1.11]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.11
|
|
337
|
+
[0.1.10]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.10
|
|
338
|
+
[0.1.9]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.9
|
|
339
|
+
[0.1.8]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.8
|
|
340
|
+
[0.1.7]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.7
|
|
341
|
+
[0.1.6]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.6
|
|
292
342
|
[0.1.5]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.5
|
|
293
343
|
[0.1.4]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.4
|
|
294
344
|
[0.1.3]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.3
|
data/lib/muxr/application.rb
CHANGED
|
@@ -67,12 +67,36 @@ module Muxr
|
|
|
67
67
|
@current_client = nil
|
|
68
68
|
@client_write_buffer = +"".b
|
|
69
69
|
@listening_socket = nil
|
|
70
|
+
# The directory bin/muxr was launched from — Process.daemon(true, ...)
|
|
71
|
+
# preserves it across daemonization. Every new pane (and the drawer)
|
|
72
|
+
# starts here, treating it as the session's project root regardless of
|
|
73
|
+
# where the focused pane's shell has wandered.
|
|
74
|
+
@origin_cwd = Dir.pwd
|
|
70
75
|
@socket_path = self.class.socket_path_for(@session_name)
|
|
71
76
|
@control_socket_path = self.class.control_socket_path_for(@session_name)
|
|
72
77
|
@control_server = nil
|
|
73
78
|
@paste_buffer = +""
|
|
79
|
+
# Trailing bytes of an in-flight INPUT chunk that look like the start of
|
|
80
|
+
# a bracketed-paste marker but were cut off by the 4 KiB read boundary.
|
|
81
|
+
# Held back and prepended to the next chunk so a split marker still gets
|
|
82
|
+
# recognized — see #strip_bracketed_paste_markers.
|
|
83
|
+
@paste_marker_tail = +"".b
|
|
74
84
|
@last_render_at = nil
|
|
75
85
|
@foreground_poller = nil
|
|
86
|
+
# Opt-in diagnostic tap. When MUXR_TRACE_OUTPUT names a writable path, the
|
|
87
|
+
# server appends every byte it sends to the client — i.e. exactly what the
|
|
88
|
+
# outer terminal receives. Replaying it (`cat` it into a fresh terminal, or
|
|
89
|
+
# feed it to a reference emulator) reproduces a rendering bug from the byte
|
|
90
|
+
# stream alone, which tells us whether corruption is in muxr's emitted
|
|
91
|
+
# output or somewhere downstream. Off unless the env var is set.
|
|
92
|
+
@trace_output = open_trace(ENV["MUXR_TRACE_OUTPUT"])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def open_trace(path)
|
|
96
|
+
return nil if path.nil? || path.empty?
|
|
97
|
+
File.open(path, "ab")
|
|
98
|
+
rescue SystemCallError
|
|
99
|
+
nil
|
|
76
100
|
end
|
|
77
101
|
|
|
78
102
|
# Interval for the background thread that refreshes each pane's
|
|
@@ -94,13 +118,65 @@ module Muxr
|
|
|
94
118
|
|
|
95
119
|
# ---------- public action API (called from InputHandler / CommandDispatcher) ----------
|
|
96
120
|
|
|
121
|
+
# Bytes the outer terminal wraps around a paste once bracketed-paste mode
|
|
122
|
+
# is on (the client enables it unconditionally — see
|
|
123
|
+
# Client#enter_terminal_mode).
|
|
124
|
+
BRACKETED_PASTE_MARKERS = ["\e[200~".b, "\e[201~".b].freeze
|
|
125
|
+
|
|
97
126
|
def send_to_focused(data)
|
|
98
127
|
target = focused_target
|
|
99
|
-
target
|
|
128
|
+
return unless target
|
|
129
|
+
data = strip_bracketed_paste_markers(data, target)
|
|
130
|
+
target.write(data) unless data.empty?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The client turns bracketed-paste mode on for the *outer* terminal so big
|
|
134
|
+
# pastes arrive wrapped in \e[200~…\e[201~ (which lets shells/editors that
|
|
135
|
+
# speak the protocol collapse them). But the focused program may not speak
|
|
136
|
+
# it — in that case the markers would print as a literal "^[[200~" before
|
|
137
|
+
# and after the text. So: forward the markers untouched when the focused
|
|
138
|
+
# program enabled DECSET 2004, strip them otherwise.
|
|
139
|
+
#
|
|
140
|
+
# A marker can straddle a 4 KiB read boundary, so any trailing bytes that
|
|
141
|
+
# form a partial marker (but not a bare ESC, which must reach the program
|
|
142
|
+
# immediately as the Escape key) are held back and prepended next chunk.
|
|
143
|
+
def strip_bracketed_paste_markers(data, target)
|
|
144
|
+
data = data.b
|
|
145
|
+
term = target.respond_to?(:terminal) ? target.terminal : nil
|
|
146
|
+
buf = @paste_marker_tail + data
|
|
147
|
+
@paste_marker_tail = +"".b
|
|
148
|
+
|
|
149
|
+
if term&.bracketed_paste?
|
|
150
|
+
# Program wants the markers — hand back everything, partial included.
|
|
151
|
+
return buf
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
hold = pending_marker_prefix(buf)
|
|
155
|
+
if hold.positive?
|
|
156
|
+
@paste_marker_tail = buf.byteslice(buf.bytesize - hold, hold)
|
|
157
|
+
buf = buf.byteslice(0, buf.bytesize - hold) || +"".b
|
|
158
|
+
end
|
|
159
|
+
BRACKETED_PASTE_MARKERS.each { |m| buf = buf.gsub(m, "") }
|
|
160
|
+
buf
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Length (2..5) of the longest suffix of `buf` that is a proper prefix of a
|
|
164
|
+
# bracketed-paste marker, so the remainder can arrive in the next chunk. A
|
|
165
|
+
# bare trailing ESC (length 1) is deliberately not held: it's almost always
|
|
166
|
+
# the Escape key and the program must see it without waiting on the next
|
|
167
|
+
# keystroke. Worst case a marker split right after its ESC leaks a few
|
|
168
|
+
# bytes, which the program reads as a harmless unknown escape.
|
|
169
|
+
def pending_marker_prefix(buf)
|
|
170
|
+
max = [buf.bytesize, 5].min
|
|
171
|
+
max.downto(2) do |k|
|
|
172
|
+
tail = buf.byteslice(buf.bytesize - k, k)
|
|
173
|
+
return k if BRACKETED_PASTE_MARKERS.any? { |m| m.byteslice(0, k) == tail }
|
|
174
|
+
end
|
|
175
|
+
0
|
|
100
176
|
end
|
|
101
177
|
|
|
102
178
|
def new_pane(cwd: nil)
|
|
103
|
-
cwd ||=
|
|
179
|
+
cwd ||= @origin_cwd
|
|
104
180
|
pane = make_pane(cwd: cwd)
|
|
105
181
|
@session.window.add_pane(pane)
|
|
106
182
|
@session.focus_drawer = false
|
|
@@ -116,6 +192,7 @@ module Muxr
|
|
|
116
192
|
else
|
|
117
193
|
@session.window.focus_next
|
|
118
194
|
end
|
|
195
|
+
sync_input_mode_to_focus
|
|
119
196
|
invalidate
|
|
120
197
|
end
|
|
121
198
|
|
|
@@ -126,6 +203,7 @@ module Muxr
|
|
|
126
203
|
else
|
|
127
204
|
@session.window.focus_prev
|
|
128
205
|
end
|
|
206
|
+
sync_input_mode_to_focus
|
|
129
207
|
invalidate
|
|
130
208
|
end
|
|
131
209
|
|
|
@@ -136,6 +214,7 @@ module Muxr
|
|
|
136
214
|
else
|
|
137
215
|
@session.window.focus_last
|
|
138
216
|
end
|
|
217
|
+
sync_input_mode_to_focus
|
|
139
218
|
invalidate
|
|
140
219
|
end
|
|
141
220
|
|
|
@@ -145,9 +224,23 @@ module Muxr
|
|
|
145
224
|
return unless idx >= 0 && idx < @session.window.panes.length
|
|
146
225
|
@session.focus_drawer = false
|
|
147
226
|
@session.window.focus_index(idx)
|
|
227
|
+
sync_input_mode_to_focus
|
|
148
228
|
invalidate
|
|
149
229
|
end
|
|
150
230
|
|
|
231
|
+
# After a focus change, reconcile the input mode with the newly-focused
|
|
232
|
+
# pane: if it was left scrolled back, re-enter scrollback so the user
|
|
233
|
+
# lands exactly where they were reading ("navigating back to the scrolled
|
|
234
|
+
# pane puts you back into scrollback"). We only ever auto-ENTER here —
|
|
235
|
+
# the InputHandler's @prefix_return is what keeps you in scrollback when
|
|
236
|
+
# you hop onto a live pane, so we never auto-leave.
|
|
237
|
+
def sync_input_mode_to_focus
|
|
238
|
+
target = focused_target
|
|
239
|
+
return unless target&.terminal&.scrolled_back?
|
|
240
|
+
@input.enter_scrollback_mode
|
|
241
|
+
@renderer.reset_frame!
|
|
242
|
+
end
|
|
243
|
+
|
|
151
244
|
# Move focus to the pane spatially adjacent in `direction` (:left/:right/
|
|
152
245
|
# :up/:down). Called by the normal-mode hjkl bindings. Pulling the live
|
|
153
246
|
# layout rects keeps this in sync with whatever the renderer is showing.
|
|
@@ -168,12 +261,14 @@ module Muxr
|
|
|
168
261
|
when :right, :down then win.focus_next
|
|
169
262
|
when :left, :up then win.focus_prev
|
|
170
263
|
end
|
|
264
|
+
sync_input_mode_to_focus
|
|
171
265
|
invalidate
|
|
172
266
|
return
|
|
173
267
|
end
|
|
174
268
|
|
|
175
269
|
return unless idx
|
|
176
270
|
win.focus_index(idx)
|
|
271
|
+
sync_input_mode_to_focus
|
|
177
272
|
invalidate
|
|
178
273
|
end
|
|
179
274
|
|
|
@@ -275,6 +370,23 @@ module Muxr
|
|
|
275
370
|
invalidate
|
|
276
371
|
end
|
|
277
372
|
|
|
373
|
+
# Bound to `r` (normal) / `Ctrl-a r` (passthrough). Two-layer repaint to
|
|
374
|
+
# recover from a corrupted display, whichever layer drifted:
|
|
375
|
+
# 1. Nudge the focused program to redraw itself (SIGWINCH wiggle). This
|
|
376
|
+
# fixes muxr's own Terminal grid when an unhandled or wide glyph
|
|
377
|
+
# desynced the cursor — reset_frame! alone can't, since it would just
|
|
378
|
+
# faithfully re-emit the wrong grid.
|
|
379
|
+
# 2. Force a full re-emit of our composed frame to the outer terminal,
|
|
380
|
+
# fixing the case where the outer display lost/garbled bytes but our
|
|
381
|
+
# grid is correct.
|
|
382
|
+
def refresh_focused
|
|
383
|
+
target = focused_target
|
|
384
|
+
target.request_redraw if target.respond_to?(:request_redraw)
|
|
385
|
+
@renderer.reset_frame!
|
|
386
|
+
flash("refreshed")
|
|
387
|
+
invalidate
|
|
388
|
+
end
|
|
389
|
+
|
|
278
390
|
def promote_master
|
|
279
391
|
@session.window.promote_to_master
|
|
280
392
|
invalidate
|
|
@@ -502,7 +614,6 @@ module Muxr
|
|
|
502
614
|
def exit_selection(yank:)
|
|
503
615
|
target = focused_target
|
|
504
616
|
term = target&.terminal
|
|
505
|
-
yanked = false
|
|
506
617
|
if yank
|
|
507
618
|
# No anchor → no-op. User is still positioning; they can press v
|
|
508
619
|
# first, then yank. Esc/q is the way to exit from navigation.
|
|
@@ -512,18 +623,14 @@ module Muxr
|
|
|
512
623
|
@paste_buffer = text
|
|
513
624
|
spawn_pbcopy(text)
|
|
514
625
|
flash("yanked #{text.bytesize} bytes")
|
|
515
|
-
yanked = true
|
|
516
626
|
end
|
|
517
627
|
end
|
|
518
628
|
term&.clear_selection
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
else
|
|
525
|
-
@input.enter_scrollback_mode
|
|
526
|
-
end
|
|
629
|
+
# Drop back into scrollback at the current position whether or not we
|
|
630
|
+
# yanked. We no longer snap to the live bottom on yank — the user stays
|
|
631
|
+
# where they were reading so they can keep selecting or scrolling, and
|
|
632
|
+
# `q`/Esc is still there when they want to return to the bottom.
|
|
633
|
+
@input.enter_scrollback_mode
|
|
527
634
|
@renderer.reset_frame!
|
|
528
635
|
invalidate
|
|
529
636
|
end
|
|
@@ -610,6 +717,9 @@ module Muxr
|
|
|
610
717
|
# the server is also trying to read from that same client.
|
|
611
718
|
def deliver_output(bytes)
|
|
612
719
|
return unless @current_client
|
|
720
|
+
if @trace_output
|
|
721
|
+
@trace_output.write(bytes) rescue nil
|
|
722
|
+
end
|
|
613
723
|
@client_write_buffer << Protocol.frame(Protocol::OUTPUT, bytes)
|
|
614
724
|
drain_client_writes
|
|
615
725
|
end
|
|
@@ -718,6 +828,10 @@ module Muxr
|
|
|
718
828
|
end
|
|
719
829
|
@session&.window&.panes&.each(&:close)
|
|
720
830
|
@session&.drawer&.close
|
|
831
|
+
if @trace_output
|
|
832
|
+
@trace_output.close rescue nil
|
|
833
|
+
@trace_output = nil
|
|
834
|
+
end
|
|
721
835
|
end
|
|
722
836
|
|
|
723
837
|
def loop_forever
|
|
@@ -1049,7 +1163,7 @@ module Muxr
|
|
|
1049
1163
|
|
|
1050
1164
|
def ensure_drawer(command: nil)
|
|
1051
1165
|
return if @session.drawer
|
|
1052
|
-
cwd =
|
|
1166
|
+
cwd = @origin_cwd
|
|
1053
1167
|
pane = Pane.new(
|
|
1054
1168
|
id: :drawer,
|
|
1055
1169
|
rows: 10,
|
data/lib/muxr/input_handler.rb
CHANGED
|
@@ -19,6 +19,15 @@ module Muxr
|
|
|
19
19
|
# finish. :scrollback and :selection also return to @base_mode so that
|
|
20
20
|
# exiting back from a scroll/yank lands you back in passthrough if that's
|
|
21
21
|
# where you came from.
|
|
22
|
+
#
|
|
23
|
+
# Scrollback is effectively pane-bound. Ctrl-a is honored from inside
|
|
24
|
+
# :scrollback and :selection — it drops into :prefix (with @prefix_return
|
|
25
|
+
# = :scrollback) so a pane switch keeps you in scrollback on the pane you
|
|
26
|
+
# move to, while the pane you left keeps its own scroll position. Coming
|
|
27
|
+
# the other way, the Application re-enters scrollback whenever you focus a
|
|
28
|
+
# pane that was left scrolled back. `i` from scrollback drops to insert
|
|
29
|
+
# (passthrough) without snapping to the bottom; only `q`/Esc returns the
|
|
30
|
+
# pane to the live bottom.
|
|
22
31
|
class InputHandler
|
|
23
32
|
PREFIX = "\x01".freeze # Ctrl-a
|
|
24
33
|
|
|
@@ -51,6 +60,7 @@ module Muxr
|
|
|
51
60
|
"K" => [:move_direction, :up],
|
|
52
61
|
"L" => [:move_direction, :right],
|
|
53
62
|
"a" => :focus_last,
|
|
63
|
+
"r" => :refresh_focused,
|
|
54
64
|
"~" => :toggle_drawer,
|
|
55
65
|
"C" => :toggle_claude_drawer,
|
|
56
66
|
"P" => :toggle_private_focused,
|
|
@@ -66,6 +76,7 @@ module Muxr
|
|
|
66
76
|
"n" => :focus_next,
|
|
67
77
|
"p" => :focus_prev,
|
|
68
78
|
"a" => :focus_last,
|
|
79
|
+
"r" => :refresh_focused,
|
|
69
80
|
"x" => :request_close,
|
|
70
81
|
"\t" => :cycle_layout,
|
|
71
82
|
"\r" => :promote_master,
|
|
@@ -158,6 +169,11 @@ module Muxr
|
|
|
158
169
|
@command_buffer = +""
|
|
159
170
|
@search_buffer = +""
|
|
160
171
|
@search_direction = :forward
|
|
172
|
+
# When the prefix state is entered from scrollback/selection (Ctrl-a),
|
|
173
|
+
# this records :scrollback so that a pane switch lands you back in
|
|
174
|
+
# scrollback on the newly-focused pane instead of dropping to the base
|
|
175
|
+
# mode. nil means "use @base_mode" (the normal passthrough behavior).
|
|
176
|
+
@prefix_return = nil
|
|
161
177
|
end
|
|
162
178
|
|
|
163
179
|
def feed(data)
|
|
@@ -305,6 +321,12 @@ module Muxr
|
|
|
305
321
|
end
|
|
306
322
|
|
|
307
323
|
def handle_prefix(ch)
|
|
324
|
+
# Where to land once the prefix binding finishes. Normally the base
|
|
325
|
+
# mode, but :scrollback when we entered the prefix from scrollback /
|
|
326
|
+
# selection so a pane switch keeps you in scrollback on the new pane.
|
|
327
|
+
# Consume it immediately so it never leaks into the next prefix.
|
|
328
|
+
ret = @prefix_return || @base_mode
|
|
329
|
+
@prefix_return = nil
|
|
308
330
|
action = PREFIX_BINDINGS[ch]
|
|
309
331
|
case
|
|
310
332
|
when ch == "\e"
|
|
@@ -321,15 +343,18 @@ module Muxr
|
|
|
321
343
|
@state = @base_mode
|
|
322
344
|
when DIGIT_RE.match?(ch)
|
|
323
345
|
@app.focus_pane_number(ch.to_i)
|
|
324
|
-
|
|
346
|
+
# The focus action may auto-enter scrollback (landing on a pane that
|
|
347
|
+
# was left scrolled). Only fall back to `ret` if it didn't.
|
|
348
|
+
@state = ret if @state == :prefix
|
|
325
349
|
when action
|
|
326
350
|
@app.public_send(action)
|
|
327
351
|
# The action may have set a new state (confirm_quit, confirm_close,
|
|
328
|
-
# scrollback, help). Only
|
|
329
|
-
|
|
352
|
+
# scrollback via auto-enter, help). Only fall back to `ret` if we're
|
|
353
|
+
# still in :prefix.
|
|
354
|
+
@state = ret if @state == :prefix
|
|
330
355
|
else
|
|
331
|
-
# Unknown prefix key: return to
|
|
332
|
-
@state =
|
|
356
|
+
# Unknown prefix key: return to where we came from silently.
|
|
357
|
+
@state = ret
|
|
333
358
|
end
|
|
334
359
|
end
|
|
335
360
|
|
|
@@ -352,6 +377,25 @@ module Muxr
|
|
|
352
377
|
end
|
|
353
378
|
|
|
354
379
|
def handle_scrollback_input(ch)
|
|
380
|
+
if ch == PREFIX
|
|
381
|
+
# Ctrl-a is the escape hatch even from scrollback: drop into the
|
|
382
|
+
# prefix state so the user can switch panes (Ctrl-a n/p/a/1-9) or
|
|
383
|
+
# run any other prefix binding without first leaving scrollback.
|
|
384
|
+
# @prefix_return = :scrollback keeps the user in scrollback on the
|
|
385
|
+
# pane they switch to; the source pane keeps its scroll position so
|
|
386
|
+
# it stays put. Scrollback is effectively pane-bound now.
|
|
387
|
+
@prefix_return = :scrollback
|
|
388
|
+
@state = :prefix
|
|
389
|
+
return
|
|
390
|
+
end
|
|
391
|
+
if ch == "i"
|
|
392
|
+
# Drop straight into insert (passthrough) without snapping to the
|
|
393
|
+
# live bottom — the pane stays where it's scrolled. Mirrors the
|
|
394
|
+
# normal-mode `i` so "type now" is one key from scrollback too.
|
|
395
|
+
enter_passthrough_mode
|
|
396
|
+
@app.enter_passthrough_mode
|
|
397
|
+
return
|
|
398
|
+
end
|
|
355
399
|
if SCROLLBACK_EXITS.include?(ch)
|
|
356
400
|
enter_idle_mode
|
|
357
401
|
@app.exit_scrollback
|
|
@@ -434,6 +478,16 @@ module Muxr
|
|
|
434
478
|
end
|
|
435
479
|
|
|
436
480
|
def handle_selection_input(ch)
|
|
481
|
+
if ch == PREFIX
|
|
482
|
+
# Same escape hatch as scrollback: Ctrl-a enters the prefix state so
|
|
483
|
+
# pane switching (and any other prefix binding) works mid-selection.
|
|
484
|
+
# We return to :scrollback (not :selection) on the new pane — you
|
|
485
|
+
# don't want to be mid-select on a pane you just arrived at — while
|
|
486
|
+
# the source pane keeps its scroll position and selection intact.
|
|
487
|
+
@prefix_return = :scrollback
|
|
488
|
+
@state = :prefix
|
|
489
|
+
return
|
|
490
|
+
end
|
|
437
491
|
if SELECTION_YANK.include?(ch)
|
|
438
492
|
@app.exit_selection(yank: true)
|
|
439
493
|
return
|
data/lib/muxr/pane.rb
CHANGED
|
@@ -117,6 +117,12 @@ module Muxr
|
|
|
117
117
|
@process.resize(rows, cols)
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
+
# Force the inner program to repaint itself (see PTYProcess#nudge_redraw).
|
|
121
|
+
# Used by the refresh keybinding to recover from emulation drift.
|
|
122
|
+
def request_redraw
|
|
123
|
+
@process.nudge_redraw
|
|
124
|
+
end
|
|
125
|
+
|
|
120
126
|
def alive?
|
|
121
127
|
@process.alive?
|
|
122
128
|
end
|
data/lib/muxr/pty_process.rb
CHANGED
|
@@ -81,6 +81,26 @@ module Muxr
|
|
|
81
81
|
end
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
# Coax the foreground program into repainting from scratch by briefly
|
|
85
|
+
# toggling the PTY window size, which delivers SIGWINCH to the tty's
|
|
86
|
+
# foreground process group. Full-screen TUIs (vim, htop, less, fzf) redraw
|
|
87
|
+
# on WINCH, which rewrites muxr's Terminal grid and clears any emulation
|
|
88
|
+
# drift (e.g. a wide glyph that desynced the cursor). The size is restored
|
|
89
|
+
# immediately, so the program redraws at the real dimensions: it reads the
|
|
90
|
+
# current (restored) winsize in its handler and never observes the
|
|
91
|
+
# transient size. No-op when the pane is too narrow to wiggle.
|
|
92
|
+
def nudge_redraw
|
|
93
|
+
return if @exited
|
|
94
|
+
smaller = [@cols - 1, 1].max
|
|
95
|
+
return if smaller == @cols
|
|
96
|
+
begin
|
|
97
|
+
@reader.winsize = [@rows, smaller, 0, 0]
|
|
98
|
+
@reader.winsize = [@rows, @cols, 0, 0]
|
|
99
|
+
rescue StandardError
|
|
100
|
+
# Some platforms reject winsize pokes; reset_frame! still re-emits.
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
84
104
|
def alive?
|
|
85
105
|
return false if @exited
|
|
86
106
|
Process.kill(0, @pid)
|
data/lib/muxr/renderer.rb
CHANGED
|
@@ -395,6 +395,7 @@ module Muxr
|
|
|
395
395
|
" | - f e S layout: columns / rows / spiral / centered / stack",
|
|
396
396
|
" Tab / Enter cycle layout / promote to master",
|
|
397
397
|
" a / 1..9 last pane / jump by number",
|
|
398
|
+
" r refresh / redraw (fixes a corrupted pane)",
|
|
398
399
|
" s enter scrollback",
|
|
399
400
|
" ~ / C / P drawer / Claude drawer / toggle private",
|
|
400
401
|
" : / ? command prompt / toggle this help",
|
|
@@ -405,14 +406,18 @@ module Muxr
|
|
|
405
406
|
" C-a c x t w g m same as normal-mode bindings",
|
|
406
407
|
" C-a Tab Enter cycle layout / promote master",
|
|
407
408
|
" C-a n / p / a next / prev / last pane",
|
|
409
|
+
" C-a r refresh / redraw (fixes a corrupted pane)",
|
|
408
410
|
" C-a [ ] scrollback / paste buffer",
|
|
409
411
|
" C-a C-a send literal Ctrl-a to focused pane",
|
|
410
412
|
"",
|
|
411
|
-
"SCROLLBACK mode (
|
|
413
|
+
"SCROLLBACK mode (pane-bound: follows you as you switch panes)",
|
|
412
414
|
" j/k ↑/↓ d/u f/b g/G scroll C-b/C-f page v→cursor",
|
|
413
415
|
" / search-fwd ? search-back n/N next/prev match",
|
|
416
|
+
" C-a n/p/a/1-9 switch pane, stay in scrollback (each keeps its pos)",
|
|
417
|
+
" i insert here (keeps scroll pos) q/Esc exit to live bottom",
|
|
414
418
|
" cursor: h/j/k/l 0/^/$ w/e/b W/E/B H/M/L g/G",
|
|
415
|
-
" v select, C-v block, y/Enter yank
|
|
419
|
+
" v select, C-v block, y/Enter yank (stays in scrollback)",
|
|
420
|
+
" q/Esc cancel C-a n/p/a/1-9 switch pane",
|
|
416
421
|
"",
|
|
417
422
|
"Commands: layout {tall|wide|columns|rows|grid|spiral|centered|stack|monocle},",
|
|
418
423
|
" drawer {toggle|show|hide|reset},",
|
|
@@ -543,6 +548,11 @@ module Muxr
|
|
|
543
548
|
if same_size && @prev[y][x] == cell
|
|
544
549
|
next
|
|
545
550
|
end
|
|
551
|
+
# The right half of a wide glyph (char "") is painted by its lead
|
|
552
|
+
# cell to the left, which spans both columns in the outer terminal.
|
|
553
|
+
# Emitting anything here would clobber that glyph, so skip it — and
|
|
554
|
+
# leave last_x untouched, since we didn't move the outer cursor.
|
|
555
|
+
next if cell.char.empty?
|
|
546
556
|
if last_y != y || last_x != x
|
|
547
557
|
out << "\e[#{y + 1};#{x + 1}H"
|
|
548
558
|
end
|
|
@@ -559,7 +569,19 @@ module Muxr
|
|
|
559
569
|
end
|
|
560
570
|
out << cell.char
|
|
561
571
|
last_y = y
|
|
562
|
-
|
|
572
|
+
if contiguous_after?(cell.char)
|
|
573
|
+
# Advance by the glyph's display width, not its codepoint count: a
|
|
574
|
+
# wide char ("中") moves the outer cursor two columns though it's
|
|
575
|
+
# one codepoint, and a base+combining cell ("é") moves one though
|
|
576
|
+
# it's two. Keeping last_x in sync with the real cursor lets the
|
|
577
|
+
# next cell skip a redundant CUP.
|
|
578
|
+
last_x = x + Terminal.char_width(cell.char.ord)
|
|
579
|
+
else
|
|
580
|
+
# The outer terminal might advance its cursor by a different number
|
|
581
|
+
# of columns than we think for this glyph — force an absolute
|
|
582
|
+
# reposition for the next cell so the disagreement can't cascade.
|
|
583
|
+
last_x = nil
|
|
584
|
+
end
|
|
563
585
|
end
|
|
564
586
|
end
|
|
565
587
|
out << "\e]8;;\e\\" if cur_hyperlink
|
|
@@ -573,6 +595,24 @@ module Muxr
|
|
|
573
595
|
@prev_h = frame.length
|
|
574
596
|
end
|
|
575
597
|
|
|
598
|
+
# Whether we can trust the outer terminal's cursor to be exactly one
|
|
599
|
+
# display-width past this glyph, so the next contiguous cell needs no
|
|
600
|
+
# cursor-position escape. Safe only for glyphs whose width every terminal
|
|
601
|
+
# agrees on: ASCII (always one column), and the box-drawing / block-element
|
|
602
|
+
# band 0x2500–0x259F (reliably one column, and we emit a lot of them for
|
|
603
|
+
# borders, so keeping them contiguous matters). Everything else non-ASCII —
|
|
604
|
+
# CJK, emoji, and East Asian Ambiguous symbols like ·, …, ●, arrows, and
|
|
605
|
+
# the ⏺/✻/❯ glyphs Claude Code's UI is full of — can be drawn two columns
|
|
606
|
+
# wide by some terminals. We can't know which, so we force an absolute
|
|
607
|
+
# reposition after them: a width disagreement then clips a single glyph
|
|
608
|
+
# instead of shifting the whole rest of the line. A base+combining cell
|
|
609
|
+
# (multi-codepoint) is treated the same way out of caution.
|
|
610
|
+
def contiguous_after?(char)
|
|
611
|
+
return false if char.length > 1
|
|
612
|
+
cp = char.ord
|
|
613
|
+
cp < 0x80 || (cp >= 0x2500 && cp <= 0x259F)
|
|
614
|
+
end
|
|
615
|
+
|
|
576
616
|
def cursor_position(session, input_state:, command_buffer:, search_buffer: "")
|
|
577
617
|
if input_state == :command
|
|
578
618
|
col = 1 + command_buffer.length + 1 # ':' + buffer
|
data/lib/muxr/terminal.rb
CHANGED
|
@@ -12,6 +12,67 @@ module Muxr
|
|
|
12
12
|
|
|
13
13
|
SCROLLBACK_MAX = 5000
|
|
14
14
|
|
|
15
|
+
# Codepoint ranges that occupy two display columns (East Asian Wide /
|
|
16
|
+
# Fullwidth per UAX #11, plus the common emoji blocks). A wide glyph is
|
|
17
|
+
# stored in its lead cell with a continuation cell (char "") to its right
|
|
18
|
+
# reserving the second column — see #put_char. Kept as a flat, sorted list
|
|
19
|
+
# of ranges; #char_width only consults it for codepoints >= 0x300, so the
|
|
20
|
+
# ASCII/Latin-1 fast path never pays for the scan.
|
|
21
|
+
WIDE_RANGES = [
|
|
22
|
+
0x1100..0x115F, # Hangul Jamo
|
|
23
|
+
0x2329..0x232A, # angle brackets
|
|
24
|
+
0x2E80..0x303E, # CJK radicals, Kangxi, CJK symbols & punctuation
|
|
25
|
+
0x3041..0x33FF, # Hiragana … CJK compatibility
|
|
26
|
+
0x3400..0x4DBF, # CJK Unified Ext A
|
|
27
|
+
0x4E00..0x9FFF, # CJK Unified Ideographs
|
|
28
|
+
0xA000..0xA4CF, # Yi
|
|
29
|
+
0xA960..0xA97F, # Hangul Jamo Ext-A
|
|
30
|
+
0xAC00..0xD7A3, # Hangul Syllables
|
|
31
|
+
0xF900..0xFAFF, # CJK Compatibility Ideographs
|
|
32
|
+
0xFE10..0xFE19, # vertical forms
|
|
33
|
+
0xFE30..0xFE6F, # CJK compatibility / small form variants
|
|
34
|
+
0xFF00..0xFF60, # Fullwidth Forms
|
|
35
|
+
0xFFE0..0xFFE6, # Fullwidth signs
|
|
36
|
+
0x1B000..0x1B16F, # Kana supplement / extended
|
|
37
|
+
0x1F300..0x1F64F, # Misc symbols & pictographs, emoticons
|
|
38
|
+
0x1F680..0x1F6FF, # transport & map symbols
|
|
39
|
+
0x1F900..0x1F9FF, # supplemental symbols & pictographs
|
|
40
|
+
0x1FA70..0x1FAFF, # symbols & pictographs extended-A
|
|
41
|
+
0x20000..0x3FFFD # CJK Unified Ext B and beyond
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
# Codepoint ranges that occupy zero display columns: combining marks,
|
|
45
|
+
# variation selectors, and zero-width formatting characters. These fold
|
|
46
|
+
# onto the preceding glyph rather than consuming a column (#attach_combining)
|
|
47
|
+
# so the cursor stays aligned with what a real terminal would do.
|
|
48
|
+
ZERO_WIDTH_RANGES = [
|
|
49
|
+
0x0300..0x036F, # combining diacritical marks
|
|
50
|
+
0x0483..0x0489, # Cyrillic combining
|
|
51
|
+
0x0591..0x05BD, 0x05BF..0x05BF, 0x05C1..0x05C2, 0x05C4..0x05C5,
|
|
52
|
+
0x0610..0x061A, 0x064B..0x065F, 0x0670..0x0670,
|
|
53
|
+
0x06D6..0x06DC, 0x06DF..0x06E4, 0x06E7..0x06E8, 0x06EA..0x06ED,
|
|
54
|
+
0x0711..0x0711, 0x0730..0x074A,
|
|
55
|
+
0x200B..0x200F, # zero-width space/joiner/non-joiner, marks
|
|
56
|
+
0x2028..0x202E, 0x2060..0x2064,
|
|
57
|
+
0x20D0..0x20FF, # combining marks for symbols
|
|
58
|
+
0x1AB0..0x1AFF, 0x1DC0..0x1DFF, # combining extensions
|
|
59
|
+
0xFE00..0xFE0F, # variation selectors
|
|
60
|
+
0xFE20..0xFE2F, # combining half marks
|
|
61
|
+
0xFEFF..0xFEFF, # BOM / zero-width no-break space
|
|
62
|
+
0xE0100..0xE01EF # variation selectors supplement
|
|
63
|
+
].freeze
|
|
64
|
+
|
|
65
|
+
# Display width of a codepoint in terminal columns: 0 (combining /
|
|
66
|
+
# zero-width), 2 (East Asian wide / emoji), or 1 (everything else). The
|
|
67
|
+
# Renderer uses this to advance its emit cursor by the right number of
|
|
68
|
+
# columns; #put_char uses it to lay glyphs into the grid.
|
|
69
|
+
def self.char_width(cp)
|
|
70
|
+
return 1 if cp < 0x0300
|
|
71
|
+
return 0 if ZERO_WIDTH_RANGES.any? { |r| r.cover?(cp) }
|
|
72
|
+
return 2 if WIDE_RANGES.any? { |r| r.cover?(cp) }
|
|
73
|
+
1
|
|
74
|
+
end
|
|
75
|
+
|
|
15
76
|
# Cap on the OSC payload we buffer before parsing. URLs in OSC 8 can be
|
|
16
77
|
# long but rarely exceed a few hundred bytes; 4 KiB lets the parser stay
|
|
17
78
|
# tolerant of weird inputs without giving an attacker an unbounded sink.
|
|
@@ -96,6 +157,11 @@ module Muxr
|
|
|
96
157
|
@selection_mode = :linear
|
|
97
158
|
@sync_pending = false
|
|
98
159
|
@sync_started_at = nil
|
|
160
|
+
# True once the inner program enables bracketed-paste mode (DECSET
|
|
161
|
+
# 2004). The Application consults this to decide whether to forward the
|
|
162
|
+
# \e[200~…\e[201~ paste markers the outer terminal wraps around a paste
|
|
163
|
+
# or strip them — see Application#send_to_focused.
|
|
164
|
+
@bracketed_paste = false
|
|
99
165
|
@pending_replies = +"".b
|
|
100
166
|
@search_query = nil
|
|
101
167
|
@search_direction = :forward
|
|
@@ -134,6 +200,14 @@ module Muxr
|
|
|
134
200
|
@sync_started_at + SYNC_TIMEOUT
|
|
135
201
|
end
|
|
136
202
|
|
|
203
|
+
# True iff the inner program has enabled bracketed-paste mode (DECSET
|
|
204
|
+
# 2004). When false, the Application strips paste markers before writing
|
|
205
|
+
# so a program that doesn't speak bracketed paste never prints a literal
|
|
206
|
+
# "^[[200~".
|
|
207
|
+
def bracketed_paste?
|
|
208
|
+
@bracketed_paste
|
|
209
|
+
end
|
|
210
|
+
|
|
137
211
|
attr_reader :selection_mode
|
|
138
212
|
|
|
139
213
|
def cell(r, c)
|
|
@@ -578,12 +652,20 @@ module Muxr
|
|
|
578
652
|
end
|
|
579
653
|
end
|
|
580
654
|
|
|
655
|
+
# Build the scan text alongside a codepoint→cell map. A wide
|
|
656
|
+
# continuation half (char "") contributes no codepoints, and a
|
|
657
|
+
# base+combining cell contributes more than one, so we can't assume the
|
|
658
|
+
# old 1:1 cell↔codepoint indexing — map every codepoint back to its
|
|
659
|
+
# source cell instead. URLs are ASCII, but a wide glyph earlier on the
|
|
660
|
+
# line would otherwise shift every later offset off its cell.
|
|
581
661
|
text = String.new(capacity: rows.length * @cols)
|
|
582
662
|
cells = []
|
|
583
663
|
rows.each do |row|
|
|
584
664
|
row.each do |cell|
|
|
585
|
-
|
|
586
|
-
|
|
665
|
+
ch = cell.char
|
|
666
|
+
next if ch.empty?
|
|
667
|
+
ch.each_char { cells << cell }
|
|
668
|
+
text << ch
|
|
587
669
|
end
|
|
588
670
|
end
|
|
589
671
|
|
|
@@ -773,19 +855,20 @@ module Muxr
|
|
|
773
855
|
when ">", "<", "=", "!"
|
|
774
856
|
return
|
|
775
857
|
when "?"
|
|
776
|
-
# DEC private modes — most we treat as no-ops, but
|
|
777
|
-
# (Synchronized Output)
|
|
778
|
-
#
|
|
858
|
+
# DEC private modes — most we treat as no-ops, but two we track:
|
|
859
|
+
# 2026 (Synchronized Output) — a render-timing hint we honor so the
|
|
860
|
+
# outer paint lands on fully-formed frames from fzf/nvim/helix.
|
|
861
|
+
# 2004 (Bracketed Paste) — whether the inner program wants pastes
|
|
862
|
+
# wrapped in \e[200~…\e[201~; the Application strips those
|
|
863
|
+
# markers when it's off (see Application#send_to_focused).
|
|
779
864
|
if final == "h" || final == "l"
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
@sync_pending = false
|
|
786
|
-
@sync_started_at = nil
|
|
787
|
-
end
|
|
865
|
+
enabled = (final == "h")
|
|
866
|
+
params = csi_params
|
|
867
|
+
if params.include?(2026)
|
|
868
|
+
@sync_pending = enabled
|
|
869
|
+
@sync_started_at = enabled ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
|
|
788
870
|
end
|
|
871
|
+
@bracketed_paste = enabled if params.include?(2004)
|
|
789
872
|
end
|
|
790
873
|
return
|
|
791
874
|
end
|
|
@@ -879,22 +962,65 @@ module Muxr
|
|
|
879
962
|
end
|
|
880
963
|
|
|
881
964
|
def put_char(ch)
|
|
965
|
+
width = self.class.char_width(ch.ord)
|
|
966
|
+
|
|
967
|
+
# Zero-width: fold the mark onto the preceding glyph instead of taking a
|
|
968
|
+
# column, so the cursor stays where a real terminal would leave it.
|
|
969
|
+
return attach_combining(ch) if width.zero?
|
|
970
|
+
|
|
882
971
|
if @autowrap_pending
|
|
883
972
|
@cursor_col = 0
|
|
884
973
|
line_feed
|
|
885
974
|
@autowrap_pending = false
|
|
886
975
|
end
|
|
887
|
-
|
|
976
|
+
|
|
977
|
+
# A wide glyph needs two columns; if only the last column is free, leave
|
|
978
|
+
# it blank and wrap first (matching xterm/VTE deferral behavior).
|
|
979
|
+
if width == 2 && @cursor_col == @cols - 1
|
|
980
|
+
@buffer[@cursor_row][@cursor_col].reset!
|
|
981
|
+
@cursor_col = 0
|
|
982
|
+
line_feed
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
c = @cursor_col
|
|
986
|
+
write_cell(@buffer[@cursor_row][c], ch)
|
|
987
|
+
if width == 2
|
|
988
|
+
# The continuation half carries no glyph (char "") but inherits the
|
|
989
|
+
# lead's colors so a styled wide cell paints both columns; the Renderer
|
|
990
|
+
# skips emitting it since the lead already covers both columns.
|
|
991
|
+
write_cell(@buffer[@cursor_row][c + 1], "")
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
last_col = c + width - 1
|
|
995
|
+
if last_col >= @cols - 1
|
|
996
|
+
@cursor_col = @cols - 1
|
|
997
|
+
@autowrap_pending = true
|
|
998
|
+
else
|
|
999
|
+
@cursor_col = last_col + 1
|
|
1000
|
+
end
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
def write_cell(cell, ch)
|
|
888
1004
|
cell.char = ch
|
|
889
1005
|
cell.fg = @fg
|
|
890
1006
|
cell.bg = @bg
|
|
891
1007
|
cell.attrs = @attrs
|
|
892
1008
|
cell.hyperlink = @current_hyperlink
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1009
|
+
end
|
|
1010
|
+
|
|
1011
|
+
# Fold a zero-width mark (combining accent, variation selector, …) onto the
|
|
1012
|
+
# glyph in the cell the cursor just left, so the outer terminal composes
|
|
1013
|
+
# them (e + ◌́ → é) without the mark consuming a column. Marks with nothing
|
|
1014
|
+
# to attach to — start of line, or landing on a wide continuation half —
|
|
1015
|
+
# are dropped; column alignment matters more than the lost accent.
|
|
1016
|
+
def attach_combining(ch)
|
|
1017
|
+
target =
|
|
1018
|
+
if @autowrap_pending then @buffer[@cursor_row][@cols - 1]
|
|
1019
|
+
elsif @cursor_col > 0 then @buffer[@cursor_row][@cursor_col - 1]
|
|
1020
|
+
end
|
|
1021
|
+
return unless target
|
|
1022
|
+
return if target.char.empty?
|
|
1023
|
+
target.char += ch
|
|
898
1024
|
end
|
|
899
1025
|
|
|
900
1026
|
def line_feed
|
|
@@ -1035,12 +1161,25 @@ module Muxr
|
|
|
1035
1161
|
timeline_size.times do |tr|
|
|
1036
1162
|
row = timeline_row(tr)
|
|
1037
1163
|
next if row.nil?
|
|
1164
|
+
# Build the row text and a parallel codepoint→column map so matches can
|
|
1165
|
+
# be reported in column coordinates even when wide glyphs (one cell, two
|
|
1166
|
+
# columns) and combining marks (multi-codepoint, one cell) break the
|
|
1167
|
+
# 1:1 char-index↔column relationship. For all-ASCII rows col_at[i] == i,
|
|
1168
|
+
# so this is identical to the old behavior on the common path.
|
|
1038
1169
|
line = String.new(capacity: @cols)
|
|
1039
|
-
|
|
1170
|
+
col_at = []
|
|
1171
|
+
@cols.times do |c|
|
|
1172
|
+
ch = row[c]&.char
|
|
1173
|
+
next if ch == "" # wide continuation half — occupies no text slot
|
|
1174
|
+
ch = " " if ch.nil?
|
|
1175
|
+
ch.each_char { col_at << c }
|
|
1176
|
+
line << ch
|
|
1177
|
+
end
|
|
1040
1178
|
haystack = case_sensitive ? line : line.downcase
|
|
1041
1179
|
start = 0
|
|
1042
1180
|
while (idx = haystack.index(needle, start))
|
|
1043
|
-
|
|
1181
|
+
last = idx + needle.length - 1
|
|
1182
|
+
matches << [tr, col_at[idx], col_at[last] || col_at.last || idx]
|
|
1044
1183
|
# Advance past the start of this match so overlapping needles
|
|
1045
1184
|
# ("aa" in "aaaa") still emit one match per starting position.
|
|
1046
1185
|
start = idx + 1
|
data/lib/muxr/version.rb
CHANGED
data/lib/muxr/window.rb
CHANGED