Class: Muxr::Application

Inherits:
Object
  • Object
show all
Defined in:
lib/muxr/application.rb

Overview

The Application is the muxr server. It owns the Session, panes, Renderer, and InputHandler, and listens on a Unix socket at ~/.muxr/sockets/<name>.sock for a Client to attach. Shells and other PTY processes survive client detach/reattach — only the listening socket and the one currently-attached client come and go.

The Renderer’s output sink is a small adapter that frames its bytes into OUTPUT messages on the attached client; when no client is attached the bytes are silently dropped (we also skip the render entirely in that case). PTY data still gets drained even with no client, so the in-memory Terminal grids stay up to date and are repainted in full on the next attach via Renderer#reset_frame!.

Defined Under Namespace

Classes: FramedOutput

Constant Summary collapse

SELECT_TIMEOUT =
0.05
SOCKETS_DIR =
File.join(Dir.home, ".muxr", "sockets").freeze
DEFAULT_WIDTH =
80
DEFAULT_HEIGHT =
24

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(argv = []) ⇒ Application

Returns a new instance of Application.



29
30
31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/muxr/application.rb', line 29

def initialize(argv = [])
  @argv = argv
  @session_name = parse_session_name(argv)
  @running = false
  @needs_render = true
  @message = nil
  @message_expires = nil
  @help_visible = false
  @next_pane_id = 0
  @current_client = nil
  @listening_socket = nil
  @socket_path = self.class.socket_path_for(@session_name)
  @paste_buffer = +""
end

Instance Attribute Details

#inputObject (readonly)

Returns the value of attribute input.



23
24
25
# File 'lib/muxr/application.rb', line 23

def input
  @input
end

#paste_bufferObject (readonly)

Returns the value of attribute paste_buffer.



44
45
46
# File 'lib/muxr/application.rb', line 44

def paste_buffer
  @paste_buffer
end

#rendererObject (readonly)

Returns the value of attribute renderer.



23
24
25
# File 'lib/muxr/application.rb', line 23

def renderer
  @renderer
end

#sessionObject (readonly)

Returns the value of attribute session.



23
24
25
# File 'lib/muxr/application.rb', line 23

def session
  @session
end

#session_nameObject (readonly)

Returns the value of attribute session_name.



23
24
25
# File 'lib/muxr/application.rb', line 23

def session_name
  @session_name
end

Class Method Details

.socket_path_for(name) ⇒ Object



25
26
27
# File 'lib/muxr/application.rb', line 25

def self.socket_path_for(name)
  File.join(SOCKETS_DIR, "#{name}.sock")
end

Instance Method Details

#cancel_quitObject



195
196
197
198
199
200
# File 'lib/muxr/application.rb', line 195

def cancel_quit
  @message = nil
  @message_expires = nil
  flash("cancelled")
  invalidate
end

#close_focusedObject



109
110
111
112
113
114
115
116
117
118
# File 'lib/muxr/application.rb', line 109

def close_focused
  if @session.focus_drawer && @session.drawer&.visible?
    hide_drawer
    return
  end
  pane = focused_pane
  return unless pane
  @session.window.remove_pane(pane)
  invalidate
end

#confirm_quitObject



191
192
193
# File 'lib/muxr/application.rb', line 191

def confirm_quit
  shutdown_server
end

#cycle_layoutObject



120
121
122
123
124
# File 'lib/muxr/application.rb', line 120

def cycle_layout
  @session.window.cycle_layout
  flash("layout: #{@session.window.layout}")
  invalidate
end

#deliver_output(bytes) ⇒ Object

Called by the FramedOutput adapter; ships one OUTPUT frame to the currently attached client. No-op when nobody is attached.



366
367
368
369
370
371
372
# File 'lib/muxr/application.rb', line 366

def deliver_output(bytes)
  sock = @current_client
  return unless sock
  Protocol.write(sock, Protocol::OUTPUT, bytes)
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
  drop_client_silently
end

#detachObject



167
168
169
170
171
# File 'lib/muxr/application.rb', line 167

def detach
  flash("detached")
  disconnect_client(reason: "detached")
  # Server keeps running. Next `bin/muxr <name>` invocation will re-attach.
end

#dismiss_helpObject



213
214
215
216
# File 'lib/muxr/application.rb', line 213

def dismiss_help
  @help_visible = false
  invalidate
end

#enter_scrollbackObject



218
219
220
221
222
223
224
# File 'lib/muxr/application.rb', line 218

def enter_scrollback
  target = focused_target
  return unless target
  @input.enter_scrollback_mode
  @renderer.reset_frame!
  invalidate
end

#enter_selectionObject



252
253
254
255
256
257
258
259
260
261
262
# File 'lib/muxr/application.rb', line 252

