Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions tools/cutover/carry_toad_arrow_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Carry retired per-control arrow identities into the native navigation declarations.

Only installer input accepts these names. The current settings decoder and runtime
key routing do not retain aliases for them.
"""

import re
from dataclasses import dataclass
from typing import ClassVar

from pydantic import JsonValue
from textual.keys import DownKey, LeftKey, NavigationKey, RightKey, UpKey

from agent_comms.declared_family import DeclaredFamily
from agent_comms.errors import RelationViolationError
from agent_comms.field_codec import FieldCodec, NominalSpelling, NominalText
from cutover.carry_toad_key_mode import ToadSettingsCarry
from toad.key_modes import KeyMode
from toad.keymap import KeyInvocationId, Keymap, KeyRemap
from toad.preferences import ToadSettings, UiSettings


class PreviousInvocationId(NominalText):
"""An old saved action name accepted only by this installer input decoder."""


class PreviousInvocationSpelling(NominalSpelling[PreviousInvocationId]):
spelling = re.compile(r".+")


class PreviousArrow(DeclaredFamily, affix="Arrow"):
previous: ClassVar[tuple[PreviousInvocationId, ...]]
current: ClassVar[type[NavigationKey]]

@classmethod
def replacements(cls) -> dict[PreviousInvocationId, KeyInvocationId]:
return {previous: KeyInvocationId(member.current().key_identity())
for member in cls.members_with(cls) for previous in member.previous}


class PreviousUpArrow(PreviousArrow):
previous = (PreviousInvocationId("textual.widgets._text_area.TextArea.action_cursor_up"),
PreviousInvocationId("toad.widget_actions.CursorUpAction"))
current = UpKey


class PreviousDownArrow(PreviousArrow):
previous = (PreviousInvocationId("textual.widgets._text_area.TextArea.action_cursor_down"),
PreviousInvocationId("toad.widget_actions.CursorDownAction"))
current = DownKey


class PreviousLeftArrow(PreviousArrow):
previous = (PreviousInvocationId("textual.widgets._text_area.TextArea.action_cursor_left"),
PreviousInvocationId("textual.widgets._input.Input.action_cursor_left"),
PreviousInvocationId("toad.widget_actions.CursorLeftAction"))
current = LeftKey


class PreviousRightArrow(PreviousArrow):
previous = (PreviousInvocationId("textual.widgets._text_area.TextArea.action_cursor_right"),
PreviousInvocationId("textual.widgets._input.Input.action_cursor_right"),
PreviousInvocationId("toad.widget_actions.CursorRightAction"))
current = RightKey


@dataclass(frozen=True)
class PreviousKeyRemap:
declaration: PreviousInvocationId
mode: type[KeyMode]
keys: tuple[str, ...]

def carried(self, replacements: dict[PreviousInvocationId, KeyInvocationId]) -> KeyRemap:
reference = replacements.get(self.declaration, KeyInvocationId(str(self.declaration)))
return KeyRemap(reference, self.mode, self.keys)


class ToadArrowKeys(ToadSettingsCarry):
def rewrite(self, document: dict[str, JsonValue]) -> int:
ui_key = FieldCodec.record_key(ToadSettings, "ui")
ui = FieldCodec.decode(dict[str, JsonValue], document.get(ui_key, {}))
keymap_key = FieldCodec.record_key(UiSettings, "keymap")
keymap = FieldCodec.decode(dict[str, JsonValue], ui.get(keymap_key, {}))
bindings_key = FieldCodec.record_key(Keymap, "bindings")
previous = FieldCodec.decode(tuple[PreviousKeyRemap, ...], keymap.get(bindings_key, []))
replacements = PreviousArrow.replacements()
changed = sum(row.declaration in replacements for row in previous)
if not changed:
return 0
destinations = frozenset(replacements.values())
arrows: dict[tuple[KeyInvocationId, type[KeyMode]], KeyRemap] = {}
carried: list[KeyRemap] = []
for row in previous:
candidate = row.carried(replacements)
if candidate.declaration in destinations:
identity = candidate.declaration, candidate.mode
if identity in arrows:
existing = arrows[identity]
if existing.keys != candidate.keys:
raise RelationViolationError(
f"Conflicting saved arrow choices for {candidate.declaration} "
f"in {candidate.mode.declared_name}: {existing.keys!r} vs {candidate.keys!r}"
)
continue
arrows[identity] = candidate
carried.append(candidate)
keymap[bindings_key] = FieldCodec.encode(tuple(carried), tuple[KeyRemap, ...])
ui[keymap_key] = keymap
document[ui_key] = ui
return changed
31 changes: 23 additions & 8 deletions tools/cutover/carry_toad_key_mode.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Carry the saved Vim preference before the editable keymap replaces its wire field."""

from abc import abstractmethod
from dataclasses import dataclass
from typing import Annotated, ClassVar

Expand All @@ -25,30 +26,44 @@ def mode(self) -> type[KeyMode]:
return VimNormal if self.vim_keys else Standard


class ToadKeyMode(Carry):
class ToadSettingsCarry(Carry):
"""One confined saved-settings write, shared by its retired-input carries."""

opened_files: ClassVar[tuple[type[RootFile], ...]] = ()
writer_owned = (SavedSettings,)

@abstractmethod
def rewrite(self, document: dict[str, JsonValue]) -> int:
"""Rewrite this member's retired input in memory, or report no change."""

def carry(self, *, apply: bool) -> Carried:
resource, = SavedSettings.resources(self.files)
path = self.files.location(resource)
self.require_confined(path)
if not path.exists():
return Carried(0)
document = FieldCodec.decode(dict[str, JsonValue], FieldCodec.parse(path.read_text()))
changed = sum(member(self.files, self.spill_dir).rewrite(document)
for member in ToadSettingsCarry.members_with(ToadSettingsCarry))
if not changed:
return Carried(0)
FieldCodec.decode(ToadSettings, document)
if apply:
AtomicFile(path).write_text(FieldCodec.compact(document, allow_nan=False))
return Carried(changed, rewritten=(resource.path,) if apply else ())


class ToadKeyMode(ToadSettingsCarry):
def rewrite(self, document: dict[str, JsonValue]) -> int:
ui_key = FieldCodec.record_key(ToadSettings, "ui")
ui = FieldCodec.decode(dict[str, JsonValue], document.get(ui_key, {}))
previous_key = FieldCodec.record_key(PreviousVimPreference, "vim_keys")
if previous_key not in ui:
FieldCodec.decode(ToadSettings, document)
return Carried(0)
return 0
mode_key = FieldCodec.record_key(UiSettings, "key_mode")
if mode_key in ui:
raise RelationViolationError(f"{path}: both old and current keyboard preferences exist")
raise RelationViolationError("Both old and current keyboard preferences exist")
previous = FieldCodec.decode(PreviousVimPreference, {previous_key: ui.pop(previous_key)})
ui[mode_key] = FieldCodec.encode(previous.mode(), type[KeyMode])
document[ui_key] = ui
FieldCodec.decode(ToadSettings, document)
if apply:
AtomicFile(path).write_text(FieldCodec.compact(document, allow_nan=False))
return Carried(1, rewritten=(resource.path,) if apply else ())
return 1
Loading