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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require "pty"
|
|
2
|
+
|
|
3
|
+
module Muxr
|
|
4
|
+
# Owns a single pseudo-terminal pair plus the child shell process attached to
|
|
5
|
+
# the slave side. The parent side is exposed via #io / #read_nonblock / #write.
|
|
6
|
+
class PTYProcess
|
|
7
|
+
attr_reader :pid, :io, :rows, :cols
|
|
8
|
+
|
|
9
|
+
def initialize(command: nil, rows: 24, cols: 80, cwd: nil, env_overrides: {})
|
|
10
|
+
@rows = rows
|
|
11
|
+
@cols = cols
|
|
12
|
+
@exited = false
|
|
13
|
+
|
|
14
|
+
shell = command || ENV["SHELL"] || "/bin/sh"
|
|
15
|
+
env = ENV.to_h.merge("TERM" => "xterm-256color").merge(env_overrides)
|
|
16
|
+
env["LINES"] = rows.to_s
|
|
17
|
+
env["COLUMNS"] = cols.to_s
|
|
18
|
+
|
|
19
|
+
chdir = (cwd && File.directory?(cwd)) ? cwd : Dir.pwd
|
|
20
|
+
|
|
21
|
+
@reader, @writer, @pid = PTY.spawn(env, shell, chdir: chdir)
|
|
22
|
+
@io = @reader
|
|
23
|
+
resize(rows, cols)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def write(data)
|
|
27
|
+
@writer.write(data)
|
|
28
|
+
@writer.flush
|
|
29
|
+
rescue Errno::EIO, IOError, Errno::EPIPE
|
|
30
|
+
@exited = true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def read_nonblock(max = 8192)
|
|
34
|
+
@reader.read_nonblock(max)
|
|
35
|
+
rescue IO::WaitReadable
|
|
36
|
+
nil
|
|
37
|
+
rescue EOFError, Errno::EIO
|
|
38
|
+
@exited = true
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resize(rows, cols)
|
|
43
|
+
@rows = rows
|
|
44
|
+
@cols = cols
|
|
45
|
+
begin
|
|
46
|
+
@reader.winsize = [rows, cols, 0, 0]
|
|
47
|
+
rescue StandardError
|
|
48
|
+
# Some platforms reject zero pixel sizes; ignore.
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def alive?
|
|
53
|
+
return false if @exited
|
|
54
|
+
Process.kill(0, @pid)
|
|
55
|
+
true
|
|
56
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
57
|
+
@exited = true
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def reap
|
|
62
|
+
Process.waitpid(@pid, Process::WNOHANG)
|
|
63
|
+
rescue Errno::ECHILD
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def close
|
|
68
|
+
reap
|
|
69
|
+
Process.kill("TERM", @pid) if alive?
|
|
70
|
+
@reader.close unless @reader.closed?
|
|
71
|
+
@writer.close if @writer != @reader && !@writer.closed?
|
|
72
|
+
rescue Errno::ESRCH, Errno::EBADF, IOError
|
|
73
|
+
# already gone
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Best-effort cwd of the child process. Used to inherit cwd when opening
|
|
77
|
+
# the drawer or for session save/restore. Falls back to nil if the system
|
|
78
|
+
# doesn't expose the information.
|
|
79
|
+
def cwd
|
|
80
|
+
if File.directory?("/proc/#{@pid}")
|
|
81
|
+
File.readlink("/proc/#{@pid}/cwd")
|
|
82
|
+
else
|
|
83
|
+
# macOS / BSD fallback via lsof.
|
|
84
|
+
out = `lsof -a -p #{@pid} -d cwd -Fn 2>/dev/null`
|
|
85
|
+
line = out.lines.find { |l| l.start_with?("n/") }
|
|
86
|
+
line && line[1..].strip
|
|
87
|
+
end
|
|
88
|
+
rescue StandardError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
module Muxr
|
|
2
|
+
# The Renderer composites the Session into a single grid of cells and then
|
|
3
|
+
# emits ANSI escape sequences to write that grid to STDOUT. It compares the
|
|
4
|
+
# new frame against the previous one and only repositions/redraws cells
|
|
5
|
+
# whose contents changed, keeping output volume low between ticks.
|
|
6
|
+
class Renderer
|
|
7
|
+
BORDER_FOCUSED = [:c256, 11].freeze # yellow
|
|
8
|
+
BORDER_UNFOCUSED = [:c256, 8].freeze # grey
|
|
9
|
+
BORDER_DRAWER_FOCUS = [:c256, 13].freeze # magenta
|
|
10
|
+
BORDER_DRAWER_IDLE = [:c256, 5].freeze # dark magenta
|
|
11
|
+
STATUS_BG = [:c256, 236].freeze
|
|
12
|
+
STATUS_FG = [:c256, 252].freeze
|
|
13
|
+
|
|
14
|
+
HORIZONTAL = "─".freeze
|
|
15
|
+
VERTICAL = "│".freeze
|
|
16
|
+
TL = "┌".freeze
|
|
17
|
+
TR = "┐".freeze
|
|
18
|
+
BL = "└".freeze
|
|
19
|
+
BR = "┘".freeze
|
|
20
|
+
|
|
21
|
+
Cell = Struct.new(:char, :fg, :bg, :attrs) do
|
|
22
|
+
def ==(other)
|
|
23
|
+
other.is_a?(Cell) && char == other.char && fg == other.fg && bg == other.bg && attrs == other.attrs
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(out: $stdout)
|
|
28
|
+
@out = out
|
|
29
|
+
@prev = nil
|
|
30
|
+
@prev_w = 0
|
|
31
|
+
@prev_h = 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def enter_alt_screen
|
|
35
|
+
@out.write("\e[?1049h\e[?25l\e[2J\e[H\e[0m")
|
|
36
|
+
@out.flush
|
|
37
|
+
@prev = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def exit_alt_screen
|
|
41
|
+
@out.write("\e[0m\e[?25h\e[?1049l")
|
|
42
|
+
@out.flush
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def reset_frame!
|
|
46
|
+
@prev = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def render(session, input_state: :idle, command_buffer: "", message: nil, help: false)
|
|
50
|
+
w = session.width
|
|
51
|
+
h = session.height
|
|
52
|
+
return if w < 4 || h < 3
|
|
53
|
+
|
|
54
|
+
frame = Array.new(h) { Array.new(w) { Cell.new(" ", nil, nil, 0) } }
|
|
55
|
+
|
|
56
|
+
compose_panes(frame, session)
|
|
57
|
+
compose_drawer(frame, session) if session.drawer&.visible?
|
|
58
|
+
compose_status_bar(frame, session, input_state: input_state, command_buffer: command_buffer, message: message)
|
|
59
|
+
compose_help(frame, session) if help
|
|
60
|
+
|
|
61
|
+
emit_frame(frame, session, input_state: input_state, command_buffer: command_buffer)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def compose_panes(frame, session)
|
|
67
|
+
win = session.window
|
|
68
|
+
content_area = LayoutManager::Rect.new(0, 0, session.width, session.height - 1)
|
|
69
|
+
rects = LayoutManager.compute(
|
|
70
|
+
win.layout,
|
|
71
|
+
win.panes.length,
|
|
72
|
+
content_area,
|
|
73
|
+
focused_index: win.focused_index,
|
|
74
|
+
master_index: win.master_index
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
monocle = win.layout == :monocle
|
|
78
|
+
|
|
79
|
+
win.panes.each_with_index do |pane, i|
|
|
80
|
+
rect = rects[i]
|
|
81
|
+
pane.rect = rect
|
|
82
|
+
next unless rect && rect.w >= 3 && rect.h >= 3
|
|
83
|
+
|
|
84
|
+
inner_w = rect.w - 2
|
|
85
|
+
inner_h = rect.h - 2
|
|
86
|
+
pane.resize(inner_h, inner_w)
|
|
87
|
+
|
|
88
|
+
# In monocle every rect is identical, so drawing all panes would just
|
|
89
|
+
# stack them and let the last-in-array win. Only composite the focused
|
|
90
|
+
# pane; the others stay resized so their PTYs are ready when focus moves.
|
|
91
|
+
next if monocle && i != win.focused_index
|
|
92
|
+
|
|
93
|
+
focused = (i == win.focused_index) && !(session.focus_drawer && session.drawer&.visible?)
|
|
94
|
+
title = "##{i + 1}"
|
|
95
|
+
title += "/#{win.panes.length}" if monocle
|
|
96
|
+
title += " ★" if i == win.master_index
|
|
97
|
+
title += " (" + win.layout.to_s + ")" if i == win.focused_index
|
|
98
|
+
if pane.terminal.scrolled_back?
|
|
99
|
+
title += " [scrollback #{pane.terminal.view_offset}/#{pane.terminal.scrollback_size}]"
|
|
100
|
+
end
|
|
101
|
+
draw_box(frame, rect,
|
|
102
|
+
border: focused ? BORDER_FOCUSED : BORDER_UNFOCUSED,
|
|
103
|
+
bold_border: focused,
|
|
104
|
+
title: title,
|
|
105
|
+
title_focused: focused)
|
|
106
|
+
copy_terminal(frame, pane, rect.x + 1, rect.y + 1)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def compose_drawer(frame, session)
|
|
111
|
+
drawer = session.drawer
|
|
112
|
+
return unless drawer&.pane
|
|
113
|
+
|
|
114
|
+
w = session.width
|
|
115
|
+
h = session.height
|
|
116
|
+
dh = (h * 0.35).round.clamp(5, h - 2)
|
|
117
|
+
dy = h - 1 - dh
|
|
118
|
+
rect = LayoutManager::Rect.new(0, dy, w, dh)
|
|
119
|
+
drawer.pane.rect = rect
|
|
120
|
+
inner_w = rect.w - 2
|
|
121
|
+
inner_h = rect.h - 2
|
|
122
|
+
drawer.pane.resize(inner_h, inner_w)
|
|
123
|
+
|
|
124
|
+
# Wipe the area under the drawer so panes don't bleed through.
|
|
125
|
+
(rect.y...(rect.y + rect.h)).each do |y|
|
|
126
|
+
(rect.x...(rect.x + rect.w)).each do |x|
|
|
127
|
+
c = frame[y][x]
|
|
128
|
+
c.char = " "
|
|
129
|
+
c.fg = nil
|
|
130
|
+
c.bg = nil
|
|
131
|
+
c.attrs = 0
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
focused = session.focus_drawer
|
|
136
|
+
title = focused ? "Drawer" : "Drawer (hidden focus)"
|
|
137
|
+
term = drawer.pane.terminal
|
|
138
|
+
title += " [scrollback #{term.view_offset}/#{term.scrollback_size}]" if term.scrolled_back?
|
|
139
|
+
draw_box(frame, rect,
|
|
140
|
+
border: focused ? BORDER_DRAWER_FOCUS : BORDER_DRAWER_IDLE,
|
|
141
|
+
bold_border: true,
|
|
142
|
+
title: title,
|
|
143
|
+
title_focused: focused)
|
|
144
|
+
copy_terminal(frame, drawer.pane, rect.x + 1, rect.y + 1)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def compose_status_bar(frame, session, input_state:, command_buffer:, message:)
|
|
148
|
+
y = session.height - 1
|
|
149
|
+
w = session.width
|
|
150
|
+
win = session.window
|
|
151
|
+
drawer_state =
|
|
152
|
+
if session.drawer.nil? then "off"
|
|
153
|
+
elsif session.drawer.visible? then "shown"
|
|
154
|
+
else "hidden"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
left = " [#{session.name}]"
|
|
158
|
+
left << " panes:#{win.panes.length}"
|
|
159
|
+
left << " layout:#{win.layout}"
|
|
160
|
+
focused_label =
|
|
161
|
+
if session.focus_drawer && session.drawer&.visible?
|
|
162
|
+
"drawer"
|
|
163
|
+
elsif win.panes.empty?
|
|
164
|
+
"-"
|
|
165
|
+
else
|
|
166
|
+
"##{win.focused_index + 1}"
|
|
167
|
+
end
|
|
168
|
+
left << " focused:#{focused_label}"
|
|
169
|
+
left << " drawer:#{drawer_state} "
|
|
170
|
+
|
|
171
|
+
right = " muxr ^a ? "
|
|
172
|
+
|
|
173
|
+
bar = (left + " " * w)[0, w - right.length] + right
|
|
174
|
+
bar = bar[0, w]
|
|
175
|
+
|
|
176
|
+
bar.each_char.with_index do |ch, x|
|
|
177
|
+
c = frame[y][x]
|
|
178
|
+
c.char = ch
|
|
179
|
+
c.fg = STATUS_FG
|
|
180
|
+
c.bg = STATUS_BG
|
|
181
|
+
c.attrs = 0
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
if input_state == :command
|
|
185
|
+
overlay = ":#{command_buffer}"
|
|
186
|
+
overlay = overlay[0, w]
|
|
187
|
+
overlay.each_char.with_index do |ch, x|
|
|
188
|
+
c = frame[y][x]
|
|
189
|
+
c.char = ch
|
|
190
|
+
c.fg = [:c256, 232]
|
|
191
|
+
c.bg = [:c256, 226]
|
|
192
|
+
c.attrs = Terminal::BOLD
|
|
193
|
+
end
|
|
194
|
+
(overlay.length...w).each do |x|
|
|
195
|
+
c = frame[y][x]
|
|
196
|
+
c.char = " "
|
|
197
|
+
c.fg = nil
|
|
198
|
+
c.bg = [:c256, 226]
|
|
199
|
+
c.attrs = 0
|
|
200
|
+
end
|
|
201
|
+
elsif input_state == :scrollback
|
|
202
|
+
overlay = " SCROLLBACK j/k line d/u half f/b page g/G top/bot v select q quit "
|
|
203
|
+
overlay = overlay[0, w]
|
|
204
|
+
overlay.each_char.with_index do |ch, x|
|
|
205
|
+
c = frame[y][x]
|
|
206
|
+
c.char = ch
|
|
207
|
+
c.fg = [:c256, 232]
|
|
208
|
+
c.bg = [:c256, 214]
|
|
209
|
+
c.attrs = Terminal::BOLD
|
|
210
|
+
end
|
|
211
|
+
(overlay.length...w).each do |x|
|
|
212
|
+
c = frame[y][x]
|
|
213
|
+
c.char = " "
|
|
214
|
+
c.fg = nil
|
|
215
|
+
c.bg = [:c256, 214]
|
|
216
|
+
c.attrs = 0
|
|
217
|
+
end
|
|
218
|
+
elsif input_state == :selection
|
|
219
|
+
mode = nil
|
|
220
|
+
focused = session.window.focused_pane
|
|
221
|
+
if focused && focused.terminal.selection_active?
|
|
222
|
+
mode = focused.terminal.selection_mode == :block ? "BLOCK" : "CHAR"
|
|
223
|
+
end
|
|
224
|
+
label = mode ? "SELECTION/#{mode}" : "SELECTION (cursor)"
|
|
225
|
+
overlay = " #{label} h/j/k/l move v char C-v block y/Enter yank q cancel "
|
|
226
|
+
overlay = overlay[0, w]
|
|
227
|
+
overlay.each_char.with_index do |ch, x|
|
|
228
|
+
c = frame[y][x]
|
|
229
|
+
c.char = ch
|
|
230
|
+
c.fg = [:c256, 232]
|
|
231
|
+
c.bg = [:c256, 156]
|
|
232
|
+
c.attrs = Terminal::BOLD
|
|
233
|
+
end
|
|
234
|
+
(overlay.length...w).each do |x|
|
|
235
|
+
c = frame[y][x]
|
|
236
|
+
c.char = " "
|
|
237
|
+
c.fg = nil
|
|
238
|
+
c.bg = [:c256, 156]
|
|
239
|
+
c.attrs = 0
|
|
240
|
+
end
|
|
241
|
+
elsif message
|
|
242
|
+
msg = " #{message} "
|
|
243
|
+
start = [w - msg.length, 0].max
|
|
244
|
+
msg.each_char.with_index do |ch, i|
|
|
245
|
+
x = start + i
|
|
246
|
+
next unless x < w
|
|
247
|
+
c = frame[y][x]
|
|
248
|
+
c.char = ch
|
|
249
|
+
c.fg = [:c256, 15]
|
|
250
|
+
c.bg = [:c256, 28]
|
|
251
|
+
c.attrs = Terminal::BOLD
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
HELP_LINES = [
|
|
257
|
+
"muxr — keybindings",
|
|
258
|
+
"",
|
|
259
|
+
" C-a c new pane",
|
|
260
|
+
" C-a n / p next / prev pane",
|
|
261
|
+
" C-a a toggle to previously focused pane",
|
|
262
|
+
" C-a 1..9 jump to pane by number",
|
|
263
|
+
" C-a k close focused pane",
|
|
264
|
+
" C-a Tab cycle layout (tall → grid → monocle)",
|
|
265
|
+
" C-a Enter promote focused pane to master",
|
|
266
|
+
" C-a ~ toggle drawer",
|
|
267
|
+
" C-a [ enter scrollback (j/k d/u f/b g/G; v→cursor, q quits)",
|
|
268
|
+
" in cursor mode: v char-select, C-v block-select, y yank",
|
|
269
|
+
" C-a ] paste internal copy buffer",
|
|
270
|
+
" C-a d detach (server keeps running)",
|
|
271
|
+
" C-a q kill session (asks y/n)",
|
|
272
|
+
" C-a : command prompt",
|
|
273
|
+
" C-a ? toggle this help",
|
|
274
|
+
" C-a C-a send literal C-a",
|
|
275
|
+
"",
|
|
276
|
+
"Commands: layout {tall|grid|monocle}, drawer {toggle|show|hide|reset},",
|
|
277
|
+
" save, restore, sessions, quit, new, close, next, prev",
|
|
278
|
+
"",
|
|
279
|
+
"press any key to dismiss"
|
|
280
|
+
].freeze
|
|
281
|
+
|
|
282
|
+
def compose_help(frame, session)
|
|
283
|
+
w = session.width
|
|
284
|
+
h = session.height
|
|
285
|
+
max_len = HELP_LINES.map(&:length).max
|
|
286
|
+
box_w = [max_len + 4, w - 4].min
|
|
287
|
+
box_h = [HELP_LINES.length + 2, h - 4].min
|
|
288
|
+
x = (w - box_w) / 2
|
|
289
|
+
y = (h - box_h) / 2
|
|
290
|
+
rect = LayoutManager::Rect.new(x, y, box_w, box_h)
|
|
291
|
+
|
|
292
|
+
(rect.y...(rect.y + rect.h)).each do |yy|
|
|
293
|
+
(rect.x...(rect.x + rect.w)).each do |xx|
|
|
294
|
+
c = frame[yy][xx]
|
|
295
|
+
c.char = " "
|
|
296
|
+
c.fg = [:c256, 252]
|
|
297
|
+
c.bg = [:c256, 236]
|
|
298
|
+
c.attrs = 0
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
draw_box(frame, rect, border: [:c256, 51], bold_border: true, title: "Help", title_focused: true)
|
|
302
|
+
|
|
303
|
+
HELP_LINES.first(box_h - 2).each_with_index do |line, i|
|
|
304
|
+
line[0, box_w - 4].chars.each_with_index do |ch, j|
|
|
305
|
+
c = frame[rect.y + 1 + i][rect.x + 2 + j]
|
|
306
|
+
c.char = ch
|
|
307
|
+
c.fg = [:c256, 252]
|
|
308
|
+
c.bg = [:c256, 236]
|
|
309
|
+
c.attrs = i == 0 ? Terminal::BOLD : 0
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def draw_box(frame, rect, border:, bold_border:, title: nil, title_focused: false)
|
|
315
|
+
attrs = bold_border ? Terminal::BOLD : 0
|
|
316
|
+
x2 = rect.x + rect.w - 1
|
|
317
|
+
y2 = rect.y + rect.h - 1
|
|
318
|
+
(rect.x..x2).each do |x|
|
|
319
|
+
set_cell(frame, rect.y, x, HORIZONTAL, fg: border, attrs: attrs)
|
|
320
|
+
set_cell(frame, y2, x, HORIZONTAL, fg: border, attrs: attrs)
|
|
321
|
+
end
|
|
322
|
+
(rect.y..y2).each do |y|
|
|
323
|
+
set_cell(frame, y, rect.x, VERTICAL, fg: border, attrs: attrs)
|
|
324
|
+
set_cell(frame, y, x2, VERTICAL, fg: border, attrs: attrs)
|
|
325
|
+
end
|
|
326
|
+
set_cell(frame, rect.y, rect.x, TL, fg: border, attrs: attrs)
|
|
327
|
+
set_cell(frame, rect.y, x2, TR, fg: border, attrs: attrs)
|
|
328
|
+
set_cell(frame, y2, rect.x, BL, fg: border, attrs: attrs)
|
|
329
|
+
set_cell(frame, y2, x2, BR, fg: border, attrs: attrs)
|
|
330
|
+
|
|
331
|
+
if title && rect.w >= title.length + 4
|
|
332
|
+
text = " #{title} "
|
|
333
|
+
title_attrs = title_focused ? Terminal::BOLD : 0
|
|
334
|
+
text.each_char.with_index do |ch, i|
|
|
335
|
+
set_cell(frame, rect.y, rect.x + 2 + i, ch, fg: border, attrs: title_attrs)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def copy_terminal(frame, pane, dst_x, dst_y)
|
|
341
|
+
term = pane.terminal
|
|
342
|
+
rows = term.rows
|
|
343
|
+
cols = term.cols
|
|
344
|
+
selection = term.selection_active?
|
|
345
|
+
rows.times do |r|
|
|
346
|
+
fy = dst_y + r
|
|
347
|
+
next if fy < 0 || fy >= frame.length
|
|
348
|
+
cols.times do |c|
|
|
349
|
+
fx = dst_x + c
|
|
350
|
+
next if fx < 0 || fx >= frame[0].length
|
|
351
|
+
src = term.visible_cell(r, c)
|
|
352
|
+
dst = frame[fy][fx]
|
|
353
|
+
dst.char = src.char
|
|
354
|
+
dst.fg = src.fg
|
|
355
|
+
dst.bg = src.bg
|
|
356
|
+
dst.attrs = src.attrs
|
|
357
|
+
dst.attrs |= Terminal::REVERSE if selection && term.selected_at_visible?(r, c)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def set_cell(frame, y, x, char, fg: nil, bg: nil, attrs: 0)
|
|
363
|
+
return if y < 0 || y >= frame.length
|
|
364
|
+
return if x < 0 || x >= frame[0].length
|
|
365
|
+
c = frame[y][x]
|
|
366
|
+
c.char = char
|
|
367
|
+
c.fg = fg
|
|
368
|
+
c.bg = bg
|
|
369
|
+
c.attrs = attrs
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def emit_frame(frame, session, input_state:, command_buffer:)
|
|
373
|
+
out = String.new("\e[0m")
|
|
374
|
+
same_size = @prev && @prev_w == frame[0].length && @prev_h == frame.length
|
|
375
|
+
cur_fg = :unset
|
|
376
|
+
cur_bg = :unset
|
|
377
|
+
cur_attrs = :unset
|
|
378
|
+
last_y = nil
|
|
379
|
+
last_x = nil
|
|
380
|
+
|
|
381
|
+
frame.each_with_index do |row, y|
|
|
382
|
+
row.each_with_index do |cell, x|
|
|
383
|
+
if same_size && @prev[y][x] == cell
|
|
384
|
+
next
|
|
385
|
+
end
|
|
386
|
+
if last_y != y || last_x != x
|
|
387
|
+
out << "\e[#{y + 1};#{x + 1}H"
|
|
388
|
+
end
|
|
389
|
+
unless cell.fg == cur_fg && cell.bg == cur_bg && cell.attrs == cur_attrs
|
|
390
|
+
out << sgr(cell)
|
|
391
|
+
cur_fg = cell.fg
|
|
392
|
+
cur_bg = cell.bg
|
|
393
|
+
cur_attrs = cell.attrs
|
|
394
|
+
end
|
|
395
|
+
out << cell.char
|
|
396
|
+
last_y = y
|
|
397
|
+
last_x = x + cell.char.length
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
out << "\e[0m"
|
|
401
|
+
out << cursor_position(session, input_state: input_state, command_buffer: command_buffer)
|
|
402
|
+
@out.write(out)
|
|
403
|
+
@out.flush
|
|
404
|
+
@prev = frame.map { |row| row.map(&:dup) }
|
|
405
|
+
@prev_w = frame[0].length
|
|
406
|
+
@prev_h = frame.length
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def cursor_position(session, input_state:, command_buffer:)
|
|
410
|
+
if input_state == :command
|
|
411
|
+
col = 1 + command_buffer.length + 1 # ':' + buffer
|
|
412
|
+
return "\e[#{session.height};#{col}H\e[?25h"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
target =
|
|
416
|
+
if session.focus_drawer && session.drawer&.visible? && session.drawer.pane
|
|
417
|
+
session.drawer.pane
|
|
418
|
+
else
|
|
419
|
+
session.window.focused_pane
|
|
420
|
+
end
|
|
421
|
+
return "\e[?25l" unless target&.rect
|
|
422
|
+
|
|
423
|
+
term = target.terminal
|
|
424
|
+
rect = target.rect
|
|
425
|
+
if input_state == :selection
|
|
426
|
+
pos = term.selection_cursor_visible
|
|
427
|
+
return "\e[?25l" unless pos
|
|
428
|
+
row = rect.y + 1 + pos[0] + 1
|
|
429
|
+
col = rect.x + 1 + pos[1] + 1
|
|
430
|
+
return "\e[#{row};#{col}H\e[?25h"
|
|
431
|
+
end
|
|
432
|
+
return "\e[?25l" if term.scrolled_back?
|
|
433
|
+
row = rect.y + 1 + term.cursor_row + 1
|
|
434
|
+
col = rect.x + 1 + term.cursor_col + 1
|
|
435
|
+
"\e[#{row};#{col}H\e[?25h"
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def sgr(cell)
|
|
439
|
+
parts = ["0"]
|
|
440
|
+
attrs = cell.attrs.to_i
|
|
441
|
+
parts << "1" if (attrs & Terminal::BOLD) != 0
|
|
442
|
+
parts << "4" if (attrs & Terminal::UNDERLINE) != 0
|
|
443
|
+
parts << "7" if (attrs & Terminal::REVERSE) != 0
|
|
444
|
+
append_color(parts, cell.fg, true)
|
|
445
|
+
append_color(parts, cell.bg, false)
|
|
446
|
+
"\e[#{parts.join(';')}m"
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def append_color(parts, color, fg)
|
|
450
|
+
return if color.nil?
|
|
451
|
+
case color
|
|
452
|
+
when Integer
|
|
453
|
+
if color < 8
|
|
454
|
+
parts << ((fg ? 30 : 40) + color).to_s
|
|
455
|
+
else
|
|
456
|
+
parts << ((fg ? 90 : 100) + (color - 8)).to_s
|
|
457
|
+
end
|
|
458
|
+
when Array
|
|
459
|
+
case color[0]
|
|
460
|
+
when :c256
|
|
461
|
+
parts << "#{fg ? 38 : 48};5;#{color[1]}"
|
|
462
|
+
when :rgb
|
|
463
|
+
parts << "#{fg ? 38 : 48};2;#{color[1]};#{color[2]};#{color[3]}"
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
data/lib/muxr/session.rb
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Muxr
|
|
5
|
+
# A Session bundles the user's Window + Drawer plus a snapshot of the
|
|
6
|
+
# terminal dimensions. It is responsible for persisting/restoring its own
|
|
7
|
+
# state as JSON on disk (~/.muxr/sessions/<name>.json). Only the shape of
|
|
8
|
+
# the session (pane count, layout, cwds, drawer state) is persisted — the
|
|
9
|
+
# live shell history is not.
|
|
10
|
+
class Session
|
|
11
|
+
SESSIONS_DIR = File.join(Dir.home, ".muxr", "sessions").freeze
|
|
12
|
+
|
|
13
|
+
attr_accessor :width, :height, :window, :drawer, :focus_drawer
|
|
14
|
+
attr_reader :name
|
|
15
|
+
|
|
16
|
+
def initialize(name: "default", width: 80, height: 24)
|
|
17
|
+
@name = name
|
|
18
|
+
@width = width
|
|
19
|
+
@height = height
|
|
20
|
+
@window = Window.new(name: name)
|
|
21
|
+
@drawer = nil
|
|
22
|
+
@focus_drawer = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def save_path
|
|
26
|
+
File.join(SESSIONS_DIR, "#{@name}.json")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.save_path_for(name)
|
|
30
|
+
File.join(SESSIONS_DIR, "#{name}.json")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def save
|
|
34
|
+
FileUtils.mkdir_p(SESSIONS_DIR)
|
|
35
|
+
File.write(save_path, JSON.pretty_generate(serialize))
|
|
36
|
+
save_path
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def serialize
|
|
40
|
+
{
|
|
41
|
+
"name" => @name,
|
|
42
|
+
"width" => @width,
|
|
43
|
+
"height" => @height,
|
|
44
|
+
"layout" => @window.layout.to_s,
|
|
45
|
+
"focused_index" => @window.focused_index,
|
|
46
|
+
"master_index" => @window.master_index,
|
|
47
|
+
"focus_drawer" => @focus_drawer,
|
|
48
|
+
"panes" => @window.panes.map { |p| { "cwd" => safe_cwd(p) } },
|
|
49
|
+
"drawer" => serialize_drawer
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.load(name)
|
|
54
|
+
path = save_path_for(name)
|
|
55
|
+
return nil unless File.exist?(path)
|
|
56
|
+
JSON.parse(File.read(path))
|
|
57
|
+
rescue JSON::ParserError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.exists?(name)
|
|
62
|
+
File.exist?(save_path_for(name))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.list
|
|
66
|
+
return [] unless File.directory?(SESSIONS_DIR)
|
|
67
|
+
Dir.children(SESSIONS_DIR).filter_map do |entry|
|
|
68
|
+
next unless entry.end_with?(".json")
|
|
69
|
+
File.basename(entry, ".json")
|
|
70
|
+
end.sort
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def safe_cwd(pane)
|
|
76
|
+
pane.respond_to?(:cwd) ? pane.cwd : nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def serialize_drawer
|
|
80
|
+
return nil unless @drawer
|
|
81
|
+
{
|
|
82
|
+
"visible" => @drawer.visible?,
|
|
83
|
+
"cwd" => @drawer.cwd
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|