diff --git a/tools/cutover/carry_toad_arrow_keys.py b/tools/cutover/carry_toad_arrow_keys.py new file mode 100644 index 0000000000..f7a2f47ba0 --- /dev/null +++ b/tools/cutover/carry_toad_arrow_keys.py @@ -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 diff --git a/tools/cutover/carry_toad_key_mode.py b/tools/cutover/carry_toad_key_mode.py index 42a7d3ae17..3136137fb5 100644 --- a/tools/cutover/carry_toad_key_mode.py +++ b/tools/cutover/carry_toad_key_mode.py @@ -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 @@ -25,10 +26,16 @@ 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) @@ -36,19 +43,27 @@ def carry(self, *, apply: bool) -> Carried: 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