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.
@@ -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
@@ -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