muxr 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +27 -11
- data/lib/muxr/application.rb +110 -0
- data/lib/muxr/command_dispatcher.rb +1 -1
- data/lib/muxr/input_handler.rb +131 -6
- data/lib/muxr/renderer.rb +64 -15
- data/lib/muxr/terminal.rb +151 -0
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr/window.rb +13 -0
- 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: 03650b7088c85aa8fbe993dbfe17e7f8aac8334f617c3ff148589964d83bb701
|
|
4
|
+
data.tar.gz: 9b878beb1c05f1c83273f3f1c8e51944be7c75b3fdcd13935c96f851a2aa8405
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 91ccd01254428f3a2333e3abe94cd3d039598813c484730a4819c938f9f0d733ab611bd9c975314ca5bfb0af07c243c69814ed918fc02315e7652436454170b8
|
|
7
|
+
data.tar.gz: c58966f41d4a62978e3bfd7560d72ef132b6fcbdededddf129cf94216d77689b1f1f52c44cf7660cab9780a7ec8f3180fe138a9f2f75f8ec741548595c415665
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.7] - 2026-05-20
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Scrollback search via `/` (forward) and `?` (backward). Enter commits
|
|
13
|
+
the query, `n` / `N` cycle through matches with wrap. Smart-case
|
|
14
|
+
matching scans both scrollback and the live buffer; the chosen match
|
|
15
|
+
is centered in the viewport and every match is highlighted in yellow
|
|
16
|
+
until the user exits scrollback.
|
|
17
|
+
- Arrow / page keys in scrollback. `↑`/`↓` scroll a line, `PgUp`/`PgDn`
|
|
18
|
+
half-page, `Home`/`End` jump to top/bottom. `InputHandler#feed` now
|
|
19
|
+
peeks for CSI escape sequences so a bare `Esc` still exits scrollback
|
|
20
|
+
the way it always has.
|
|
21
|
+
- Shift-`H`/`J`/`K`/`L` swaps the focused pane with the spatial
|
|
22
|
+
neighbor in that direction (linear next/prev fallback in monocle).
|
|
23
|
+
Focus tracks the moved pane so you can keep dragging it; swapping
|
|
24
|
+
into position 0 of tall/grid promotes it to master.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- Pane close is now confirmed. The close binding moved from `K` to
|
|
28
|
+
`x` (in both normal mode and the Ctrl-a prefix) so shift-`HJKL` is
|
|
29
|
+
free for the new move action. Close routes through a `:confirm_close`
|
|
30
|
+
state that flashes `close pane? (y/n)` — drawer hide stays
|
|
31
|
+
prompt-free since it's reversible.
|
|
32
|
+
|
|
9
33
|
## [0.1.6] - 2026-05-15
|
|
10
34
|
|
|
11
35
|
### Added
|
data/README.md
CHANGED
|
@@ -88,8 +88,9 @@ bin/muxr # same flags as the installed `muxr` executable
|
|
|
88
88
|
muxr has two top-level input modes, modeled on vim:
|
|
89
89
|
|
|
90
90
|
- **Normal** (default at startup) — single keys act on the multiplexer.
|
|
91
|
-
`hjkl` moves focus between panes, `
|
|
92
|
-
`t`/`g`/`m` set the layout, etc.
|
|
91
|
+
`hjkl` moves focus between panes, `HJKL` moves the focused pane
|
|
92
|
+
itself, `c`/`x` create/close panes, `t`/`g`/`m` set the layout, etc.
|
|
93
|
+
No prefix needed.
|
|
93
94
|
- **Passthrough** (entered with `i`) — every keystroke is forwarded to
|
|
94
95
|
the focused pane, exactly like a regular terminal. muxr commands are
|
|
95
96
|
reached via the historical `Ctrl-a` prefix. `Ctrl-a Esc` returns to
|
|
@@ -98,18 +99,20 @@ muxr has two top-level input modes, modeled on vim:
|
|
|
98
99
|
The active mode appears as a `[MODE]` chip in the top-right corner of
|
|
99
100
|
the focused pane (and the leftmost slot of the status bar). The
|
|
100
101
|
focused pane's border is colored by mode — cyan for normal, green for
|
|
101
|
-
passthrough, orange for scrollback
|
|
102
|
-
the command prompt, red during the
|
|
103
|
-
while help is open.
|
|
104
|
-
unfocused border,
|
|
102
|
+
passthrough, orange for scrollback (and its `/` search prompt),
|
|
103
|
+
magenta for selection, yellow for the command prompt, red during the
|
|
104
|
+
kill-session or close-pane confirmation, blue while help is open.
|
|
105
|
+
Unfocused panes always render with the grey unfocused border,
|
|
106
|
+
regardless of mode.
|
|
105
107
|
|
|
106
108
|
### Normal mode
|
|
107
109
|
|
|
108
110
|
| Keys | Action |
|
|
109
111
|
|----------------------|-----------------------------------------------------|
|
|
110
112
|
| `h` / `j` / `k` / `l`| focus pane left / down / up / right (spatial) |
|
|
113
|
+
| `H` / `J` / `K` / `L`| move focused pane left / down / up / right |
|
|
111
114
|
| `i` | drop into passthrough mode |
|
|
112
|
-
| `c` / `
|
|
115
|
+
| `c` / `x` | new / close focused pane (close asks `y/n`) |
|
|
113
116
|
| `t` / `g` / `m` | layout: tall / grid / monocle |
|
|
114
117
|
| `Tab` / `Enter` | cycle layout / promote focused to master |
|
|
115
118
|
| `a` / `1` … `9` | toggle last pane / jump to pane by number |
|
|
@@ -125,6 +128,12 @@ direction. In monocle (where every pane occupies the full area) it
|
|
|
125
128
|
falls back to linear next/previous so the keys still do something
|
|
126
129
|
useful.
|
|
127
130
|
|
|
131
|
+
`H`/`J`/`K`/`L` swap the focused pane with the spatial neighbor in
|
|
132
|
+
that direction, then keep focus on the moved pane so you can keep
|
|
133
|
+
dragging it. Swapping into position 0 of `tall`/`grid` promotes the
|
|
134
|
+
pane to master (the layout master is always `panes[0]`). In monocle
|
|
135
|
+
the move falls back to linear next/prev shuffling.
|
|
136
|
+
|
|
128
137
|
### Passthrough mode (`Ctrl-a` prefix)
|
|
129
138
|
|
|
130
139
|
| Keys | Action |
|
|
@@ -134,7 +143,7 @@ useful.
|
|
|
134
143
|
| `C-a n` / `p` | focus next / previous pane (linear) |
|
|
135
144
|
| `C-a a` | toggle last (previously focused) pane |
|
|
136
145
|
| `C-a 1` … `9` | jump to pane by its label |
|
|
137
|
-
| `C-a
|
|
146
|
+
| `C-a x` | close focused pane (asks `y/n`; hides drawer with no prompt) |
|
|
138
147
|
| `C-a Tab` | cycle layout (`tall` → `grid` → `monocle`) |
|
|
139
148
|
| `C-a Enter` | promote focused pane to master |
|
|
140
149
|
| `C-a ~` | toggle drawer (shell) |
|
|
@@ -157,12 +166,19 @@ navigation; the status bar shows a key hint and the pane title gains
|
|
|
157
166
|
|
|
158
167
|
| Keys | Action |
|
|
159
168
|
|-------------------------|-------------------------------------|
|
|
160
|
-
| `j` / `k`
|
|
161
|
-
| `d` / `u` (or `C-d`/`C-u`) | half page
|
|
169
|
+
| `j` / `k` or `↓` / `↑` | scroll one line |
|
|
170
|
+
| `d` / `u` (or `C-d`/`C-u`, `PgDn`/`PgUp`) | half page |
|
|
162
171
|
| `f` / Space (or `C-f`/`C-b`) | full page |
|
|
163
|
-
| `g` / `G`
|
|
172
|
+
| `g` / `G` (or `Home` / `End`) | top / bottom |
|
|
173
|
+
| `/` *query* `Enter` | search forward (toward newer); `?` searches backward |
|
|
174
|
+
| `n` / `N` | next / previous match in the search direction (wraps) |
|
|
164
175
|
| `q` / `Esc` / `C-c` | exit back to normal mode |
|
|
165
176
|
|
|
177
|
+
Search uses smart-case (case-insensitive unless the query has an
|
|
178
|
+
uppercase letter), scans both scrollback and the live buffer, and
|
|
179
|
+
centers the chosen match in the viewport. Matches stay highlighted in
|
|
180
|
+
yellow while you're in scrollback; exiting clears the highlight.
|
|
181
|
+
|
|
166
182
|
Press `v` inside scrollback to enter a movable-cursor selection mode.
|
|
167
183
|
Vim-style motions are supported:
|
|
168
184
|
|
data/lib/muxr/application.rb
CHANGED
|
@@ -177,6 +177,37 @@ module Muxr
|
|
|
177
177
|
invalidate
|
|
178
178
|
end
|
|
179
179
|
|
|
180
|
+
# Swap the focused pane with its spatial neighbor in `direction`. Bound
|
|
181
|
+
# to shift-HJKL in normal mode. Mirrors focus_direction's geometry-aware
|
|
182
|
+
# lookup so the same "what does my arrow point at" intuition decides
|
|
183
|
+
# which neighbor gets bumped. Monocle has no spatial layout, so HJKL
|
|
184
|
+
# falls back to reordering by linear next/prev — useful for shuffling
|
|
185
|
+
# the master before flipping back to tall/grid.
|
|
186
|
+
def move_direction(direction)
|
|
187
|
+
return if @session.window.panes.empty?
|
|
188
|
+
# The drawer isn't part of the tiled pane list; HJKL while focused on
|
|
189
|
+
# it would be ambiguous. No-op.
|
|
190
|
+
return if @session.focus_drawer && @session.drawer&.visible?
|
|
191
|
+
|
|
192
|
+
win = @session.window
|
|
193
|
+
idx = LayoutManager.neighbor(current_pane_rects, win.focused_index, direction)
|
|
194
|
+
if idx.nil? && win.layout == :monocle
|
|
195
|
+
target = case direction
|
|
196
|
+
when :right, :down then (win.focused_index + 1) % win.panes.length
|
|
197
|
+
when :left, :up then (win.focused_index - 1) % win.panes.length
|
|
198
|
+
end
|
|
199
|
+
if target && target != win.focused_index
|
|
200
|
+
win.move_focused_to(target)
|
|
201
|
+
invalidate
|
|
202
|
+
end
|
|
203
|
+
return
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
return unless idx
|
|
207
|
+
win.move_focused_to(idx)
|
|
208
|
+
invalidate
|
|
209
|
+
end
|
|
210
|
+
|
|
180
211
|
# Explicit layout set, used by the normal-mode t/g/m bindings and the
|
|
181
212
|
# `:layout <name>` command.
|
|
182
213
|
def set_layout(layout)
|
|
@@ -202,6 +233,31 @@ module Muxr
|
|
|
202
233
|
invalidate
|
|
203
234
|
end
|
|
204
235
|
|
|
236
|
+
# Two-step close — same shape as the quit flow. Hiding the drawer is
|
|
237
|
+
# cheap and reversible, so we skip the prompt for the drawer case.
|
|
238
|
+
def request_close
|
|
239
|
+
if @session.focus_drawer && @session.drawer&.visible?
|
|
240
|
+
hide_drawer
|
|
241
|
+
return
|
|
242
|
+
end
|
|
243
|
+
return unless focused_pane
|
|
244
|
+
return if @input.state == :confirm_close
|
|
245
|
+
@input.enter_confirm_close
|
|
246
|
+
flash("close pane? (y/n)")
|
|
247
|
+
invalidate
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def confirm_close
|
|
251
|
+
close_focused
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def cancel_close
|
|
255
|
+
@message = nil
|
|
256
|
+
@message_expires = nil
|
|
257
|
+
flash("cancelled")
|
|
258
|
+
invalidate
|
|
259
|
+
end
|
|
260
|
+
|
|
205
261
|
def close_focused
|
|
206
262
|
if @session.focus_drawer && @session.drawer&.visible?
|
|
207
263
|
hide_drawer
|
|
@@ -337,11 +393,63 @@ module Muxr
|
|
|
337
393
|
def exit_scrollback
|
|
338
394
|
target = focused_target
|
|
339
395
|
target&.terminal&.clear_selection
|
|
396
|
+
target&.terminal&.clear_search
|
|
340
397
|
target&.terminal&.scroll_to_bottom
|
|
341
398
|
@renderer.reset_frame!
|
|
342
399
|
invalidate
|
|
343
400
|
end
|
|
344
401
|
|
|
402
|
+
# Bound to `/` (forward) and `?` (backward) in scrollback mode. Drops
|
|
403
|
+
# the user into a buffered prompt; commit_search / cancel_search exit
|
|
404
|
+
# back to scrollback.
|
|
405
|
+
def enter_search(direction: :forward)
|
|
406
|
+
@input.enter_search_mode(direction: direction)
|
|
407
|
+
invalidate
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def commit_search(query)
|
|
411
|
+
target = focused_target
|
|
412
|
+
return unless target
|
|
413
|
+
term = target.terminal
|
|
414
|
+
direction = @input.search_direction
|
|
415
|
+
count = term.search(query, direction: direction)
|
|
416
|
+
if query.empty?
|
|
417
|
+
# Empty query just dismisses the prompt; leave the prior search
|
|
418
|
+
# state alone (term.search already cleared it though).
|
|
419
|
+
elsif count.zero?
|
|
420
|
+
flash("not found: #{query}")
|
|
421
|
+
else
|
|
422
|
+
flash("#{count} match#{count == 1 ? "" : "es"} (n/N to navigate)")
|
|
423
|
+
end
|
|
424
|
+
@renderer.reset_frame!
|
|
425
|
+
invalidate
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def cancel_search
|
|
429
|
+
@renderer.reset_frame!
|
|
430
|
+
invalidate
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def find_next
|
|
434
|
+
step_search(@input.search_direction)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def find_prev
|
|
438
|
+
step_search(@input.search_direction == :forward ? :backward : :forward)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def step_search(direction)
|
|
442
|
+
target = focused_target
|
|
443
|
+
return unless target
|
|
444
|
+
term = target.terminal
|
|
445
|
+
if term.search_matches.empty?
|
|
446
|
+
flash("no search active")
|
|
447
|
+
return
|
|
448
|
+
end
|
|
449
|
+
term.find_in_direction(direction)
|
|
450
|
+
invalidate
|
|
451
|
+
end
|
|
452
|
+
|
|
345
453
|
def scroll_focused(action)
|
|
346
454
|
target = focused_target
|
|
347
455
|
return unless target
|
|
@@ -834,6 +942,8 @@ module Muxr
|
|
|
834
942
|
@session,
|
|
835
943
|
input_state: @input.state,
|
|
836
944
|
command_buffer: @input.command_buffer,
|
|
945
|
+
search_buffer: @input.search_buffer,
|
|
946
|
+
search_direction: @input.search_direction,
|
|
837
947
|
message: @message,
|
|
838
948
|
help: @help_visible
|
|
839
949
|
)
|
data/lib/muxr/input_handler.rb
CHANGED
|
@@ -11,7 +11,8 @@ module Muxr
|
|
|
11
11
|
# normal mode.
|
|
12
12
|
#
|
|
13
13
|
# Plus the sub-states pre-existing from before modes existed:
|
|
14
|
-
# :prefix, :command, :confirm_quit, :help, :scrollback,
|
|
14
|
+
# :prefix, :command, :confirm_quit, :confirm_close, :help, :scrollback,
|
|
15
|
+
# :search, :selection.
|
|
15
16
|
#
|
|
16
17
|
# One-shot sub-states (prefix, command, confirm_quit, help) return to
|
|
17
18
|
# @base_mode (whichever of :normal/:passthrough is active) when they
|
|
@@ -28,7 +29,7 @@ module Muxr
|
|
|
28
29
|
# [:symbol, *args] → @app.public_send(:symbol, *args)
|
|
29
30
|
NORMAL_BINDINGS = {
|
|
30
31
|
"c" => :new_pane,
|
|
31
|
-
"
|
|
32
|
+
"x" => :request_close,
|
|
32
33
|
"t" => [:set_layout, :tall],
|
|
33
34
|
"g" => [:set_layout, :grid],
|
|
34
35
|
"m" => [:set_layout, :monocle],
|
|
@@ -39,6 +40,10 @@ module Muxr
|
|
|
39
40
|
"j" => [:focus_direction, :down],
|
|
40
41
|
"k" => [:focus_direction, :up],
|
|
41
42
|
"l" => [:focus_direction, :right],
|
|
43
|
+
"H" => [:move_direction, :left],
|
|
44
|
+
"J" => [:move_direction, :down],
|
|
45
|
+
"K" => [:move_direction, :up],
|
|
46
|
+
"L" => [:move_direction, :right],
|
|
42
47
|
"a" => :focus_last,
|
|
43
48
|
"~" => :toggle_drawer,
|
|
44
49
|
"C" => :toggle_claude_drawer,
|
|
@@ -55,7 +60,7 @@ module Muxr
|
|
|
55
60
|
"n" => :focus_next,
|
|
56
61
|
"p" => :focus_prev,
|
|
57
62
|
"a" => :focus_last,
|
|
58
|
-
"
|
|
63
|
+
"x" => :request_close,
|
|
59
64
|
"\t" => :cycle_layout,
|
|
60
65
|
"\r" => :promote_master,
|
|
61
66
|
"\n" => :promote_master,
|
|
@@ -85,6 +90,20 @@ module Muxr
|
|
|
85
90
|
"G" => :bottom
|
|
86
91
|
}.freeze
|
|
87
92
|
|
|
93
|
+
# CSI sequences (arrow / page keys) recognized in scrollback mode. Built
|
|
94
|
+
# for terminal raw-mode emission: arrow keys come through as ESC `[`
|
|
95
|
+
# followed by a single final letter, PageUp/PageDown as ESC `[5~` /
|
|
96
|
+
# `[6~`. Lookahead in #feed peels these off as one chunk so a bare ESC
|
|
97
|
+
# still exits scrollback the way it always has.
|
|
98
|
+
SCROLLBACK_CSI = {
|
|
99
|
+
"\e[A" => :line_back, # Up
|
|
100
|
+
"\e[B" => :line_forward, # Down
|
|
101
|
+
"\e[5~" => :half_back, # PageUp
|
|
102
|
+
"\e[6~" => :half_forward, # PageDown
|
|
103
|
+
"\e[H" => :top, # Home
|
|
104
|
+
"\e[F" => :bottom # End
|
|
105
|
+
}.freeze
|
|
106
|
+
|
|
88
107
|
SCROLLBACK_EXITS = ["q", "\e", "\x03"].freeze # q, Esc, Ctrl-c
|
|
89
108
|
|
|
90
109
|
SELECTION_BINDINGS = {
|
|
@@ -124,13 +143,15 @@ module Muxr
|
|
|
124
143
|
|
|
125
144
|
DIGIT_RE = /\A[1-9]\z/.freeze
|
|
126
145
|
|
|
127
|
-
attr_reader :state, :command_buffer, :base_mode
|
|
146
|
+
attr_reader :state, :command_buffer, :search_buffer, :search_direction, :base_mode
|
|
128
147
|
|
|
129
148
|
def initialize(app)
|
|
130
149
|
@app = app
|
|
131
150
|
@state = :normal
|
|
132
151
|
@base_mode = :normal
|
|
133
152
|
@command_buffer = +""
|
|
153
|
+
@search_buffer = +""
|
|
154
|
+
@search_direction = :forward
|
|
134
155
|
end
|
|
135
156
|
|
|
136
157
|
def feed(data)
|
|
@@ -151,6 +172,21 @@ module Muxr
|
|
|
151
172
|
next
|
|
152
173
|
end
|
|
153
174
|
|
|
175
|
+
# Multi-byte CSI lookahead for scrollback / search: arrow / page
|
|
176
|
+
# keys arrive as `\e[<final>` and would otherwise trip the
|
|
177
|
+
# bare-Esc-exits behavior. In :scrollback we map them to scroll
|
|
178
|
+
# actions; in :search we silently consume them so a stray arrow
|
|
179
|
+
# doesn't kick the user out of the prompt. An incomplete `\e[…`
|
|
180
|
+
# (rare in raw-mode TTY) falls through and the bare `\e` exits as
|
|
181
|
+
# before.
|
|
182
|
+
if (@state == :scrollback || @state == :search) && remaining.start_with?("\e[")
|
|
183
|
+
consumed = consume_csi_escape(remaining)
|
|
184
|
+
if consumed > 0
|
|
185
|
+
remaining = remaining[consumed..] || ""
|
|
186
|
+
next
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
154
190
|
ch = remaining[0]
|
|
155
191
|
remaining = remaining[1..] || ""
|
|
156
192
|
case @state
|
|
@@ -161,12 +197,16 @@ module Muxr
|
|
|
161
197
|
@state = @base_mode
|
|
162
198
|
when :confirm_quit
|
|
163
199
|
handle_confirm_quit(ch)
|
|
200
|
+
when :confirm_close
|
|
201
|
+
handle_confirm_close(ch)
|
|
164
202
|
when :prefix
|
|
165
203
|
handle_prefix(ch)
|
|
166
204
|
when :command
|
|
167
205
|
handle_command_input(ch)
|
|
168
206
|
when :scrollback
|
|
169
207
|
handle_scrollback_input(ch)
|
|
208
|
+
when :search
|
|
209
|
+
handle_search_input(ch)
|
|
170
210
|
when :selection
|
|
171
211
|
handle_selection_input(ch)
|
|
172
212
|
end
|
|
@@ -181,10 +221,20 @@ module Muxr
|
|
|
181
221
|
@state = :confirm_quit
|
|
182
222
|
end
|
|
183
223
|
|
|
224
|
+
def enter_confirm_close
|
|
225
|
+
@state = :confirm_close
|
|
226
|
+
end
|
|
227
|
+
|
|
184
228
|
def enter_scrollback_mode
|
|
185
229
|
@state = :scrollback
|
|
186
230
|
end
|
|
187
231
|
|
|
232
|
+
def enter_search_mode(direction: :forward)
|
|
233
|
+
@state = :search
|
|
234
|
+
@search_direction = direction
|
|
235
|
+
@search_buffer = +""
|
|
236
|
+
end
|
|
237
|
+
|
|
188
238
|
def enter_selection_mode
|
|
189
239
|
@state = :selection
|
|
190
240
|
end
|
|
@@ -268,8 +318,8 @@ module Muxr
|
|
|
268
318
|
@state = @base_mode
|
|
269
319
|
when action
|
|
270
320
|
@app.public_send(action)
|
|
271
|
-
# The action may have set a new state (confirm_quit,
|
|
272
|
-
# help). Only revert to base mode if we're still in :prefix.
|
|
321
|
+
# The action may have set a new state (confirm_quit, confirm_close,
|
|
322
|
+
# scrollback, help). Only revert to base mode if we're still in :prefix.
|
|
273
323
|
@state = @base_mode if @state == :prefix
|
|
274
324
|
else
|
|
275
325
|
# Unknown prefix key: return to base mode silently.
|
|
@@ -286,6 +336,15 @@ module Muxr
|
|
|
286
336
|
end
|
|
287
337
|
end
|
|
288
338
|
|
|
339
|
+
def handle_confirm_close(ch)
|
|
340
|
+
@state = @base_mode
|
|
341
|
+
if ch == "y" || ch == "Y"
|
|
342
|
+
@app.confirm_close
|
|
343
|
+
else
|
|
344
|
+
@app.cancel_close
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
289
348
|
def handle_scrollback_input(ch)
|
|
290
349
|
if SCROLLBACK_EXITS.include?(ch)
|
|
291
350
|
enter_idle_mode
|
|
@@ -296,12 +355,78 @@ module Muxr
|
|
|
296
355
|
@app.enter_selection
|
|
297
356
|
return
|
|
298
357
|
end
|
|
358
|
+
case ch
|
|
359
|
+
when "/"
|
|
360
|
+
# Flip state directly so tests with a bare FakeApp transition; the
|
|
361
|
+
# Application callback redundantly flips state and runs side-effects.
|
|
362
|
+
enter_search_mode(direction: :forward)
|
|
363
|
+
@app.enter_search(direction: :forward)
|
|
364
|
+
return
|
|
365
|
+
when "?"
|
|
366
|
+
enter_search_mode(direction: :backward)
|
|
367
|
+
@app.enter_search(direction: :backward)
|
|
368
|
+
return
|
|
369
|
+
when "n"
|
|
370
|
+
@app.find_next
|
|
371
|
+
return
|
|
372
|
+
when "N"
|
|
373
|
+
@app.find_prev
|
|
374
|
+
return
|
|
375
|
+
end
|
|
299
376
|
action = SCROLLBACK_BINDINGS[ch]
|
|
300
377
|
@app.scroll_focused(action) if action
|
|
301
378
|
# Unknown keys: ignored. Avoids accidental shell input when the user
|
|
302
379
|
# mistypes inside scrollback mode.
|
|
303
380
|
end
|
|
304
381
|
|
|
382
|
+
def handle_search_input(ch)
|
|
383
|
+
case ch
|
|
384
|
+
when "\r", "\n"
|
|
385
|
+
query = @search_buffer.dup
|
|
386
|
+
@search_buffer = +""
|
|
387
|
+
@state = :scrollback
|
|
388
|
+
@app.commit_search(query)
|
|
389
|
+
when "\e", "\x03"
|
|
390
|
+
@search_buffer = +""
|
|
391
|
+
@state = :scrollback
|
|
392
|
+
@app.cancel_search
|
|
393
|
+
when "\x7f", "\b"
|
|
394
|
+
@search_buffer.chop!
|
|
395
|
+
@app.invalidate
|
|
396
|
+
else
|
|
397
|
+
# Printable ASCII / UTF-8. We treat anything at or above 0x20 as
|
|
398
|
+
# input; control bytes besides the ones handled above are dropped
|
|
399
|
+
# to keep stray Ctrl-keys from corrupting the query.
|
|
400
|
+
@search_buffer << ch if ch.ord >= 0x20
|
|
401
|
+
@app.invalidate
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Find the final byte of a CSI escape sequence and return the number
|
|
406
|
+
# of bytes consumed. In :scrollback we map recognized sequences to
|
|
407
|
+
# scroll actions; in :search we just swallow them so a stray arrow
|
|
408
|
+
# key doesn't kick the user out of the prompt. Returns 0 only when
|
|
409
|
+
# the sequence is incomplete in this chunk — the caller falls through
|
|
410
|
+
# so a bare \e still exits.
|
|
411
|
+
def consume_csi_escape(remaining)
|
|
412
|
+
i = 2
|
|
413
|
+
max = [remaining.bytesize, 16].min
|
|
414
|
+
while i < max
|
|
415
|
+
b = remaining.getbyte(i)
|
|
416
|
+
if b >= 0x40 && b <= 0x7e
|
|
417
|
+
seq = remaining.byteslice(0, i + 1)
|
|
418
|
+
if @state == :scrollback
|
|
419
|
+
action = SCROLLBACK_CSI[seq]
|
|
420
|
+
@app.scroll_focused(action) if action
|
|
421
|
+
end
|
|
422
|
+
return i + 1
|
|
423
|
+
end
|
|
424
|
+
return 0 if b < 0x20 || b > 0x7e # malformed; fall through
|
|
425
|
+
i += 1
|
|
426
|
+
end
|
|
427
|
+
0
|
|
428
|
+
end
|
|
429
|
+
|
|
305
430
|
def handle_selection_input(ch)
|
|
306
431
|
if SELECTION_YANK.include?(ch)
|
|
307
432
|
@app.exit_selection(yank: true)
|
data/lib/muxr/renderer.rb
CHANGED
|
@@ -23,11 +23,19 @@ module Muxr
|
|
|
23
23
|
prefix: [:c256, 42].freeze, # green (passthrough sub-state)
|
|
24
24
|
command: [:c256, 226].freeze, # yellow
|
|
25
25
|
scrollback: [:c256, 214].freeze, # orange
|
|
26
|
+
search: [:c256, 214].freeze, # orange (scrollback sub-state)
|
|
26
27
|
selection: [:c256, 201].freeze, # magenta
|
|
27
|
-
confirm_quit:
|
|
28
|
-
|
|
28
|
+
confirm_quit: [:c256, 196].freeze, # red
|
|
29
|
+
confirm_close: [:c256, 196].freeze, # red
|
|
30
|
+
help: [:c256, 39].freeze # blue
|
|
29
31
|
}.freeze
|
|
30
32
|
|
|
33
|
+
# Background applied to cells that match the active scrollback search.
|
|
34
|
+
# Bright enough to stand out over typical foreground SGRs while leaving
|
|
35
|
+
# the original glyph readable.
|
|
36
|
+
SEARCH_MATCH_BG = [:c256, 226].freeze # yellow
|
|
37
|
+
SEARCH_MATCH_FG = [:c256, 16].freeze # black
|
|
38
|
+
|
|
31
39
|
HORIZONTAL = "─".freeze
|
|
32
40
|
VERTICAL = "│".freeze
|
|
33
41
|
TL = "┌".freeze
|
|
@@ -66,7 +74,7 @@ module Muxr
|
|
|
66
74
|
@prev = nil
|
|
67
75
|
end
|
|
68
76
|
|
|
69
|
-
def render(session, input_state: :normal, command_buffer: "", message: nil, help: false)
|
|
77
|
+
def render(session, input_state: :normal, command_buffer: "", search_buffer: "", search_direction: :forward, message: nil, help: false)
|
|
70
78
|
w = session.width
|
|
71
79
|
h = session.height
|
|
72
80
|
return if w < 4 || h < 3
|
|
@@ -75,10 +83,17 @@ module Muxr
|
|
|
75
83
|
|
|
76
84
|
compose_panes(frame, session, input_state: input_state)
|
|
77
85
|
compose_drawer(frame, session, input_state: input_state) if session.drawer&.visible?
|
|
78
|
-
compose_status_bar(
|
|
86
|
+
compose_status_bar(
|
|
87
|
+
frame, session,
|
|
88
|
+
input_state: input_state,
|
|
89
|
+
command_buffer: command_buffer,
|
|
90
|
+
search_buffer: search_buffer,
|
|
91
|
+
search_direction: search_direction,
|
|
92
|
+
message: message
|
|
93
|
+
)
|
|
79
94
|
compose_help(frame, session) if help
|
|
80
95
|
|
|
81
|
-
emit_frame(frame, session, input_state: input_state, command_buffer: command_buffer)
|
|
96
|
+
emit_frame(frame, session, input_state: input_state, command_buffer: command_buffer, search_buffer: search_buffer)
|
|
82
97
|
end
|
|
83
98
|
|
|
84
99
|
private
|
|
@@ -216,14 +231,16 @@ module Muxr
|
|
|
216
231
|
when :prefix then "^A"
|
|
217
232
|
when :command then "CMD"
|
|
218
233
|
when :scrollback then "SCROLL"
|
|
234
|
+
when :search then "SEARCH"
|
|
219
235
|
when :selection then "SEL"
|
|
220
|
-
when :confirm_quit
|
|
221
|
-
when :
|
|
236
|
+
when :confirm_quit then "QUIT?"
|
|
237
|
+
when :confirm_close then "CLOSE?"
|
|
238
|
+
when :help then "HELP"
|
|
222
239
|
else "?"
|
|
223
240
|
end
|
|
224
241
|
end
|
|
225
242
|
|
|
226
|
-
def compose_status_bar(frame, session, input_state:, command_buffer:, message:)
|
|
243
|
+
def compose_status_bar(frame, session, input_state:, command_buffer:, search_buffer: "", search_direction: :forward, message: nil)
|
|
227
244
|
y = session.height - 1
|
|
228
245
|
w = session.width
|
|
229
246
|
win = session.window
|
|
@@ -294,7 +311,25 @@ module Muxr
|
|
|
294
311
|
c.attrs = 0
|
|
295
312
|
end
|
|
296
313
|
elsif input_state == :scrollback
|
|
297
|
-
overlay = " SCROLLBACK j/k line d/u half f/b page g/G top/bot v select q quit "
|
|
314
|
+
overlay = " SCROLLBACK ↑↓/j/k line d/u half f/b page g/G top/bot / search n/N next/prev v select q quit "
|
|
315
|
+
overlay = overlay[0, w]
|
|
316
|
+
overlay.each_char.with_index do |ch, x|
|
|
317
|
+
c = frame[y][x]
|
|
318
|
+
c.char = ch
|
|
319
|
+
c.fg = [:c256, 232]
|
|
320
|
+
c.bg = [:c256, 214]
|
|
321
|
+
c.attrs = Terminal::BOLD
|
|
322
|
+
end
|
|
323
|
+
(overlay.length...w).each do |x|
|
|
324
|
+
c = frame[y][x]
|
|
325
|
+
c.char = " "
|
|
326
|
+
c.fg = nil
|
|
327
|
+
c.bg = [:c256, 214]
|
|
328
|
+
c.attrs = 0
|
|
329
|
+
end
|
|
330
|
+
elsif input_state == :search
|
|
331
|
+
prefix = search_direction == :backward ? "?" : "/"
|
|
332
|
+
overlay = "#{prefix}#{search_buffer}"
|
|
298
333
|
overlay = overlay[0, w]
|
|
299
334
|
overlay.each_char.with_index do |ch, x|
|
|
300
335
|
c = frame[y][x]
|
|
@@ -353,8 +388,9 @@ module Muxr
|
|
|
353
388
|
"",
|
|
354
389
|
"NORMAL mode (default; no prefix)",
|
|
355
390
|
" h / j / k / l focus pane left / down / up / right",
|
|
391
|
+
" H / J / K / L move pane left / down / up / right",
|
|
356
392
|
" i drop into passthrough mode",
|
|
357
|
-
" c /
|
|
393
|
+
" c / x new / close pane (close asks y/n)",
|
|
358
394
|
" t / g / m layout: tall / grid / monocle",
|
|
359
395
|
" Tab / Enter cycle layout / promote to master",
|
|
360
396
|
" a / 1..9 last pane / jump by number",
|
|
@@ -365,14 +401,15 @@ module Muxr
|
|
|
365
401
|
"",
|
|
366
402
|
"PASSTHROUGH mode (keys reach the focused pane; prefix is Ctrl-a)",
|
|
367
403
|
" C-a Esc return to normal mode",
|
|
368
|
-
" C-a c
|
|
404
|
+
" C-a c x t g m same as normal-mode bindings",
|
|
369
405
|
" C-a Tab Enter cycle layout / promote master",
|
|
370
406
|
" C-a n / p / a next / prev / last pane",
|
|
371
407
|
" C-a [ ] scrollback / paste buffer",
|
|
372
408
|
" C-a C-a send literal Ctrl-a to focused pane",
|
|
373
409
|
"",
|
|
374
410
|
"SCROLLBACK mode (exits to the mode you came from)",
|
|
375
|
-
" j/k d/u f/b g/G scroll C-b/C-f page v→cursor",
|
|
411
|
+
" j/k ↑/↓ d/u f/b g/G scroll C-b/C-f page v→cursor",
|
|
412
|
+
" / search-fwd ? search-back n/N next/prev match",
|
|
376
413
|
" cursor: h/j/k/l 0/^/$ w/e/b W/E/B H/M/L g/G",
|
|
377
414
|
" v select, C-v block, y/Enter yank, q/Esc cancel",
|
|
378
415
|
"",
|
|
@@ -445,6 +482,7 @@ module Muxr
|
|
|
445
482
|
rows = term.rows
|
|
446
483
|
cols = term.cols
|
|
447
484
|
selection = term.selection_active?
|
|
485
|
+
search = term.search_active?
|
|
448
486
|
rows.times do |r|
|
|
449
487
|
fy = dst_y + r
|
|
450
488
|
next if fy < 0 || fy >= frame.length
|
|
@@ -457,6 +495,13 @@ module Muxr
|
|
|
457
495
|
dst.fg = src.fg
|
|
458
496
|
dst.bg = src.bg
|
|
459
497
|
dst.attrs = src.attrs
|
|
498
|
+
if search && term.cell_in_match?(r, c)
|
|
499
|
+
# Highlight wins over the cell's own bg so matches stay visible
|
|
500
|
+
# across whatever SGR the underlying program was using. Selection
|
|
501
|
+
# still applies on top via REVERSE below.
|
|
502
|
+
dst.fg = SEARCH_MATCH_FG
|
|
503
|
+
dst.bg = SEARCH_MATCH_BG
|
|
504
|
+
end
|
|
460
505
|
dst.attrs |= Terminal::REVERSE if selection && term.selected_at_visible?(r, c)
|
|
461
506
|
dst.hyperlink = src.hyperlink
|
|
462
507
|
end
|
|
@@ -473,7 +518,7 @@ module Muxr
|
|
|
473
518
|
c.attrs = attrs
|
|
474
519
|
end
|
|
475
520
|
|
|
476
|
-
def emit_frame(frame, session, input_state:, command_buffer:)
|
|
521
|
+
def emit_frame(frame, session, input_state:, command_buffer:, search_buffer: "")
|
|
477
522
|
# \e[?2026h enters synchronized-output mode so terminals that support it
|
|
478
523
|
# (Ghostty, kitty, iTerm2 ≥3.5, WezTerm, Alacritty ≥0.13, foot) present
|
|
479
524
|
# the whole frame atomically instead of repainting incrementally as bytes
|
|
@@ -517,7 +562,7 @@ module Muxr
|
|
|
517
562
|
end
|
|
518
563
|
out << "\e]8;;\e\\" if cur_hyperlink
|
|
519
564
|
out << "\e[0m"
|
|
520
|
-
out << cursor_position(session, input_state: input_state, command_buffer: command_buffer)
|
|
565
|
+
out << cursor_position(session, input_state: input_state, command_buffer: command_buffer, search_buffer: search_buffer)
|
|
521
566
|
out << "\e[?2026l"
|
|
522
567
|
@out.write(out)
|
|
523
568
|
@out.flush
|
|
@@ -526,11 +571,15 @@ module Muxr
|
|
|
526
571
|
@prev_h = frame.length
|
|
527
572
|
end
|
|
528
573
|
|
|
529
|
-
def cursor_position(session, input_state:, command_buffer:)
|
|
574
|
+
def cursor_position(session, input_state:, command_buffer:, search_buffer: "")
|
|
530
575
|
if input_state == :command
|
|
531
576
|
col = 1 + command_buffer.length + 1 # ':' + buffer
|
|
532
577
|
return "\e[#{session.height};#{col}H\e[?25h"
|
|
533
578
|
end
|
|
579
|
+
if input_state == :search
|
|
580
|
+
col = 1 + search_buffer.length + 1 # '/' or '?' + buffer
|
|
581
|
+
return "\e[#{session.height};#{col}H\e[?25h"
|
|
582
|
+
end
|
|
534
583
|
|
|
535
584
|
target =
|
|
536
585
|
if session.focus_drawer && session.drawer&.visible? && session.drawer.pane
|
data/lib/muxr/terminal.rb
CHANGED
|
@@ -77,6 +77,10 @@ module Muxr
|
|
|
77
77
|
@sync_pending = false
|
|
78
78
|
@sync_started_at = nil
|
|
79
79
|
@pending_replies = +"".b
|
|
80
|
+
@search_query = nil
|
|
81
|
+
@search_direction = :forward
|
|
82
|
+
@search_matches = []
|
|
83
|
+
@search_current = nil
|
|
80
84
|
end
|
|
81
85
|
|
|
82
86
|
# Bytes the emulator owes back to the inner program in response to a
|
|
@@ -351,6 +355,81 @@ module Muxr
|
|
|
351
355
|
@dirty = true
|
|
352
356
|
end
|
|
353
357
|
|
|
358
|
+
# ---------- search ----------
|
|
359
|
+
#
|
|
360
|
+
# Substring search over the full timeline (scrollback + live buffer).
|
|
361
|
+
# Smart-case: case-insensitive if the query is all-lowercase, sensitive
|
|
362
|
+
# otherwise. Matches are kept in timeline coordinates so they stay
|
|
363
|
+
# anchored to the same text as the user pages history.
|
|
364
|
+
|
|
365
|
+
attr_reader :search_query, :search_matches, :search_current, :search_direction
|
|
366
|
+
|
|
367
|
+
def search(query, direction: :forward)
|
|
368
|
+
query = query.to_s
|
|
369
|
+
if query.empty?
|
|
370
|
+
clear_search
|
|
371
|
+
return 0
|
|
372
|
+
end
|
|
373
|
+
@search_query = query
|
|
374
|
+
@search_direction = direction
|
|
375
|
+
@search_matches = collect_matches(query)
|
|
376
|
+
if @search_matches.empty?
|
|
377
|
+
@search_current = nil
|
|
378
|
+
@dirty = true
|
|
379
|
+
return 0
|
|
380
|
+
end
|
|
381
|
+
@search_current = nearest_match_in_direction(current_search_anchor_row, direction, inclusive: true)
|
|
382
|
+
@search_current ||= direction == :forward ? 0 : @search_matches.length - 1
|
|
383
|
+
scroll_view_to_match(@search_current)
|
|
384
|
+
@dirty = true
|
|
385
|
+
@search_matches.length
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Move to the next/previous match in the given direction, wrapping.
|
|
389
|
+
# Returns the new current match index, or nil if there are no matches.
|
|
390
|
+
# Anchors on the current match (not the viewport top) so n always
|
|
391
|
+
# advances even when scroll_view_to_match has centered the previous
|
|
392
|
+
# hit and dragged the viewport top behind it.
|
|
393
|
+
def find_in_direction(direction)
|
|
394
|
+
return nil if @search_matches.empty?
|
|
395
|
+
anchor_tr =
|
|
396
|
+
if @search_current && @search_matches[@search_current]
|
|
397
|
+
@search_matches[@search_current][0]
|
|
398
|
+
else
|
|
399
|
+
current_search_anchor_row
|
|
400
|
+
end
|
|
401
|
+
idx = strict_next_in_direction(anchor_tr, direction)
|
|
402
|
+
if idx.nil?
|
|
403
|
+
# Wrap: pick the far end depending on direction.
|
|
404
|
+
idx = direction == :forward ? 0 : @search_matches.length - 1
|
|
405
|
+
end
|
|
406
|
+
@search_current = idx
|
|
407
|
+
scroll_view_to_match(idx)
|
|
408
|
+
@dirty = true
|
|
409
|
+
idx
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def cell_in_match?(visible_r, c)
|
|
413
|
+
return false if @search_matches.empty?
|
|
414
|
+
tr = timeline_row_for_visible(visible_r)
|
|
415
|
+
# Linear scan is fine: SCROLLBACK_MAX caps matches at O(rows*cols), and
|
|
416
|
+
# the renderer touches each visible cell once per frame. A row-indexed
|
|
417
|
+
# cache would matter at much larger buffer sizes than ours.
|
|
418
|
+
@search_matches.any? { |mr, sc, ec| mr == tr && c >= sc && c <= ec }
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def search_active?
|
|
422
|
+
!(@search_query.nil? || @search_matches.empty?)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def clear_search
|
|
426
|
+
return if @search_query.nil? && @search_matches.empty?
|
|
427
|
+
@search_query = nil
|
|
428
|
+
@search_matches = []
|
|
429
|
+
@search_current = nil
|
|
430
|
+
@dirty = true
|
|
431
|
+
end
|
|
432
|
+
|
|
354
433
|
def selected_at_visible?(r, c)
|
|
355
434
|
return false unless @selection_anchor
|
|
356
435
|
tr = timeline_row_for_visible(r)
|
|
@@ -770,6 +849,11 @@ module Muxr
|
|
|
770
849
|
@selection_anchor[0] = [@selection_anchor[0] - 1, 0].max
|
|
771
850
|
@selection_cursor[0] = [@selection_cursor[0] - 1, 0].max
|
|
772
851
|
end
|
|
852
|
+
unless @search_matches.empty?
|
|
853
|
+
@search_matches.each { |m| m[0] -= 1 }
|
|
854
|
+
@search_matches.reject! { |m| m[0] < 0 }
|
|
855
|
+
@search_current = nil if @search_current && @search_current >= @search_matches.length
|
|
856
|
+
end
|
|
773
857
|
end
|
|
774
858
|
# Keep the user's view frozen on the same content when new rows arrive
|
|
775
859
|
# while they're scrolled back.
|
|
@@ -873,6 +957,73 @@ module Muxr
|
|
|
873
957
|
end
|
|
874
958
|
end
|
|
875
959
|
|
|
960
|
+
def collect_matches(query)
|
|
961
|
+
case_sensitive = query.match?(/[A-Z]/)
|
|
962
|
+
needle = case_sensitive ? query : query.downcase
|
|
963
|
+
matches = []
|
|
964
|
+
timeline_size.times do |tr|
|
|
965
|
+
row = timeline_row(tr)
|
|
966
|
+
next if row.nil?
|
|
967
|
+
line = String.new(capacity: @cols)
|
|
968
|
+
@cols.times { |c| line << (row[c]&.char || " ") }
|
|
969
|
+
haystack = case_sensitive ? line : line.downcase
|
|
970
|
+
start = 0
|
|
971
|
+
while (idx = haystack.index(needle, start))
|
|
972
|
+
matches << [tr, idx, idx + needle.length - 1]
|
|
973
|
+
# Advance past the start of this match so overlapping needles
|
|
974
|
+
# ("aa" in "aaaa") still emit one match per starting position.
|
|
975
|
+
start = idx + 1
|
|
976
|
+
end
|
|
977
|
+
end
|
|
978
|
+
matches
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
# Top of the current viewport in timeline coordinates. Used as the
|
|
982
|
+
# reference point for "search from where the user is looking now".
|
|
983
|
+
def current_search_anchor_row
|
|
984
|
+
timeline_row_for_visible(0)
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# Smallest match index whose row is >= anchor_tr (forward) or largest
|
|
988
|
+
# match index whose row is <= anchor_tr (backward). Used by search() so
|
|
989
|
+
# the first jump lands on the nearest match in the search direction
|
|
990
|
+
# without forcing the user to press n.
|
|
991
|
+
def nearest_match_in_direction(anchor_tr, direction, inclusive:)
|
|
992
|
+
if direction == :forward
|
|
993
|
+
@search_matches.each_with_index do |(mr, _, _), i|
|
|
994
|
+
return i if inclusive ? mr >= anchor_tr : mr > anchor_tr
|
|
995
|
+
end
|
|
996
|
+
nil
|
|
997
|
+
else
|
|
998
|
+
best = nil
|
|
999
|
+
@search_matches.each_with_index do |(mr, _, _), i|
|
|
1000
|
+
if (inclusive ? mr <= anchor_tr : mr < anchor_tr)
|
|
1001
|
+
best = i
|
|
1002
|
+
else
|
|
1003
|
+
break
|
|
1004
|
+
end
|
|
1005
|
+
end
|
|
1006
|
+
best
|
|
1007
|
+
end
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
# Strict next match (n/N) — never matches the current row; that would
|
|
1011
|
+
# leave the user stuck on the same line they're already on.
|
|
1012
|
+
def strict_next_in_direction(anchor_tr, direction)
|
|
1013
|
+
nearest_match_in_direction(anchor_tr, direction, inclusive: false)
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
# Center the match in the viewport when possible. set_view_offset clamps
|
|
1017
|
+
# to [0, scrollback.size] so recent matches end up showing the live
|
|
1018
|
+
# buffer at the bottom and very old ones pin to the top.
|
|
1019
|
+
def scroll_view_to_match(idx)
|
|
1020
|
+
match = @search_matches[idx]
|
|
1021
|
+
return unless match
|
|
1022
|
+
tr = match[0]
|
|
1023
|
+
desired = @scrollback.size - tr + (@rows / 2)
|
|
1024
|
+
set_view_offset(desired)
|
|
1025
|
+
end
|
|
1026
|
+
|
|
876
1027
|
def ensure_selection_cursor_visible
|
|
877
1028
|
return unless @selection_cursor
|
|
878
1029
|
tr = @selection_cursor[0]
|
data/lib/muxr/version.rb
CHANGED
data/lib/muxr/window.rb
CHANGED
|
@@ -86,6 +86,19 @@ module Muxr
|
|
|
86
86
|
@panes[i], @panes[j] = @panes[j], @panes[i]
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
+
# Swap the focused pane with the pane at `idx` and keep focus on the
|
|
90
|
+
# moved pane (so HJKL can keep dragging it across the layout). Unlike
|
|
91
|
+
# `focused_index=`, this does not record a last_focused_pane — the user
|
|
92
|
+
# is still on the same pane, just at a new index in the array.
|
|
93
|
+
def move_focused_to(idx)
|
|
94
|
+
return false if @panes.empty?
|
|
95
|
+
return false unless idx.is_a?(Integer) && idx >= 0 && idx < @panes.length
|
|
96
|
+
return false if idx == @focused_index
|
|
97
|
+
@panes[@focused_index], @panes[idx] = @panes[idx], @panes[@focused_index]
|
|
98
|
+
@focused_index = idx
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
89
102
|
def cycle_layout
|
|
90
103
|
i = LAYOUTS.index(@layout) || 0
|
|
91
104
|
@layout = LAYOUTS[(i + 1) % LAYOUTS.length]
|