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.

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],
  "g"  => [:set_layout, :grid],
  "m"  => [:set_layout, :monocle],
  "\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,
  "~"  => :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,
  "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.



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

def initialize(app)
  @app = app
  @state = :normal
  @base_mode = :normal
  @command_buffer = +""
  @search_buffer = +""
  @search_direction = :forward
end

Instance Attribute Details

#base_modeObject (readonly)

Returns the value of attribute base_mode.



146
147
148
# File 'lib/muxr/input_handler.rb', line 146

def base_mode
  @base_mode
end

#command_bufferObject (readonly)

Returns the value of attribute command_buffer.



146
147
148
# File 'lib/muxr/input_handler.rb', line 146

def command_buffer
  @command_buffer
end

#search_bufferObject (readonly)

Returns the value of attribute search_buffer.



146
147
148
# File 'lib/muxr/input_handler.rb', line 146

def search_buffer
  @search_buffer
end

#search_directionObject (readonly)

Returns the value of attribute search_direction.



146
147
148
# File 'lib/muxr/input_handler.rb', line 146

def search_direction
  @search_direction
end

#stateObject (readonly)

Returns the value of attribute state.



146
147
148
# File 'lib/muxr/input_handler.rb', line 146

def state
  @state
end

Instance Method Details

#cancelObject



264
265
266
267
# File 'lib/muxr/input_handler.rb', line 264

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

#enter_confirm_closeObject



224
225
226
# File 'lib/muxr/input_handler.rb', line 224

def enter_confirm_close
  @state = :confirm_close
end

#enter_confirm_quitObject



220
221
222
# File 'lib/muxr/input_handler.rb', line 220

def enter_confirm_quit
  @state = :confirm_quit
end

#enter_help_modeObject



216
217
218
# File 'lib/muxr/input_handler.rb', line 216

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.



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

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.



252
253
254
255
# File 'lib/muxr/input_handler.rb', line 252

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.



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

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

#enter_scrollback_modeObject



228
229
230
# File 'lib/muxr/input_handler.rb', line 228

def enter_scrollback_mode
  @state = :scrollback
end

#enter_search_mode(direction: :forward) ⇒ Object



232
233
234
235
236
# File 'lib/muxr/input_handler.rb', line 232

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

#enter_selection_modeObject



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

def enter_selection_mode
  @state = :selection
end

#feed(data) ⇒ Object



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
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
# File 'lib/muxr/input_handler.rb', line 157

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