def enter_selection
  target = focused_target
  return unless target
  # Vim-style: drop the user at a movable cursor with NO selection yet.
  # They navigate with h/j/k/l, then press v (linear) or C-v (block) to
  # anchor.
  target.terminal.place_selection_cursor(0, 0)
  @input.enter_selection_mode
  @renderer.reset_frame!
  invalidate
end

#exit_scrollbackObject



226
227
228
229
230
231
232
# File 'lib/muxr/application.rb', line 226

def exit_scrollback
  target = focused_target
  target&.terminal&.clear_selection
  target&.terminal&.scroll_to_bottom
  @renderer.reset_frame!
  invalidate
end

#exit_selection(yank:) ⇒ Object



281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
# File 'lib/muxr/application.rb', line 281

def exit_selection(yank:)
  target = focused_target
  term = target&.terminal
  if yank
    # No anchor → no-op. User is still positioning; they can press v
    # first, then yank. Esc/q is the way to exit from navigation.
    return unless term&.selection_active?
    text = term.extract_selection_text
    unless text.empty?
      @paste_buffer = text
      spawn_pbcopy(text)
      flash("yanked #{text.bytesize} bytes")
    end
  end
  term&.clear_selection
  @input.enter_scrollback_mode
  @renderer.reset_frame!
  invalidate
end

#flash(msg) ⇒ Object



330
331
332
333
334
# File 'lib/muxr/application.rb', line 330

def flash(msg)
  @message = msg
  @message_expires = Time.now + 2.5
  invalidate
end

#focus_lastObject



90
91
92
93
94
95
96
97
98
# File 'lib/muxr/application.rb', line 90

def focus_last
  return if @session.window.panes.empty?
  if @session.focus_drawer && @session.drawer&.visible?
    @session.focus_drawer = false
  else
    @session.window.focus_last
  end
  invalidate
end

#focus_nextObject



70
71
72
73
74
75
76
77
78
# File 'lib/muxr/application.rb', line 70

def focus_next
  return if @session.window.panes.empty?
  if @session.focus_drawer && @session.drawer&.visible?
    @session.focus_drawer = false
  else
    @session.window.focus_next
  end
  invalidate
end

#focus_pane_number(n) ⇒ Object



100
101
102
103
104
105
106
107
# File 'lib/muxr/application.rb', line 100

def focus_pane_number(n)
  return if @session.window.panes.empty?
  idx = n - 1
  return unless idx >= 0 && idx < @session.window.panes.length
  @session.focus_drawer = false
  @session.window.focus_index(idx)
  invalidate
end

#focus_prevObject



80
81
82
83
84
85
86
87
88
# File 'lib/muxr/application.rb', line 80

def focus_prev
  return if @session.window.panes.empty?
  if @session.focus_drawer && @session.drawer&.visible?
    @session.focus_drawer = false
  else
    @session.window.focus_prev
  end
  invalidate
end

#hide_drawerObject



148
149
150
151
152
153
154
# File 'lib/muxr/application.rb', line 148

def hide_drawer
  return unless @session.drawer&.visible?
  @session.drawer.hide!
  @session.focus_drawer = false
  renderer.reset_frame!
  invalidate
end

#invalidateObject



336
337
338
# File 'lib/muxr/application.rb', line 336

def invalidate
  @needs_render = true
end

#list_sessionsObject



354
355
356
357
358
359
360
361
362
# File 'lib/muxr/application.rb', line 354

def list_sessions
  names = Session.list
  if names.empty?
    flash("no saved sessions")
  else
    marker = ->(n) { n == @session_name ? "*#{n}" : n }
    flash("sessions: #{names.map(&marker).join(", ")}")
  end
end

#move_selection(action) ⇒ Object



301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'lib/muxr/application.rb', line 301

def move_selection(action)
  target = focused_target
  return unless target
  term = target.terminal
  rows = term.rows
  cols = term.cols
  case action
  when :left       then term.move_selection_cursor_by(0, -1)
  when :right      then term.move_selection_cursor_by(0, 1)
  when :up         then term.move_selection_cursor_by(-1, 0)
  when :down       then term.move_selection_cursor_by(1, 0)
  when :half_up    then term.move_selection_cursor_by(-[rows / 2, 1].max, 0)
  when :half_down  then term.move_selection_cursor_by([rows / 2, 1].max, 0)
  when :full_up    then term.move_selection_cursor_by(-[rows - 1, 1].max, 0)
  when :full_down  then term.move_selection_cursor_by([rows - 1, 1].max, 0)
  when :line_start then term.selection_cursor_to_line_start
  when :line_end   then term.selection_cursor_to_line_end
  when :top        then term.selection_cursor_to_top
  when :bottom     then term.selection_cursor_to_bottom
  end
  invalidate
end

#new_paneObject



62
63
64
65
66
67
68
# File 'lib/muxr/application.rb', line 62

