Class: Muxr::InputHandler

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

Overview

Translates raw keystrokes into either commands or pane input. Two top-level modes:

:normal       Default. Single-key bindings (hjkl navigation, t/g/m for
              layouts, c/K for create/kill, etc.) act directly without
              any prefix. `i` drops into passthrough.

:passthrough  Historical mode: every key is forwarded to the focused
              pane unless prefixed by Ctrl-a. `Ctrl-a Esc` returns to
              normal mode.

Plus the sub-states pre-existing from before modes existed:

:prefix, :command, :confirm_quit, :confirm_close, :help, :scrollback,
:search, :selection.

One-shot sub-states (prefix, command, confirm_quit, help) return to finish. :scrollback and :selection also return to @base_mode so that exiting back from a scroll/yank lands you back in passthrough if that’s where you came from.

Scrollback is effectively pane-bound. Ctrl-a is honored from inside :scrollback and :selection — it drops into :prefix (with @prefix_return

:scrollback) so a pane switch keeps you in scrollback on the pane you

move to, while the pane you left keeps its own scroll position. Coming the other way, the Application re-enters scrollback whenever you focus a pane that was left scrolled back. ‘i` from scrollback drops to insert (passthrough) without snapping to the bottom; only `q`/Esc returns the pane to the live bottom.

Constant Summary collapse

PREFIX =

Ctrl-a

"\x01".freeze
NORMAL_BINDINGS =

Single-key bindings in normal mode. Same actions as their Ctrl-a- prefixed counterparts in passthrough, just without the prefix. Value forms:

:symbol            → @app.public_send(:symbol)
[:symbol, *args]   → @app.public_send(:symbol, *args)
{
  "c"  => :new_pane,
  "x"  => :request_close,
  "t"  => [:set_layout, :tall],
  "w"  => [:set_layout, :wide],
  "g"  => [:set_layout, :grid],
  "m"  => [:set_layout, :monocle],
  "|"  => [:set_layout, :columns],
  "-"  => [:set_layout, :rows],
  "f"  => [:set_layout, :spiral],
  "e"  => [:set_layout, :centered],
  "S"  => [:set_layout, :stack],
  "\t" => :cycle_layout,
  "\r" => :promote_master,
  "\n" => :promote_master,
  "h"  => [:focus_direction, :left],
  "j"  => [:focus_direction, :down],
  "k"  => [:focus_direction, :up],
  "l"  => [:focus_direction, :right],
  "H"  => [:move_direction, :left],
  "J"  => [:move_direction, :down],
  "K"  => [:move_direction, :up],
  "L"  => [:move_direction, :right],
  "a"  => :focus_last,
  "r"  => :refresh_focused,
  "~"  => :toggle_drawer,
  "C"  => :toggle_claude_drawer,
  "P"  => :toggle_private_focused,
  "d"  => :detach,
  "?"  => :show_help,
  "q"  => :quit_immediate,
  "s"  => :enter_scrollback,
  "]"  => :paste_from_buffer
}.freeze
PREFIX_BINDINGS =
{
  "c"    => :new_pane,
  "n"    => :focus_next,
  "p"    => :focus_prev,
  "a"    => :focus_last,
  "r"    => :refresh_focused,
  "x"    => :request_close,
  "\t"   => :cycle_layout,
  "\r"   => :promote_master,
  "\n"   => :promote_master,
  "~"    => :toggle_drawer,
  "C"    => :toggle_claude_drawer,
  "P"    => :toggle_private_focused,
  "d"    => :detach,
  "?"    => :show_help,
  "q"    => :quit_immediate,
  "["    => :enter_scrollback,
  "]"    => :paste_from_buffer
}.freeze
SCROLLBACK_BINDINGS =
{
  "j"    => :line_forward,
  "k"    => :line_back,
  "\x04" => :half_forward, # Ctrl-d
  "\x15" => :half_back,    # Ctrl-u
  "d"    => :half_forward,
  "u"    => :half_back,
  "\x06" => :full_forward, # Ctrl-f
  "\x02" => :full_back,    # Ctrl-b
  "f"    => :full_forward,
  "b"    => :full_back,
  " "    => :full_forward,
  "g"    => :top,
  "G"    => :bottom
}.freeze
SCROLLBACK_CSI =

CSI sequences (arrow / page keys) recognized in scrollback mode. Built for terminal raw-mode emission: arrow keys come through as ESC ‘[` followed by a single final letter, PageUp/PageDown as ESC `[5~` / `[6~`. Lookahead in #feed peels these off as one chunk so a bare ESC still exits scrollback the way it always has.

