muxr 0.1.8 → 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 +83 -1
- data/README.md +75 -8
- data/lib/muxr/application.rb +127 -13
- data/lib/muxr/command_dispatcher.rb +5 -3
- data/lib/muxr/input_handler.rb +65 -5
- data/lib/muxr/layout_manager.rb +157 -4
- data/lib/muxr/pane.rb +6 -0
- data/lib/muxr/pty_process.rb +20 -0
- data/lib/muxr/renderer.rb +48 -6
- 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,82 @@ 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
|
+
|
|
53
|
+
## [0.1.10] - 2026-05-29
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
- Six new layouts join `tall`, `grid`, and `monocle`:
|
|
57
|
+
- **`wide`** (`w`) — master on top, slaves split across the bottom.
|
|
58
|
+
- **`columns`** (`|`) — equal-width, full-height vertical strips.
|
|
59
|
+
- **`rows`** (`-`) — equal-height, full-width horizontal strips.
|
|
60
|
+
- **`spiral`** (`f`) — Fibonacci spiral winding inward, each pane half
|
|
61
|
+
the size of the last.
|
|
62
|
+
- **`centered`** (`e`) — master in a centred column with slaves dealt
|
|
63
|
+
to both sides.
|
|
64
|
+
- **`stack`** (`S`) — accordion: the focused pane expands while the
|
|
65
|
+
others collapse to title slivers.
|
|
66
|
+
The `:layout` command resolves any unambiguous name prefix across all
|
|
67
|
+
nine layouts; `C-a Tab` / `Tab` cycles through them in order. README
|
|
68
|
+
screenshots cover every layout.
|
|
69
|
+
|
|
70
|
+
## [0.1.9] - 2026-05-29
|
|
71
|
+
|
|
72
|
+
### Added
|
|
73
|
+
- `:layout` accepts short-form prefixes — `:layout t` / `g` / `m` map to
|
|
74
|
+
tall / grid / monocle via prefix matching. Full names still work; an
|
|
75
|
+
ambiguous prefix flashes the candidate layouts.
|
|
76
|
+
- README screenshots are now generated by [VHS](https://github.com/charmbracelet/vhs)
|
|
77
|
+
tapes under `docs/screenshots/tapes/`, regenerated with a single
|
|
78
|
+
`regenerate.sh` run. New captures cover scrollback `/` search and
|
|
79
|
+
movable-cursor visual selection.
|
|
80
|
+
|
|
81
|
+
### Documentation
|
|
82
|
+
- Documented that wrapped plain-text URLs are stamped with OSC 8
|
|
83
|
+
hyperlink ids (the 0.1.8 feature) in the README architecture section.
|
|
84
|
+
|
|
9
85
|
## [0.1.8] - 2026-05-22
|
|
10
86
|
|
|
11
87
|
### Added
|
|
@@ -256,7 +332,13 @@ Initial release.
|
|
|
256
332
|
boundaries.
|
|
257
333
|
- Renderer that composes one frame and diff-emits ANSI to STDOUT.
|
|
258
334
|
|
|
259
|
-
[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
|
|
260
342
|
[0.1.5]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.5
|
|
261
343
|
[0.1.4]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.4
|
|
262
344
|
[0.1.3]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.3
|
data/README.md
CHANGED
|
@@ -33,17 +33,49 @@ with the border color — tracks the current [input mode](#modes).
|
|
|
33
33
|
|
|
34
34
|
## Screenshots
|
|
35
35
|
|
|
36
|
-
The
|
|
36
|
+
The built-in layouts (pick directly with the keys below in normal mode, or cycle with `Tab` / `C-a Tab`):
|
|
37
|
+
|
|
38
|
+
| Layout | Key | Geometry |
|
|
39
|
+
|--------|-----|----------|
|
|
40
|
+
| `tall` | `t` | master on the left, slaves stacked on the right |
|
|
41
|
+
| `wide` | `w` | master on top, slaves split across the bottom |
|
|
42
|
+
| `columns` | `\|` | equal-width full-height vertical strips |
|
|
43
|
+
| `rows` | `-` | equal-height full-width horizontal strips |
|
|
44
|
+
| `grid` | `g` | roughly-square even tiling |
|
|
45
|
+
| `spiral` | `f` | Fibonacci spiral winding inward (each pane half the last) |
|
|
46
|
+
| `centered` | `e` | master in a centred column, slaves dealt to both sides |
|
|
47
|
+
| `stack` | `S` | accordion — focused pane expands, others collapse to title slivers |
|
|
48
|
+
| `monocle` | `m` | focused pane fullscreen |
|
|
37
49
|
|
|
38
50
|
<table>
|
|
39
51
|
<tr>
|
|
40
52
|
<td align="center"><strong>tall</strong><br/>master + stacked slaves</td>
|
|
41
|
-
<td align="center"><strong>
|
|
42
|
-
<td align="center"><strong>
|
|
53
|
+
<td align="center"><strong>wide</strong><br/>master on top, slaves below</td>
|
|
54
|
+
<td align="center"><strong>columns</strong><br/>equal-width strips</td>
|
|
43
55
|
</tr>
|
|
44
56
|
<tr>
|
|
45
57
|
<td><img src="docs/screenshots/01-layout-tall.png" alt="tall layout"></td>
|
|
58
|
+
<td><img src="docs/screenshots/05-layout-wide.png" alt="wide layout"></td>
|
|
59
|
+
<td><img src="docs/screenshots/06-layout-columns.png" alt="columns layout"></td>
|
|
60
|
+
</tr>
|
|
61
|
+
<tr>
|
|
62
|
+
<td align="center"><strong>rows</strong><br/>equal-height strips</td>
|
|
63
|
+
<td align="center"><strong>grid</strong><br/>even tiling</td>
|
|
64
|
+
<td align="center"><strong>spiral</strong><br/>Fibonacci spiral</td>
|
|
65
|
+
</tr>
|
|
66
|
+
<tr>
|
|
67
|
+
<td><img src="docs/screenshots/07-layout-rows.png" alt="rows layout"></td>
|
|
46
68
|
<td><img src="docs/screenshots/02-layout-grid.png" alt="grid layout"></td>
|
|
69
|
+
<td><img src="docs/screenshots/08-layout-spiral.png" alt="spiral layout"></td>
|
|
70
|
+
</tr>
|
|
71
|
+
<tr>
|
|
72
|
+
<td align="center"><strong>centered</strong><br/>master flanked by slaves</td>
|
|
73
|
+
<td align="center"><strong>stack</strong><br/>accordion of title slivers</td>
|
|
74
|
+
<td align="center"><strong>monocle</strong><br/>focused pane fullscreen</td>
|
|
75
|
+
</tr>
|
|
76
|
+
<tr>
|
|
77
|
+
<td><img src="docs/screenshots/09-layout-centered.png" alt="centered layout"></td>
|
|
78
|
+
<td><img src="docs/screenshots/10-layout-stack.png" alt="stack layout"></td>
|
|
47
79
|
<td><img src="docs/screenshots/03-layout-monocle.png" alt="monocle layout"></td>
|
|
48
80
|
</tr>
|
|
49
81
|
</table>
|
|
@@ -52,6 +84,17 @@ The Quake-style drawer overlay (`~` in normal mode, `C-a ~` in passthrough):
|
|
|
52
84
|
|
|
53
85
|

|
|
54
86
|
|
|
87
|
+
Scrollback / copy-mode (`s`) with `/` search — matches highlight in
|
|
88
|
+
yellow and the focused pane border turns orange:
|
|
89
|
+
|
|
90
|
+

|
|
91
|
+
|
|
92
|
+
Movable-cursor visual selection (`v` inside scrollback) — the border
|
|
93
|
+
turns magenta and the swept region is highlighted, ready to yank with
|
|
94
|
+
`y`:
|
|
95
|
+
|
|
96
|
+

|
|
97
|
+
|
|
55
98
|
## Install / run
|
|
56
99
|
|
|
57
100
|
```bash
|
|
@@ -89,7 +132,7 @@ muxr has two top-level input modes, modeled on vim:
|
|
|
89
132
|
|
|
90
133
|
- **Normal** (default at startup) — single keys act on the multiplexer.
|
|
91
134
|
`hjkl` moves focus between panes, `HJKL` moves the focused pane
|
|
92
|
-
itself, `c`/`x` create/close panes, `t`/`g`/`m` set the layout, etc.
|
|
135
|
+
itself, `c`/`x` create/close panes, `t`/`w`/`g`/`m` (and `|`/`-`/`f`/`e`/`S`) set the layout, etc.
|
|
93
136
|
No prefix needed.
|
|
94
137
|
- **Passthrough** (entered with `i`) — every keystroke is forwarded to
|
|
95
138
|
the focused pane, exactly like a regular terminal. muxr commands are
|
|
@@ -113,7 +156,8 @@ regardless of mode.
|
|
|
113
156
|
| `H` / `J` / `K` / `L`| move focused pane left / down / up / right |
|
|
114
157
|
| `i` | drop into passthrough mode |
|
|
115
158
|
| `c` / `x` | new / close focused pane (close asks `y/n`) |
|
|
116
|
-
| `t` / `g` / `m
|
|
159
|
+
| `t` / `w` / `g` / `m`| layout: tall / wide / grid / monocle |
|
|
160
|
+
| `\|` / `-` / `f` / `e` / `S` | layout: columns / rows / spiral / centered / stack |
|
|
117
161
|
| `Tab` / `Enter` | cycle layout / promote focused to master |
|
|
118
162
|
| `a` / `1` … `9` | toggle last pane / jump to pane by number |
|
|
119
163
|
| `s` | enter scrollback / copy-mode |
|
|
@@ -144,7 +188,7 @@ the move falls back to linear next/prev shuffling.
|
|
|
144
188
|
| `C-a a` | toggle last (previously focused) pane |
|
|
145
189
|
| `C-a 1` … `9` | jump to pane by its label |
|
|
146
190
|
| `C-a x` | close focused pane (asks `y/n`; hides drawer with no prompt) |
|
|
147
|
-
| `C-a Tab` | cycle layout (`tall` → `grid` → `monocle`)
|
|
191
|
+
| `C-a Tab` | cycle layout (`tall` → `wide` → `columns` → `rows` → `grid` → `spiral` → `centered` → `stack` → `monocle`) |
|
|
148
192
|
| `C-a Enter` | promote focused pane to master |
|
|
149
193
|
| `C-a ~` | toggle drawer (shell) |
|
|
150
194
|
| `C-a C` | toggle Claude Code drawer (MCP-aware) |
|
|
@@ -206,7 +250,10 @@ focused pane.
|
|
|
206
250
|
## Commands (typed after `:` in normal mode, or `C-a :` in passthrough)
|
|
207
251
|
|
|
208
252
|
```
|
|
209
|
-
layout {tall|grid|monocle}
|
|
253
|
+
layout {tall|wide|columns|rows|grid|spiral|centered|stack|monocle}
|
|
254
|
+
# any unambiguous name prefix works (t, w, r, g, m, …);
|
|
255
|
+
# ambiguous ones (c → columns/centered, s → spiral/stack)
|
|
256
|
+
# flash the candidates. layout (no arg) → cycle
|
|
210
257
|
drawer {toggle|show|hide|reset}
|
|
211
258
|
claude # toggle the Claude Code drawer
|
|
212
259
|
private # toggle private flag on focused pane
|
|
@@ -324,7 +371,11 @@ The per-pane `Terminal` is a real VT100 emulator (cursor movement, SGR
|
|
|
324
371
|
including 256-color/truecolor and underline subparameters, erase/insert/
|
|
325
372
|
delete, autowrap, scroll regions). Scrollback is composited into the
|
|
326
373
|
visible grid through a view-offset that auto-tracks new rows while
|
|
327
|
-
scrolled back, so reviewed content stays frozen.
|
|
374
|
+
scrolled back, so reviewed content stays frozen. Plain-text `http`/
|
|
375
|
+
`https`/`ftp` URLs that wrap across rows are re-stamped with matching
|
|
376
|
+
OSC 8 hyperlink ids after each feed, so terminals like Ghostty, iTerm2,
|
|
377
|
+
kitty, and WezTerm merge the wrapped halves back into one clickable
|
|
378
|
+
link (program-emitted OSC 8 payloads are left untouched).
|
|
328
379
|
|
|
329
380
|
## Session persistence
|
|
330
381
|
|
|
@@ -390,6 +441,22 @@ On-disk layout:
|
|
|
390
441
|
└─ logs/<name>.log server stdout/stderr
|
|
391
442
|
```
|
|
392
443
|
|
|
444
|
+
### Regenerating the README screenshots
|
|
445
|
+
|
|
446
|
+
The PNGs under `docs/screenshots/` are produced by [`vhs`](https://github.com/charmbracelet/vhs)
|
|
447
|
+
driving muxr itself — one `.tape` file per screenshot under
|
|
448
|
+
`docs/screenshots/tapes/`. After a UI change, refresh them with:
|
|
449
|
+
|
|
450
|
+
```bash
|
|
451
|
+
brew install vhs # one-time
|
|
452
|
+
docs/screenshots/tapes/regenerate.sh # renders all six
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Each tape spawns a throwaway `shot` session, populates one or more panes
|
|
456
|
+
with `ls`/`git log`/`wc` output, drives the feature being shown (layout,
|
|
457
|
+
drawer, scrollback search, selection), and writes a single PNG via
|
|
458
|
+
`Screenshot`. Tweak the tape if the keybindings or status bar change.
|
|
459
|
+
|
|
393
460
|
## Contributing
|
|
394
461
|
|
|
395
462
|
Contributions are welcome from anyone, with one requirement: **the code
|
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,
|
|
@@ -45,10 +45,12 @@ module Muxr
|
|
|
45
45
|
@app.cycle_layout
|
|
46
46
|
return
|
|
47
47
|
end
|
|
48
|
-
|
|
49
|
-
if
|
|
50
|
-
@app.session.window.set_layout(
|
|
48
|
+
matches = Window::LAYOUTS.select { |l| l.to_s.start_with?(name) }
|
|
49
|
+
if matches.length == 1
|
|
50
|
+
@app.session.window.set_layout(matches.first)
|
|
51
51
|
@app.invalidate
|
|
52
|
+
elsif matches.length > 1
|
|
53
|
+
@app.flash("ambiguous layout: #{name} (#{matches.join(", ")})")
|
|
52
54
|
else
|
|
53
55
|
@app.flash("unknown layout: #{name}")
|
|
54
56
|
end
|
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
|
|
|
@@ -31,8 +40,14 @@ module Muxr
|
|
|
31
40
|
"c" => :new_pane,
|
|
32
41
|
"x" => :request_close,
|
|
33
42
|
"t" => [:set_layout, :tall],
|
|
43
|
+
"w" => [:set_layout, :wide],
|
|
34
44
|
"g" => [:set_layout, :grid],
|
|
35
45
|
"m" => [:set_layout, :monocle],
|
|
46
|
+
"|" => [:set_layout, :columns],
|
|
47
|
+
"-" => [:set_layout, :rows],
|
|
48
|
+
"f" => [:set_layout, :spiral],
|
|
49
|
+
"e" => [:set_layout, :centered],
|
|
50
|
+
"S" => [:set_layout, :stack],
|
|
36
51
|
"\t" => :cycle_layout,
|
|
37
52
|
"\r" => :promote_master,
|
|
38
53
|
"\n" => :promote_master,
|
|
@@ -45,6 +60,7 @@ module Muxr
|
|
|
45
60
|
"K" => [:move_direction, :up],
|
|
46
61
|
"L" => [:move_direction, :right],
|
|
47
62
|
"a" => :focus_last,
|
|
63
|
+
"r" => :refresh_focused,
|
|
48
64
|
"~" => :toggle_drawer,
|
|
49
65
|
"C" => :toggle_claude_drawer,
|
|
50
66
|
"P" => :toggle_private_focused,
|
|
@@ -60,6 +76,7 @@ module Muxr
|
|
|
60
76
|
"n" => :focus_next,
|
|
61
77
|
"p" => :focus_prev,
|
|
62
78
|
"a" => :focus_last,
|
|
79
|
+
"r" => :refresh_focused,
|
|
63
80
|
"x" => :request_close,
|
|
64
81
|
"\t" => :cycle_layout,
|
|
65
82
|
"\r" => :promote_master,
|
|
@@ -152,6 +169,11 @@ module Muxr
|
|
|
152
169
|
@command_buffer = +""
|
|
153
170
|
@search_buffer = +""
|
|
154
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
|
|
155
177
|
end
|
|
156
178
|
|
|
157
179
|
def feed(data)
|
|
@@ -299,6 +321,12 @@ module Muxr
|
|
|
299
321
|
end
|
|
300
322
|
|
|
301
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
|
|
302
330
|
action = PREFIX_BINDINGS[ch]
|
|
303
331
|
case
|
|
304
332
|
when ch == "\e"
|
|
@@ -315,15 +343,18 @@ module Muxr
|
|
|
315
343
|
@state = @base_mode
|
|
316
344
|
when DIGIT_RE.match?(ch)
|
|
317
345
|
@app.focus_pane_number(ch.to_i)
|
|
318
|
-
|
|
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
|
|
319
349
|
when action
|
|
320
350
|
@app.public_send(action)
|
|
321
351
|
# The action may have set a new state (confirm_quit, confirm_close,
|
|
322
|
-
# scrollback, help). Only
|
|
323
|
-
|
|
352
|
+
# scrollback via auto-enter, help). Only fall back to `ret` if we're
|
|
353
|
+
# still in :prefix.
|
|
354
|
+
@state = ret if @state == :prefix
|
|
324
355
|
else
|
|
325
|
-
# Unknown prefix key: return to
|
|
326
|
-
@state =
|
|
356
|
+
# Unknown prefix key: return to where we came from silently.
|
|
357
|
+
@state = ret
|
|
327
358
|
end
|
|
328
359
|
end
|
|
329
360
|
|
|
@@ -346,6 +377,25 @@ module Muxr
|
|
|
346
377
|
end
|
|
347
378
|
|
|
348
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
|
|
349
399
|
if SCROLLBACK_EXITS.include?(ch)
|
|
350
400
|
enter_idle_mode
|
|
351
401
|
@app.exit_scrollback
|
|
@@ -428,6 +478,16 @@ module Muxr
|
|
|
428
478
|
end
|
|
429
479
|
|
|
430
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
|
|
431
491
|
if SELECTION_YANK.include?(ch)
|
|
432
492
|
@app.exit_selection(yank: true)
|
|
433
493
|
return
|
data/lib/muxr/layout_manager.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Muxr
|
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
LAYOUTS = %i[tall grid monocle].freeze
|
|
13
|
+
LAYOUTS = %i[tall wide columns rows grid spiral centered stack monocle].freeze
|
|
14
14
|
|
|
15
15
|
module_function
|
|
16
16
|
|
|
@@ -19,9 +19,15 @@ module Muxr
|
|
|
19
19
|
master_index = master_index.clamp(0, count - 1)
|
|
20
20
|
focused_index = focused_index.clamp(0, count - 1)
|
|
21
21
|
case layout
|
|
22
|
-
when :tall
|
|
23
|
-
when :
|
|
24
|
-
when :
|
|
22
|
+
when :tall then tall(count, area, master_index)
|
|
23
|
+
when :wide then wide(count, area, master_index)
|
|
24
|
+
when :columns then columns(count, area)
|
|
25
|
+
when :rows then rows(count, area)
|
|
26
|
+
when :grid then grid(count, area)
|
|
27
|
+
when :spiral then spiral(count, area)
|
|
28
|
+
when :centered then centered(count, area, master_index)
|
|
29
|
+
when :stack then stack(count, area, focused_index)
|
|
30
|
+
when :monocle then monocle(count, area, focused_index)
|
|
25
31
|
else
|
|
26
32
|
raise ArgumentError, "Unknown layout: #{layout.inspect}"
|
|
27
33
|
end
|
|
@@ -52,6 +58,153 @@ module Muxr
|
|
|
52
58
|
rects
|
|
53
59
|
end
|
|
54
60
|
|
|
61
|
+
# The transpose of `tall`: master pane spans the full width across the top
|
|
62
|
+
# half; remaining panes sit side-by-side in the bottom half, dividing the
|
|
63
|
+
# remaining width evenly.
|
|
64
|
+
def wide(count, area, master_index = 0)
|
|
65
|
+
master_index = master_index.clamp(0, count - 1)
|
|
66
|
+
return [Rect.new(area.x, area.y, area.w, area.h)] if count == 1
|
|
67
|
+
|
|
68
|
+
master_h = [area.h / 2, 1].max
|
|
69
|
+
stack_h = [area.h - master_h, 1].max
|
|
70
|
+
others = (0...count).to_a - [master_index]
|
|
71
|
+
slave_count = others.length
|
|
72
|
+
base_w = area.w / slave_count
|
|
73
|
+
remainder = area.w - base_w * slave_count
|
|
74
|
+
|
|
75
|
+
rects = Array.new(count)
|
|
76
|
+
rects[master_index] = Rect.new(area.x, area.y, area.w, master_h)
|
|
77
|
+
|
|
78
|
+
x = area.x
|
|
79
|
+
others.each_with_index do |idx, i|
|
|
80
|
+
w = base_w + (i < remainder ? 1 : 0)
|
|
81
|
+
rects[idx] = Rect.new(x, area.y + master_h, w, stack_h)
|
|
82
|
+
x += w
|
|
83
|
+
end
|
|
84
|
+
rects
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Equal-width, full-height vertical strips, side by side. No master.
|
|
88
|
+
def columns(count, area)
|
|
89
|
+
base_w = area.w / count
|
|
90
|
+
rem = area.w - base_w * count
|
|
91
|
+
rects = []
|
|
92
|
+
x = area.x
|
|
93
|
+
count.times do |i|
|
|
94
|
+
w = base_w + (i < rem ? 1 : 0)
|
|
95
|
+
rects << Rect.new(x, area.y, w, area.h)
|
|
96
|
+
x += w
|
|
97
|
+
end
|
|
98
|
+
rects
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Equal-height, full-width horizontal strips, stacked. The dual of columns.
|
|
102
|
+
def rows(count, area)
|
|
103
|
+
base_h = area.h / count
|
|
104
|
+
rem = area.h - base_h * count
|
|
105
|
+
rects = []
|
|
106
|
+
y = area.y
|
|
107
|
+
count.times do |i|
|
|
108
|
+
h = base_h + (i < rem ? 1 : 0)
|
|
109
|
+
rects << Rect.new(area.x, y, area.w, h)
|
|
110
|
+
y += h
|
|
111
|
+
end
|
|
112
|
+
rects
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Fibonacci spiral: each pane takes half of the remaining region, splitting
|
|
116
|
+
# vertically then horizontally in alternation, so panes wind inward toward
|
|
117
|
+
# the bottom-right. The last pane fills whatever is left.
|
|
118
|
+
def spiral(count, area)
|
|
119
|
+
x, y, w, h = area.x, area.y, area.w, area.h
|
|
120
|
+
rects = []
|
|
121
|
+
count.times do |i|
|
|
122
|
+
if i == count - 1
|
|
123
|
+
rects << Rect.new(x, y, w, h)
|
|
124
|
+
elsif i.even?
|
|
125
|
+
left = [w / 2, 1].max
|
|
126
|
+
rects << Rect.new(x, y, left, h)
|
|
127
|
+
x += left
|
|
128
|
+
w = [w - left, 1].max
|
|
129
|
+
else
|
|
130
|
+
top = [h / 2, 1].max
|
|
131
|
+
rects << Rect.new(x, y, w, top)
|
|
132
|
+
y += top
|
|
133
|
+
h = [h - top, 1].max
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
rects
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Three-column master: master occupies the centre column full-height; the
|
|
140
|
+
# remaining panes are dealt alternately to a left and a right column and
|
|
141
|
+
# stacked within each. With a single slave there is no symmetry to keep, so
|
|
142
|
+
# it falls back to a simple master/slave vertical split (like `tall`).
|
|
143
|
+
def centered(count, area, master_index = 0)
|
|
144
|
+
master_index = master_index.clamp(0, count - 1)
|
|
145
|
+
return [Rect.new(area.x, area.y, area.w, area.h)] if count == 1
|
|
146
|
+
|
|
147
|
+
others = (0...count).to_a - [master_index]
|
|
148
|
+
rects = Array.new(count)
|
|
149
|
+
|
|
150
|
+
if others.length == 1
|
|
151
|
+
master_w = [area.w / 2, 1].max
|
|
152
|
+
rects[master_index] = Rect.new(area.x, area.y, master_w, area.h)
|
|
153
|
+
rects[others[0]] = Rect.new(area.x + master_w, area.y, [area.w - master_w, 1].max, area.h)
|
|
154
|
+
return rects
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
master_w = [area.w / 2, 1].max
|
|
158
|
+
side_w = area.w - master_w
|
|
159
|
+
left_w = [side_w / 2, 1].max
|
|
160
|
+
right_w = [side_w - left_w, 1].max
|
|
161
|
+
|
|
162
|
+
rects[master_index] = Rect.new(area.x + left_w, area.y, master_w, area.h)
|
|
163
|
+
left = others.select.with_index { |_, i| i.even? }
|
|
164
|
+
right = others.select.with_index { |_, i| i.odd? }
|
|
165
|
+
stack_column(rects, left, area.x, area.y, left_w, area.h)
|
|
166
|
+
stack_column(rects, right, area.x + left_w + master_w, area.y, right_w, area.h)
|
|
167
|
+
rects
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Accordion: the focused pane expands to fill the leftover height while the
|
|
171
|
+
# others collapse to short "title sliver" rows, all stacked vertically.
|
|
172
|
+
# Like monocle but the other panes stay visible (and spatially reachable).
|
|
173
|
+
def stack(count, area, focused_index = 0)
|
|
174
|
+
return [Rect.new(area.x, area.y, area.w, area.h)] if count == 1
|
|
175
|
+
focused_index = focused_index.clamp(0, count - 1)
|
|
176
|
+
|
|
177
|
+
others = count - 1
|
|
178
|
+
# Sliver is 3 rows so draw_box can still render the title; shrink it only
|
|
179
|
+
# when the terminal is too short to give the focused pane its own 3 rows.
|
|
180
|
+
sliver = [3, [area.h - 3, 0].max / others].min
|
|
181
|
+
sliver = [sliver, 1].max
|
|
182
|
+
focus_h = area.h - sliver * others
|
|
183
|
+
|
|
184
|
+
rects = Array.new(count)
|
|
185
|
+
y = area.y
|
|
186
|
+
count.times do |i|
|
|
187
|
+
h = (i == focused_index) ? focus_h : sliver
|
|
188
|
+
rects[i] = Rect.new(area.x, y, area.w, h)
|
|
189
|
+
y += h
|
|
190
|
+
end
|
|
191
|
+
rects
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Stack the given pane indices vertically within a single column, dividing
|
|
195
|
+
# the height evenly (remainder to the topmost panes). Used by `centered`.
|
|
196
|
+
def stack_column(rects, indices, x, y, w, total_h)
|
|
197
|
+
return if indices.empty?
|
|
198
|
+
base_h = total_h / indices.length
|
|
199
|
+
rem = total_h - base_h * indices.length
|
|
200
|
+
cy = y
|
|
201
|
+
indices.each_with_index do |idx, i|
|
|
202
|
+
h = base_h + (i < rem ? 1 : 0)
|
|
203
|
+
rects[idx] = Rect.new(x, cy, w, h)
|
|
204
|
+
cy += h
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
55
208
|
# Roughly square grid. Each row stretches its panes to fill the full width
|
|
56
209
|
# so an underfull bottom row doesn't leave gaps.
|
|
57
210
|
def grid(count, area)
|
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
|
@@ -391,9 +391,11 @@ module Muxr
|
|
|
391
391
|
" H / J / K / L move pane left / down / up / right",
|
|
392
392
|
" i drop into passthrough mode",
|
|
393
393
|
" c / x new / close pane (close asks y/n)",
|
|
394
|
-
" t / g / m
|
|
394
|
+
" t / w / g / m layout: tall / wide / grid / monocle",
|
|
395
|
+
" | - f e S layout: columns / rows / spiral / centered / stack",
|
|
395
396
|
" Tab / Enter cycle layout / promote to master",
|
|
396
397
|
" a / 1..9 last pane / jump by number",
|
|
398
|
+
" r refresh / redraw (fixes a corrupted pane)",
|
|
397
399
|
" s enter scrollback",
|
|
398
400
|
" ~ / C / P drawer / Claude drawer / toggle private",
|
|
399
401
|
" : / ? command prompt / toggle this help",
|
|
@@ -401,19 +403,24 @@ module Muxr
|
|
|
401
403
|
"",
|
|
402
404
|
"PASSTHROUGH mode (keys reach the focused pane; prefix is Ctrl-a)",
|
|
403
405
|
" C-a Esc return to normal mode",
|
|
404
|
-
" C-a c x t g m
|
|
406
|
+
" C-a c x t w g m same as normal-mode bindings",
|
|
405
407
|
" C-a Tab Enter cycle layout / promote master",
|
|
406
408
|
" C-a n / p / a next / prev / last pane",
|
|
409
|
+
" C-a r refresh / redraw (fixes a corrupted pane)",
|
|
407
410
|
" C-a [ ] scrollback / paste buffer",
|
|
408
411
|
" C-a C-a send literal Ctrl-a to focused pane",
|
|
409
412
|
"",
|
|
410
|
-
"SCROLLBACK mode (
|
|
413
|
+
"SCROLLBACK mode (pane-bound: follows you as you switch panes)",
|
|
411
414
|
" j/k ↑/↓ d/u f/b g/G scroll C-b/C-f page v→cursor",
|
|
412
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",
|
|
413
418
|
" 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
|
|
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",
|
|
415
421
|
"",
|
|
416
|
-
"Commands: layout {tall|grid|
|
|
422
|
+
"Commands: layout {tall|wide|columns|rows|grid|spiral|centered|stack|monocle},",
|
|
423
|
+
" drawer {toggle|show|hide|reset},",
|
|
417
424
|
" claude, save, restore, sessions, quit, new, close, next, prev",
|
|
418
425
|
"",
|
|
419
426
|
"press any key to dismiss"
|
|
@@ -541,6 +548,11 @@ module Muxr
|
|
|
541
548
|
if same_size && @prev[y][x] == cell
|
|
542
549
|
next
|
|
543
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?
|
|
544
556
|
if last_y != y || last_x != x
|
|
545
557
|
out << "\e[#{y + 1};#{x + 1}H"
|
|
546
558
|
end
|
|
@@ -557,7 +569,19 @@ module Muxr
|
|
|
557
569
|
end
|
|
558
570
|
out << cell.char
|
|
559
571
|
last_y = y
|
|
560
|
-
|
|
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
|
|
561
585
|
end
|
|
562
586
|
end
|
|
563
587
|
out << "\e]8;;\e\\" if cur_hyperlink
|
|
@@ -571,6 +595,24 @@ module Muxr
|
|
|
571
595
|
@prev_h = frame.length
|
|
572
596
|
end
|
|
573
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
|
+
|
|
574
616
|
def cursor_position(session, input_state:, command_buffer:, search_buffer: "")
|
|
575
617
|
if input_state == :command
|
|
576
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