def new_pane
  cwd = focused_pane&.cwd
  @session.window.add_pane(make_pane(cwd: cwd))
  @session.focus_drawer = false
  @session.window.focused_index = @session.window.panes.length - 1
  invalidate
end

#paste_from_bufferObject



324
325
326
327
328
# File 'lib/muxr/application.rb', line 324

def paste_from_buffer
  return if @paste_buffer.nil? || @paste_buffer.empty?
  target = focused_target
  target&.write(@paste_buffer)
end

#promote_masterObject



126
127
128
129
# File 'lib/muxr/application.rb', line 126

def promote_master
  @session.window.promote_to_master
  invalidate
end

#quitObject

Both Ctrl-a q and :quit funnel through here. We don’t kill the server immediately — InputHandler enters a confirmation state and the user has to press ‘y’ to actually shut down (see :request_quit_confirmed).



176
177
178
# File 'lib/muxr/application.rb', line 176

def quit
  request_quit
end

#quit_immediateObject



180
181
182
# File 'lib/muxr/application.rb', line 180

def quit_immediate
  request_quit
end

#request_quitObject



184
185
186
187
188
189
# File 'lib/muxr/application.rb', line 184

def request_quit
  return if @input.state == :confirm_quit
  @input.enter_confirm_quit
  flash("kill session? (y/n)")
  invalidate
end

#reset_drawerObject



156
157
158
159
160
161
162
163
164
165
# File 'lib/muxr/application.rb', line 156

def reset_drawer
  if @session.drawer
    @session.drawer.close
    @session.drawer = nil
  end
  @session.focus_drawer = false
  renderer.reset_frame!
  flash("drawer reset")
  invalidate
end

#restore_sessionObject



345
346
347
348
349
350
351
352
# File 'lib/muxr/application.rb', line 345

def restore_session
  data = Session.load(@session_name)
  if data
    flash("session file: #{Session.save_path_for(@session_name)}")
  else
    flash("no saved session")
  end
end

#runObject



46
47
48
49
50
51
52
53
# File 'lib/muxr/application.rb', line 46

def run
  setup
  begin
    loop_forever
  ensure
    teardown
  end
end

#run_command(cmd_line) ⇒ Object



202
203
204
205
# File 'lib/muxr/application.rb', line 202

def run_command(cmd_line)
  CommandDispatcher.new(self).dispatch(cmd_line)
  invalidate
end

#save_sessionObject



340
341
342
343
# File 'lib/muxr/application.rb', line 340

def save_session
  path = @session.save
  flash("saved: #{path}")
end

#scroll_focused(action) ⇒ Object



234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/muxr/application.rb', line 234

def scroll_focused(action)
  target = focused_target
  return unless target
  term = target.terminal
  rows = term.rows
  case action
  when :line_back     then term.scroll_back(1)
  when :line_forward  then term.scroll_forward(1)
  when :half_back     then term.scroll_back([rows / 2, 1].max)
  when :half_forward  then term.scroll_forward([rows / 2, 1].max)
  when :full_back     then term.scroll_back([rows - 1, 1].max)
  when :full_forward  then term.scroll_forward([rows - 1, 1].max)
  when :top           then term.scroll_to_top
  when :bottom        then term.scroll_to_bottom
  end
  invalidate
end

#send_to_focused(data) ⇒ Object

———- public action API (called from InputHandler / CommandDispatcher) ———-



57
58
59
60
# File 'lib/muxr/application.rb', line 57

def send_to_focused(data)
  target = focused_target
  target&.write(data)
end

#show_drawerObject



140
141
142
143
144
145
146
# File 'lib/muxr/application.rb', line 140

def show_drawer
  ensure_drawer
  @session.drawer.show!
  @session.focus_drawer = true
  renderer.reset_frame!
  invalidate
end

#show_helpObject



207
208
209
210
211
# File 'lib/muxr/application.rb', line 207

def show_help
  @help_visible = true
  @input.enter_help_mode
  invalidate
end

#toggle_drawerObject



131
132
133
134
135
136
137
138
# File 'lib/muxr/application.rb', line 131

def toggle_drawer
  ensure_drawer
  @session.drawer.toggle!
  @session.focus_drawer = @session.drawer.visible?
  @session.focus_drawer = false unless @session.drawer.visible?
  renderer.reset_frame!
  invalidate
end

#toggle_selection(mode) ⇒ Object



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'lib/muxr/application.rb', line 264

def toggle_selection(mode)
  target = focused_target
  return unless target
  term = target.terminal
  if term.selection_active? && term.selection_mode == mode
    # Same mode pressed again — drop the anchor, return to navigation.
    term.clear_anchor!
  else
    # No anchor, or switching between linear/block — anchor at the
    # current cursor in the requested mode (vim keeps the visual range
    # when switching shapes, and we mirror that by not moving the
    # cursor).
    term.anchor_selection!(mode: mode)
  end
  invalidate
end