{
  "\e[A"  => :line_back,    # Up
  "\e[B"  => :line_forward, # Down
  "\e[5~" => :half_back,    # PageUp
  "\e[6~" => :half_forward, # PageDown
  "\e[H"  => :top,          # Home
  "\e[F"  => :bottom        # End
}.freeze
SCROLLBACK_EXITS =

q, Esc, Ctrl-c

["q", "\e", "\x03"].freeze
SELECTION_BINDINGS =
{
  "h"    => :left,
  "l"    => :right,
  "j"    => :down,
  "k"    => :up,
  "0"    => :line_start,
  "$"    => :line_end,
  "^"    => :line_first_nonblank,
  "g"    => :top,
  "G"    => :bottom,
  "H"    => :screen_top,
  "M"    => :screen_middle,
  "L"    => :screen_bottom,
  "w"    => :word_forward,
  "W"    => :word_forward_big,
  "e"    => :word_end,
  "E"    => :word_end_big,
  # `b` is vim word-back here; the tmux-style page-back alias lives on Ctrl-b.
  "b"    => :word_backward,
  "B"    => :word_backward_big,
  "\x04" => :half_down, # Ctrl-d
  "\x15" => :half_up,   # Ctrl-u
  "d"    => :half_down,
  "u"    => :half_up,
  "\x06" => :full_down, # Ctrl-f
  "\x02" => :full_up,   # Ctrl-b
  "f"    => :full_down
  # NOTE: space is intentionally absent here — it's a top-level toggle
  # for linear selection (see handle_selection_input), mirroring vim's
  # `v` so the right thumb has a one-key way to anchor/release.
}.freeze
SELECTION_YANK =
["\r", "\n", "y"].freeze
SELECTION_CANCEL =

q, Esc, Ctrl-c

["q", "\e", "\x03"].freeze
DIGIT_RE =
/\A[1-9]\z/.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(app) ⇒ InputHandler

Returns a new instance of InputHandler.



165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/muxr/input_handler.rb', line 165

def initialize(app)
  @app = app
  @state = :normal
  @base_mode = :normal
  @command_buffer = +""
  @search_buffer = +""
  @search_direction = :forward
  # When the prefix state is entered from scrollback/selection (Ctrl-a),
  # this records :scrollback so that a pane switch lands you back in
  # scrollback on the newly-focused pane instead of dropping to the base
  # mode. nil means "use @base_mode" (the normal passthrough behavior).
  @prefix_return = nil
end

Instance Attribute Details

#base_modeObject (readonly)

Returns the value of attribute base_mode.



163
164
165
# File 'lib/muxr/input_handler.rb', line 163

def base_mode
  @base_mode
end

#command_bufferObject (readonly)

Returns the value of attribute command_buffer.



163
164
165
# File 'lib/muxr/input_handler.rb', line 163

def command_buffer
  @command_buffer
end

#search_bufferObject (readonly)

Returns the value of attribute search_buffer.



163
164
165
# File 'lib/muxr/input_handler.rb', line 163

def search_buffer
  @search_buffer
end

#search_directionObject (readonly)

Returns the value of attribute search_direction.



163
164
165
# File 'lib/muxr/input_handler.rb', line 163

def search_direction
  @search_direction
end

#stateObject (readonly)

Returns the value of attribute state.



163
164
165
# File 'lib/muxr/input_handler.rb', line 163

def state
  @state
end

Instance Method Details

#cancelObject



286
287
288
289
# File 'lib/muxr/input_handler.rb', line 286

