muxr 0.1.0
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 +7 -0
- data/CHANGELOG.md +46 -0
- data/LICENSE.txt +21 -0
- data/README.md +211 -0
- data/bin/muxr +137 -0
- data/lib/muxr/application.rb +669 -0
- data/lib/muxr/client.rb +145 -0
- data/lib/muxr/command_dispatcher.rb +65 -0
- data/lib/muxr/drawer.rb +44 -0
- data/lib/muxr/input_handler.rb +218 -0
- data/lib/muxr/layout_manager.rb +91 -0
- data/lib/muxr/pane.rb +52 -0
- data/lib/muxr/protocol.rb +73 -0
- data/lib/muxr/pty_process.rb +92 -0
- data/lib/muxr/renderer.rb +468 -0
- data/lib/muxr/session.rb +87 -0
- data/lib/muxr/terminal.rb +817 -0
- data/lib/muxr/version.rb +3 -0
- data/lib/muxr/window.rb +110 -0
- data/lib/muxr.rb +18 -0
- data/muxr.gemspec +42 -0
- metadata +99 -0
data/lib/muxr/client.rb
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
require "io/console"
|
|
3
|
+
|
|
4
|
+
module Muxr
|
|
5
|
+
# The muxr client. It is a small front-end whose only jobs are:
|
|
6
|
+
# 1. Connect to the server's Unix socket and send HELLO with the
|
|
7
|
+
# terminal's current size.
|
|
8
|
+
# 2. Put the controlling TTY into the alt screen + raw mode.
|
|
9
|
+
# 3. Forward every STDIN read as an INPUT frame.
|
|
10
|
+
# 4. Write every OUTPUT frame payload straight to STDOUT.
|
|
11
|
+
# 5. Send RESIZE on SIGWINCH; exit cleanly on BYE / server EOF.
|
|
12
|
+
#
|
|
13
|
+
# The client owns no Session, no PTYs, and no Renderer — that all lives in
|
|
14
|
+
# the server process. This is the piece that comes and goes during detach
|
|
15
|
+
# / reattach.
|
|
16
|
+
class Client
|
|
17
|
+
SELECT_TIMEOUT = 0.1
|
|
18
|
+
|
|
19
|
+
def initialize(session_name)
|
|
20
|
+
@session_name = session_name
|
|
21
|
+
@socket_path = Application.socket_path_for(session_name)
|
|
22
|
+
@sock = nil
|
|
23
|
+
@running = false
|
|
24
|
+
@resize_pending = false
|
|
25
|
+
@exit_code = 0
|
|
26
|
+
@bye_reason = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Opens the socket. Returns true on success. Raises Errno::ENOENT /
|
|
30
|
+
# Errno::ECONNREFUSED to the caller, which is bin/muxr's job to handle by
|
|
31
|
+
# spawning a server.
|
|
32
|
+
def connect
|
|
33
|
+
@sock = UNIXSocket.new(@socket_path)
|
|
34
|
+
rows, cols = terminal_size
|
|
35
|
+
Protocol.write(@sock, Protocol::HELLO, Protocol.encode_size(rows, cols))
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def run
|
|
40
|
+
raise "must call #connect first" unless @sock
|
|
41
|
+
|
|
42
|
+
enter_terminal_mode
|
|
43
|
+
install_winch_trap
|
|
44
|
+
@running = true
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
loop_forever
|
|
48
|
+
ensure
|
|
49
|
+
leave_terminal_mode
|
|
50
|
+
@sock.close rescue nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@exit_code
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def loop_forever
|
|
59
|
+
while @running
|
|
60
|
+
if @resize_pending
|
|
61
|
+
@resize_pending = false
|
|
62
|
+
send_resize
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
ready, = IO.select([STDIN, @sock], nil, nil, SELECT_TIMEOUT)
|
|
66
|
+
next unless ready
|
|
67
|
+
|
|
68
|
+
ready.each do |io|
|
|
69
|
+
if io == STDIN
|
|
70
|
+
forward_stdin
|
|
71
|
+
else
|
|
72
|
+
consume_server_frame
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def forward_stdin
|
|
79
|
+
data = STDIN.read_nonblock(4096)
|
|
80
|
+
Protocol.write(@sock, Protocol::INPUT, data)
|
|
81
|
+
rescue IO::WaitReadable
|
|
82
|
+
# spurious wake-up; nothing to do.
|
|
83
|
+
rescue EOFError, Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
84
|
+
@running = false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def consume_server_frame
|
|
88
|
+
type, payload = Protocol.read(@sock)
|
|
89
|
+
if type.nil?
|
|
90
|
+
@running = false
|
|
91
|
+
@bye_reason ||= "server closed connection"
|
|
92
|
+
return
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
case type
|
|
96
|
+
when Protocol::OUTPUT
|
|
97
|
+
STDOUT.write(payload)
|
|
98
|
+
STDOUT.flush
|
|
99
|
+
when Protocol::BYE
|
|
100
|
+
@bye_reason = payload.to_s
|
|
101
|
+
@running = false
|
|
102
|
+
else
|
|
103
|
+
# Unknown frame type; ignore.
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def send_resize
|
|
108
|
+
rows, cols = terminal_size
|
|
109
|
+
Protocol.write(@sock, Protocol::RESIZE, Protocol.encode_size(rows, cols))
|
|
110
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
111
|
+
@running = false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def terminal_size
|
|
115
|
+
IO.console.winsize
|
|
116
|
+
rescue StandardError
|
|
117
|
+
[24, 80]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def install_winch_trap
|
|
121
|
+
Signal.trap("WINCH") { @resize_pending = true }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def enter_terminal_mode
|
|
125
|
+
STDIN.raw!
|
|
126
|
+
STDIN.echo = false
|
|
127
|
+
STDOUT.write("\e[?1049h\e[?25l\e[2J\e[H\e[0m")
|
|
128
|
+
STDOUT.flush
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def leave_terminal_mode
|
|
132
|
+
STDOUT.write("\e[0m\e[?25h\e[?1049l")
|
|
133
|
+
STDOUT.flush
|
|
134
|
+
begin
|
|
135
|
+
STDIN.cooked!
|
|
136
|
+
STDIN.echo = true
|
|
137
|
+
rescue StandardError
|
|
138
|
+
# terminal may have already been reset by a signal handler.
|
|
139
|
+
end
|
|
140
|
+
if @bye_reason && !@bye_reason.empty? && @bye_reason != "detached"
|
|
141
|
+
$stderr.puts "muxr: #{@bye_reason}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Muxr
|
|
2
|
+
# Parses ":"-prefixed commands typed at the command prompt and routes them
|
|
3
|
+
# to the Application. Unknown commands result in a flashed status message
|
|
4
|
+
# rather than a hard error so the user never gets dropped out of the
|
|
5
|
+
# multiplexer for a typo.
|
|
6
|
+
class CommandDispatcher
|
|
7
|
+
def initialize(app)
|
|
8
|
+
@app = app
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def dispatch(line)
|
|
12
|
+
parts = line.to_s.strip.split(/\s+/)
|
|
13
|
+
return if parts.empty?
|
|
14
|
+
|
|
15
|
+
cmd, *args = parts
|
|
16
|
+
case cmd
|
|
17
|
+
when "layout" then handle_layout(args)
|
|
18
|
+
when "drawer" then handle_drawer(args)
|
|
19
|
+
when "save" then @app.save_session
|
|
20
|
+
when "restore" then @app.restore_session
|
|
21
|
+
when "sessions", "ls" then @app.list_sessions
|
|
22
|
+
when "quit", "q", "exit"
|
|
23
|
+
@app.quit
|
|
24
|
+
when "new", "c"
|
|
25
|
+
@app.new_pane
|
|
26
|
+
when "close", "kill", "k"
|
|
27
|
+
@app.close_focused
|
|
28
|
+
when "next" then @app.focus_next
|
|
29
|
+
when "prev" then @app.focus_prev
|
|
30
|
+
when "master" then @app.promote_master
|
|
31
|
+
when "help" then @app.show_help
|
|
32
|
+
when "detach" then @app.detach
|
|
33
|
+
else
|
|
34
|
+
@app.flash("unknown command: #{cmd}")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def handle_layout(args)
|
|
41
|
+
name = args[0]
|
|
42
|
+
if name.nil?
|
|
43
|
+
@app.cycle_layout
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
sym = name.to_sym
|
|
47
|
+
if Window::LAYOUTS.include?(sym)
|
|
48
|
+
@app.session.window.set_layout(sym)
|
|
49
|
+
@app.invalidate
|
|
50
|
+
else
|
|
51
|
+
@app.flash("unknown layout: #{name}")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def handle_drawer(args)
|
|
56
|
+
case args[0]
|
|
57
|
+
when nil, "toggle" then @app.toggle_drawer
|
|
58
|
+
when "show" then @app.show_drawer
|
|
59
|
+
when "hide" then @app.hide_drawer
|
|
60
|
+
when "reset" then @app.reset_drawer
|
|
61
|
+
else @app.flash("drawer: #{args[0]}?")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/muxr/drawer.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Muxr
|
|
2
|
+
# A Drawer is a single persistent overlay pane, rendered on top of the tiled
|
|
3
|
+
# layout. Toggling visibility never tears down the underlying PTY — the
|
|
4
|
+
# drawer's shell process and scrollback survive across hide/show.
|
|
5
|
+
#
|
|
6
|
+
# The actual Pane is injected (rather than constructed here) so tests can
|
|
7
|
+
# exercise the visibility/state machine without spawning real shells.
|
|
8
|
+
class Drawer
|
|
9
|
+
attr_accessor :pane, :visible
|
|
10
|
+
attr_reader :origin_cwd
|
|
11
|
+
|
|
12
|
+
def initialize(pane: nil, origin_cwd: nil)
|
|
13
|
+
@pane = pane
|
|
14
|
+
@visible = false
|
|
15
|
+
@origin_cwd = origin_cwd
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def visible?
|
|
19
|
+
@visible
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def show!
|
|
23
|
+
@visible = true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def hide!
|
|
27
|
+
@visible = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def toggle!
|
|
31
|
+
@visible = !@visible
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def cwd
|
|
35
|
+
pane&.cwd || @origin_cwd
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def close
|
|
39
|
+
@pane&.close
|
|
40
|
+
@pane = nil
|
|
41
|
+
@visible = false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
module Muxr
|
|
2
|
+
# Translates raw keystrokes into either commands (when the Ctrl-a prefix is
|
|
3
|
+
# active) or passthrough bytes to the focused pane. The handler is a small
|
|
4
|
+
# state machine: :idle → :prefix → :idle, with a separate :command branch
|
|
5
|
+
# for the ":"-driven mini-command line.
|
|
6
|
+
class InputHandler
|
|
7
|
+
PREFIX = "\x01".freeze # Ctrl-a
|
|
8
|
+
|
|
9
|
+
PREFIX_BINDINGS = {
|
|
10
|
+
"c" => :new_pane,
|
|
11
|
+
"n" => :focus_next,
|
|
12
|
+
"p" => :focus_prev,
|
|
13
|
+
"a" => :focus_last,
|
|
14
|
+
"k" => :close_focused,
|
|
15
|
+
"\t" => :cycle_layout,
|
|
16
|
+
"\r" => :promote_master,
|
|
17
|
+
"\n" => :promote_master,
|
|
18
|
+
"~" => :toggle_drawer,
|
|
19
|
+
"d" => :detach,
|
|
20
|
+
"?" => :show_help,
|
|
21
|
+
"q" => :quit_immediate,
|
|
22
|
+
"[" => :enter_scrollback,
|
|
23
|
+
"]" => :paste_from_buffer
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
SCROLLBACK_BINDINGS = {
|
|
27
|
+
"j" => :line_forward,
|
|
28
|
+
"k" => :line_back,
|
|
29
|
+
"\x04" => :half_forward, # Ctrl-d
|
|
30
|
+
"\x15" => :half_back, # Ctrl-u
|
|
31
|
+
"d" => :half_forward,
|
|
32
|
+
"u" => :half_back,
|
|
33
|
+
"\x06" => :full_forward, # Ctrl-f
|
|
34
|
+
"\x02" => :full_back, # Ctrl-b
|
|
35
|
+
"f" => :full_forward,
|
|
36
|
+
"b" => :full_back,
|
|
37
|
+
" " => :full_forward,
|
|
38
|
+
"g" => :top,
|
|
39
|
+
"G" => :bottom
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
SCROLLBACK_EXITS = ["q", "\e", "\x03"].freeze # q, Esc, Ctrl-c
|
|
43
|
+
|
|
44
|
+
SELECTION_BINDINGS = {
|
|
45
|
+
"h" => :left,
|
|
46
|
+
"l" => :right,
|
|
47
|
+
"j" => :down,
|
|
48
|
+
"k" => :up,
|
|
49
|
+
"0" => :line_start,
|
|
50
|
+
"$" => :line_end,
|
|
51
|
+
"g" => :top,
|
|
52
|
+
"G" => :bottom,
|
|
53
|
+
"\x04" => :half_down, # Ctrl-d
|
|
54
|
+
"\x15" => :half_up, # Ctrl-u
|
|
55
|
+
"d" => :half_down,
|
|
56
|
+
"u" => :half_up,
|
|
57
|
+
"\x06" => :full_down, # Ctrl-f
|
|
58
|
+
"\x02" => :full_up, # Ctrl-b
|
|
59
|
+
"f" => :full_down,
|
|
60
|
+
"b" => :full_up,
|
|
61
|
+
" " => :full_down
|
|
62
|
+
}.freeze
|
|
63
|
+
|
|
64
|
+
SELECTION_YANK = ["\r", "\n", "y"].freeze
|
|
65
|
+
SELECTION_CANCEL = ["q", "\e", "\x03"].freeze # q, Esc, Ctrl-c
|
|
66
|
+
|
|
67
|
+
DIGIT_RE = /\A[1-9]\z/.freeze
|
|
68
|
+
|
|
69
|
+
attr_reader :state, :command_buffer
|
|
70
|
+
|
|
71
|
+
def initialize(app)
|
|
72
|
+
@app = app
|
|
73
|
+
@state = :idle
|
|
74
|
+
@command_buffer = +""
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def feed(data)
|
|
78
|
+
data.each_char do |ch|
|
|
79
|
+
case @state
|
|
80
|
+
when :help
|
|
81
|
+
@app.dismiss_help
|
|
82
|
+
@state = :idle
|
|
83
|
+
when :confirm_quit
|
|
84
|
+
handle_confirm_quit(ch)
|
|
85
|
+
when :idle
|
|
86
|
+
if ch == PREFIX
|
|
87
|
+
@state = :prefix
|
|
88
|
+
else
|
|
89
|
+
@app.send_to_focused(ch)
|
|
90
|
+
end
|
|
91
|
+
when :prefix
|
|
92
|
+
handle_prefix(ch)
|
|
93
|
+
when :command
|
|
94
|
+
handle_command_input(ch)
|
|
95
|
+
when :scrollback
|
|
96
|
+
handle_scrollback_input(ch)
|
|
97
|
+
when :selection
|
|
98
|
+
handle_selection_input(ch)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def enter_help_mode
|
|
104
|
+
@state = :help
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def enter_confirm_quit
|
|
108
|
+
@state = :confirm_quit
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def enter_scrollback_mode
|
|
112
|
+
@state = :scrollback
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def enter_selection_mode
|
|
116
|
+
@state = :selection
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def enter_idle_mode
|
|
120
|
+
@state = :idle
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def cancel
|
|
124
|
+
@state = :idle
|
|
125
|
+
@command_buffer = +""
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def handle_prefix(ch)
|
|
131
|
+
action = PREFIX_BINDINGS[ch]
|
|
132
|
+
case
|
|
133
|
+
when ch == ":"
|
|
134
|
+
@state = :command
|
|
135
|
+
@command_buffer = +""
|
|
136
|
+
when ch == PREFIX
|
|
137
|
+
@app.send_to_focused(PREFIX)
|
|
138
|
+
@state = :idle
|
|
139
|
+
when DIGIT_RE.match?(ch)
|
|
140
|
+
@app.focus_pane_number(ch.to_i)
|
|
141
|
+
@state = :idle
|
|
142
|
+
when action
|
|
143
|
+
@app.public_send(action)
|
|
144
|
+
@state = :idle if @state == :prefix
|
|
145
|
+
else
|
|
146
|
+
# Unknown prefix-key: return to idle silently.
|
|
147
|
+
@state = :idle
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def handle_confirm_quit(ch)
|
|
152
|
+
@state = :idle
|
|
153
|
+
if ch == "y" || ch == "Y"
|
|
154
|
+
@app.confirm_quit
|
|
155
|
+
else
|
|
156
|
+
@app.cancel_quit
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def handle_scrollback_input(ch)
|
|
161
|
+
if SCROLLBACK_EXITS.include?(ch)
|
|
162
|
+
@state = :idle
|
|
163
|
+
@app.exit_scrollback
|
|
164
|
+
return
|
|
165
|
+
end
|
|
166
|
+
if ch == "v"
|
|
167
|
+
@app.enter_selection
|
|
168
|
+
return
|
|
169
|
+
end
|
|
170
|
+
action = SCROLLBACK_BINDINGS[ch]
|
|
171
|
+
@app.scroll_focused(action) if action
|
|
172
|
+
# Unknown keys: ignored. Avoids accidental shell input when the user
|
|
173
|
+
# mistypes inside scrollback mode.
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def handle_selection_input(ch)
|
|
177
|
+
if SELECTION_YANK.include?(ch)
|
|
178
|
+
@app.exit_selection(yank: true)
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
if SELECTION_CANCEL.include?(ch)
|
|
182
|
+
@app.exit_selection(yank: false)
|
|
183
|
+
return
|
|
184
|
+
end
|
|
185
|
+
case ch
|
|
186
|
+
when "v"
|
|
187
|
+
@app.toggle_selection(:linear)
|
|
188
|
+
return
|
|
189
|
+
when "\x16" # Ctrl-v
|
|
190
|
+
@app.toggle_selection(:block)
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
action = SELECTION_BINDINGS[ch]
|
|
194
|
+
@app.move_selection(action) if action
|
|
195
|
+
# Unknown keys ignored — same rationale as scrollback mode.
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def handle_command_input(ch)
|
|
199
|
+
case ch
|
|
200
|
+
when "\r", "\n"
|
|
201
|
+
cmd = @command_buffer.dup
|
|
202
|
+
@command_buffer = +""
|
|
203
|
+
@state = :idle
|
|
204
|
+
@app.run_command(cmd)
|
|
205
|
+
when "\e"
|
|
206
|
+
@command_buffer = +""
|
|
207
|
+
@state = :idle
|
|
208
|
+
@app.invalidate
|
|
209
|
+
when "\x7f", "\b"
|
|
210
|
+
@command_buffer.chop!
|
|
211
|
+
@app.invalidate
|
|
212
|
+
else
|
|
213
|
+
@command_buffer << ch if ch.ord >= 0x20
|
|
214
|
+
@app.invalidate
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module Muxr
|
|
2
|
+
# Pure functions that turn (layout_name, pane_count, screen_rect) into an
|
|
3
|
+
# array of pane rectangles. No mutable state; safe to call repeatedly on
|
|
4
|
+
# every render. Following xmonad, layouts decide geometry — users never
|
|
5
|
+
# resize panes by hand.
|
|
6
|
+
module LayoutManager
|
|
7
|
+
Rect = Struct.new(:x, :y, :w, :h) do
|
|
8
|
+
def to_a
|
|
9
|
+
[x, y, w, h]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
LAYOUTS = %i[tall grid monocle].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def compute(layout, count, area, focused_index: 0, master_index: 0)
|
|
18
|
+
return [] if count <= 0
|
|
19
|
+
master_index = master_index.clamp(0, count - 1)
|
|
20
|
+
focused_index = focused_index.clamp(0, count - 1)
|
|
21
|
+
case layout
|
|
22
|
+
when :tall then tall(count, area, master_index)
|
|
23
|
+
when :grid then grid(count, area)
|
|
24
|
+
when :monocle then monocle(count, area, focused_index)
|
|
25
|
+
else
|
|
26
|
+
raise ArgumentError, "Unknown layout: #{layout.inspect}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Master pane on the left taking half the width; remaining panes stack
|
|
31
|
+
# vertically on the right, dividing the remaining height evenly.
|
|
32
|
+
def tall(count, area, master_index = 0)
|
|
33
|
+
master_index = master_index.clamp(0, count - 1)
|
|
34
|
+
return [Rect.new(area.x, area.y, area.w, area.h)] if count == 1
|
|
35
|
+
|
|
36
|
+
master_w = [area.w / 2, 1].max
|
|
37
|
+
stack_w = [area.w - master_w, 1].max
|
|
38
|
+
others = (0...count).to_a - [master_index]
|
|
39
|
+
slave_count = others.length
|
|
40
|
+
base_h = area.h / slave_count
|
|
41
|
+
remainder = area.h - base_h * slave_count
|
|
42
|
+
|
|
43
|
+
rects = Array.new(count)
|
|
44
|
+
rects[master_index] = Rect.new(area.x, area.y, master_w, area.h)
|
|
45
|
+
|
|
46
|
+
y = area.y
|
|
47
|
+
others.each_with_index do |idx, i|
|
|
48
|
+
h = base_h + (i < remainder ? 1 : 0)
|
|
49
|
+
rects[idx] = Rect.new(area.x + master_w, y, stack_w, h)
|
|
50
|
+
y += h
|
|
51
|
+
end
|
|
52
|
+
rects
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Roughly square grid. Each row stretches its panes to fill the full width
|
|
56
|
+
# so an underfull bottom row doesn't leave gaps.
|
|
57
|
+
def grid(count, area)
|
|
58
|
+
cols_per_row = Math.sqrt(count).ceil
|
|
59
|
+
rows = (count.to_f / cols_per_row).ceil
|
|
60
|
+
|
|
61
|
+
base_h = area.h / rows
|
|
62
|
+
h_rem = area.h - base_h * rows
|
|
63
|
+
|
|
64
|
+
rects = []
|
|
65
|
+
idx = 0
|
|
66
|
+
y = area.y
|
|
67
|
+
rows.times do |r|
|
|
68
|
+
remaining = count - idx
|
|
69
|
+
in_row = [cols_per_row, remaining].min
|
|
70
|
+
row_h = base_h + (r < h_rem ? 1 : 0)
|
|
71
|
+
col_w = area.w / in_row
|
|
72
|
+
w_rem = area.w - col_w * in_row
|
|
73
|
+
x = area.x
|
|
74
|
+
in_row.times do |c|
|
|
75
|
+
w = col_w + (c < w_rem ? 1 : 0)
|
|
76
|
+
rects << Rect.new(x, y, w, row_h)
|
|
77
|
+
x += w
|
|
78
|
+
idx += 1
|
|
79
|
+
end
|
|
80
|
+
y += row_h
|
|
81
|
+
end
|
|
82
|
+
rects
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# All panes occupy the full area; the focused pane is the one drawn last
|
|
86
|
+
# (the Renderer is responsible for the z-order).
|
|
87
|
+
def monocle(count, area, _focused_index = 0)
|
|
88
|
+
Array.new(count) { Rect.new(area.x, area.y, area.w, area.h) }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/muxr/pane.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Muxr
|
|
2
|
+
# A Pane bundles a Terminal emulator buffer with the PTYProcess running the
|
|
3
|
+
# shell that feeds it. The Window keeps a list of panes; the Renderer asks
|
|
4
|
+
# each pane for its current grid contents and cursor position.
|
|
5
|
+
class Pane
|
|
6
|
+
attr_reader :id, :terminal, :process
|
|
7
|
+
attr_accessor :rect
|
|
8
|
+
|
|
9
|
+
def initialize(id:, rows: 24, cols: 80, cwd: nil, command: nil, process: nil)
|
|
10
|
+
@id = id
|
|
11
|
+
@rows = rows
|
|
12
|
+
@cols = cols
|
|
13
|
+
@terminal = Terminal.new(rows: rows, cols: cols)
|
|
14
|
+
@process = process || PTYProcess.new(rows: rows, cols: cols, cwd: cwd, command: command)
|
|
15
|
+
@rect = nil
|
|
16
|
+
@initial_cwd = cwd || @process.cwd
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def io
|
|
20
|
+
@process.io
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def write(data)
|
|
24
|
+
@process.write(data)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def read_from_pty
|
|
28
|
+
data = @process.read_nonblock
|
|
29
|
+
return nil unless data
|
|
30
|
+
@terminal.feed(data)
|
|
31
|
+
data
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resize(rows, cols)
|
|
35
|
+
return if rows == @terminal.rows && cols == @terminal.cols
|
|
36
|
+
@terminal.resize(rows, cols)
|
|
37
|
+
@process.resize(rows, cols)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def alive?
|
|
41
|
+
@process.alive?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def cwd
|
|
45
|
+
@process.cwd || @initial_cwd
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def close
|
|
49
|
+
@process.close
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Muxr
|
|
2
|
+
# Length-prefixed framing for client <-> server messages over a Unix socket.
|
|
3
|
+
#
|
|
4
|
+
# Wire format per message:
|
|
5
|
+
# [1 byte type][4 bytes BE length][length bytes payload]
|
|
6
|
+
#
|
|
7
|
+
# Types are single ASCII letters so they're easy to recognise in tcpdump or
|
|
8
|
+
# hex dumps:
|
|
9
|
+
# H hello (client -> server, payload: "ROWS COLS")
|
|
10
|
+
# I input (client -> server, payload: raw STDIN bytes)
|
|
11
|
+
# R resize (client -> server, payload: "ROWS COLS")
|
|
12
|
+
# B bye (either way, payload: optional reason string)
|
|
13
|
+
# O output (server -> client, payload: raw bytes to write to STDOUT)
|
|
14
|
+
module Protocol
|
|
15
|
+
HELLO = "H".freeze
|
|
16
|
+
INPUT = "I".freeze
|
|
17
|
+
RESIZE = "R".freeze
|
|
18
|
+
BYE = "B".freeze
|
|
19
|
+
OUTPUT = "O".freeze
|
|
20
|
+
|
|
21
|
+
HEADER_SIZE = 5
|
|
22
|
+
|
|
23
|
+
# Reads exactly one framed message from +io+. Returns [type, payload] or
|
|
24
|
+
# nil on EOF / truncated frame. Blocks until the full message arrives.
|
|
25
|
+
def self.read(io)
|
|
26
|
+
header = read_exact(io, HEADER_SIZE)
|
|
27
|
+
return nil unless header
|
|
28
|
+
type = header[0]
|
|
29
|
+
length = header.byteslice(1, 4).unpack1("N")
|
|
30
|
+
payload =
|
|
31
|
+
if length.zero?
|
|
32
|
+
""
|
|
33
|
+
else
|
|
34
|
+
read_exact(io, length)
|
|
35
|
+
end
|
|
36
|
+
return nil unless payload
|
|
37
|
+
[type, payload]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Writes one framed message. +payload+ is treated as raw bytes (binary).
|
|
41
|
+
def self.write(io, type, payload = "")
|
|
42
|
+
raise ArgumentError, "type must be a single byte" unless type.is_a?(String) && type.bytesize == 1
|
|
43
|
+
bytes = payload.to_s.b
|
|
44
|
+
io.write(type + [bytes.bytesize].pack("N") + bytes)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Encodes a "ROWS COLS" string for HELLO / RESIZE payloads.
|
|
48
|
+
def self.encode_size(rows, cols)
|
|
49
|
+
"#{rows.to_i} #{cols.to_i}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns [rows, cols] or nil if malformed.
|
|
53
|
+
def self.decode_size(payload)
|
|
54
|
+
parts = payload.to_s.strip.split(/\s+/)
|
|
55
|
+
return nil unless parts.length == 2
|
|
56
|
+
r = Integer(parts[0]) rescue nil
|
|
57
|
+
c = Integer(parts[1]) rescue nil
|
|
58
|
+
return nil unless r && c
|
|
59
|
+
[r, c]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.read_exact(io, n)
|
|
63
|
+
buf = +""
|
|
64
|
+
while buf.bytesize < n
|
|
65
|
+
chunk = io.read(n - buf.bytesize)
|
|
66
|
+
return nil if chunk.nil? || chunk.empty?
|
|
67
|
+
buf << chunk
|
|
68
|
+
end
|
|
69
|
+
buf
|
|
70
|
+
end
|
|
71
|
+
private_class_method :read_exact
|
|
72
|
+
end
|
|
73
|
+
end
|