defmodule Vibe.TUI.Keymap do @moduledoc "Maps Ghostty keyboard events and fallback terminal bytes to Vibe editor actions." @type key :: Vibe.UI.Editor.key() @spec from_event(Ghostty.KeyEvent.t()) :: [key()] def from_event(%Ghostty.KeyEvent{key: :arrow_left}), do: [:left] def from_event(%Ghostty.KeyEvent{key: :arrow_right}), do: [:right] def from_event(%Ghostty.KeyEvent{key: :arrow_up}), do: [:up] def from_event(%Ghostty.KeyEvent{key: :arrow_down}), do: [:down] def from_event(%Ghostty.KeyEvent{key: :home}), do: [:home] def from_event(%Ghostty.KeyEvent{key: :end}), do: [:end] def from_event(%Ghostty.KeyEvent{key: :delete}), do: [:delete] def from_event(%Ghostty.KeyEvent{key: :backspace}), do: [:backspace] def from_event(%Ghostty.KeyEvent{key: :enter, mods: mods}) do if has_any_mod?(mods, [:shift, :alt]), do: [:enter], else: [:submit] end def from_event(%Ghostty.KeyEvent{key: :tab, mods: mods}) do if has_mod?(mods, :shift), do: [:cycle_effort], else: [:tab] end def from_event(%Ghostty.KeyEvent{key: :arrow_left, mods: []}), do: [:left] def from_event(%Ghostty.KeyEvent{key: :escape}), do: [:cancel] def from_event(%Ghostty.KeyEvent{key: :c, mods: mods} = event), do: if_mod(mods, :ctrl, [:cancel], printable_event(event)) def from_event(%Ghostty.KeyEvent{key: :v, mods: mods} = event), do: if_mod(mods, :ctrl, [:paste_image], printable_event(event)) def from_event(%Ghostty.KeyEvent{key: :w, mods: mods} = event), do: if_mod(mods, :ctrl, [:delete_word_left], printable_event(event)) def from_event(%Ghostty.KeyEvent{key: :o, mods: mods} = event), do: if_mod(mods, :ctrl, [:toggle_truncation], printable_event(event)) def from_event(%Ghostty.KeyEvent{key: :p, mods: mods} = event) do cond do has_mod?(mods, :ctrl) and has_mod?(mods, :shift) -> [:cycle_model_backward] has_mod?(mods, :ctrl) -> [:cycle_model_forward] true -> printable_event(event) end end def from_event(%Ghostty.KeyEvent{key: :l, mods: mods} = event), do: if_mod(mods, :ctrl, [:open_model_selector], printable_event(event)) def from_event(%Ghostty.KeyEvent{key: :b, mods: mods} = event), do: if_mod(mods, :alt, [:word_left], printable_event(event)) def from_event(%Ghostty.KeyEvent{key: :f, mods: mods} = event), do: if_mod(mods, :alt, [:word_right], printable_event(event)) def from_event(%Ghostty.KeyEvent{utf8: utf8}) when is_binary(utf8) and byte_size(utf8) > 1, do: [{:paste, utf8}] def from_event(%Ghostty.KeyEvent{utf8: utf8}) when is_binary(utf8), do: [{:insert, utf8}] def from_event(%Ghostty.KeyEvent{}), do: [] @spec from_bytes(binary()) :: [key()] def from_bytes(""), do: [] def from_bytes("\e[200~" <> rest) do case String.split(rest, "\e[201~", parts: 2) do [paste, ""] -> [{:paste, paste}] [paste, tail] -> [{:paste, paste} | from_bytes(tail)] [_unterminated] -> [{:paste, rest}] end end def from_bytes("\e\r"), do: [:enter] def from_bytes("\e\n"), do: [:enter] def from_bytes("\e[Z"), do: [:cycle_effort] def from_bytes(<<12>>), do: [:open_model_selector] def from_bytes(<<15>>), do: [:toggle_truncation] def from_bytes(<<16>>), do: [:cycle_model_forward] def from_bytes(<<22>>), do: [:paste_image] def from_bytes(<<23>>), do: [:delete_word_left] def from_bytes(data) when is_binary(data) do case Ghostty.KeyDecoder.decode(data) do {:key, event} -> from_event(event) {:data, data} -> decode_data(data) end end defp if_mod(mods, mod, keys, fallback), do: if(has_mod?(mods, mod), do: keys, else: fallback) defp printable_event(%Ghostty.KeyEvent{utf8: utf8}) when is_binary(utf8) and byte_size(utf8) > 1, do: [{:paste, utf8}] defp printable_event(%Ghostty.KeyEvent{utf8: utf8}) when is_binary(utf8), do: [{:insert, utf8}] defp printable_event(%Ghostty.KeyEvent{}), do: [] defp has_any_mod?(mods, candidates), do: Enum.any?(candidates, &has_mod?(mods, &1)) defp has_mod?(mods, mod), do: mod in List.wrap(mods) defp decode_data(data) when is_binary(data) do if multiline_paste?(data), do: [{:paste, data}], else: data |> decode_data([], "") |> Enum.reverse() end defp decode_data("", keys, pending), do: flush_pending(keys, pending) defp decode_data(<>, keys, pending) when char in [?\r, ?\n] do keys = [:submit | flush_pending(keys, pending)] decode_data(rest, keys, "") end defp decode_data(<>, keys, pending) do char = <> if printable?(char) do decode_data(rest, keys, pending <> char) else decode_data(rest, keys, pending) end end defp flush_pending(keys, ""), do: keys defp flush_pending(keys, <<_::utf8>> = pending), do: [{:insert, pending} | keys] defp flush_pending(keys, pending), do: [{:paste, pending} | keys] defp multiline_paste?(data) do byte_size(data) > 1 and String.contains?(data, ["\n", "\r"]) and printable_paste?(data) end defp printable_paste?(data) do data |> String.replace("\r\n", "\n") |> String.replace("\r", "\n") |> String.replace("\n", "") |> printable?() end defp printable?(""), do: true defp printable?(data), do: String.printable?(data) and not String.match?(data, ~r/[\p{C}]/u) end