def cancel
  @state = @base_mode
  @command_buffer = +""
end

#enter_confirm_closeObject



246
247
248
# File 'lib/muxr/input_handler.rb', line 246

def enter_confirm_close
  @state = :confirm_close
end

#enter_confirm_quitObject



242
243
244
# File 'lib/muxr/input_handler.rb', line 242

def enter_confirm_quit
  @state = :confirm_quit
end

#enter_help_modeObject



238
239
240
# File 'lib/muxr/input_handler.rb', line 238

def enter_help_mode
  @state = :help
end

#enter_idle_modeObject

Exit a sub-state (scrollback, selection-yank) and resume the mode the user was in before they entered scrollback. Preserves @base_mode so a passthrough → scrollback → exit round-trip lands back in passthrough.



282
283
284
# File 'lib/muxr/input_handler.rb', line 282

def enter_idle_mode
  @state = @base_mode
end

#enter_normal_modeObject

Return to normal mode. Used by the ‘Ctrl-a Esc` binding from passthrough — explicitly resets @base_mode so the user genuinely leaves passthrough.



274
275
276
277
# File 'lib/muxr/input_handler.rb', line 274

def enter_normal_mode
  @state = :normal
  @base_mode = :normal
end

#enter_passthrough_modeObject

Drop into passthrough — every key reaches the focused pane until the user issues Ctrl-a Esc.



266
267
268
269
# File 'lib/muxr/input_handler.rb', line 266

def enter_passthrough_mode
  @state = :passthrough
  @base_mode = :passthrough
end

#enter_scrollback_modeObject



250
251
252
# File 'lib/muxr/input_handler.rb', line 250

def enter_scrollback_mode
  @state = :scrollback
end

#enter_search_mode(direction: :forward) ⇒ Object



254
255
256
257
258
# File 'lib/muxr/input_handler.rb', line 254

def enter_search_mode(direction: :forward)
  @state = :search
  @search_direction = direction
  @search_buffer = +""
end

#enter_selection_modeObject



260
261
262
# File 'lib/muxr/input_handler.rb', line 260

def enter_selection_mode
  @state = :selection
end

#feed(data) ⇒ Object



179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/muxr/input_handler.rb', line 179

def feed(data)
  remaining = data
  until remaining.empty?
    if @state == :passthrough
      # Fast path: batch everything up to the next Ctrl-a as one chunk so
      # a large paste doesn't turn into one PTY write per byte. PREFIX is
      # single-byte ASCII (\x01) and never appears mid-UTF-8.
      idx = remaining.index(PREFIX)
      if idx.nil?
        @app.send_to_focused(remaining)
        return
      end
      @app.send_to_focused(remaining[0...idx]) if idx > 0
      @state = :prefix
      remaining = remaining[(idx + 1)..] || ""
      next
    end

    # Multi-byte CSI lookahead for scrollback / search: arrow / page
    # keys arrive as `\e[<final>` and would otherwise trip the
    # bare-Esc-exits behavior. In :scrollback we map them to scroll
    # actions; in :search we silently consume them so a stray arrow
    # doesn't kick the user out of the prompt. An incomplete `\e[…`
    # (rare in raw-mode TTY) falls through and the bare `\e` exits as
    # before.
    if (@state == :scrollback || @state == :search) && remaining.start_with?("\e[")
      consumed = consume_csi_escape(remaining)
      if consumed > 0
        remaining = remaining[consumed..] || ""
        next
      end
    end

    ch = remaining[0]
    remaining = remaining[1..] || ""
    case @state
    when :normal
      handle_normal(ch)
    when :help
      @app.dismiss_help
      @state = @base_mode
    when :confirm_quit
      handle_confirm_quit(ch)
    when :confirm_close
      handle_confirm_close(ch)
    when :prefix
      handle_prefix(ch)
    when :command
      handle_command_input(ch)
    when :scrollback
      handle_scrollback_input(ch)
    when :search
      handle_search_input(ch)
    when :selection
      handle_selection_input(ch)
    end
  end
end