muxr 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -0
- data/README.md +229 -68
- data/lib/muxr/application.rb +236 -0
- data/lib/muxr/command_dispatcher.rb +1 -1
- data/lib/muxr/foreground_command.rb +86 -0
- data/lib/muxr/input_handler.rb +256 -27
- data/lib/muxr/layout_manager.rb +59 -0
- data/lib/muxr/pane.rb +10 -0
- data/lib/muxr/renderer.rb +193 -43
- data/lib/muxr/terminal.rb +195 -2
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr/window.rb +13 -0
- data/lib/muxr.rb +1 -0
- metadata +2 -1
data/lib/muxr/input_handler.rb
CHANGED
|
@@ -1,17 +1,66 @@
|
|
|
1
1
|
module Muxr
|
|
2
|
-
# Translates raw keystrokes into either commands
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
2
|
+
# Translates raw keystrokes into either commands or pane input. Two top-level
|
|
3
|
+
# modes:
|
|
4
|
+
#
|
|
5
|
+
# :normal Default. Single-key bindings (hjkl navigation, t/g/m for
|
|
6
|
+
# layouts, c/K for create/kill, etc.) act directly without
|
|
7
|
+
# any prefix. `i` drops into passthrough.
|
|
8
|
+
#
|
|
9
|
+
# :passthrough Historical mode: every key is forwarded to the focused
|
|
10
|
+
# pane unless prefixed by Ctrl-a. `Ctrl-a Esc` returns to
|
|
11
|
+
# normal mode.
|
|
12
|
+
#
|
|
13
|
+
# Plus the sub-states pre-existing from before modes existed:
|
|
14
|
+
# :prefix, :command, :confirm_quit, :confirm_close, :help, :scrollback,
|
|
15
|
+
# :search, :selection.
|
|
16
|
+
#
|
|
17
|
+
# One-shot sub-states (prefix, command, confirm_quit, help) return to
|
|
18
|
+
# @base_mode (whichever of :normal/:passthrough is active) when they
|
|
19
|
+
# finish. :scrollback and :selection also return to @base_mode so that
|
|
20
|
+
# exiting back from a scroll/yank lands you back in passthrough if that's
|
|
21
|
+
# where you came from.
|
|
6
22
|
class InputHandler
|
|
7
23
|
PREFIX = "\x01".freeze # Ctrl-a
|
|
8
24
|
|
|
25
|
+
# Single-key bindings in normal mode. Same actions as their Ctrl-a-
|
|
26
|
+
# prefixed counterparts in passthrough, just without the prefix.
|
|
27
|
+
# Value forms:
|
|
28
|
+
# :symbol → @app.public_send(:symbol)
|
|
29
|
+
# [:symbol, *args] → @app.public_send(:symbol, *args)
|
|
30
|
+
NORMAL_BINDINGS = {
|
|
31
|
+
"c" => :new_pane,
|
|
32
|
+
"x" => :request_close,
|
|
33
|
+
"t" => [:set_layout, :tall],
|
|
34
|
+
"g" => [:set_layout, :grid],
|
|
35
|
+
"m" => [:set_layout, :monocle],
|
|
36
|
+
"\t" => :cycle_layout,
|
|
37
|
+
"\r" => :promote_master,
|
|
38
|
+
"\n" => :promote_master,
|
|
39
|
+
"h" => [:focus_direction, :left],
|
|
40
|
+
"j" => [:focus_direction, :down],
|
|
41
|
+
"k" => [:focus_direction, :up],
|
|
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],
|
|
47
|
+
"a" => :focus_last,
|
|
48
|
+
"~" => :toggle_drawer,
|
|
49
|
+
"C" => :toggle_claude_drawer,
|
|
50
|
+
"P" => :toggle_private_focused,
|
|
51
|
+
"d" => :detach,
|
|
52
|
+
"?" => :show_help,
|
|
53
|
+
"q" => :quit_immediate,
|
|
54
|
+
"s" => :enter_scrollback,
|
|
55
|
+
"]" => :paste_from_buffer
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
9
58
|
PREFIX_BINDINGS = {
|
|
10
59
|
"c" => :new_pane,
|
|
11
60
|
"n" => :focus_next,
|
|
12
61
|
"p" => :focus_prev,
|
|
13
62
|
"a" => :focus_last,
|
|
14
|
-
"
|
|
63
|
+
"x" => :request_close,
|
|
15
64
|
"\t" => :cycle_layout,
|
|
16
65
|
"\r" => :promote_master,
|
|
17
66
|
"\n" => :promote_master,
|
|
@@ -41,6 +90,20 @@ module Muxr
|
|
|
41
90
|
"G" => :bottom
|
|
42
91
|
}.freeze
|
|
43
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
|
+
|
|
44
107
|
SCROLLBACK_EXITS = ["q", "\e", "\x03"].freeze # q, Esc, Ctrl-c
|
|
45
108
|
|
|
46
109
|
SELECTION_BINDINGS = {
|
|
@@ -69,8 +132,10 @@ module Muxr
|
|
|
69
132
|
"u" => :half_up,
|
|
70
133
|
"\x06" => :full_down, # Ctrl-f
|
|
71
134
|
"\x02" => :full_up, # Ctrl-b
|
|
72
|
-
"f" => :full_down
|
|
73
|
-
|
|
135
|
+
"f" => :full_down
|
|
136
|
+
# NOTE: space is intentionally absent here — it's a top-level toggle
|
|
137
|
+
# for linear selection (see handle_selection_input), mirroring vim's
|
|
138
|
+
# `v` so the right thumb has a one-key way to anchor/release.
|
|
74
139
|
}.freeze
|
|
75
140
|
|
|
76
141
|
SELECTION_YANK = ["\r", "\n", "y"].freeze
|
|
@@ -78,22 +143,24 @@ module Muxr
|
|
|
78
143
|
|
|
79
144
|
DIGIT_RE = /\A[1-9]\z/.freeze
|
|
80
145
|
|
|
81
|
-
attr_reader :state, :command_buffer
|
|
146
|
+
attr_reader :state, :command_buffer, :search_buffer, :search_direction, :base_mode
|
|
82
147
|
|
|
83
148
|
def initialize(app)
|
|
84
149
|
@app = app
|
|
85
|
-
@state = :
|
|
150
|
+
@state = :normal
|
|
151
|
+
@base_mode = :normal
|
|
86
152
|
@command_buffer = +""
|
|
153
|
+
@search_buffer = +""
|
|
154
|
+
@search_direction = :forward
|
|
87
155
|
end
|
|
88
156
|
|
|
89
157
|
def feed(data)
|
|
90
158
|
remaining = data
|
|
91
159
|
until remaining.empty?
|
|
92
|
-
if @state == :
|
|
93
|
-
# Fast path
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
# appears mid-UTF-8, so byte/char index match.
|
|
160
|
+
if @state == :passthrough
|
|
161
|
+
# Fast path: batch everything up to the next Ctrl-a as one chunk so
|
|
162
|
+
# a large paste doesn't turn into one PTY write per byte. PREFIX is
|
|
163
|
+
# single-byte ASCII (\x01) and never appears mid-UTF-8.
|
|
97
164
|
idx = remaining.index(PREFIX)
|
|
98
165
|
if idx.nil?
|
|
99
166
|
@app.send_to_focused(remaining)
|
|
@@ -105,20 +172,41 @@ module Muxr
|
|
|
105
172
|
next
|
|
106
173
|
end
|
|
107
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
|
+
|
|
108
190
|
ch = remaining[0]
|
|
109
191
|
remaining = remaining[1..] || ""
|
|
110
192
|
case @state
|
|
193
|
+
when :normal
|
|
194
|
+
handle_normal(ch)
|
|
111
195
|
when :help
|
|
112
196
|
@app.dismiss_help
|
|
113
|
-
@state =
|
|
197
|
+
@state = @base_mode
|
|
114
198
|
when :confirm_quit
|
|
115
199
|
handle_confirm_quit(ch)
|
|
200
|
+
when :confirm_close
|
|
201
|
+
handle_confirm_close(ch)
|
|
116
202
|
when :prefix
|
|
117
203
|
handle_prefix(ch)
|
|
118
204
|
when :command
|
|
119
205
|
handle_command_input(ch)
|
|
120
206
|
when :scrollback
|
|
121
207
|
handle_scrollback_input(ch)
|
|
208
|
+
when :search
|
|
209
|
+
handle_search_input(ch)
|
|
122
210
|
when :selection
|
|
123
211
|
handle_selection_input(ch)
|
|
124
212
|
end
|
|
@@ -133,48 +221,114 @@ module Muxr
|
|
|
133
221
|
@state = :confirm_quit
|
|
134
222
|
end
|
|
135
223
|
|
|
224
|
+
def enter_confirm_close
|
|
225
|
+
@state = :confirm_close
|
|
226
|
+
end
|
|
227
|
+
|
|
136
228
|
def enter_scrollback_mode
|
|
137
229
|
@state = :scrollback
|
|
138
230
|
end
|
|
139
231
|
|
|
232
|
+
def enter_search_mode(direction: :forward)
|
|
233
|
+
@state = :search
|
|
234
|
+
@search_direction = direction
|
|
235
|
+
@search_buffer = +""
|
|
236
|
+
end
|
|
237
|
+
|
|
140
238
|
def enter_selection_mode
|
|
141
239
|
@state = :selection
|
|
142
240
|
end
|
|
143
241
|
|
|
242
|
+
# Drop into passthrough — every key reaches the focused pane until the
|
|
243
|
+
# user issues Ctrl-a Esc.
|
|
244
|
+
def enter_passthrough_mode
|
|
245
|
+
@state = :passthrough
|
|
246
|
+
@base_mode = :passthrough
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Return to normal mode. Used by the `Ctrl-a Esc` binding from
|
|
250
|
+
# passthrough — explicitly resets @base_mode so the user genuinely
|
|
251
|
+
# leaves passthrough.
|
|
252
|
+
def enter_normal_mode
|
|
253
|
+
@state = :normal
|
|
254
|
+
@base_mode = :normal
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Exit a sub-state (scrollback, selection-yank) and resume the mode the
|
|
258
|
+
# user was in before they entered scrollback. Preserves @base_mode so
|
|
259
|
+
# a passthrough → scrollback → exit round-trip lands back in passthrough.
|
|
144
260
|
def enter_idle_mode
|
|
145
|
-
@state =
|
|
261
|
+
@state = @base_mode
|
|
146
262
|
end
|
|
147
263
|
|
|
148
264
|
def cancel
|
|
149
|
-
@state =
|
|
265
|
+
@state = @base_mode
|
|
150
266
|
@command_buffer = +""
|
|
151
267
|
end
|
|
152
268
|
|
|
153
269
|
private
|
|
154
270
|
|
|
271
|
+
def handle_normal(ch)
|
|
272
|
+
if ch == "i"
|
|
273
|
+
# Internal state flip happens here so a bare FakeApp in tests still
|
|
274
|
+
# transitions; the Application callback redundantly flips state
|
|
275
|
+
# (idempotent) and adds the user-visible flash.
|
|
276
|
+
enter_passthrough_mode
|
|
277
|
+
@app.enter_passthrough_mode
|
|
278
|
+
return
|
|
279
|
+
end
|
|
280
|
+
if ch == ":"
|
|
281
|
+
@state = :command
|
|
282
|
+
@command_buffer = +""
|
|
283
|
+
return
|
|
284
|
+
end
|
|
285
|
+
if DIGIT_RE.match?(ch)
|
|
286
|
+
@app.focus_pane_number(ch.to_i)
|
|
287
|
+
return
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
action = NORMAL_BINDINGS[ch]
|
|
291
|
+
case action
|
|
292
|
+
when Symbol
|
|
293
|
+
@app.public_send(action)
|
|
294
|
+
when Array
|
|
295
|
+
@app.public_send(*action)
|
|
296
|
+
end
|
|
297
|
+
# Unknown key: ignore. Avoids accidental side-effects when the user
|
|
298
|
+
# mistypes — same rationale as scrollback mode.
|
|
299
|
+
end
|
|
300
|
+
|
|
155
301
|
def handle_prefix(ch)
|
|
156
302
|
action = PREFIX_BINDINGS[ch]
|
|
157
303
|
case
|
|
304
|
+
when ch == "\e"
|
|
305
|
+
# Ctrl-a Esc → return to normal mode. Flip state directly so tests
|
|
306
|
+
# with a bare FakeApp transition; the Application callback is
|
|
307
|
+
# idempotent and adds the flash message.
|
|
308
|
+
enter_normal_mode
|
|
309
|
+
@app.enter_normal_mode
|
|
158
310
|
when ch == ":"
|
|
159
311
|
@state = :command
|
|
160
312
|
@command_buffer = +""
|
|
161
313
|
when ch == PREFIX
|
|
162
314
|
@app.send_to_focused(PREFIX)
|
|
163
|
-
@state =
|
|
315
|
+
@state = @base_mode
|
|
164
316
|
when DIGIT_RE.match?(ch)
|
|
165
317
|
@app.focus_pane_number(ch.to_i)
|
|
166
|
-
@state =
|
|
318
|
+
@state = @base_mode
|
|
167
319
|
when action
|
|
168
320
|
@app.public_send(action)
|
|
169
|
-
|
|
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.
|
|
323
|
+
@state = @base_mode if @state == :prefix
|
|
170
324
|
else
|
|
171
|
-
# Unknown prefix
|
|
172
|
-
@state =
|
|
325
|
+
# Unknown prefix key: return to base mode silently.
|
|
326
|
+
@state = @base_mode
|
|
173
327
|
end
|
|
174
328
|
end
|
|
175
329
|
|
|
176
330
|
def handle_confirm_quit(ch)
|
|
177
|
-
@state =
|
|
331
|
+
@state = @base_mode
|
|
178
332
|
if ch == "y" || ch == "Y"
|
|
179
333
|
@app.confirm_quit
|
|
180
334
|
else
|
|
@@ -182,9 +336,18 @@ module Muxr
|
|
|
182
336
|
end
|
|
183
337
|
end
|
|
184
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
|
+
|
|
185
348
|
def handle_scrollback_input(ch)
|
|
186
349
|
if SCROLLBACK_EXITS.include?(ch)
|
|
187
|
-
|
|
350
|
+
enter_idle_mode
|
|
188
351
|
@app.exit_scrollback
|
|
189
352
|
return
|
|
190
353
|
end
|
|
@@ -192,12 +355,78 @@ module Muxr
|
|
|
192
355
|
@app.enter_selection
|
|
193
356
|
return
|
|
194
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
|
|
195
376
|
action = SCROLLBACK_BINDINGS[ch]
|
|
196
377
|
@app.scroll_focused(action) if action
|
|
197
378
|
# Unknown keys: ignored. Avoids accidental shell input when the user
|
|
198
379
|
# mistypes inside scrollback mode.
|
|
199
380
|
end
|
|
200
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
|
+
|
|
201
430
|
def handle_selection_input(ch)
|
|
202
431
|
if SELECTION_YANK.include?(ch)
|
|
203
432
|
@app.exit_selection(yank: true)
|
|
@@ -208,7 +437,7 @@ module Muxr
|
|
|
208
437
|
return
|
|
209
438
|
end
|
|
210
439
|
case ch
|
|
211
|
-
when "v"
|
|
440
|
+
when "v", " "
|
|
212
441
|
@app.toggle_selection(:linear)
|
|
213
442
|
return
|
|
214
443
|
when "\x16" # Ctrl-v
|
|
@@ -225,11 +454,11 @@ module Muxr
|
|
|
225
454
|
when "\r", "\n"
|
|
226
455
|
cmd = @command_buffer.dup
|
|
227
456
|
@command_buffer = +""
|
|
228
|
-
@state =
|
|
457
|
+
@state = @base_mode
|
|
229
458
|
@app.run_command(cmd)
|
|
230
459
|
when "\e"
|
|
231
460
|
@command_buffer = +""
|
|
232
|
-
@state =
|
|
461
|
+
@state = @base_mode
|
|
233
462
|
@app.invalidate
|
|
234
463
|
when "\x7f", "\b"
|
|
235
464
|
@command_buffer.chop!
|
data/lib/muxr/layout_manager.rb
CHANGED
|
@@ -87,5 +87,64 @@ module Muxr
|
|
|
87
87
|
def monocle(count, area, _focused_index = 0)
|
|
88
88
|
Array.new(count) { Rect.new(area.x, area.y, area.w, area.h) }
|
|
89
89
|
end
|
|
90
|
+
|
|
91
|
+
# Return the index of the closest pane in `direction` (:left/:right/:up/:down)
|
|
92
|
+
# from the focused pane. Pure function over the rect list — does not know
|
|
93
|
+
# about the layout that produced the rects.
|
|
94
|
+
#
|
|
95
|
+
# Selection rule: among panes strictly on the requested side, prefer the
|
|
96
|
+
# one with the largest perpendicular overlap with the focused pane;
|
|
97
|
+
# tie-break by smallest axis-distance, then by smallest center offset.
|
|
98
|
+
# Returns nil when nothing qualifies (e.g. focused is the rightmost pane
|
|
99
|
+
# and direction is :right, or monocle where every rect is identical).
|
|
100
|
+
def neighbor(rects, focused_index, direction)
|
|
101
|
+
return nil if rects.nil? || rects.empty?
|
|
102
|
+
return nil unless focused_index.is_a?(Integer)
|
|
103
|
+
return nil unless focused_index.between?(0, rects.length - 1)
|
|
104
|
+
focused = rects[focused_index]
|
|
105
|
+
return nil unless focused
|
|
106
|
+
|
|
107
|
+
best = nil
|
|
108
|
+
rects.each_with_index do |rect, idx|
|
|
109
|
+
next if idx == focused_index || rect.nil?
|
|
110
|
+
|
|
111
|
+
case direction
|
|
112
|
+
when :right
|
|
113
|
+
next unless rect.x >= focused.x + focused.w
|
|
114
|
+
axis_dist = rect.x - (focused.x + focused.w)
|
|
115
|
+
overlap = overlap_extent(focused.y, focused.h, rect.y, rect.h)
|
|
116
|
+
center = ((rect.y + rect.h / 2.0) - (focused.y + focused.h / 2.0)).abs
|
|
117
|
+
when :left
|
|
118
|
+
next unless rect.x + rect.w <= focused.x
|
|
119
|
+
axis_dist = focused.x - (rect.x + rect.w)
|
|
120
|
+
overlap = overlap_extent(focused.y, focused.h, rect.y, rect.h)
|
|
121
|
+
center = ((rect.y + rect.h / 2.0) - (focused.y + focused.h / 2.0)).abs
|
|
122
|
+
when :down
|
|
123
|
+
next unless rect.y >= focused.y + focused.h
|
|
124
|
+
axis_dist = rect.y - (focused.y + focused.h)
|
|
125
|
+
overlap = overlap_extent(focused.x, focused.w, rect.x, rect.w)
|
|
126
|
+
center = ((rect.x + rect.w / 2.0) - (focused.x + focused.w / 2.0)).abs
|
|
127
|
+
when :up
|
|
128
|
+
next unless rect.y + rect.h <= focused.y
|
|
129
|
+
axis_dist = focused.y - (rect.y + rect.h)
|
|
130
|
+
overlap = overlap_extent(focused.x, focused.w, rect.x, rect.w)
|
|
131
|
+
center = ((rect.x + rect.w / 2.0) - (focused.x + focused.w / 2.0)).abs
|
|
132
|
+
else
|
|
133
|
+
return nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
score = [-overlap, axis_dist, center]
|
|
137
|
+
if best.nil? || (score <=> best[0]) < 0
|
|
138
|
+
best = [score, idx]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
best && best[1]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def overlap_extent(a_start, a_size, b_start, b_size)
|
|
145
|
+
finish = [a_start + a_size, b_start + b_size].min
|
|
146
|
+
start = [a_start, b_start].max
|
|
147
|
+
[finish - start, 0].max
|
|
148
|
+
end
|
|
90
149
|
end
|
|
91
150
|
end
|
data/lib/muxr/pane.rb
CHANGED
|
@@ -14,6 +14,11 @@ module Muxr
|
|
|
14
14
|
class Pane
|
|
15
15
|
attr_reader :id, :terminal, :process
|
|
16
16
|
attr_accessor :rect
|
|
17
|
+
# Last value written by Application's foreground poller thread. nil when
|
|
18
|
+
# the shell itself is foreground (the common empty-prompt case) or when
|
|
19
|
+
# the lookup hasn't run / couldn't read. Renderer surfaces this in the
|
|
20
|
+
# pane title.
|
|
21
|
+
attr_accessor :foreground_command
|
|
17
22
|
|
|
18
23
|
def initialize(id: nil, rows: 24, cols: 80, cwd: nil, command: nil, env_overrides: nil, process: nil)
|
|
19
24
|
@id = id || SecureRandom.hex(3)
|
|
@@ -30,6 +35,11 @@ module Muxr
|
|
|
30
35
|
@rect = nil
|
|
31
36
|
@initial_cwd = cwd || @process.cwd
|
|
32
37
|
@private_flag = false
|
|
38
|
+
@foreground_command = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def pid
|
|
42
|
+
@process.pid
|
|
33
43
|
end
|
|
34
44
|
|
|
35
45
|
# Private panes are invisible to the MCP control surface — their cwd is
|